Skip to content

feat(config): add ${BRANCH} variable expansion for per-branch remote dirs#176

Open
tim4net wants to merge 10 commits intorileyhilliard:mainfrom
tim4net:feat/branch-variable
Open

feat(config): add ${BRANCH} variable expansion for per-branch remote dirs#176
tim4net wants to merge 10 commits intorileyhilliard:mainfrom
tim4net:feat/branch-variable

Conversation

@tim4net
Copy link
Copy Markdown

@tim4net tim4net commented Feb 10, 2026

Summary

  • Add ${BRANCH} variable for dir field in host config
  • Enables per-branch remote directories (useful with git worktrees)
  • Branch names are sanitized for filesystem safety (/ → -, plus Windows-unsafe chars)
  • Falls back to "HEAD" in detached HEAD state or outside a git repo

Motivation

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:

dir: ~/rr/${PROJECT}-${BRANCH}

Each branch gets its own isolated remote directory with preserved node_modules.

Test plan

  • go fmt passes
  • golangci-lint run passes (0 issues)
  • go test ./... passes (new table-driven tests for sanitizeBranch + expansion)
  • make coverage-check passes (63.6%, above 50% threshold)

Summary by CodeRabbit

Release Notes

  • New Features

    • ${BRANCH} variable now available in directory configuration for flexible per-branch directory setup across remote hosts
    • New clean command discovers and removes stale per-branch directories from remote systems with dry-run and interactive confirmation modes
  • Documentation

    • Configuration documentation updated with ${BRANCH} variable reference, per-branch directory examples, branch name sanitization details, and fallback behavior for detached or non-git contexts

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 10, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR introduces branch-aware directory expansion and a new clean CLI command to discover and remove stale per-branch directories on remote hosts. The feature adds ${BRANCH} variable support to path templates, implements branch sanitization, and provides end-to-end discovery and removal workflows with SSH integration.

Changes

Cohort / File(s) Summary
Core branch expansion
internal/config/expand.go, internal/config/expand_test.go
Implements ${BRANCH} variable expansion with sync.Once caching, branch sanitization (filesystem-safe characters), and introduces ExpandRemoteGlob (with wildcard substitution), ExtractBranchFromPath (branch extraction from expanded paths), and ListLocalBranches (enumeration of local Git branches).
Config model and persistence
internal/config/types.go, internal/config/loader.go, internal/config/validate.go, internal/config/config_test.go
Adds DirTemplate field to Host struct to preserve raw template during save/load cycles; updates validation to recognize ${BRANCH} in supported variable set; marks "clean" as reserved task name; adds tests for DirTemplate round-trip preservation.
Clean module (discovery and removal)
internal/clean/clean.go, internal/clean/clean_test.go
New module providing RemoteExecutor interface and StaleDir struct; implements Discover (identify stale per-branch directories via glob expansion and branch filtering) and Remove (validate and delete matched directories with aggregated error handling); comprehensive test suite with mock executors and edge case coverage.
CLI clean command
internal/cli/clean.go, internal/cli/commands.go
Adds CleanOptions type and complete end-to-end workflow: loads config, selects hosts, probes SSH connectivity, discovers stale directories, displays summary, requests user confirmation (via interactive form), and removes matched directories with dry-run support and error aggregation.
Documentation and configuration
docs/configuration.md, .rr.yaml
Adds ${BRANCH} variable documentation with branch sanitization details and fallback behavior; renames example task from "clean" to "tidy" to avoid conflict with new CLI command.
Minor iteration safety improvements
internal/cli/status.go, internal/doctor/hosts.go, internal/host/selector.go
Introduces local variable bindings (h := hosts[name]) in map iteration closures to avoid data race scenarios where closure captures loop variable by reference instead of value.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(config): add ${BRANCH} variable expansion for per-branch remote dirs' directly and clearly summarizes the main change: introducing a new ${BRANCH} variable for expanding remote directory paths to support per-branch configurations.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@rileyhilliard
Copy link
Copy Markdown
Owner

rileyhilliard commented Feb 13, 2026

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 git worktrees and havn't experienced the perf issue this is trying to solve, but it's possible I am not working within a pattern that would encounter this.

Is the pain point what we think it is?

rr already ships with preserve patterns that protect node_modules/, .venv/, .cache/, and data/ from rsync's --delete. So when you switch worktrees, rsync sends the delta of changed source files, but your dependencies stay intact on the remote.

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 preserve patterns should already be handling it. Maybe give the preserve interface a shot to see if that addresses the performance issue for you.

  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 ${BRANCH} directory, it's a full rsync to an empty remote directory. That's likely more expensive than the incremental delta sync you're trying to avoid. Plus the remote needs a fresh npm|yarn|bun install since node_modules doesn't exist in the new directory yet.

So the feature trades one-time incremental re-sync for guaranteed full-sync on every newly created branch.

Directory proliferation with no cleanup story

This is my main concern. If dir: ~/rr/${PROJECT}-${BRANCH} is in use, every branch creates a permanent new directory on the remote host. Over the lifetime of an active project that could be dozens or hundreds of directories, each with a full copy of the repo plus node_modules and build artifacts.

There's no cleanup mechanism in rr today (there arguably should be one though). No rr clean, no rr prune, no way to list stale remote directories. Users would have to SSH in and manually clean up. A single node_modules can be 500MB-1GB, so ~50 branches over 6 months is potentially 25-50GB of stale directories sitting on a host.

The existing rr host remove cleanup only removes the current branch's expanded directory (and removes the host from the config), not all the accumulated dirs/dependencies from other branches.

If the issue is npm install latency

I could see a perf issue if n worktrees had different dependencies changes that required a npm install between rr runs to be sure new dependencies were loaded and if the way that was handled was by purging the node_modules before install, but that could maybe be better solved by a pattern like this (watching for changes in package-lock.json and only running npm install when changes are observed and preserving the base-set of provisioned mode_modules)

  sync:
    on_change:
      - files: [yarn.lock] # yarn example, but just apply this pattern to nom / bun / pnpm etc
        run: yarn install --frozen-lockfile

What I'd want before merging

At minimum:

  1. Some before/after performance benchmarks on the problem and some more clarity on what preserve is solving that shows the delta sync pain is real enough to justify the tradeoff.
  2. A rr clean command that ships alongside this feature (even a simple one that lists stale remote directories and offers to delete them). Ideally an auto-cleaning mechanism that looks for stale worktree dirs and cleans them up

@tim4net
Copy link
Copy Markdown
Author

tim4net commented Feb 13, 2026

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 switching

I'm not switching between branches one at a time. I have multiple git worktree checkouts of a large app open simultaneously, working in 2-4 of them at once. Each worktree is a different local directory on a different branch.

Without ${BRANCH}, all worktrees resolve to the same remote directory (same ${PROJECT}, same host config). This means:

Concurrent syncs stomp each other. Running rr test from worktree A (feat-auth) then from worktree B (fix-payments) overwrites A's source files in the shared remote dir. Locking prevents concurrent execution, but the next time I run from A, rsync has to re-send everything B overwrote.

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 preserve doesn't apply here

preserve protects remote-only directories like node_modules/ from --delete. That's about dependencies. The files being thrashed here are the actual source files — the thing rr exists to sync. No amount of exclude/preserve configuration changes the fact that two worktrees are fighting over the same remote directory.

Re: first-sync paradox

This framing assumes branches are short-lived. In a concurrent worktree workflow, each worktree lives for days or weeks. The one-time full sync is paid once when I create the worktree, then amortized across dozens of subsequent runs that are all fast incremental syncs against an isolated remote dir.

Without ${BRANCH}, I pay a partial re-sync on every run when I move between worktrees. That's the worse tradeoff.

Re: directory proliferation — agreed, the app probably needs some cleanup abilities

You're right that there's no cleanup story, and I think that's worth solving. But it's worth noting this gap already exists today — if someone changes their dir template or renames a project, stale remote dirs accumulate silently. ${BRANCH} makes the problem more likely, so fair to address it here.

I'd propose adding rr clean. The mechanics:

  1. Read the host's dir template (e.g., ~/rr/${PROJECT}-${BRANCH})
  2. Replace ${BRANCH} with * (and expand other vars) to build a glob
  3. SSH to the remote and list matching directories
  4. Compare against local branches (git branch --list)
  5. Show which remote dirs have no corresponding local branch
  6. Prompt to confirm deletion (with --dry-run support)
$ rr clean
Scanning mini for stale branch directories...

  ~/rr/myapp-feat-auth       (branch exists)
  ~/rr/myapp-fix-payments    (branch exists)
  ~/rr/myapp-old-experiment  (no local branch) — 340 MB
  ~/rr/myapp-spike-redis     (no local branch) — 280 MB

Remove 2 stale directories (620 MB)? [y/N]

For configs that don't use ${BRANCH}, rr clean would be a no-op (or could just show the current remote dir and its disk usage — still useful).

Re: sync.on_change

Interesting idea for a different problem (automating npm install on lockfile changes), but it doesn't address the concurrent worktree case. The issue isn't post-sync hooks — it's that multiple local directories need to map to different remote directories.

Summary

${BRANCH} is opt-in — users add it to their host config only if they need it. For concurrent worktree workflows, it's the simplest solution that works within rr's existing sync model. Pairing it with rr clean addresses the lifecycle concern.

Happy to add rr clean to this PR before merge. Let me know what you think.

@rileyhilliard
Copy link
Copy Markdown
Owner

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 rr run / rr <task>

The SetupWorkflow path (used by rr run, rr exec, and all task execution) acquires the lock before syncing:

Connect -> Lock -> Sync -> Execute -> Release

So if you're running rr test from worktree A and then rr test from worktree B, B waits for the lock. Once A finishes and releases, B acquires the lock, syncs its source files, and runs. There's no window where A's files get stomped while A is executing. The lock serializes the full sync+exec sequence.

The scenario you described (concurrent syncs overwriting each other) doesn't happen through the normal rr run / rr <task> workflow, however:

There is a real bug in rr sync though

The standalone rr sync command does not acquire a lock. It connects and syncs directly. So if someone runs rr sync from worktree A while rr run from worktree B holds the lock and is executing tests, A's sync would overwrite B's source files mid-execution. I have a feeling you're using rr sync in your workflow, which would explain why you're seeing the sync clobbering that's being described. That's a legit bug that should be fixed and I opened a new issue to track it: #181.

Re-sync cost at scale

One thing I don't want to dismiss: for a large enough codebase, the re-sync overhead when switching between worktrees could be non-trivial. Each worktree is its own directory with its own file state, so when the remote has worktree A's state and you sync from worktree B, rsync has to stat every file on both sides to compute the diff. In a large monorepo with tens of thousands of files, that comparison pass alone could add up, especially over a remote connection.

If that's what you're experiencing, it does change the cost-benefit analysis. I'd be curious what your actual sync times look like when switching between worktrees. If we're talking a couple seconds, the current serial model works fine. If we're talking 30+ seconds of re-sync on every context switch, that's a real productivity hit worth solving. In my experience in a medium sized codebase, the sync is usually ~1s - ~5s, but if this is a significant performance burden at a larger scale then I think that starts to justify the changes being proposed here.

What would help

A couple things that would help me understand where the friction actually is:

  1. Are you using standalone rr sync as part of your workflow? If so, the lockless sync bug (bug: rr sync does not acquire lock, can overwrite files during active execution #181) could be the root cause of the overwriting you're seeing.
  2. How large is the codebase and what are your typical sync times when switching between worktrees? If we're in the 15-30+ second range, that's a different conversation than 2-5 seconds.

@tim4net
Copy link
Copy Markdown
Author

tim4net commented Feb 14, 2026

Update: rr clean command added

I've pushed a new commit that adds the rr clean command to this PR, addressing the directory proliferation concern. Here's what it does:

Usage

rr clean              # Scan all hosts, prompt before deleting
rr clean --dry-run    # Show what would be removed without deleting
rr clean --host mini  # Clean only a specific host

How it works

  1. Reads each host's dir template — if it contains ${BRANCH}, builds a glob pattern (e.g., ~/rr/myproject-*)
  2. SSHes to each host and lists matching directories
  3. Extracts the branch segment from each path and compares against local git branches (sanitized to match)
  4. Shows stale directories with disk usage and prompts for confirmation
$ rr clean
Scanning mini for stale directories...  ✓

  mini:
    ~/rr/myapp-old-experiment  (no local branch) — 340M
    ~/rr/myapp-spike-redis     (no local branch) — 280M

Remove 2 stale directories? [y/N]

For hosts without ${BRANCH} in their dir template, rr clean is a no-op.

Architecture

Three-layer design following the existing codebase patterns:

  • internal/config/expand.goExpandRemoteGlob (template→glob), ExtractBranchFromPath (inverse), ListLocalBranches (sanitized)
  • internal/clean/ — Domain logic with RemoteExecutor interface for testability. Discover and Remove are separate functions (different reasons to change).
  • internal/cli/clean.go — Thin Cobra command orchestrating config, SSH, and domain logic

Safety

  • Dangerous paths rejected ("", /, ~, ~/)
  • Remote command exit codes checked (permission errors surface, don't silently pass)
  • Multi-${BRANCH} templates rejected (ambiguous extraction)
  • Shell quoting preserves ~ and * while escaping everything else
  • Interactive confirmation before deletion, --dry-run for review
  • Per-host error handling (unreachable host = warning, not abort)

Tests

12 new test cases across config and clean packages, all passing. Full suite (26 packages) green with zero regressions.

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: 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

validateRemotePath rejects ${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. validateRemotePath rejects any path containing "${", so users cannot write dir: ~/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 calling ValidateResolved(), or modify validateRemotePath to 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: TestListLocalBranches will fail outside a git repository (e.g., tarball-based CI builds).

This test calls git branch and asserts NotEmpty. 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 with t.Skip when git is 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.

Expand and ExpandRemote both call getBranch() which execs git rev-parse. In workflows where expansion is called multiple times (e.g., multi-host clean), this spawns redundant processes. A sync.Once or package-level lazy cache would eliminate the repeated overhead.


318-332: sanitizeBranch allocates a new strings.NewReplacer per call.

This is fine functionally, but since it's called per-branch in ListLocalBranches and can be called frequently, promoting the replacer to a package-level var avoids 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 both err and stderr are present.

Line 80-83: if err != nil, errMsg is overwritten with err.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.responses iteration 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: Use defer conn.Close() to avoid leaking connections if Discover panics.

Currently conn.Close() is called inline at line 86. If clean.Discover panics (however unlikely), the connection leaks. A defer is 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 defer correctly per iteration.


161-183: Same defer conn.Close() pattern applies to the removal loop.

Line 176 has the same inline conn.Close() concern. Additionally, if clean.Remove panics, the connection leaks.


228-242: connectToHost error 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.

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: 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 from ListLocalBranches.

The raw error from exec.Command is returned without context. If this surfaces to the user (e.g., via rr 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.WrapWithCode from internal/errors if you want the structured error.)

As per coding guidelines, "Ensure errors are wrapped with context using the internal/errors package".


210-235: sync.Once caching 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:

  1. 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.
  2. If rr ever 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 in HostInfo at 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.Hosts with break on first match gives a random host's Dir as 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: isDangerousPath could be more robust.

Currently only blocks "", "/", "~", and "~/". Paths like /home, /tmp, or /var would 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 inflates totalFailed count on reconnection failure.

When connectToHost fails (line 176), make([]error, len(r.stale)) creates a slice of nil errors. 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: connectToHost uses fmt.Errorf instead 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".

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: 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.Once caching is appropriate since the branch won't change mid-execution. Just noting that tests for functions consuming getBranch() 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.Errorf instead of the project's internal/errors package. 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 errors import from internal/errors. Note the fmt import may still be needed if used elsewhere.

internal/clean/clean.go (2)

91-101: isDangerousPath could be strengthened with a depth check.

The current blocklist protects against known dangerous paths, but a path like /boot or 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.

getDiskUsage is 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 single du -sh dir1 dir2 ....

internal/cli/clean.go (2)

249-254: pluralize is 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 using Selector.

Both connectToHost and Selector.trySSHAliases implement identical alias iteration logic, but the suggested refactoring to reuse Selector adds unnecessary overhead: Selector includes event emission, mutex locking, and caching that the clean command doesn't need. Instead, extract the core retry pattern into a lightweight utility function that both code paths can share.

tim4net and others added 6 commits February 15, 2026 08:38
…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.
- 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.
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: 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: ListLocalBranches uses fmt.Errorf instead of the project's internal/errors package.

Other error sites in the codebase wrap with errors.WrapWithCode. This function is called from cleanCommand which 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: ExtractBranchFromPath tilde resolution relies on strings.Index which 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.Once means 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 sanitizeBranch and ExpandRemoteGlob independently (not through getBranch), so this is fine for now.

internal/clean/clean.go (1)

174-184: N+1 remote commands: getDiskUsage runs a separate du for 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 ... dirN call 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: connectToHost helper 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
@rileyhilliard
Copy link
Copy Markdown
Owner

@tim4net I caught this PR branch up to current main and pushed some bug fixes/cleanup/improvements.

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.
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.

2 participants