diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-22 00:51:00 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-22 00:51:00 +0300 |
| commit | 89ca3ba933eaf493234912972be8236c9c2493e5 (patch) | |
| tree | 508a0489cf3aa15f6d454a1c86697b0a7ba4ed72 | |
| parent | a5182282a490cfb60b22776257edc5ac0fce6adc (diff) | |
fix: check out existing branches for worktree create
refs #729
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/cli-reference.mdx | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/configuration.mdx | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/socket-api.mdx | 2 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 75 | ||||
| -rw-r--r-- | src/app/api/worktrees/deferred.rs | 23 | ||||
| -rw-r--r-- | src/app/worktrees.rs | 50 | ||||
| -rw-r--r-- | src/worktree.rs | 80 |
8 files changed, 195 insertions, 40 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index ab1686cb..872da722 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -68,6 +68,7 @@ This is a hotfix release for v0.6.9. See the v0.6.9 notes for the full feature r - Mouse-click handling now respects `ui.prompt_new_tab_name`, so mouse-created tabs follow the same naming prompt setting as keyboard-created tabs. (#521, thanks @imrajyavardhan12) - Pasting now works in modal text inputs, including rename prompts, command prompts, and worktree dialogs. (#302) - Linux clipboard image reads now validate image payloads before accepting them, preventing malformed clipboard data from reaching pane image paste flows. (#534) +- Worktree creation now checks out an existing local branch instead of failing by trying to create it again. (#729) ### Added - Added remote auto-updates for agent detection manifests, with per-agent validation, local override precedence, `herdr server agent-manifests` diagnostics, and explain output showing remote manifest status. diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index f1079995..d841e564 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -91,7 +91,7 @@ herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) herdr worktree remove --workspace ID [--force] [--json] ``` -Worktrees are normal Herdr workspaces with Git checkout provenance. `worktree create` creates a Git worktree checkout, opens it as a workspace, and groups it with the parent repo workspace. Without `--path`, Herdr creates the checkout under `<worktrees.directory>/<repo>/<branch-slug>`. +Worktrees are normal Herdr workspaces with Git checkout provenance. `worktree create` creates a Git worktree checkout, opens it as a workspace, and groups it with the parent repo workspace. If `--branch` names an existing local branch, Herdr checks it out; otherwise it creates the branch from `--base` or `HEAD`. Without `--path`, Herdr creates the checkout under `<worktrees.directory>/<repo>/<branch-slug>`. `workspace close` closes Herdr state only. `worktree remove` is the explicit checkout deletion path; it runs `git worktree remove`, never deletes the branch, and requires `--force` when Git refuses a dirty checkout. diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index ad025c43..bd11d12f 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -102,7 +102,7 @@ directory = "~/.herdr/worktrees" Herdr creates checkouts under `<directory>/<repo>/<branch-slug>`. For sibling-style checkouts, set this to a directory such as `~/Projects/herdr-worktrees`. Relative values are resolved to an absolute path when the app applies the config. -Worktree actions are available from Git workspace rows. `New worktree` creates a branch and checkout, opens it as a new Herdr workspace, and groups it under the source workspace. `Open worktree...` lists existing Git worktree checkouts for that repo; choosing an already-open checkout focuses it, and choosing a closed checkout opens it in the same group. +Worktree actions are available from Git workspace rows. `New worktree` creates a checkout, checks out an existing local branch when the entered branch already exists, otherwise creates the branch, opens it as a new Herdr workspace, and groups it under the source workspace. `Open worktree...` lists existing Git worktree checkouts for that repo; choosing an already-open checkout focuses it, and choosing a closed checkout opens it in the same group. Grouped worktrees still behave like normal Herdr workspaces: they can be focused, renamed, closed, and contain their own tabs and panes. The parent row is the original workspace. Closing the parent row closes the whole Herdr group, but it does not delete checkout folders or branches. diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 74697fd8..0db2e3c6 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -268,7 +268,7 @@ Set or clear the foreground client's outer terminal window title: `type: "client_window_title"` with `changed` and reason `set`, `cleared`, or `no_foreground_client`. -Worktree methods manage Git checkouts as Herdr workspaces. `worktree.create` creates a checkout and returns the new `workspace`, `tab`, `root_pane`, and `worktree` records. `worktree.open` opens an existing checkout or returns the already-open workspace. `worktree.remove` runs `git worktree remove` against a linked child workspace and never deletes the branch. +Worktree methods manage Git checkouts as Herdr workspaces. `worktree.create` creates a checkout and returns the new `workspace`, `tab`, `root_pane`, and `worktree` records. If the requested branch already exists locally, it checks out that branch; otherwise it creates the branch from the requested base or `HEAD`. `worktree.open` opens an existing checkout or returns the already-open workspace. `worktree.remove` runs `git worktree remove` against a linked child workspace and never deletes the branch. Create a worktree from a source workspace: diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 62a46fdb..111d50cf 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -1038,12 +1038,77 @@ mod tests { let _ = std::fs::remove_dir_all(plugin_root); } + #[tokio::test] + async fn deferred_api_worktree_create_checks_out_existing_branch() { + let repo = create_committed_repo("api-worktree-create-existing-branch-repo"); + let worktree_root = unique_temp_path("api-worktree-create-existing-branch-root"); + let branch = "foo"; + run_git(&repo, &["branch", branch]); + let mut app = test_app(); + let mut parent = Workspace::test_new("main"); + parent.identity_cwd = repo.clone(); + let parent_id = parent.id.clone(); + app.state.workspaces = vec![parent]; + app.state.ensure_test_terminals(); + app.state.worktree_directory = worktree_root.clone(); + let request = || Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeCreate(WorktreeCreateParams { + workspace_id: Some(parent_id.clone()), + branch: Some(branch.into()), + ..WorktreeCreateParams::default() + }), + }; + + let (response_tx, response_rx) = response_channel(); + assert!(app.handle_deferred_worktree_api_request(request(), response_tx)); + let event = wait_for_app_event(&mut app); + app.handle_internal_event(event); + let response = response_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("create should respond"); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::WorktreeCreated { worktree, .. } = success.result else { + panic!("expected worktree_created response"); + }; + assert_eq!(worktree.branch.as_deref(), Some(branch)); + let checkout = Path::new(&worktree.path); + assert!(checkout.join("README.md").exists()); + let branch_name = std::process::Command::new("git") + .arg("-C") + .arg(checkout) + .args(["branch", "--show-current"]) + .output() + .unwrap(); + assert!(branch_name.status.success()); + assert_eq!( + String::from_utf8(branch_name.stdout).unwrap().trim(), + branch + ); + assert!(app.pending_api_worktree_creates.is_empty()); + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + let _ = std::fs::remove_dir_all(worktree_root); + let _ = std::fs::remove_dir_all(repo); + } + #[test] fn deferred_api_worktree_create_failure_clears_pending_checkout() { let repo = create_committed_repo("api-worktree-create-failure-repo"); let worktree_root = unique_temp_path("api-worktree-create-failure-root"); - let branch = "foo"; - run_git(&repo, &["branch", branch]); + let branch_name = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(["branch", "--show-current"]) + .output() + .unwrap(); + assert!(branch_name.status.success()); + let branch = String::from_utf8(branch_name.stdout) + .unwrap() + .trim() + .to_string(); let mut app = test_app(); let mut parent = Workspace::test_new("main"); parent.identity_cwd = repo.clone(); @@ -1055,7 +1120,7 @@ mod tests { id: "req".into(), method: crate::api::schema::Method::WorktreeCreate(WorktreeCreateParams { workspace_id: Some(parent_id.clone()), - branch: Some(branch.into()), + branch: Some(branch.clone()), ..WorktreeCreateParams::default() }), }; @@ -1069,10 +1134,6 @@ mod tests { .expect("failed create should respond"); let error: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "worktree_create_failed"); - assert!(error - .error - .message - .contains("fatal: a branch named 'foo' already exists")); assert!(app.pending_api_worktree_creates.is_empty()); let (second_tx, second_rx) = response_channel(); diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 19794a6e..f63a47fd 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -159,12 +159,6 @@ impl App { self.pending_api_worktree_creates .insert(checkout_key.clone(), operation_id); - let command = crate::worktree::build_worktree_add_new_branch_command( - &source.source_checkout_path, - &checkout_path, - &branch, - &base, - ); let parent_dir = checkout_path.parent().map(Path::to_path_buf); let source_workspace_id = source .workspace_idx @@ -191,15 +185,22 @@ impl App { respond_to, }; let path = checkout_path; + let source_checkout_path = api_request.source_checkout_path.clone(); let event_tx = self.event_tx.clone(); std::thread::spawn(move || { let result = if let Some(parent_dir) = parent_dir { - std::fs::create_dir_all(&parent_dir) - .map_err(|err| err.to_string()) - .and_then(|()| crate::worktree::run_worktree_command(&command)) + std::fs::create_dir_all(&parent_dir).map_err(|err| err.to_string()) } else { - crate::worktree::run_worktree_command(&command) - }; + Ok(()) + } + .and_then(|()| { + crate::worktree::run_worktree_add_command( + &source_checkout_path, + &path, + &branch, + &base, + ) + }); let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new( crate::events::WorktreeAddResult { path, diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index e1c30a77..12e7ae84 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -521,12 +521,6 @@ impl App { create.creating = true; create.error = None; - let command = crate::worktree::build_worktree_add_new_branch_command( - &create.source_checkout_path, - &create.checkout_path, - &create.branch, - "HEAD", - ); let parent_dir = create .checkout_path .parent() @@ -538,15 +532,23 @@ impl App { "starting git worktree add" ); let path = create.checkout_path.clone(); + let source_checkout_path = create.source_checkout_path.clone(); + let branch = create.branch.clone(); let event_tx = self.event_tx.clone(); std::thread::spawn(move || { let result = if let Some(parent_dir) = parent_dir { - std::fs::create_dir_all(&parent_dir) - .map_err(|err| err.to_string()) - .and_then(|()| crate::worktree::run_worktree_command(&command)) + std::fs::create_dir_all(&parent_dir).map_err(|err| err.to_string()) } else { - crate::worktree::run_worktree_command(&command) - }; + Ok(()) + } + .and_then(|()| { + crate::worktree::run_worktree_add_command( + &source_checkout_path, + &path, + &branch, + "HEAD", + ) + }); let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new( WorktreeAddResult { path, @@ -1632,7 +1634,7 @@ mod tests { } #[test] - fn start_worktree_add_existing_branch_clears_creating_and_shows_fatal_error() { + fn start_worktree_add_existing_branch_checks_out_branch() { let repo = create_committed_repo("app-worktree-add-existing-branch-repo"); let worktree_root = unique_temp_path("app-worktree-add-existing-branch-root"); let branch = "foo"; @@ -1664,18 +1666,28 @@ mod tests { let event = wait_for_worktree_event(&mut app); match event { AppEvent::WorktreeAddFinished(result) => { - app.handle_worktree_add_finished(*result); + let result = *result; + assert_eq!(result.path, checkout); + assert_eq!(result.result, Ok(())); } other => panic!("unexpected event: {other:?}"), } - let create = app.state.worktree_create.as_ref().unwrap(); - assert!(!create.creating); - let error = create.error.as_deref().unwrap(); - assert!(error.contains("Preparing worktree")); - assert!(error.contains("fatal: a branch named 'foo' already exists")); - assert!(!checkout.exists()); + assert!(checkout.join("README.md").exists()); + let branch_name = std::process::Command::new("git") + .arg("-C") + .arg(&checkout) + .args(["branch", "--show-current"]) + .output() + .unwrap(); + assert!(branch_name.status.success()); + assert_eq!( + String::from_utf8(branch_name.stdout).unwrap().trim(), + branch + ); + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); + crate::worktree::run_worktree_command(&remove).unwrap(); let _ = std::fs::remove_dir_all(worktree_root); let _ = std::fs::remove_dir_all(repo); } diff --git a/src/worktree.rs b/src/worktree.rs index f2f1dffe..33b03d56 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -246,6 +246,65 @@ pub(crate) fn build_worktree_add_new_branch_command( } } +pub(crate) fn build_worktree_add_existing_branch_command( + repo_root: &Path, + path: &Path, + branch: &str, +) -> WorktreeCommand { + WorktreeCommand { + program: "git".to_string(), + args: vec![ + "-C".to_string(), + repo_root.display().to_string(), + "worktree".to_string(), + "add".to_string(), + path.display().to_string(), + branch.to_string(), + ], + } +} + +pub(crate) fn local_branch_exists(repo_root: &Path, branch: &str) -> Result<bool, String> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["show-ref", "--verify", "--quiet"]) + .arg(format!("refs/heads/{branch}")) + .output() + .map_err(|err| err.to_string())?; + + if output.status.success() { + return Ok(true); + } + if output.status.code() == Some(1) { + return Ok(false); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stderr.is_empty() { + Err(stderr) + } else if !stdout.is_empty() { + Err(stdout) + } else { + Err(format!("git show-ref failed with status {}", output.status)) + } +} + +pub(crate) fn run_worktree_add_command( + repo_root: &Path, + path: &Path, + branch: &str, + base: &str, +) -> Result<(), String> { + let command = if local_branch_exists(repo_root, branch)? { + build_worktree_add_existing_branch_command(repo_root, path, branch) + } else { + build_worktree_add_new_branch_command(repo_root, path, branch, base) + }; + run_worktree_command(&command) +} + pub(crate) fn run_worktree_command(command: &WorktreeCommand) -> Result<(), String> { let output = std::process::Command::new(&command.program) .args(&command.args) @@ -756,6 +815,27 @@ prunable stale } #[test] + fn worktree_add_command_checks_out_existing_branch() { + let command = build_worktree_add_existing_branch_command( + Path::new("/repo/herdr"), + Path::new("/w/herdr/worktree-brave-river"), + "worktree/brave-river", + ); + assert_eq!(command.program, "git"); + assert_eq!( + command.args, + vec![ + "-C", + "/repo/herdr", + "worktree", + "add", + "/w/herdr/worktree-brave-river", + "worktree/brave-river" + ] + ); + } + + #[test] fn run_worktree_add_and_remove_create_and_delete_checkout() { let repo = create_committed_repo("worktree-run-repo"); let checkout = unique_temp_path("worktree-run-checkout"); |
