Skip to content

refactor: shrink production file count from 150 to 45 (-70%)#50

Merged
kuchmenko merged 1 commit into
mainfrom
refactor/file-count-shrink
May 19, 2026
Merged

refactor: shrink production file count from 150 to 45 (-70%)#50
kuchmenko merged 1 commit into
mainfrom
refactor/file-count-shrink

Conversation

@kuchmenko
Copy link
Copy Markdown
Owner

@kuchmenko kuchmenko commented May 19, 2026

Criterion: a file earns its size if it reads top-to-bottom as one narrative. Big is fine when there's a through-line; small is wrong when the next thing you need is in another file.

By that yardstick most of the 150 files were small and wrong — split per-view-mode, per-syscall, per-subcommand. Mechanical consolidation, no behavior change: 150 → 45 production .go files (−70%), 25 → 21 folders, LOC unchanged (just dedup'd imports), 14 MB binary unchanged, PR #49 TUI seam intact.

The heavy collapses: cli 28 → 5, agent 19 → 3, add 18 → 4, daemon 10 → 3, create 8 → 2. Ten small packages each collapse to one file at internal/<pkg>/<pkg>.go. Four folders deleted (clipboardadd, clonegit, benchfixturebench/, docscli).

Two intentional outliers: cli/root.go (1,727 LOC) and agent/tui.go (1,774 LOC). Both walk top-to-bottom — root.go is 13 declarative Cobra builders in a row, agent/tui.go is state → update → panels. Splitting either would cut a sentence in half.

go build, go test, go vet, golangci-lint, gofmt all clean. Smoke remaining: ws agent, ws alias, ws add, ws status, ws sync on a real workspace.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

📝 Walkthrough

Walkthrough

Large refactor consolidates CLI commands into root.go, rebuilds add and agent TUIs and sources, integrates GitHub auth and bootstrap sidecars, unifies config branch/machine models, adds conflict notifications, and updates L2 benchmark paths to bench/benchfixture.

Changes

Bench L2 fixture path switch

Layer / File(s) Summary
Bench docs/scripts/tests target bench/benchfixture
AGENTS.md, bench/README.md, bench/benchfixture/*, bench/scripts/run-l2.sh
References, imports, and go test paths switched from internal/benchfixture to bench/benchfixture.

Add flow refactor and sources/TUI integration

Layer / File(s) Summary
Add core API (Register, Options) and sidecar wiring
internal/add/add.go
Adds Register/Options/Mode/Result and sidecar session helpers; clipboard uses DefaultClipboardReader.
Suggestion sources (GitHub, Clipboard, Disk) and URL validation
`internal/add/*(sources
suggest).go`
Add TUI browse/edit/confirm/cloning/done views
internal/add/tui.go, internal/add/sources.go
Rebuilds browse/edit/confirm flows with cloning progress and branch prompt wiring.
Clipboard pkg removal and updated tests
internal/clipboard/*, internal/add/clipboard_test.go
Removes clipboard package; tests updated for new reader/errors and timing/platform cases.

Agent model/view/TUI consolidation

Layer / File(s) Summary
Domain types, workspace loading, and persistence
internal/agent/model.go
Adds NodeKind/Project/WorkspaceData/Chip, load/mutate workspace, icons, and activity stamping.
List key handling, worktrees/sessions, edit, which-key, launchers
internal/agent/tui.go
Implements list actions, worktrees/sessions caches, edit project, which-key, and launchers.
View rendering (list, chips, flash) and styles
internal/agent/view.go
Renders list, chips, flash, and chip action popup with styles.

Alias generation/manager integration

Layer / File(s) Summary
Alias generation, resolve, render, and installer
internal/alias/alias.go
Provides Generate/ResolveAll/RenderZsh, state file path, and zshrc installer.
Alias manager TUI model and confirm step
internal/aliasmgr/aliasmgr.go
Adds TUI to manage aliases, confirm/save results with conflict checks.

Auth/device flow and bootstrap

Layer / File(s) Summary
GitHub device flow and PAT prompt in auth.go
internal/auth/auth.go
Implements device-flow OAuth and PAT validation.
Bootstrap sidecar wrapper and done entries
internal/bootstrap/bootstrap.go
Adds sidecar wrapper, done entries, and lifecycle helpers.
Branch prompt messages relocation
internal/branchprompt/branchprompt.go
Defines Picked/Cancelled messages and emits them.

CLI consolidation

Layer / File(s) Summary
Bootstrap CLI TUI integration
internal/cli/bootstrap.go
Adds bootstrap TUI with cloning, branch prompt, progress, and styles.
Agent capability JSON generation in CLI
internal/cli/docs.go, internal/cli/docs_test.go
Generates agent capability map and tests locally.
Migrate TUI flow and sidecar commit
internal/cli/migrate.go
Adds migrate TUI with decisions, execution, resume, and commit.
Root CLI with commands
internal/cli/root.go
Adds add/alias/auth/create/daemon/doctor/explorer/favorite/path/scan/setup/status implementations.
Interactive sync conflict resolution utilities
internal/cli/sync.go
Adds conflict resolvers, prompt loop, and shell helpers.
Worktree add/list/push/rm commands
internal/cli/worktree.go
Implements worktree subcommands and state formatting.

Config and conflict notifications

Layer / File(s) Summary
Project/Branch/Machine/Validation in config.go
internal/config/config.go
Unifies BranchMeta ops, legacy migration, machine config, project types, and validation.
Conflict notifications in conflict.go
internal/conflict/conflict.go
Adds notify-send helpers.

Sequence Diagram(s)

(none)

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

Poem

a rabbit taps the CLI tree,
pruning twigs to one stout spree;
add and agent bloom anew,
sidecars hum and branches grew;
benches hop to public ground,
configs waltz with tidy sound—
thump! a conflict’s carrot found. 🥕

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/file-count-shrink

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (9)
internal/add/add.go (1)

268-277: 💤 Low value

Unused parameter in inferGroup.

The url parameter is explicitly ignored with _. If this is intentional as a placeholder for future use, consider removing the parameter entirely until needed (YAGNI), or add a // TODO: marker explaining the intent.

♻️ Suggested simplification
-func inferGroup(_ string, cat config.Category) string {
+func inferGroup(cat config.Category) string {
 	return string(cat)
 }

Then update the call site at line 228:

-	group = inferGroup(url, cat)
+	group = inferGroup(cat)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/add/add.go` around lines 268 - 277, Remove the unused url parameter
from inferGroup and update all call sites to use the new signature;
specifically, change func inferGroup(_ string, cat config.Category) string to
func inferGroup(cat config.Category) string (or alternatively keep the parameter
but add a clear TODO comment if you intend to use it), and ensure callers that
currently pass a URL argument are adjusted to pass only the category. Also
verify buildPath remains unchanged and still accepts group, cat, name.
internal/add/sources.go (1)

88-93: 💤 Low value

Redundant error wrapping branches.

Both branches of the ErrNotAuthed check wrap the error identically. The special case has no distinct handling.

♻️ Simplify error handling
 	repos, err := s.Provider.SuggestRepos(ctx, limit)
 	if err != nil {
-		if errors.Is(err, github.ErrNotAuthed) {
-			return nil, fmt.Errorf("github source: %w", err)
-		}
 		return nil, fmt.Errorf("github source: %w", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/add/sources.go` around lines 88 - 93, The code checks errors.Is(err,
github.ErrNotAuthed) but both branches return the same wrapped error, creating
redundant logic; simplify by removing the special-case branch and replace the
whole conditional with a single return that wraps err (e.g., return nil,
fmt.Errorf("github source: %w", err)), keeping the errors.Is/github.ErrNotAuthed
check only if you intend to handle that case differently in functions dealing
with err.
internal/add/tui.go (1)

1-912: ⚖️ Poor tradeoff

File exceeds 800 LOC extraction threshold.

At 912 lines, this file exceeds the 800 LOC limit specified in the coding guidelines: "extract immediately if beyond 800 lines." Consider splitting the style definitions (lines 863-911) into a separate styles.go file, or extracting the browse/edit/confirm view logic into tui_views.go.

As per coding guidelines: "Split .go files larger than 500 lines on the next touch, and extract immediately if beyond 800 lines."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/add/tui.go` around lines 1 - 912, File exceeds size threshold; split
large tui.go by extracting the style constants (addTitle, addDim, addHelp,
addCursor, addAccent, addErr, addCheck, addChip, addGroupHdr, addItemName,
addExists, addExistsTag, addPreviewName, addCursorRow) into a new styles.go in
package add (or alternatively extract view functions like viewBrowse, viewEdit,
viewConfirm into tui_views.go). Create the new file with the same package
declaration, move the var block there, run `go vet`/`go build` to ensure no
symbol changes, and keep all references unchanged (e.g., viewBrowse(),
viewEdit(), renderSelectionPreview(), renderItemLine(), buildBrowseRows()) so
callers continue to compile.
internal/cli/bootstrap.go (2)

264-264: 💤 Low value

Global mutable program pointer requires caution.

This global is a common bubbletea pattern for sending messages from goroutines, but it creates implicit coupling. The current usage is safe since runBootstrap sets and clears it within a single call, but consider documenting the invariant or eventually passing the program reference through the model.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/bootstrap.go` at line 264, The global variable program (var
program *tui.Program) is a shared mutable pointer used by runBootstrap to send
messages from goroutines, which creates implicit coupling; either document the
invariant (that runBootstrap sets program and clears it within its call and no
other goroutines access it outside that window) or refactor to remove the global
by adding a program field to the bootstrap model and passing that model (or the
*tui.Program) into any goroutines that need to send messages; update
runBootstrap to set model.program (or accept the program as an argument) and
remove/clear the package-level program variable to ensure the coupling is
explicit and thread-safe.

577-615: ⚖️ Poor tradeoff

File exceeds 500 LOC guideline.

At 615 lines, this file crosses the 500-line threshold for splitting on next touch. The style variables (lines 577-615) and view helpers (renderProgressBar, indent) are natural extraction candidates into a bootstrap_styles.go or shared styles.go. Not blocking for this mechanical refactor PR, but flagging for future.

As per coding guidelines: "Split .go files larger than 500 lines on the next touch".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/bootstrap.go` around lines 577 - 615, This file exceeds the
500-line limit and should be split: extract the style variables (e.g.,
bsTitleStyle, bsHeaderStyle, bsDimStyle, bsHelpStyle, bsCheckStyle, bsWarnStyle,
bsErrStyle, bsArrowStyle, bsBarFilledStyle, bsBarEmptyStyle, errorBannerStyle)
and related small view helpers (e.g., renderProgressBar, indent) into a new file
such as bootstrap_styles.go or styles.go, update package-level visibility if
needed, and ensure any callers in bootstrap.go reference the moved symbols; keep
behavior unchanged and run `go vet`/`go test` to verify no import or visibility
regressions.
internal/cli/worktree.go (2)

140-144: 💤 Low value

Silent error suppression on git operations.

Lines 141, 144, and 206 discard errors from SetFetchRefspec, FetchRefspec, and SetBranchUpstream. While these may be intentional for non-critical auxiliary operations, they could mask connectivity or permission issues.

Consider logging at debug level or returning early only when the operation is truly required for correctness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/worktree.go` around lines 140 - 144, The code currently swallows
errors from git.SetFetchRefspec, git.FetchRefspec, and git.SetBranchUpstream
called in the worktree flow (see HasFetchRefspec, SetFetchRefspec, FetchRefspec,
SetBranchUpstream), which can hide connectivity/permission failures; update
those calls to capture returned errors and either log them at debug/trace level
(via the existing logger) or return the error upstream when the operation is
required for correctness (e.g., if FetchRefspec fails and subsequent operations
depend on the fetch), ensuring each call checks its error and handles it
appropriately rather than discarding it.

121-238: ⚖️ Poor tradeoff

Cyclomatic complexity exceeds threshold.

The RunE closure handles three distinct flows (re-register existing worktree, attach to local/remote branch, create new branch) with approximately 27 decision points. This exceeds the 15-point threshold.

Consider extracting helpers such as handleExistingWorktree, createWorktreeFromExisting, and createWorktreeFromBase to reduce complexity and improve testability.

As per coding guidelines: "Cyclomatic complexity must not exceed 15 in production Go functions; extract immediately if over 15."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/worktree.go` around lines 121 - 238, The RunE closure in
worktree.go has excessive cyclomatic complexity; extract logical sections into
small helper functions to reduce decision points: implement
handleExistingWorktree(projectName, branch, machine, barePath, ws,
saveWorkspace) to contain the existing-worktree branch re-registration logic
(uses locateWorktreeForBranch, p.ClaimBranch, p.MarkPushed, saveWorkspace),
implement createWorktreeFromExisting(barePath, wtPath, branch, remoteExists,
fromBase) to encapsulate the local/remote attach flow (uses git.WorktreeAdd,
git.SetBranchUpstream) and implement createWorktreeFromBase(barePath, wtPath,
branch, base) to handle creating a new branch from a base (uses
proj.DefaultBranch and git.WorktreeAdd); have RunE call these helpers and handle
updating ws.Projects and printing output so the closure's decision points drop
well below 15.
internal/aliasmgr/aliasmgr.go (1)

1-12: ⚖️ Poor tradeoff

File exceeds 500 LOC guideline (635 lines).

Per coding guidelines, .go files larger than 500 lines should be split on the next touch. Consider extracting the tree-building logic (buildTree, treeRow, helper functions) or the view rendering (viewManage, viewConfirm) into separate files.

As per coding guidelines: "Split .go files larger than 500 lines on the next touch, and extract immediately if beyond 800 lines"

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/aliasmgr/aliasmgr.go` around lines 1 - 12, The file is too large
(>500 LOC); extract cohesive parts into separate files: move the tree-building
logic (buildTree, treeRow and any helper functions/types they depend on) into a
new file (e.g., tree.go) and move view rendering (viewManage, viewConfirm and
their helpers) into another file (e.g., view.go), keeping them in the same
package aliasmgr; update imports and adjust function/variable visibility
(exported vs unexported) so references remain valid, and ensure any shared
helper types/constants remain in a small shared file or kept at top of
aliasmgr.go to avoid import cycles.
internal/auth/auth.go (1)

138-167: ⚡ Quick win

HTTP request lacks explicit timeout.

http.DefaultClient has no timeout, which could cause the CLI to hang indefinitely on network issues. Consider using a client with a timeout for better UX.

Suggested approach
var httpClient = &http.Client{Timeout: 30 * time.Second}

Then use httpClient.Do(req) instead of http.DefaultClient.Do(req) throughout the file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/auth.go` around lines 138 - 167, The HTTP call in
requestDeviceCode (and other auth-related requests) uses http.DefaultClient
which has no timeout; create a package-level http client (e.g., var httpClient =
&http.Client{Timeout: 30*time.Second}) and replace http.DefaultClient.Do(...)
with httpClient.Do(...) in requestDeviceCode and any other functions in
internal/auth/auth.go that make HTTP requests so calls can't hang indefinitely;
ensure you import time if not already imported.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/add/clipboard_test.go`:
- Around line 43-52: Update TestSystemClipboardReader_DeadlineExceeded to assert
the specific error type instead of only checking err != nil: call
DefaultClipboardReader.Read(ctx) and use errors.Is to verify the returned err is
either context.DeadlineExceeded or ErrClipboardUnavailable (matching the pattern
used in TestSystemClipboardReader_ContextCancelled). Reference the test function
name TestSystemClipboardReader_DeadlineExceeded and the Read method on
DefaultClipboardReader; replace the weak t.Fatal("expected error") with an
assertion that uses errors.Is(err, context.DeadlineExceeded) || errors.Is(err,
ErrClipboardUnavailable) and fail the test if neither holds.

In `@internal/add/sources.go`:
- Around line 251-305: The shorthand SSH branch in looksLikeGitURL currently
accepts any SSH shorthand because the shorthandRegex block returns true
unconditionally; update that block so it only returns true when the extracted
host is allowed (i.e., keep the existing if allowedHosts[host] { return true }
and remove the unconditional return true), or if accepting all SSH shorthands is
intentional add a short DECISION comment above the shorthandRegex handling
describing that choice and why; ensure you modify only the shorthandRegex branch
(the code that extracts host via m := shorthandRegex.FindStringSubmatch(s)) and
leave the HTTPS/URL logic unchanged.

In `@internal/agent/tui.go`:
- Around line 1-19: The file internal/agent/tui.go is far too large (1,717
lines) and must be split; refactor by extracting related types and functions
into separate files: move Worktree, WorktreeResult, WorktreeCache,
CreateWorktree, DeleteWorktreeWithRegistry, and LoadWorktrees into a new
worktree.go; move Session, SessionCache, LoadSessions, FindSession, and
extractTitle into session.go; move LaunchClaude and LaunchShell into launch.go;
move the Edit project UI handlers (the functions handling edit UI logic
referenced around lines where edit functions appear) into edit.go; ensure you
update package-level imports and any unexported helper references (keep names
and signatures unchanged) and run go vet/go build to fix any missing references.
- Around line 865-867: The current encodeCwd function uses
strings.ReplaceAll(path, "/", "-"), which is lossy; replace it with a reversible
encoding (e.g., base64 RawURLEncoding or url.PathEscape) so encoded names are
unique and reversible. Update encodeCwd to return a reversible encoding (suggest
base64.RawURLEncoding.EncodeToString([]byte(path)) or url.PathEscape(path)), and
update all places that decode or parse these names—specifically FindSession and
LoadSessions—to decode using the matching decoder
(base64.RawURLEncoding.DecodeString or url.PathUnescape) and use the decoded
path for comparisons and session directory lookup so hyphenated paths no longer
collide. Ensure session directory naming and any lookup logic consistently use
the new encoder/decoder pair.

In `@internal/agent/view.go`:
- Around line 488-503: formatInt currently discards the sign for negative
inputs; update formatInt to preserve and emit a leading '-' for negative n by
recording sign (e.g., neg := n < 0), converting n to its absolute value for
digit extraction, and then prefixing '-' to the returned string when neg is
true; alternatively, if negatives are truly invalid, change the API to take a
unsigned type or add an assertion/panic in formatInt to fail fast—but the
quickest fix is to detect negative n and include the minus sign in the returned
string from formatInt.

In `@internal/aliasmgr/aliasmgr.go`:
- Around line 78-82: In buildItems, aliasFor currently maps target->single alias
which overwrites when multiple aliases point to the same target; change aliasFor
from map[string]string to map[string][]string, populate it with aliasFor[t] =
append(aliasFor[t], n) for each n,t in ws.Aliases, and then update any
subsequent code in buildItems that consumes aliasFor to iterate the slice of
aliases (e.g., for _, alias := range aliasFor[target] ...) so all aliases for
the same target are preserved and rendered/checked individually.

In `@internal/cli/bootstrap.go`:
- Around line 397-400: The code currently discards errors from
m.sidecar.MarkDone(...) and bootstrap.Save(m.sidecar); capture both return
values and handle non-nil errors instead of ignoring them: call
m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch) and if it returns err log
a warning or error (using the existing logger on the manager struct, e.g.
m.logger or m.log) with context including msg.project and msg.res.DefaultBranch;
then call bootstrap.Save(m.sidecar) and likewise log any error returned (or
accumulate/return the error if the calling flow supports it) so disk/permission
failures are visible and do not silently lose resume state.
- Around line 296-304: When handling tui.KeyMsg for ctrl+c in
bsStepBranchPrompt, avoid returning early and leaking the goroutine waiting on
the branch-response channel; instead ensure the branch prompt is resolved before
quitting by invoking the same cleanup that unblocks the waiter (e.g., call
updateBranchPrompt or directly call resolveBranch with an error/ cancellation)
so ans := <-ch in the clone goroutine can receive and exit, then set m.canceled
and return tui.Quit.

In `@internal/cli/migrate.go`:
- Around line 528-534: The code currently appends to m.successes and then
discards errors from m.sidecar.MarkDone and migrate.Save; instead capture their
returned errors (from m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch) and
migrate.Save(m.sidecar)), append any non-nil errors to a collector on the
migrator (e.g., m.sidecarErrors or m.errors) or log them immediately via the
existing logger, and ensure these collected errors are surfaced in the final
summary/report so users know which projects may be out of sync; do this inside
the same success branch that handles msg.project and msg.res to preserve
context.

In `@internal/cli/root.go`:
- Around line 607-617: The JSON decoding of resp.Body into the local user struct
is ignoring errors; change the json.NewDecoder(resp.Body).Decode(&user) call to
capture its error, handle it (return or print a clear error) and avoid printing
an empty user.Login. Specifically, in the same block that prints "Authenticated
as", check the Decode error returned from
json.NewDecoder(resp.Body).Decode(&user), and on error either return that error
(so root command fails) or set a safe fallback string for user.Login and log the
decode failure before printing token/CreatedAt; ensure you reference the
existing user variable and resp.Body and do not swallow the decode error.

In `@internal/cli/sync.go`:
- Around line 404-416: findWorktreePath currently ignores the branch parameter
and always returns the main project path; update the function (findWorktreePath)
to use the branch argument to resolve branch-specific worktrees by checking the
project's worktree mapping (e.g., ws.Projects[project].Worktrees or similar) and
return the branch worktree path when present, falling back to the main proj.Path
when not; also replace the manual string concatenation that builds mainPath with
filepath.Join(workspace, proj.Path) for portability and consistency so
resolveProjectConflict will open the correct directory for branch worktrees.

In `@internal/config/config.go`:
- Around line 596-616: duplicateBranchIssues currently stores only the first
index in seen and then uses prev+1 as a "count", which is wrong; change the
logic to track both the first occurrence index and the total occurrence count
for each branch (e.g., add a separate counts map[string]int or change seen to
map[string]struct{first int; count int}), set first when you see a name the
first time and initialize count=1, increment count on subsequent occurrences,
and when emitting ValidationIssue use the actual count for "has %d entries" and
the stored first index for the "first at index %d" (use those symbols:
duplicateBranchIssues, seen/first/count, prev/ValidationIssue).

---

Nitpick comments:
In `@internal/add/add.go`:
- Around line 268-277: Remove the unused url parameter from inferGroup and
update all call sites to use the new signature; specifically, change func
inferGroup(_ string, cat config.Category) string to func inferGroup(cat
config.Category) string (or alternatively keep the parameter but add a clear
TODO comment if you intend to use it), and ensure callers that currently pass a
URL argument are adjusted to pass only the category. Also verify buildPath
remains unchanged and still accepts group, cat, name.

In `@internal/add/sources.go`:
- Around line 88-93: The code checks errors.Is(err, github.ErrNotAuthed) but
both branches return the same wrapped error, creating redundant logic; simplify
by removing the special-case branch and replace the whole conditional with a
single return that wraps err (e.g., return nil, fmt.Errorf("github source: %w",
err)), keeping the errors.Is/github.ErrNotAuthed check only if you intend to
handle that case differently in functions dealing with err.

In `@internal/add/tui.go`:
- Around line 1-912: File exceeds size threshold; split large tui.go by
extracting the style constants (addTitle, addDim, addHelp, addCursor, addAccent,
addErr, addCheck, addChip, addGroupHdr, addItemName, addExists, addExistsTag,
addPreviewName, addCursorRow) into a new styles.go in package add (or
alternatively extract view functions like viewBrowse, viewEdit, viewConfirm into
tui_views.go). Create the new file with the same package declaration, move the
var block there, run `go vet`/`go build` to ensure no symbol changes, and keep
all references unchanged (e.g., viewBrowse(), viewEdit(),
renderSelectionPreview(), renderItemLine(), buildBrowseRows()) so callers
continue to compile.

In `@internal/aliasmgr/aliasmgr.go`:
- Around line 1-12: The file is too large (>500 LOC); extract cohesive parts
into separate files: move the tree-building logic (buildTree, treeRow and any
helper functions/types they depend on) into a new file (e.g., tree.go) and move
view rendering (viewManage, viewConfirm and their helpers) into another file
(e.g., view.go), keeping them in the same package aliasmgr; update imports and
adjust function/variable visibility (exported vs unexported) so references
remain valid, and ensure any shared helper types/constants remain in a small
shared file or kept at top of aliasmgr.go to avoid import cycles.

In `@internal/auth/auth.go`:
- Around line 138-167: The HTTP call in requestDeviceCode (and other
auth-related requests) uses http.DefaultClient which has no timeout; create a
package-level http client (e.g., var httpClient = &http.Client{Timeout:
30*time.Second}) and replace http.DefaultClient.Do(...) with httpClient.Do(...)
in requestDeviceCode and any other functions in internal/auth/auth.go that make
HTTP requests so calls can't hang indefinitely; ensure you import time if not
already imported.

In `@internal/cli/bootstrap.go`:
- Line 264: The global variable program (var program *tui.Program) is a shared
mutable pointer used by runBootstrap to send messages from goroutines, which
creates implicit coupling; either document the invariant (that runBootstrap sets
program and clears it within its call and no other goroutines access it outside
that window) or refactor to remove the global by adding a program field to the
bootstrap model and passing that model (or the *tui.Program) into any goroutines
that need to send messages; update runBootstrap to set model.program (or accept
the program as an argument) and remove/clear the package-level program variable
to ensure the coupling is explicit and thread-safe.
- Around line 577-615: This file exceeds the 500-line limit and should be split:
extract the style variables (e.g., bsTitleStyle, bsHeaderStyle, bsDimStyle,
bsHelpStyle, bsCheckStyle, bsWarnStyle, bsErrStyle, bsArrowStyle,
bsBarFilledStyle, bsBarEmptyStyle, errorBannerStyle) and related small view
helpers (e.g., renderProgressBar, indent) into a new file such as
bootstrap_styles.go or styles.go, update package-level visibility if needed, and
ensure any callers in bootstrap.go reference the moved symbols; keep behavior
unchanged and run `go vet`/`go test` to verify no import or visibility
regressions.

In `@internal/cli/worktree.go`:
- Around line 140-144: The code currently swallows errors from
git.SetFetchRefspec, git.FetchRefspec, and git.SetBranchUpstream called in the
worktree flow (see HasFetchRefspec, SetFetchRefspec, FetchRefspec,
SetBranchUpstream), which can hide connectivity/permission failures; update
those calls to capture returned errors and either log them at debug/trace level
(via the existing logger) or return the error upstream when the operation is
required for correctness (e.g., if FetchRefspec fails and subsequent operations
depend on the fetch), ensuring each call checks its error and handles it
appropriately rather than discarding it.
- Around line 121-238: The RunE closure in worktree.go has excessive cyclomatic
complexity; extract logical sections into small helper functions to reduce
decision points: implement handleExistingWorktree(projectName, branch, machine,
barePath, ws, saveWorkspace) to contain the existing-worktree branch
re-registration logic (uses locateWorktreeForBranch, p.ClaimBranch,
p.MarkPushed, saveWorkspace), implement createWorktreeFromExisting(barePath,
wtPath, branch, remoteExists, fromBase) to encapsulate the local/remote attach
flow (uses git.WorktreeAdd, git.SetBranchUpstream) and implement
createWorktreeFromBase(barePath, wtPath, branch, base) to handle creating a new
branch from a base (uses proj.DefaultBranch and git.WorktreeAdd); have RunE call
these helpers and handle updating ws.Projects and printing output so the
closure's decision points drop well below 15.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8f726a84-cd18-4829-8a41-b7ea35b9b99c

📥 Commits

Reviewing files that changed from the base of the PR and between 31627ef and 9b07a22.

📒 Files selected for processing (150)
  • AGENTS.md
  • bench/README.md
  • bench/benchfixture/fixture.go
  • bench/benchfixture/reconciler_bench_test.go
  • bench/benchfixture/scan_bench_test.go
  • bench/scripts/run-l2.sh
  • internal/add/add.go
  • internal/add/browse.go
  • internal/add/clipboard.go
  • internal/add/clipboard_test.go
  • internal/add/clone.go
  • internal/add/dedup.go
  • internal/add/disk.go
  • internal/add/edit.go
  • internal/add/format.go
  • internal/add/gather.go
  • internal/add/github_source.go
  • internal/add/manual.go
  • internal/add/msg.go
  • internal/add/options.go
  • internal/add/register.go
  • internal/add/sidecar.go
  • internal/add/sources.go
  • internal/add/styles.go
  • internal/add/suggest.go
  • internal/add/suggestions.go
  • internal/add/tui.go
  • internal/agent/chip_action.go
  • internal/agent/edit_project.go
  • internal/agent/flash.go
  • internal/agent/forms.go
  • internal/agent/header.go
  • internal/agent/items.go
  • internal/agent/lang.go
  • internal/agent/launcher.go
  • internal/agent/list.go
  • internal/agent/model.go
  • internal/agent/persist.go
  • internal/agent/render.go
  • internal/agent/sessions.go
  • internal/agent/source.go
  • internal/agent/stamp.go
  • internal/agent/styles.go
  • internal/agent/tui.go
  • internal/agent/types.go
  • internal/agent/view.go
  • internal/agent/whichkey.go
  • internal/agent/worktrees.go
  • internal/alias/alias.go
  • internal/alias/conflict.go
  • internal/alias/generate.go
  • internal/alias/install.go
  • internal/alias/resolve.go
  • internal/alias/shell_zsh.go
  • internal/aliasmgr/aliasmgr.go
  • internal/aliasmgr/model.go
  • internal/aliasmgr/step_confirm.go
  • internal/auth/auth.go
  • internal/auth/device_flow.go
  • internal/auth/pat.go
  • internal/bootstrap/bootstrap.go
  • internal/bootstrap/sidecar.go
  • internal/branchprompt/branchprompt.go
  • internal/branchprompt/messages.go
  • internal/cli/add.go
  • internal/cli/alias.go
  • internal/cli/auth.go
  • internal/cli/bootstrap.go
  • internal/cli/bootstrap_model.go
  • internal/cli/bootstrap_view.go
  • internal/cli/create.go
  • internal/cli/daemon.go
  • internal/cli/docs.go
  • internal/cli/docs_test.go
  • internal/cli/doctor.go
  • internal/cli/explorer.go
  • internal/cli/favorite.go
  • internal/cli/migrate.go
  • internal/cli/migrate_model.go
  • internal/cli/migrate_tui.go
  • internal/cli/migrate_view.go
  • internal/cli/path.go
  • internal/cli/root.go
  • internal/cli/scan.go
  • internal/cli/setup.go
  • internal/cli/status.go
  • internal/cli/sync.go
  • internal/cli/sync_resolve.go
  • internal/cli/worktree.go
  • internal/cli/worktree_add.go
  • internal/cli/worktree_list.go
  • internal/cli/worktree_push.go
  • internal/cli/worktree_rm.go
  • internal/clipboard/clipboard.go
  • internal/clipboard/clipboard_test.go
  • internal/clone/clone.go
  • internal/config/branch.go
  • internal/config/config.go
  • internal/config/legacy.go
  • internal/config/machine.go
  • internal/config/project.go
  • internal/config/validate.go
  • internal/conflict/conflict.go
  • internal/conflict/notify.go
  • internal/create/cmd.go
  • internal/create/create.go
  • internal/create/gh.go
  • internal/create/options.go
  • internal/create/render.go
  • internal/create/runner.go
  • internal/create/sidecar.go
  • internal/create/tui.go
  • internal/daemon/config.go
  • internal/daemon/conflicts.go
  • internal/daemon/daemon.go
  • internal/daemon/git.go
  • internal/daemon/ipc.go
  • internal/daemon/projects.go
  • internal/daemon/reconciler.go
  • internal/daemon/socket.go
  • internal/daemon/toml.go
  • internal/daemon/watcher.go
  • internal/docs/agent.go
  • internal/docs/schema.go
  • internal/doctor/doctor.go
  • internal/doctor/format.go
  • internal/doctor/project.go
  • internal/doctor/system.go
  • internal/git/bare.go
  • internal/git/clone_test.go
  • internal/git/git.go
  • internal/git/worktree.go
  • internal/github/app_provider.go
  • internal/github/cache.go
  • internal/github/client.go
  • internal/github/gh_client.go
  • internal/github/github.go
  • internal/github/http_client.go
  • internal/github/provider.go
  • internal/github/resolve.go
  • internal/migrate/check.go
  • internal/migrate/git_helpers.go
  • internal/migrate/hooks.go
  • internal/migrate/migrate.go
  • internal/migrate/resolve.go
  • internal/migrate/sidecar.go
  • internal/setup/setup.go
  • internal/setup/step_confirm.go
  • internal/setup/step_group.go
  • internal/setup/step_select.go
💤 Files with no reviewable changes (77)
  • internal/add/options.go
  • internal/agent/styles.go
  • internal/auth/pat.go
  • internal/cli/alias.go
  • internal/clipboard/clipboard_test.go
  • internal/alias/resolve.go
  • internal/add/github_source.go
  • internal/agent/whichkey.go
  • internal/branchprompt/messages.go
  • internal/agent/sessions.go
  • internal/add/manual.go
  • internal/conflict/notify.go
  • internal/agent/persist.go
  • internal/clipboard/clipboard.go
  • internal/cli/migrate_view.go
  • internal/bootstrap/sidecar.go
  • internal/alias/install.go
  • internal/agent/stamp.go
  • internal/clone/clone.go
  • internal/agent/types.go
  • internal/cli/doctor.go
  • internal/agent/source.go
  • internal/create/cmd.go
  • internal/cli/bootstrap_model.go
  • internal/cli/worktree_rm.go
  • internal/agent/chip_action.go
  • internal/cli/create.go
  • internal/alias/shell_zsh.go
  • internal/agent/lang.go
  • internal/alias/conflict.go
  • internal/add/gather.go
  • internal/cli/worktree_list.go
  • internal/config/machine.go
  • internal/alias/generate.go
  • internal/agent/worktrees.go
  • internal/config/legacy.go
  • internal/add/sidecar.go
  • internal/agent/launcher.go
  • internal/agent/header.go
  • internal/cli/auth.go
  • internal/config/branch.go
  • internal/aliasmgr/model.go
  • internal/agent/forms.go
  • internal/agent/list.go
  • internal/cli/favorite.go
  • internal/cli/status.go
  • internal/config/validate.go
  • internal/add/disk.go
  • internal/add/dedup.go
  • internal/add/browse.go
  • internal/cli/worktree_add.go
  • internal/add/msg.go
  • internal/cli/migrate_model.go
  • internal/cli/migrate_tui.go
  • internal/agent/edit_project.go
  • internal/add/suggestions.go
  • internal/agent/items.go
  • internal/add/styles.go
  • internal/auth/device_flow.go
  • internal/add/clone.go
  • internal/cli/setup.go
  • internal/cli/add.go
  • internal/cli/scan.go
  • internal/cli/explorer.go
  • internal/add/edit.go
  • internal/cli/sync_resolve.go
  • internal/cli/worktree_push.go
  • internal/cli/daemon.go
  • internal/add/clipboard.go
  • internal/agent/render.go
  • internal/config/project.go
  • internal/aliasmgr/step_confirm.go
  • internal/cli/path.go
  • internal/cli/bootstrap_view.go
  • internal/add/format.go
  • internal/add/register.go
  • internal/agent/flash.go

Comment on lines +43 to +52
func TestSystemClipboardReader_DeadlineExceeded(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
time.Sleep(10 * time.Millisecond)

_, err := DefaultClipboardReader.Read(ctx)
if err == nil {
t.Fatal("expected error")
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Verify specific error type for deadline exceeded.

The error assertion only checks err != nil without verifying the error type. For consistency with TestSystemClipboardReader_ContextCancelled (which checks for specific error types), this should verify that the error is either context.DeadlineExceeded or ErrClipboardUnavailable. The current weak assertion means the test could pass even if the wrong error is returned, or if the clipboard tool is simply unavailable rather than the deadline being exceeded.

🔍 Proposed fix to verify error type
 	_, err := DefaultClipboardReader.Read(ctx)
 	if err == nil {
 		t.Fatal("expected error")
 	}
+	if !errors.Is(err, ErrClipboardUnavailable) && !errors.Is(err, context.DeadlineExceeded) {
+		t.Errorf("unexpected error: %v", err)
+	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestSystemClipboardReader_DeadlineExceeded(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
time.Sleep(10 * time.Millisecond)
_, err := DefaultClipboardReader.Read(ctx)
if err == nil {
t.Fatal("expected error")
}
}
func TestSystemClipboardReader_DeadlineExceeded(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
time.Sleep(10 * time.Millisecond)
_, err := DefaultClipboardReader.Read(ctx)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrClipboardUnavailable) && !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("unexpected error: %v", err)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/add/clipboard_test.go` around lines 43 - 52, Update
TestSystemClipboardReader_DeadlineExceeded to assert the specific error type
instead of only checking err != nil: call DefaultClipboardReader.Read(ctx) and
use errors.Is to verify the returned err is either context.DeadlineExceeded or
ErrClipboardUnavailable (matching the pattern used in
TestSystemClipboardReader_ContextCancelled). Reference the test function name
TestSystemClipboardReader_DeadlineExceeded and the Read method on
DefaultClipboardReader; replace the weak t.Fatal("expected error") with an
assertion that uses errors.Is(err, context.DeadlineExceeded) || errors.Is(err,
ErrClipboardUnavailable) and fail the test if neither holds.

Comment thread internal/add/sources.go
Comment on lines +251 to +305
func looksLikeGitURL(s string, allowedHosts map[string]bool) bool {
s = strings.TrimSpace(s)

if strings.ContainsAny(s, " \t\n\r") {
return false
}

if m := shorthandRegex.FindStringSubmatch(s); m != nil {
host := strings.ToLower(m[1])

if allowedHosts[host] {
return true
}

return true
}

u, err := url.Parse(s)
if err != nil {
return false
}
switch strings.ToLower(u.Scheme) {
case "https", "http", "ssh", "git":

default:
return false
}
if u.Host == "" {
return false
}

host := strings.ToLower(u.Host)

if i := strings.LastIndex(host, ":"); i >= 0 {
host = host[:i]
}

pathTrimmed := strings.TrimSuffix(u.Path, "/")

if strings.HasSuffix(pathTrimmed, ".git") {
return true
}

if allowedHosts[host] {
if ownerRepoPath.MatchString(pathTrimmed+"/") || ownerRepoPath.MatchString(pathTrimmed) {
return true
}
return false
}

if ownerRepoPath.MatchString(pathTrimmed) || ownerRepoPath.MatchString(pathTrimmed+"/") {
return true
}
return false
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Inconsistent host validation for SSH shorthand URLs.

At lines 258-266, SSH shorthand URLs (user@host:owner/repo) are accepted regardless of whether the host is in allowedHosts. The check at line 261 is performed but line 265 returns true unconditionally. This differs from HTTPS URLs where unknown hosts require a .git suffix or matching path pattern.

If accepting any SSH shorthand is intentional (since SSH access implies user trust), consider adding a brief // DECISION: comment. Otherwise, this may accept clipboard content from untrusted hosts.

🛡️ Suggested fix if strict host checking is desired
 	if m := shorthandRegex.FindStringSubmatch(s); m != nil {
 		host := strings.ToLower(m[1])
-
-		if allowedHosts[host] {
-			return true
-		}
-
-		return true
+		return allowedHosts[host]
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func looksLikeGitURL(s string, allowedHosts map[string]bool) bool {
s = strings.TrimSpace(s)
if strings.ContainsAny(s, " \t\n\r") {
return false
}
if m := shorthandRegex.FindStringSubmatch(s); m != nil {
host := strings.ToLower(m[1])
if allowedHosts[host] {
return true
}
return true
}
u, err := url.Parse(s)
if err != nil {
return false
}
switch strings.ToLower(u.Scheme) {
case "https", "http", "ssh", "git":
default:
return false
}
if u.Host == "" {
return false
}
host := strings.ToLower(u.Host)
if i := strings.LastIndex(host, ":"); i >= 0 {
host = host[:i]
}
pathTrimmed := strings.TrimSuffix(u.Path, "/")
if strings.HasSuffix(pathTrimmed, ".git") {
return true
}
if allowedHosts[host] {
if ownerRepoPath.MatchString(pathTrimmed+"/") || ownerRepoPath.MatchString(pathTrimmed) {
return true
}
return false
}
if ownerRepoPath.MatchString(pathTrimmed) || ownerRepoPath.MatchString(pathTrimmed+"/") {
return true
}
return false
}
func looksLikeGitURL(s string, allowedHosts map[string]bool) bool {
s = strings.TrimSpace(s)
if strings.ContainsAny(s, " \t\n\r") {
return false
}
if m := shorthandRegex.FindStringSubmatch(s); m != nil {
host := strings.ToLower(m[1])
return allowedHosts[host]
}
u, err := url.Parse(s)
if err != nil {
return false
}
switch strings.ToLower(u.Scheme) {
case "https", "http", "ssh", "git":
default:
return false
}
if u.Host == "" {
return false
}
host := strings.ToLower(u.Host)
if i := strings.LastIndex(host, ":"); i >= 0 {
host = host[:i]
}
pathTrimmed := strings.TrimSuffix(u.Path, "/")
if strings.HasSuffix(pathTrimmed, ".git") {
return true
}
if allowedHosts[host] {
if ownerRepoPath.MatchString(pathTrimmed+"/") || ownerRepoPath.MatchString(pathTrimmed) {
return true
}
return false
}
if ownerRepoPath.MatchString(pathTrimmed) || ownerRepoPath.MatchString(pathTrimmed+"/") {
return true
}
return false
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/add/sources.go` around lines 251 - 305, The shorthand SSH branch in
looksLikeGitURL currently accepts any SSH shorthand because the shorthandRegex
block returns true unconditionally; update that block so it only returns true
when the extracted host is allowed (i.e., keep the existing if
allowedHosts[host] { return true } and remove the unconditional return true), or
if accepting all SSH shorthands is intentional add a short DECISION comment
above the shorthandRegex handling describing that choice and why; ensure you
modify only the shorthandRegex branch (the code that extracts host via m :=
shorthandRegex.FindStringSubmatch(s)) and leave the HTTPS/URL logic unchanged.

Comment thread internal/agent/tui.go
Comment on lines 1 to 19
package agent

import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"

"github.com/kuchmenko/workspace/internal/config"
"github.com/kuchmenko/workspace/internal/git"
"github.com/kuchmenko/workspace/internal/layout"
"github.com/kuchmenko/workspace/internal/tui"
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

File exceeds 800 LOC limit and must be split immediately.

This file is 1,717 lines, well beyond the 800-line threshold. Consider extracting into separate files by concern:

  • worktree.go: Worktree, WorktreeResult, WorktreeCache, CreateWorktree, DeleteWorktreeWithRegistry, LoadWorktrees (lines 541-753)
  • session.go: Session, SessionCache, LoadSessions, FindSession, extractTitle (lines 755-941)
  • launch.go: LaunchClaude, LaunchShell (lines 1685-1717)
  • edit.go: Edit project UI handlers (lines 1126-1368)

As per coding guidelines: "Split .go files larger than 500 lines on the next touch, and extract immediately if beyond 800 lines"

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/tui.go` around lines 1 - 19, The file internal/agent/tui.go is
far too large (1,717 lines) and must be split; refactor by extracting related
types and functions into separate files: move Worktree, WorktreeResult,
WorktreeCache, CreateWorktree, DeleteWorktreeWithRegistry, and LoadWorktrees
into a new worktree.go; move Session, SessionCache, LoadSessions, FindSession,
and extractTitle into session.go; move LaunchClaude and LaunchShell into
launch.go; move the Edit project UI handlers (the functions handling edit UI
logic referenced around lines where edit functions appear) into edit.go; ensure
you update package-level imports and any unexported helper references (keep
names and signatures unchanged) and run go vet/go build to fix any missing
references.

Comment thread internal/agent/tui.go
Comment on lines +865 to +867
func encodeCwd(path string) string {
return strings.ReplaceAll(path, "/", "-")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if Claude uses slash-to-hyphen encoding for project directories
if [ -d "$HOME/.claude/projects" ]; then
  echo "Claude projects directory contents:"
  ls -la "$HOME/.claude/projects" | head -20
  echo ""
  echo "Sample directory names that might reveal encoding scheme:"
  ls "$HOME/.claude/projects" 2>/dev/null | head -5
fi

Repository: kuchmenko/workspace

Length of output: 45


🏁 Script executed:

# First, check file size and read the relevant sections
wc -l internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 90


🏁 Script executed:

# Read around line 865-867 (encodeCwd function)
sed -n '860,875p' internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 366


🏁 Script executed:

# Read around line 903 to see the decoding logic
sed -n '898,910p' internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 299


🏁 Script executed:

# Search for all encodeCwd and decodeCwd usages to understand the full context
rg -n "encodeCwd|decodeCwd" internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 134


🏁 Script executed:

# Read around line 770 where encodeCwd is used
sed -n '765,780p' internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 313


🏁 Script executed:

# Search for decodeCwd function (might exist but not be used)
rg -n "decodeCwd|func decode" internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 45


🏁 Script executed:

# Look at the broader FindSession function context
sed -n '880,920p' internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 762


🏁 Script executed:

# Search for pathLookup usage after line 770
sed -n '770,850p' internal/agent/tui.go | grep -n "pathLookup"

Repository: kuchmenko/workspace

Length of output: 137


🏁 Script executed:

# Check the SaveSession function to see if it uses encodeCwd
rg -n "SaveSession" internal/agent/tui.go -A 30

Repository: kuchmenko/workspace

Length of output: 45


🏁 Script executed:

# Find where sessions are created or written to disk
rg -n "Session{" internal/agent/tui.go -B 3 -A 3

Repository: kuchmenko/workspace

Length of output: 143


🏁 Script executed:

# Look for the full context around pathLookup usage (lines 770-800)
sed -n '770,800p' internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 656


🏁 Script executed:

# Find where directories are created in claudeRoot
rg -n "mkdir|MkdirAll" internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 45


🏁 Script executed:

# Look for where session directories are written
rg -n "claudeRoot\|claudeProjectsDir" internal/agent/tui.go -A 2 | head -40

Repository: kuchmenko/workspace

Length of output: 45


🏁 Script executed:

# Get more context on how encoded paths get written to disk
sed -n '750,800p' internal/agent/tui.go

Repository: kuchmenko/workspace

Length of output: 997


Replace lossy path encoding with reversible encoding.

The encoding strings.ReplaceAll(path, "/", "-") is not reversible for paths containing hyphens. Paths /foo/bar and /foo-bar both encode to foo-bar, causing:

  • Session directories to collide in ~/.claude/projects
  • FindSession to return incorrect sessions when decoding
  • LoadSessions to fail lookups for hyphenated paths

Use base64 or percent-encoding instead:

Proposed fix
+import (
+	"encoding/base64"
+)
+
 func encodeCwd(path string) string {
-	return strings.ReplaceAll(path, "/", "-")
+	return base64.URLEncoding.EncodeToString([]byte(path))
 }
+
+func decodeCwd(encoded string) (string, error) {
+	b, err := base64.URLEncoding.DecodeString(encoded)
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+}

Update FindSession (line 903):

-		cwd := strings.ReplaceAll(entry.Name(), "-", "/")
+		cwd, err := decodeCwd(entry.Name())
+		if err != nil {
+			continue
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/tui.go` around lines 865 - 867, The current encodeCwd function
uses strings.ReplaceAll(path, "/", "-"), which is lossy; replace it with a
reversible encoding (e.g., base64 RawURLEncoding or url.PathEscape) so encoded
names are unique and reversible. Update encodeCwd to return a reversible
encoding (suggest base64.RawURLEncoding.EncodeToString([]byte(path)) or
url.PathEscape(path)), and update all places that decode or parse these
names—specifically FindSession and LoadSessions—to decode using the matching
decoder (base64.RawURLEncoding.DecodeString or url.PathUnescape) and use the
decoded path for comparisons and session directory lookup so hyphenated paths no
longer collide. Ensure session directory naming and any lookup logic
consistently use the new encoder/decoder pair.

Comment thread internal/agent/view.go
Comment on lines +488 to +503
func formatInt(n int) string {
if n == 0 {
return "0"
}
if n < 0 {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

formatInt silently discards the sign of negative numbers.

If a negative value is ever passed, this returns the absolute value without a minus sign, which could cause subtle display bugs.

Proposed fix
 func formatInt(n int) string {
 	if n == 0 {
 		return "0"
 	}
+	neg := n < 0
 	if n < 0 {
 		n = -n
 	}
 	var buf [20]byte
 	i := len(buf)
 	for n > 0 {
 		i--
 		buf[i] = byte('0' + n%10)
 		n /= 10
 	}
+	if neg {
+		i--
+		buf[i] = '-'
+	}
 	return string(buf[i:])
 }

Alternatively, if negative values are never expected, consider using uint or adding a panic/assertion for defensive clarity.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func formatInt(n int) string {
if n == 0 {
return "0"
}
if n < 0 {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
func formatInt(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if n < 0 {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/view.go` around lines 488 - 503, formatInt currently discards
the sign for negative inputs; update formatInt to preserve and emit a leading
'-' for negative n by recording sign (e.g., neg := n < 0), converting n to its
absolute value for digit extraction, and then prefixing '-' to the returned
string when neg is true; alternatively, if negatives are truly invalid, change
the API to take a unsigned type or add an assertion/panic in formatInt to fail
fast—but the quickest fix is to detect negative n and include the minus sign in
the returned string from formatInt.

Comment thread internal/cli/bootstrap.go
Comment on lines +397 to +400
if msg.res != nil {
_ = m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch)
_ = bootstrap.Save(m.sidecar)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ignored errors from MarkDone and Save could silently lose progress.

Both MarkDone and Save errors are discarded. If the sidecar file cannot be written (e.g., disk full, permissions), the user loses crash-safe resume capability without any indication.

🛡️ Proposed fix to log or accumulate warnings
 		if msg.res != nil {
-			_ = m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch)
-			_ = bootstrap.Save(m.sidecar)
+			if err := m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch); err != nil {
+				// Log or accumulate warning; sidecar is best-effort
+			}
+			if err := bootstrap.Save(m.sidecar); err != nil {
+				// Log or accumulate warning
+			}
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/bootstrap.go` around lines 397 - 400, The code currently
discards errors from m.sidecar.MarkDone(...) and bootstrap.Save(m.sidecar);
capture both return values and handle non-nil errors instead of ignoring them:
call m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch) and if it returns
err log a warning or error (using the existing logger on the manager struct,
e.g. m.logger or m.log) with context including msg.project and
msg.res.DefaultBranch; then call bootstrap.Save(m.sidecar) and likewise log any
error returned (or accumulate/return the error if the calling flow supports it)
so disk/permission failures are visible and do not silently lose resume state.

Comment thread internal/cli/migrate.go
Comment on lines +528 to +534
} else {
m.successes = append(m.successes, msg.project)
if msg.res != nil {
_ = m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch)
_ = migrate.Save(m.sidecar)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Silently ignoring sidecar save errors could lose migration state.

If MarkDone or Save fails, the user won't know the sidecar is out of sync. On TUI crash or unexpected exit, resume won't accurately reflect which projects completed.

Consider accumulating these errors to display in the summary, or at minimum logging them:

 		} else {
 			m.successes = append(m.successes, msg.project)
 			if msg.res != nil {
-				_ = m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch)
-				_ = migrate.Save(m.sidecar)
+				if err := m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch); err != nil {
+					m.errors = append(m.errors, migrateError{project: msg.project + " (sidecar)", err: err})
+				} else if err := migrate.Save(m.sidecar); err != nil {
+					m.errors = append(m.errors, migrateError{project: "<sidecar>", err: err})
+				}
 			}
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
m.successes = append(m.successes, msg.project)
if msg.res != nil {
_ = m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch)
_ = migrate.Save(m.sidecar)
}
}
} else {
m.successes = append(m.successes, msg.project)
if msg.res != nil {
if err := m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch); err != nil {
m.errors = append(m.errors, migrateError{project: msg.project + " (sidecar)", err: err})
} else if err := migrate.Save(m.sidecar); err != nil {
m.errors = append(m.errors, migrateError{project: "<sidecar>", err: err})
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/migrate.go` around lines 528 - 534, The code currently appends
to m.successes and then discards errors from m.sidecar.MarkDone and
migrate.Save; instead capture their returned errors (from
m.sidecar.MarkDone(msg.project, msg.res.DefaultBranch) and
migrate.Save(m.sidecar)), append any non-nil errors to a collector on the
migrator (e.g., m.sidecarErrors or m.errors) or log them immediately via the
existing logger, and ensure these collected errors are surfaced in the final
summary/report so users know which projects may be out of sync; do this inside
the same success branch that handles msg.project and msg.res to preserve
context.

Comment thread internal/cli/root.go
Comment on lines +607 to +617
var user struct {
Login string `json:"login"`
}
json.NewDecoder(resp.Body).Decode(&user)

path, _ := auth.TokenPath()
fmt.Printf(" Authenticated as: %s\n", user.Login)
fmt.Printf(" Token: %s\n", path)
fmt.Printf(" Scopes: %s\n", token.Scope)
fmt.Printf(" Created: %s\n", token.CreatedAt.Format("2006-01-02 15:04"))
return nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

JSON decode error ignored could produce confusing output.

If Decode fails, user.Login will be empty, resulting in output like "Authenticated as: " with no username.

Suggested fix
-			json.NewDecoder(resp.Body).Decode(&user)
-
-			path, _ := auth.TokenPath()
-			fmt.Printf("  Authenticated as: %s\n", user.Login)
+			if err := json.NewDecoder(resp.Body).Decode(&user); err != nil || user.Login == "" {
+				fmt.Println("  Token valid but could not read username from API response.")
+			} else {
+				fmt.Printf("  Authenticated as: %s\n", user.Login)
+			}
+			path, _ := auth.TokenPath()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/root.go` around lines 607 - 617, The JSON decoding of resp.Body
into the local user struct is ignoring errors; change the
json.NewDecoder(resp.Body).Decode(&user) call to capture its error, handle it
(return or print a clear error) and avoid printing an empty user.Login.
Specifically, in the same block that prints "Authenticated as", check the Decode
error returned from json.NewDecoder(resp.Body).Decode(&user), and on error
either return that error (so root command fails) or set a safe fallback string
for user.Login and log the decode failure before printing token/CreatedAt;
ensure you reference the existing user variable and resp.Body and do not swallow
the decode error.

Comment thread internal/cli/sync.go
Comment on lines +404 to +416
func findWorktreePath(workspace, project, branch string) (string, error) {
if ws == nil {
return "", fmt.Errorf("workspace not loaded")
}
proj, ok := ws.Projects[project]
if !ok {
return "", fmt.Errorf("project %s not in workspace.toml", project)
}
mainPath := workspace + string(os.PathSeparator) + proj.Path

_ = branch
return mainPath, nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bug: branch parameter is ignored, breaking branch worktree resolution.

findWorktreePath accepts a branch parameter but explicitly discards it on line 414. The function always returns mainPath, so resolveProjectConflict cannot resolve divergence conflicts on branch worktrees—it will open a shell in the wrong directory.

Additionally, line 412 uses string concatenation for path construction instead of filepath.Join, which is inconsistent with line 250 and less portable.

🐛 Proposed fix to use branch for worktree lookup
 func findWorktreePath(workspace, project, branch string) (string, error) {
 	if ws == nil {
 		return "", fmt.Errorf("workspace not loaded")
 	}
 	proj, ok := ws.Projects[project]
 	if !ok {
 		return "", fmt.Errorf("project %s not in workspace.toml", project)
 	}
-	mainPath := workspace + string(os.PathSeparator) + proj.Path
-
-	_ = branch
-	return mainPath, nil
+	mainPath := filepath.Join(workspace, proj.Path)
+	if branch == "" || branch == "main" || branch == "master" {
+		return mainPath, nil
+	}
+	barePath := layout.BarePath(mainPath)
+	wtPath := locateWorktreeForBranch(barePath, branch)
+	if wtPath == "" {
+		return "", fmt.Errorf("no worktree found for branch %s", branch)
+	}
+	return wtPath, nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/sync.go` around lines 404 - 416, findWorktreePath currently
ignores the branch parameter and always returns the main project path; update
the function (findWorktreePath) to use the branch argument to resolve
branch-specific worktrees by checking the project's worktree mapping (e.g.,
ws.Projects[project].Worktrees or similar) and return the branch worktree path
when present, falling back to the main proj.Path when not; also replace the
manual string concatenation that builds mainPath with filepath.Join(workspace,
proj.Path) for portability and consistency so resolveProjectConflict will open
the correct directory for branch worktrees.

Comment thread internal/config/config.go
Comment on lines +596 to +616
func duplicateBranchIssues(projName string, branches []BranchMeta) []ValidationIssue {
seen := make(map[string]int, len(branches))
var out []ValidationIssue
for _, b := range branches {
if b.Name == "" {
continue
}
prev, isDup := seen[b.Name]
if !isDup {
seen[b.Name] = len(seen)
continue
}
out = append(out, ValidationIssue{
Kind: ValidationDuplicateBranch,
Project: projName,
Branch: b.Name,
Detail: fmt.Sprintf("branch %q has %d entries (first at index %d)", b.Name, prev+1, prev),
})
}
return out
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Incorrect count in validation detail message.

The prev+1 value represents the 1-indexed position of the first occurrence, not the total count of duplicate entries. When a duplicate is found, the message "branch %q has %d entries" will incorrectly report the position as a count.

For example, with branches ["beta", "alpha", "gamma", "alpha"], the message for "alpha" would say "has 2 entries" (because prev=1, so prev+1=2), but this happens to be coincidentally correct only because the first occurrence was at index 1.

Proposed fix
 func duplicateBranchIssues(projName string, branches []BranchMeta) []ValidationIssue {
-	seen := make(map[string]int, len(branches))
+	seen := make(map[string]int, len(branches))   // stores first-seen index
+	count := make(map[string]int, len(branches))  // stores occurrence count
 	var out []ValidationIssue
 	for _, b := range branches {
 		if b.Name == "" {
 			continue
 		}
-		prev, isDup := seen[b.Name]
-		if !isDup {
-			seen[b.Name] = len(seen)
+		firstIdx, wasSeen := seen[b.Name]
+		count[b.Name]++
+		if !wasSeen {
+			seen[b.Name] = len(seen)
 			continue
 		}
 		out = append(out, ValidationIssue{
 			Kind:    ValidationDuplicateBranch,
 			Project: projName,
 			Branch:  b.Name,
-			Detail:  fmt.Sprintf("branch %q has %d entries (first at index %d)", b.Name, prev+1, prev),
+			Detail:  fmt.Sprintf("branch %q appears %d times (first at index %d)", b.Name, count[b.Name], firstIdx),
 		})
 	}
 	return out
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func duplicateBranchIssues(projName string, branches []BranchMeta) []ValidationIssue {
seen := make(map[string]int, len(branches))
var out []ValidationIssue
for _, b := range branches {
if b.Name == "" {
continue
}
prev, isDup := seen[b.Name]
if !isDup {
seen[b.Name] = len(seen)
continue
}
out = append(out, ValidationIssue{
Kind: ValidationDuplicateBranch,
Project: projName,
Branch: b.Name,
Detail: fmt.Sprintf("branch %q has %d entries (first at index %d)", b.Name, prev+1, prev),
})
}
return out
}
func duplicateBranchIssues(projName string, branches []BranchMeta) []ValidationIssue {
seen := make(map[string]int, len(branches))
count := make(map[string]int, len(branches))
var out []ValidationIssue
for _, b := range branches {
if b.Name == "" {
continue
}
firstIdx, wasSeen := seen[b.Name]
count[b.Name]++
if !wasSeen {
seen[b.Name] = len(seen)
continue
}
out = append(out, ValidationIssue{
Kind: ValidationDuplicateBranch,
Project: projName,
Branch: b.Name,
Detail: fmt.Sprintf("branch %q appears %d times (first at index %d)", b.Name, count[b.Name], firstIdx),
})
}
return out
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/config.go` around lines 596 - 616, duplicateBranchIssues
currently stores only the first index in seen and then uses prev+1 as a "count",
which is wrong; change the logic to track both the first occurrence index and
the total occurrence count for each branch (e.g., add a separate counts
map[string]int or change seen to map[string]struct{first int; count int}), set
first when you see a name the first time and initialize count=1, increment count
on subsequent occurrences, and when emitting ValidationIssue use the actual
count for "has %d entries" and the stored first index for the "first at index
%d" (use those symbols: duplicateBranchIssues, seen/first/count,
prev/ValidationIssue).

Criterion: a file earns its size if it reads top-to-bottom as one
narrative. Not by LOC. Big is fine when there's a through-line; small
is wrong when the next thing you need is in another file.

By that yardstick most of the 150 files were small *and* wrong — split
per-view-mode, per-syscall, per-subcommand. Mechanical consolidation,
no behavior change.

Results:
  150 -> 45 production .go files (-70%)
  25  -> 21 folders under internal/
  20,468 -> 19,592 LOC (dedup'd imports)
  binary size unchanged (14 MB)
  zero charmbracelet imports outside internal/tui/ (PR #49 seam intact)

Major package collapses:
  cli     28 -> 5  (root + bootstrap + migrate + sync + worktree + docs)
  agent   19 -> 3  (model -> view -> tui)
  add     18 -> 4  (add -> sources -> suggest -> tui)
  daemon  10 -> 3  (daemon -> reconciler -> ipc)
  create   8 -> 2  (create -> tui)

Ten small packages each collapse to one file at internal/<pkg>/<pkg>.go:
alias, aliasmgr, auth, bootstrap, config, doctor, git, github, migrate,
setup.

Four folders deleted: clipboard folds into add, clone folds into git,
benchfixture moves to bench/, docs folds into cli.

Two intentional outliers: cli/root.go (1,727 LOC) and agent/tui.go
(1,774 LOC) both walk top-to-bottom — root.go is 13 declarative Cobra
builders in a row; agent/tui.go is state -> update -> panels. Splitting
either would cut a sentence in half.

Queued, not in this PR: bubbletea eviction (drops internal/tui/ 10 -> ~3)
and a few cli-only folds (aliasmgr, setup, doctor, create, bootstrap,
migrate could fold into cli). Both deferred so this PR stays a pure
mechanical shrink.
@kuchmenko kuchmenko force-pushed the refactor/file-count-shrink branch from 9b07a22 to 234cf5d Compare May 19, 2026 13:49
@kuchmenko kuchmenko merged commit ccc1b25 into main May 19, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant