feat(config): add ${BRANCH} variable expansion for per-branch remote dirs#176
feat(config): add ${BRANCH} variable expansion for per-branch remote dirs#176tim4net wants to merge 10 commits intorileyhilliard:mainfrom
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR introduces branch-aware directory expansion and a new Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant CLI as CLI clean command
participant Config as Config loader
participant Host as SSH host
participant Clean as clean module
User->>CLI: Execute clean command
CLI->>Config: Load global config
Config-->>CLI: Return host configs with DirTemplate
CLI->>CLI: Select target hosts and get local branches
loop For each host
CLI->>Host: Connect via SSH
Host-->>CLI: SSH connection established
CLI->>Clean: Discover(executor, DirTemplate, activeBranches)
Clean->>Clean: ExpandRemoteGlob(DirTemplate) → glob pattern
Clean->>Host: Execute ls -d glob pattern
Host-->>Clean: List of directories on remote
Clean->>Clean: Extract branch name from each path
Clean->>Clean: Filter out active branches
Clean-->>CLI: Return list of StaleDir (path, branch, size)
CLI->>CLI: Display per-host stale dirs
end
CLI->>User: Show summary and request confirmation
User-->>CLI: Confirm removal
loop For each host (again)
CLI->>Host: Reconnect via SSH
Host-->>CLI: SSH connection re-established
CLI->>Clean: Remove(executor, DirTemplate, staleDirs)
Clean->>Clean: Validate each target path
Clean->>Host: Execute rm -rf on validated paths
Host-->>Clean: Deletion complete (or error)
Clean-->>CLI: Return removed paths and errors
end
CLI->>User: Report removal results and statistics
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Hey, thanks for this. The implementation is clean and follows the existing variable expansion patterns well. Before merging though, I want to talk through a few product-level concerns to better understand the problem this is solving. For what it's worth, I regularly work with multiple Is the pain point what we think it is?
If the real cost is npm install running again on the remote because package-lock.json changed, per-worktree directories technically solve that, but at a steep tradeoff(more on that below). If it's the rsync itself, the sync:
preserve:
- node_modules/
- .venv/
- data/
- .cache/
- build/ # add whatever you want preserved between runs here
- my-folder/It would be helpful to see some actual numbers here: how long does an incremental sync take when switching worktrees for you today? That'd help us figure out if the juice is worth the squeeze. For me, the overhead is around ~1000ms when preserve is being properly leveraged. A hypothesis I have: the first sync to a new branch is more expensive than the problem There's a bit of a paradox. The first time you sync to a new So the feature trades one-time incremental re-sync for guaranteed full-sync on every newly created branch. Directory proliferation with no cleanup storyThis is my main concern. If There's no cleanup mechanism in The existing If the issue is
|
|
Hey Riley, thanks for the detailed review. I think there's a core misunderstanding about the use case that changes the analysis on most of these points. The problem: concurrent worktrees, not sequential branch switchingI'm not switching between branches one at a time. I have multiple Without Concurrent syncs stomp each other. Running Every context switch is a large delta. Going back and forth between worktrees that touch different parts of a large codebase means rsync recomputes and transfers the full source diff each time. This is a source file problem, not a dependency problem. Why
|
|
Hey, thanks for the response and the thorough context. That helped me better understand the issue you're describing. I want to share some context on how rr's execution model was designed, because after digging deeper into the code I think the lock system already handles the core scenario you're running into, which makes the main argument here mostly about sync performance. The lock already protects
|
Update:
|
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/config/validate.go (1)
241-252:⚠️ Potential issue | 🔴 Critical
validateRemotePathrejects${BRANCH},${PROJECT}, and other documented variables before they can be expanded.The feature documents that Dir supports
${PROJECT},${USER},${HOME},${BRANCH}, and~, but the validation flow loads raw config from disk without expanding variables, then immediately validates it.validateRemotePathrejects any path containing"${", so users cannot writedir: ~/rr/${PROJECT}-${BRANCH}in their config—validation fails before the expand functions are ever called.The
ExpandHost()function exists but is never invoked anywhere in the codebase. Either expand variables before callingValidateResolved(), or modifyvalidateRemotePathto allowlist the documented variables (${PROJECT},${USER},${HOME},${BRANCH}).
🤖 Fix all issues with AI agents
In `@internal/clean/clean.go`:
- Around line 154-173: shellQuoteGlob currently skips empty segments which drops
leading/trailing '*' characters; update shellQuoteGlob so it does not skip empty
segments: iterate over segments := strings.Split(path, "*") and for each segment
call util.ShellQuotePreserveTilde on non-empty segs (or append an empty string
for empty segs) and always append '*' between segments (except after the last)
so leading/trailing/multiple '*' are preserved; keep function name
shellQuoteGlob and util.ShellQuotePreserveTilde references to locate the change.
- Around line 137-149: getDiskUsage currently treats empty du stdout as a valid
value because SplitN on an empty string yields [""], causing it to return an
empty string instead of "?" — modify getDiskUsage to first check whether the
trimmed output is non-empty and only then split and return parts[0]; if output
is empty or parts[0] is empty, return "?" instead. Locate the function
getDiskUsage and change the logic after executor.Exec (or add an early guard) so
you use strings.TrimSpace(stdout) to determine emptiness before extracting and
returning the size field.
- Around line 106-135: The listRemoteDirs function treats any non-zero exit from
the remote ls command as an error, which misreports the expected "no matches"
case as failure; change the error handling after executor.Exec in listRemoteDirs
so that if exitCode != 0 but stdout is empty (strings.TrimSpace(string(stdout))
== "") and stderr is empty, return an empty []remoteDir, nil (i.e., no matches)
instead of an error; otherwise preserve the existing logic that constructs msg
from stderr (or exit code) and returns an error. Ensure this check occurs before
splitting stdout and building remoteDir entries.
🧹 Nitpick comments (8)
internal/config/expand_test.go (1)
222-235:TestListLocalBrancheswill fail outside a git repository (e.g., tarball-based CI builds).This test calls
git branchand assertsNotEmpty. If the test runner checks out as an archive or shallow clone without branches, or runs outside a git repo, it will fail. Consider skipping witht.Skipwhengitis unavailable or when not inside a repo.Proposed guard
func TestListLocalBranches(t *testing.T) { + // Skip if not in a git repo (e.g., tarball-based CI builds) + if err := exec.Command("git", "rev-parse", "--git-dir").Run(); err != nil { + t.Skip("not inside a git repository") + } branches, err := ListLocalBranches()internal/config/expand.go (2)
208-222:getBranch()spawns a git subprocess on every call — consider caching.
ExpandandExpandRemoteboth callgetBranch()which execsgit rev-parse. In workflows where expansion is called multiple times (e.g., multi-host clean), this spawns redundant processes. Async.Onceor package-level lazy cache would eliminate the repeated overhead.
318-332:sanitizeBranchallocates a newstrings.NewReplacerper call.This is fine functionally, but since it's called per-branch in
ListLocalBranchesand can be called frequently, promoting the replacer to a package-levelvaravoids repeated allocation.Optional refactor
+var branchSanitizer = strings.NewReplacer( + "/", "-", + "\\", "-", + ":", "-", + "*", "-", + "?", "-", + "\"", "-", + "<", "-", + ">", "-", + "|", "-", +) + func sanitizeBranch(branch string) string { - replacer := strings.NewReplacer( - "/", "-", - "\\", "-", - ":", "-", - "*", "-", - "?", "-", - "\"", "-", - "<", "-", - ">", "-", - "|", "-", - ) - return replacer.Replace(branch) + return branchSanitizer.Replace(branch) }internal/clean/clean.go (1)
71-90:Remove— error message loses stderr when botherrandstderrare present.Line 80-83: if
err != nil,errMsgis overwritten witherr.Error(), discarding any stderr content. Consider combining both.internal/clean/clean_test.go (1)
37-44: Mock dispatch uses non-deterministic map iteration — safe here, but fragile if prefixes overlap.The
for prefix, resp := range m.responsesiteration order is random in Go. If you later add overlapping prefixes (e.g.,"rm"and"rm -rf"), the wrong handler may match. Current test prefixes are distinct, so this is fine.internal/cli/clean.go (3)
74-91: Usedefer conn.Close()to avoid leaking connections ifDiscoverpanics.Currently
conn.Close()is called inline at line 86. Ifclean.Discoverpanics (however unlikely), the connection leaks. Adeferis the idiomatic pattern.Suggested change
conn, err := connectToHost(hostCfg, probeTimeout) if err != nil { spinner.Fail() fmt.Printf(" %s Could not connect: %v\n", ui.SymbolWarning, err) continue } + defer conn.Close() staleDirs, err := clean.Discover(conn.Client, hostCfg.Dir, localBranches) - conn.Close() if err != nil {Note: since this is in a loop, you may want to extract the body into a closure or helper to scope the
defercorrectly per iteration.
161-183: Samedefer conn.Close()pattern applies to the removal loop.Line 176 has the same inline
conn.Close()concern. Additionally, ifclean.Removepanics, the connection leaks.
228-242:connectToHosterror could list which aliases were tried.The generic
"all SSH aliases unreachable"message gives no debugging context. Including the aliases (e.g.,fmt.Errorf("all SSH aliases unreachable: %v", hostCfg.SSH)) would help users diagnose connection issues.
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@internal/clean/clean.go`:
- Around line 25-30: Remove the unused exported HostResult struct to shrink the
public API: delete the HostResult type declaration from internal/clean/clean.go
(the struct with fields HostName, StaleDirs, Error). Verify that Discover()
(which returns ([]StaleDir, error)) and cli/clean.go (which uses
hostStaleResult) do not reference HostResult and run tests/build to ensure no
remaining references exist.
In `@internal/cli/clean.go`:
- Around line 79-95: Replace the fragile string-based error classification
around the connectToHost/clean.Discover call: remove the
strings.Contains(err.Error(), "connect:") branch and simply treat any returned
err from the anonymous discovery function as a discovery failure — call
spinner.Fail() and print a single message (e.g., using ui.SymbolWarning and
"Discovery failed: %v") that includes err; alternatively if you want to keep
classification, make connectToHost return a sentinel error (e.g., ErrConnect)
and check errors.Is(err, ErrConnect) instead of string matching.
In `@internal/config/expand_test.go`:
- Around line 223-241: In TestListLocalBranches, replace the non-fatal check
assert.NoError(t, err) with a fatal check require.NoError(t, err) so the test
aborts immediately when ListLocalBranches returns an error; update the imports
to include "github.com/stretchr/testify/require" and keep the subsequent
assert.NotEmpty and branch-sanitization loop as-is so they only run when
branches is non-nil.
In `@internal/config/loader.go`:
- Around line 135-141: SaveGlobal is persisting expanded host directories
instead of the original templates; before writing the cfg.Hosts map in
SaveGlobal, iterate the hosts (cfg.Hosts), and for each host entry (use the same
host struct reference as in LoadGlobal), restore the template by setting h.Dir =
h.DirTemplate and assign back to cfg.Hosts[name]; then proceed with the existing
save logic so the persisted file keeps the raw DirTemplate values rather than
expanded snapshots.
🧹 Nitpick comments (7)
internal/config/expand.go (2)
314-329: Unwrapped error fromListLocalBranches.The raw error from
exec.Commandis returned without context. If this surfaces to the user (e.g., viarr clean), the message will be opaque.Proposed fix
func ListLocalBranches() ([]string, error) { cmd := exec.Command("git", "branch", "--format=%(refname:short)") out, err := cmd.Output() if err != nil { - return nil, err + return nil, fmt.Errorf("failed to list local git branches: %w", err) }(Or use
errors.WrapWithCodefrominternal/errorsif you want the structured error.)As per coding guidelines, "Ensure errors are wrapped with context using the internal/errors package".
210-235:sync.Oncecaching prevents testability and could surprise long-running processes.The branch is cached for the process lifetime. This is fine for CLI invocations, but be aware:
- Tests that call
getBranch()(or any function using it) will be stuck with the first result for the entire test suite run. If tests need different branch values, they'll silently get stale data.- If
rrever becomes a daemon/watcher, cached branch won't update on checkout.For now this is acceptable given the CLI model, but consider exposing a
resetBranchCache()(test-only, unexported) if you run into flaky tests later.internal/host/selector.go (1)
347-349: Minor: double map lookup in the loop body.
s.hosts[name]is looked up twice on lines 348-349. Assigning to a local variable (like done inHostInfoat line 528) would be cleaner and consistent within this file.Suggested fix
for name := range s.hosts { - if hasTag(s.hosts[name].Tags, tag) { - matchingHosts[name] = s.hosts[name] + h := s.hosts[name] + if hasTag(h.Tags, tag) { + matchingHosts[name] = h }internal/cli/host.go (1)
95-97: Non-deterministic selection of default remote dir.Iterating
range cfg.Hostswithbreakon first match gives a random host'sDiras the default, since Go map iteration order is undefined. This was true before this change too, so not a regression — just worth noting if consistency matters.internal/clean/clean.go (1)
98-105:isDangerousPathcould be more robust.Currently only blocks
"","/","~", and"~/". Paths like/home,/tmp, or/varwould pass through. Consider also rejecting paths with fewer than ~3 components or checking against a denylist of common system directories. Not critical since inputs come from config-derived templates, but worth hardening for defense-in-depth.internal/cli/clean.go (2)
174-191: Nil-error slice inflatestotalFailedcount on reconnection failure.When
connectToHostfails (line 176),make([]error, len(r.stale))creates a slice ofnilerrors.totalFailed += len(errs)on line 185 counts all of them, but the loop at lines 186-190 skips nil errors and prints nothing. The count is correct in intent (those dirs weren't removed), but the mechanism is misleading for anyone reading this code.Clearer approach
removed, errs := func() ([]string, []error) { conn, err := connectToHost(r.hostCfg, probeTimeout) if err != nil { fmt.Printf("%s %s: reconnection failed: %v\n", ui.SymbolFail, r.hostName, err) - return nil, make([]error, len(r.stale)) // count all dirs as failed + return nil, nil } defer conn.Close() return clean.Remove(conn.Client, r.stale) }() totalRemoved += len(removed) - totalFailed += len(errs) + if removed == nil && errs == nil { + // Connection failed — count all dirs as failed + totalFailed += len(r.stale) + } else { + totalFailed += len(errs) + }
236-250:connectToHostusesfmt.Errorfinstead of the internal errors package.Per coding guidelines, errors should be wrapped with context using the internal/errors package.
As per coding guidelines,
**/*.go: "Ensure errors are wrapped with context using the internal/errors package".
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@internal/cli/clean.go`:
- Around line 154-156: The current handling of form.Run() swallows all errors;
change the error branch for form.Run() to distinguish user cancellation by using
errors.Is(err, huh.ErrUserAborted) — if that check is true return nil (user
cancelled), otherwise return the original err. Ensure the package imports
"errors" (and keep reference to form.Run() and huh.ErrUserAborted) so terminal
I/O or other failures are propagated instead of being treated as cancellation.
🧹 Nitpick comments (6)
internal/config/expand.go (2)
211-236: Process-lifetime cache is correct for the use case but blocks unit testing.The
sync.Oncecaching is appropriate since the branch won't change mid-execution. Just noting that tests for functions consuminggetBranch()can't override the cached value. If that becomes a problem later, injecting a branch-provider function would help.
315-330: Use the internal errors package for wrapping.Line 319 uses
fmt.Errorfinstead of the project'sinternal/errorspackage. Other error sites in this file don't return errors, but this exported function should follow the project convention. As per coding guidelines, "Ensure errors are wrapped with context using the internal/errors package".Suggested fix
- return nil, fmt.Errorf("failed to list local git branches: %w", err) + return nil, errors.WrapWithCode(err, errors.ErrExec, + "Failed to list local git branches", + "Make sure you're in a git repository.")This would require adding the
errorsimport frominternal/errors. Note thefmtimport may still be needed if used elsewhere.internal/clean/clean.go (2)
91-101:isDangerousPathcould be strengthened with a depth check.The current blocklist protects against known dangerous paths, but a path like
/bootor a single-segment absolute path (/anything) would pass. Consider adding a heuristic: reject any absolute path with fewer than N segments (e.g., 3), since legitimate per-branch dirs are always deeply nested.Optional: add depth heuristic
func isDangerousPath(path string) bool { trimmed := strings.TrimSpace(path) switch trimmed { case "", "/", "~", "~/", "/home", "/tmp", "/var", "/etc", "/usr", "/opt", "/root", "/home/", "/tmp/", "/var/", "/etc/", "/usr/", "/opt/", "/root/": return true } + // Reject shallow absolute paths (fewer than 3 segments like /foo/bar) + if strings.HasPrefix(trimmed, "/") { + segments := strings.Split(strings.Trim(trimmed, "/"), "/") + if len(segments) < 3 { + return true + } + } return false }
108-142: N+1 remote calls for disk usage — acceptable for expected scale.
getDiskUsageis called once per directory, resulting in N+1 remote round-trips. For the expected handful of stale directories this is fine, but if someone accumulates dozens of stale branch dirs, it could get slow. A future optimization could batch into a singledu -sh dir1 dir2 ....internal/cli/clean.go (2)
249-254:pluralizeis brittle — only works for "directory/directories".It returns
"y"or"ies", relying on the caller to provide"director"as the prefix. This is fragile and non-obvious. A clearer API would take the full word.Suggested improvement
-func pluralize(n int) string { - if n == 1 { - return "y" - } - return "ies" +func pluralize(n int, singular, plural string) string { + if n == 1 { + return singular + } + return plural }Usage:
pluralize(allStale, "directory", "directories")
229-247: Consider extracting a shared SSH alias retry utility instead of usingSelector.Both
connectToHostandSelector.trySSHAliasesimplement identical alias iteration logic, but the suggested refactoring to reuseSelectoradds unnecessary overhead:Selectorincludes event emission, mutex locking, and caching that thecleancommand doesn't need. Instead, extract the core retry pattern into a lightweight utility function that both code paths can share.
…directories
When using git worktrees, all branches sync to the same remote directory,
causing expensive re-syncs of node_modules and build artifacts. With ${BRANCH},
users can configure `dir: ~/rr/${PROJECT}-${BRANCH}` so each branch gets its
own isolated remote directory.
Branch names are sanitized for filesystem safety (/ \ : * ? " < > | become -).
Falls back to "HEAD" in detached HEAD state or outside a git repo.
When using ${BRANCH} in host dir templates (e.g., dir: ~/rr/${PROJECT}-${BRANCH}),
remote directories accumulate as branches are merged or deleted. rr clean discovers
stale directories by comparing remote dirs against local git branches and offers
interactive deletion with --dry-run support.
Architecture:
- config/expand.go: ExpandRemoteGlob, ExtractBranchFromPath, ListLocalBranches
- internal/clean/: domain layer with RemoteExecutor interface for testability
- internal/cli/clean.go: Cobra command with --host, --dry-run, --probe-timeout
Safety: dangerous path rejection, confirmation prompt, exit code checking,
sanitized branch comparison, multi-${BRANCH} template validation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The clean command was non-functional because LoadGlobal() expands
${BRANCH} in Dir before cleanCommand sees it, so ExpandRemoteGlob
never found ${BRANCH} and always skipped every host.
Fix by adding DirTemplate to Host struct, preserving the raw template
before expansion. cleanCommand now uses DirTemplate for glob pattern
generation and branch extraction.
Additional fixes addressing CodeRabbit review:
- listRemoteDirs: treat ls exit 1 with empty output as "no matches"
- getDiskUsage: return "?" for empty du output instead of empty string
- shellQuoteGlob: preserve leading/trailing wildcards correctly
- Remove: combine err and stderr in error messages
- getBranch: cache with sync.Once to avoid redundant git subprocesses
- sanitizeBranch: promote replacer to package-level var
- TestListLocalBranches: add git repo guard for CI environments
- cli/clean.go: use defer conn.Close() via closures for safety
- connectToHost: include SSH aliases in error message for debugging
- validateRemotePath: improve error message with supported variables
- Mock executor: longest-prefix matching for deterministic dispatch
- Fix rangeValCopy lint warnings across codebase (Host struct grew)
Adds regression test proving expanded dir returns nil from Discover
while raw template finds stale dirs, plus integration test for
DirTemplate preservation through LoadGlobal.
- Remove unused HostResult struct from clean package - Replace fragile string-matching error classification with separate connect/discover error paths in cleanCommand - Fix SaveGlobal to restore DirTemplate before persisting, preventing template corruption on LoadGlobal -> SaveGlobal round-trips - Use require.NoError for fatal checks in TestListLocalBranches - Wrap ListLocalBranches error with context - Expand isDangerousPath to block common system directories - Fix nil-error slice inflating totalFailed count on reconnect failure - Use internal errors package for connectToHost - Fix double map lookup in SelectByTag - Add SaveGlobal round-trip test for DirTemplate preservation
…vals
Instead of maintaining a denylist of dangerous paths (isDangerousPath),
validate each path against the DirTemplate pattern before deletion.
A path is only eligible for removal when it provably matches the
template's ${BRANCH} expansion, has no path separators in the branch
segment, and meets minimum depth requirements.
Three issues discovered during manual testing against a real remote host: 1. Rename 'clean' task to 'tidy' in .rr.yaml to avoid conflict with the new 'rr clean' subcommand (reserved task name validation). 2. Handle zsh's "no matches found" error in listRemoteDirs. When a glob matches nothing, zsh emits this to stderr before ls runs. Treat it the same as empty output. 3. Fix ExtractBranchFromPath tilde-to-absolute path mismatch. Remote ls -d expands ~ to /Users/... or /home/..., but the template prefix uses ~/. Now infers the home directory from the returned absolute path when the tilde prefix doesn't match directly.
dfd3539 to
81a4ffe
Compare
- Distinguish huh.ErrUserAborted from other form.Run() errors so terminal I/O failures propagate instead of being silently swallowed. - Replace brittle pluralize() helper with dirNoun() that returns "directory" or "directories" directly, making the API self-evident.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@internal/config/expand.go`:
- Around line 348-363: The current branchSanitizer (and sanitizeBranch) maps
both "/" and "-" to "-" causing collisions (e.g., "feature/login" vs
"feature-login"); add a brief comment above branchSanitizer documenting this
known limitation, and also add a runtime collision check at expansion time where
sanitizeBranch is used (detect if two distinct input branch names produce the
same sanitized string and emit a clear error or warning) so callers can fail
fast or surface the conflict to users.
🧹 Nitpick comments (5)
internal/config/expand.go (3)
331-346:ListLocalBranchesusesfmt.Errorfinstead of the project'sinternal/errorspackage.Other error sites in the codebase wrap with
errors.WrapWithCode. This function is called fromcleanCommandwhich then re-wraps, so it's not critical, but it's inconsistent.As per coding guidelines, "Ensure errors are wrapped with context using the internal/errors package".
296-315:ExtractBranchFromPathtilde resolution relies onstrings.Indexwhich finds the first occurrence.If the relative prefix (e.g.,
rr/proj-) appears more than once in the absolute path (unlikely but possible with nested dirs), this could extract the wrong branch segment. The current usage context makes this improbable, but worth a comment.
211-236: Process-lifetime cache for branch is correct for CLI usage but complicates testing.
sync.Oncemeans unit tests can't reset the cached branch between test cases. If tests ever need to exercise different branch values, they'd need to use a build-tag-guarded reset function or refactor to accept a branch provider.Current test coverage seems to test
sanitizeBranchandExpandRemoteGlobindependently (not throughgetBranch), so this is fine for now.internal/clean/clean.go (1)
174-184: N+1 remote commands:getDiskUsageruns a separatedufor each directory.For hosts with many stale directories, this means N individual SSH round-trips for disk usage. You could batch them into a single
du -sh dir1 dir2 ... dirNcall and parse the multi-line output. Not blocking, but would improve latency noticeably for hosts with 10+ stale dirs.Sketch of batched approach
- for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - usage := getDiskUsage(executor, line) - dirs = append(dirs, remoteDir{path: line, diskUsage: usage}) - } + var paths []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + paths = append(paths, line) + } + } + usageMap := getBatchDiskUsage(executor, paths) + for _, p := range paths { + dirs = append(dirs, remoteDir{path: p, diskUsage: usageMap[p]}) + }internal/cli/clean.go (1)
234-252:connectToHosthelper is clean and reusable.Tries each SSH alias in order, returns the first successful connection with latency info. Last error propagated with context. Consider whether this should live in a shared location (it's useful beyond just
clean), but fine here for now.
Branches like "feature/login" and "feature-login" both sanitize to the same directory name, which would cause them to share a remote directory. ListLocalBranches now detects these collisions and returns an error so callers can surface the conflict to users. Also documented the known limitation on branchSanitizer.
- Add rr clean to SKILL.md quick reference, commands table, and
"when to use" table
- Add full rr clean command reference to commands.md with flags
and usage examples
- Add ${BRANCH} variable to config.md variable expansion table
- Add cleanup note to docs/configuration.md BRANCH section
|
@tim4net I caught this PR branch up to current Can you pull this down and triple check it all behaves as you'd expect it to? |
The old sanitizer mapped all unsafe chars to '-', causing collisions
(e.g., feature/login and feature-login both became feature-login).
The new scheme uses underscore-prefixed escape sequences ('_s' for /,
'__' for _) that are provably collision-free. Adds UnsanitizeBranch
for reversibility and removes the runtime collision detection that
was a band-aid for the flawed design.
Summary
${BRANCH}variable fordirfield in host configMotivation
When using git worktrees, all branches sync to the same remote directory,
causing expensive re-syncs of node_modules and build artifacts when switching
between branches with different dependency trees.
With
${BRANCH}, users can configure:Each branch gets its own isolated remote directory with preserved node_modules.
Test plan
go fmtpassesgolangci-lint runpasses (0 issues)go test ./...passes (new table-driven tests for sanitizeBranch + expansion)make coverage-checkpasses (63.6%, above 50% threshold)Summary by CodeRabbit
Release Notes
New Features
cleancommand discovers and removes stale per-branch directories from remote systems with dry-run and interactive confirmation modesDocumentation