-
Notifications
You must be signed in to change notification settings - Fork 10
pair-harness: robust worktree reuse, remote-branch tracking, and pair locking #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -38,52 +38,228 @@ impl WorktreeManager { | |||||||||||||||||||
|
|
||||||||||||||||||||
| info!(pair_id, ticket_id, branch = %branch_name, "Creating worktree"); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Update remote refs for origin/main (best-effort) | ||||||||||||||||||||
| if let Err(e) = self.run_git_in_main(&["fetch", "origin", "main"]) { | ||||||||||||||||||||
| warn!(error = %e, "git fetch origin/main failed, continuing"); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| if let Err(e) = self.run_git_in_main(&["merge", "origin/main"]) { | ||||||||||||||||||||
| warn!(error = %e, "git merge origin/main failed, continuing"); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Keep stale worktree pruning but DO NOT delete existing branches by default. | ||||||||||||||||||||
| self.prune_stale_worktrees(); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Acquire a per-pair filesystem lock to avoid concurrent creation/reuse races. | ||||||||||||||||||||
| let lock_path = self.worktrees_dir.join(format!("{}.lock", pair_id)); | ||||||||||||||||||||
| struct LockGuard(PathBuf); | ||||||||||||||||||||
| impl Drop for LockGuard { | ||||||||||||||||||||
| fn drop(&mut self) { | ||||||||||||||||||||
| let _ = std::fs::remove_file(&self.0); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if worktree_path.exists() { | ||||||||||||||||||||
| warn!(path = %worktree_path.display(), "Worktree already exists, removing"); | ||||||||||||||||||||
| self.remove_worktree(pair_id)?; | ||||||||||||||||||||
| let mut lock_acquired = false; | ||||||||||||||||||||
| let mut attempts = 0u8; | ||||||||||||||||||||
| while attempts < 50 { | ||||||||||||||||||||
| match std::fs::OpenOptions::new() | ||||||||||||||||||||
| .write(true) | ||||||||||||||||||||
| .create_new(true) | ||||||||||||||||||||
| .open(&lock_path) | ||||||||||||||||||||
| { | ||||||||||||||||||||
| Ok(_) => { | ||||||||||||||||||||
| lock_acquired = true; | ||||||||||||||||||||
| break; | ||||||||||||||||||||
| } | ||||||||||||||||||||
| Err(_) => { | ||||||||||||||||||||
| std::thread::sleep(std::time::Duration::from_millis(50)); | ||||||||||||||||||||
| attempts = attempts.saturating_add(1); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| self.prune_stale_worktrees(); | ||||||||||||||||||||
| self.delete_branch_if_exists(&branch_name); | ||||||||||||||||||||
| if !lock_acquired { | ||||||||||||||||||||
| return Err(anyhow!("Failed to acquire worktree lock for {} after retries", pair_id)); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| let _lock_guard = LockGuard(lock_path.clone()); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| std::fs::create_dir_all(&self.worktrees_dir) | ||||||||||||||||||||
| .context("Failed to create worktrees directory")?; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| let output = Command::new("git") | ||||||||||||||||||||
| .args(["worktree", "add"]) | ||||||||||||||||||||
| .arg(&worktree_path) | ||||||||||||||||||||
| .args(["-b", &branch_name]) | ||||||||||||||||||||
| .current_dir(&self.project_root) | ||||||||||||||||||||
| .output() | ||||||||||||||||||||
| .context("Failed to run git worktree add")?; | ||||||||||||||||||||
| if worktree_path.exists() { | ||||||||||||||||||||
| info!(path = %worktree_path.display(), "Worktree already exists, reusing"); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // If there are uncommitted changes in the worktree, stash them and keep the stash. | ||||||||||||||||||||
| let status = Command::new("git") | ||||||||||||||||||||
| .args(["status", "--porcelain"]) | ||||||||||||||||||||
| .current_dir(&worktree_path) | ||||||||||||||||||||
| .output() | ||||||||||||||||||||
| .context("Failed to run git status")?; | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
||||||||||||||||||||
| if !status.status.success() { | |
| return Err(anyhow!( | |
| "Failed to inspect existing worktree at {}: {}", | |
| worktree_path.display(), | |
| String::from_utf8_lossy(&status.stderr) | |
| )); | |
| } |
Copilot
AI
Apr 17, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The local/remote branch existence probes (git branch --list ... and git ls-remote --heads ...) don’t check output.status.success(). If either command fails (e.g., no origin remote, transient network/DNS issue, permission issue), stdout will be empty and the code will treat the branch as non-existent and attempt to create a new branch. Check the exit status and handle failures explicitly (error out or at least warn and avoid destructive branch creation).
Copilot
AI
Apr 17, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When creating a new worktree (worktree path does not exist), git worktree add <path> -b <branch> does not specify a start point, so the new branch is created from whatever HEAD is in the main worktree (which may not be origin/main). If the intent is “always branch from latest origin/main”, pass origin/main as the commit-ish to git worktree add, or explicitly create/check out the branch from origin/main after adding the worktree.
| .args(["worktree", "add"]) | |
| .arg(&worktree_path) | |
| .args(["-b", &branch_name]) | |
| .args(["worktree", "add", "-b", &branch_name]) | |
| .arg(&worktree_path) | |
| .arg("origin/main") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sinke237 glad to review when conflicts resolve
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lock acquisition happens before
std::fs::create_dir_all(&self.worktrees_dir). If theworktrees/directory doesn’t exist yet,OpenOptions::open(lock_path)will fail withENOENT, be treated as lock contention, and the loop will sleep/retry until it returns an error. Create the worktrees directory before attempting to create the lockfile (and consider handling non-"already exists" errors separately).