diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 6c6b35f53..aefbeb30b 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -14,7 +14,7 @@ copy-on-write build caches. - [wt switch](https://worktrunk.dev/switch.md): Switch to a worktree; create if needed. - [wt list](https://worktrunk.dev/list.md): List worktrees and their status. -- [wt merge](https://worktrunk.dev/merge.md): Merge current branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree. +- [wt merge](https://worktrunk.dev/merge.md): Merge a branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree. - [wt remove](https://worktrunk.dev/remove.md): Remove worktree; delete branch if merged. Defaults to the current worktree. - [wt config](https://worktrunk.dev/config.md): Manage user & project configs. Includes shell integration, hooks, and saved state. - [wt step](https://worktrunk.dev/step.md): Run individual operations. The building blocks of wt merge — commit, squash, rebase, push — plus standalone utilities. diff --git a/docs/src/content/docs/merge.md b/docs/src/content/docs/merge.md index 05f213c8d..1dc611e47 100644 --- a/docs/src/content/docs/merge.md +++ b/docs/src/content/docs/merge.md @@ -1,14 +1,14 @@ --- title: "wt merge" -description: "Merge current branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree." +description: "Merge a branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree." sidebar: order: 12 --- -Merge current branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree. +Merge a branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree. -Unlike `git merge`, this merges the current branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The target defaults to the default branch. +Unlike `git merge`, this merges the source branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The source defaults to the current branch; the target defaults to the default branch.
@@ -43,6 +43,15 @@ Merge to a different branch: $ wt merge develop ``` +Merge another worktree's branch without leaving the current worktree: + +```console +$ wt merge --branch feature/auth +$ wt merge develop --branch feature/auth +``` + +The source branch must have a checked-out worktree. Commit, squash, rebase, and hooks use that worktree and its project configuration. Cleanup removes the source worktree; the invoking worktree stays in place. A rebase conflict is left in the source worktree to resolve or abort. + Keep the worktree after merging: ```console @@ -83,7 +92,7 @@ $ wt merge --no-commit --no-rebase 4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](/hook/). 5. **Merge** — Fast-forward merge to the target branch ([`wt step push`](/step/#wt-step-push)). With `--no-ff`, a merge commit is created instead — semi-linear history after the default rebase, while explicit `--no-rebase` preserves the graph produced by earlier steps before adding the merge commit. Non-fast-forward merges are rejected. 6. **Pre-remove hooks** — Hooks run before removing worktree. Failures abort. -7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, or locked, the worktree is preserved. +7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, locked, or containing another registered worktree, the worktree is preserved. 8. **Post-remove + post-merge hooks** — Run in background after cleanup. Use `--no-commit` to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless `--no-rebase` is passed. Combining both flags preserves the exact source graph and requires the target to be its ancestor. Useful after preparing commits manually with `wt step commit`. Requires a clean working tree. @@ -114,7 +123,7 @@ lint = "cargo clippy" ## Command reference ```text wt-command-reference -wt merge - Merge current branch into the target branch +wt merge - Merge a branch into the target branch Squash & rebase, fast-forward the target branch, remove the worktree. @@ -127,6 +136,9 @@ Arguments: Defaults to default branch. Options: + -b, --branch + Branch to merge from (defaults to current worktree) + --no-squash Skip commit squashing diff --git a/plugins/worktrunk/skills/worktrunk/reference/merge.md b/plugins/worktrunk/skills/worktrunk/reference/merge.md index b3fa8a208..d3c8de63d 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/merge.md +++ b/plugins/worktrunk/skills/worktrunk/reference/merge.md @@ -1,8 +1,8 @@ # wt merge -Merge current branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree. +Merge a branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree. -Unlike `git merge`, this merges the current branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The target defaults to the default branch. +Unlike `git merge`, this merges the source branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The source defaults to the current branch; the target defaults to the default branch. ## Examples @@ -29,6 +29,15 @@ Merge to a different branch: $ wt merge develop ``` +Merge another worktree's branch without leaving the current worktree: + +```console +$ wt merge --branch feature/auth +$ wt merge develop --branch feature/auth +``` + +The source branch must have a checked-out worktree. Commit, squash, rebase, and hooks use that worktree and its project configuration. Cleanup removes the source worktree; the invoking worktree stays in place. A rebase conflict is left in the source worktree to resolve or abort. + Keep the worktree after merging: ```console @@ -69,7 +78,7 @@ $ wt merge --no-commit --no-rebase 4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](https://worktrunk.dev/hook/). 5. **Merge** — Fast-forward merge to the target branch ([`wt step push`](https://worktrunk.dev/step/#wt-step-push)). With `--no-ff`, a merge commit is created instead — semi-linear history after the default rebase, while explicit `--no-rebase` preserves the graph produced by earlier steps before adding the merge commit. Non-fast-forward merges are rejected. 6. **Pre-remove hooks** — Hooks run before removing worktree. Failures abort. -7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, or locked, the worktree is preserved. +7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, locked, or containing another registered worktree, the worktree is preserved. 8. **Post-remove + post-merge hooks** — Run in background after cleanup. Use `--no-commit` to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless `--no-rebase` is passed. Combining both flags preserves the exact source graph and requires the target to be its ancestor. Useful after preparing commits manually with `wt step commit`. Requires a clean working tree. @@ -94,7 +103,7 @@ lint = "cargo clippy" ## Command reference ``` -wt merge - Merge current branch into the target branch +wt merge - Merge a branch into the target branch Squash & rebase, fast-forward the target branch, remove the worktree. @@ -107,6 +116,9 @@ Arguments: Defaults to default branch. Options: + -b, --branch + Branch to merge from (defaults to current worktree) + --no-squash Skip commit squashing diff --git a/skills/worktrunk/reference/merge.md b/skills/worktrunk/reference/merge.md index b3fa8a208..d3c8de63d 100644 --- a/skills/worktrunk/reference/merge.md +++ b/skills/worktrunk/reference/merge.md @@ -1,8 +1,8 @@ # wt merge -Merge current branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree. +Merge a branch into the target branch. Squash & rebase, fast-forward the target branch, remove the worktree. -Unlike `git merge`, this merges the current branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The target defaults to the default branch. +Unlike `git merge`, this merges the source branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The source defaults to the current branch; the target defaults to the default branch. ## Examples @@ -29,6 +29,15 @@ Merge to a different branch: $ wt merge develop ``` +Merge another worktree's branch without leaving the current worktree: + +```console +$ wt merge --branch feature/auth +$ wt merge develop --branch feature/auth +``` + +The source branch must have a checked-out worktree. Commit, squash, rebase, and hooks use that worktree and its project configuration. Cleanup removes the source worktree; the invoking worktree stays in place. A rebase conflict is left in the source worktree to resolve or abort. + Keep the worktree after merging: ```console @@ -69,7 +78,7 @@ $ wt merge --no-commit --no-rebase 4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](https://worktrunk.dev/hook/). 5. **Merge** — Fast-forward merge to the target branch ([`wt step push`](https://worktrunk.dev/step/#wt-step-push)). With `--no-ff`, a merge commit is created instead — semi-linear history after the default rebase, while explicit `--no-rebase` preserves the graph produced by earlier steps before adding the merge commit. Non-fast-forward merges are rejected. 6. **Pre-remove hooks** — Hooks run before removing worktree. Failures abort. -7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, or locked, the worktree is preserved. +7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, locked, or containing another registered worktree, the worktree is preserved. 8. **Post-remove + post-merge hooks** — Run in background after cleanup. Use `--no-commit` to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless `--no-rebase` is passed. Combining both flags preserves the exact source graph and requires the target to be its ancestor. Useful after preparing commits manually with `wt step commit`. Requires a clean working tree. @@ -94,7 +103,7 @@ lint = "cargo clippy" ## Command reference ``` -wt merge - Merge current branch into the target branch +wt merge - Merge a branch into the target branch Squash & rebase, fast-forward the target branch, remove the worktree. @@ -107,6 +116,9 @@ Arguments: Defaults to default branch. Options: + -b, --branch + Branch to merge from (defaults to current worktree) + --no-squash Skip commit squashing diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 763e54a6e..4e76e08a8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -556,6 +556,10 @@ pub(crate) struct RemoveArgs { #[derive(Args)] pub(crate) struct MergeArgs { + /// Branch to merge from (defaults to current worktree) + #[arg(short, long, add = crate::completion::worktree_only_completer(), value_parser = crate::cli::non_empty_branch)] + pub(crate) branch: Option, + /// Target branch /// /// Defaults to default branch. @@ -1429,11 +1433,11 @@ Detached worktrees have no branch name. Pass the worktree path instead: `wt remo "#)] Remove(RemoveArgs), - /// Merge current branch into the target branch + /// Merge a branch into the target branch /// /// Squash & rebase, fast-forward the target branch, remove the worktree. #[command( - after_long_help = r#"Unlike `git merge`, this merges the current branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The target defaults to the default branch. + after_long_help = r#"Unlike `git merge`, this merges the source branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The source defaults to the current branch; the target defaults to the default branch. ## Examples @@ -1462,6 +1466,15 @@ Merge to a different branch: $ wt merge develop ``` +Merge another worktree's branch without leaving the current worktree: + +```console +$ wt merge --branch feature/auth +$ wt merge develop --branch feature/auth +``` + +The source branch must have a checked-out worktree. Commit, squash, rebase, and hooks use that worktree and its project configuration. Cleanup removes the source worktree; the invoking worktree stays in place. A rebase conflict is left in the source worktree to resolve or abort. + Keep the worktree after merging: ```console @@ -1502,7 +1515,7 @@ $ wt merge --no-commit --no-rebase 4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](/hook/). 5. **Merge** — Fast-forward merge to the target branch ([`wt step push`](/step/#wt-step-push)). With `--no-ff`, a merge commit is created instead — semi-linear history after the default rebase, while explicit `--no-rebase` preserves the graph produced by earlier steps before adding the merge commit. Non-fast-forward merges are rejected. 6. **Pre-remove hooks** — Hooks run before removing worktree. Failures abort. -7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, or locked, the worktree is preserved. +7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, locked, or containing another registered worktree, the worktree is preserved. 8. **Post-remove + post-merge hooks** — Run in background after cleanup. Use `--no-commit` to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless `--no-rebase` is passed. Combining both flags preserves the exact source graph and requires the target to be its ancestor. Useful after preparing commits manually with `wt step commit`. Requires a clean working tree. diff --git a/src/commands/hooks.rs b/src/commands/hooks.rs index 3ff645744..b0366cda6 100644 --- a/src/commands/hooks.rs +++ b/src/commands/hooks.rs @@ -57,8 +57,8 @@ //! set. (Aliases get the property structurally instead: the body is frozen //! into `AliasEntry` before the gate, like `ApprovedHookPlan`.) //! -//! `ctx.repo` is the invoking worktree — except `wt step commit --branch ` -//! and `wt -C ` re-root the whole command (the commit, its hooks, and +//! `ctx.repo` is the invoking worktree — except `wt step commit --branch `, +//! `wt merge --branch `, and `wt -C ` re-root the whole command (the commit, its hooks, and //! `ctx.repo` are all ``), so "the invoking worktree" follows them. //! //! A present-but-malformed config aborts the operation rather than silently diff --git a/src/commands/merge.rs b/src/commands/merge.rs index 02a0f910e..e06b2d17c 100644 --- a/src/commands/merge.rs +++ b/src/commands/merge.rs @@ -1,3 +1,7 @@ +//! Merge resolves its source once, then carries that context through every +//! mutation and hook. The invoking worktree is tracked separately: removing +//! another source must not emit a shell directory change or a post-switch hook. + use std::path::Path; use anyhow::Context; @@ -5,6 +9,7 @@ use color_print::cformat; use worktrunk::HookType; use worktrunk::config::{MergeConfig, UserConfig}; use worktrunk::git::Repository; +use worktrunk::path::format_path_for_display; use worktrunk::styling::{eprintln, info_message}; use crate::output::print_json; @@ -71,6 +76,7 @@ pub struct ResolvedMergeFlags { /// the six boolean flags; `stage` is the same shape but for stage mode. pub struct MergeOptions<'a> { pub target: Option<&'a str>, + pub branch: Option<&'a str>, pub flags: MergeFlagOverrides, pub yes: bool, pub stage: Option, @@ -80,8 +86,8 @@ pub struct MergeOptions<'a> { /// Build the frozen [`ApprovedHookPlan`] for the merge's covered hooks, gating /// every project command once. /// -/// Every hook selects its commands from the invoking worktree's -/// `.config/wt.toml` — `repo`'s cwd, the feature worktree `wt merge` ran in. +/// Every hook selects its commands from the source worktree's +/// `.config/wt.toml` — `repo`'s cwd, including when selected with `--branch`. /// The *anchor* — the executor's plan lookup key — is the worktree each hook /// runs in: /// @@ -104,6 +110,7 @@ fn approve_merge_plan( project_id: &str, verify: bool, will_remove: bool, + will_switch: bool, will_create_commit: bool, yes: bool, ) -> anyhow::Result> { @@ -138,9 +145,9 @@ fn approve_merge_plan( builder.add(feature_root, &feature_hooks); // `post-merge` runs in the destination, and `post-switch` lands the user // there (the feature worktree is removed) — both still selected from the - // invoking worktree's config. + // source worktree's config. builder.add(destination_path, &[HookType::PostMerge]); - if will_remove { + if will_switch { builder.add(destination_path, &[HookType::PostSwitch]); } @@ -148,6 +155,45 @@ fn approve_merge_plan( } pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { + // Load config once, run LLM setup prompt if committing, then reuse config + let mut config = UserConfig::load().context("Failed to load config")?; + if opts.flags.commit.unwrap_or(true) { + // One-time LLM setup prompt (errors logged internally; don't block merge) + let _ = crate::output::prompt_commit_generation(&mut config); + } + + let invoking_repo = Repository::current()?; + let env = match opts.branch { + Some(selector) => CommandEnv::for_selector(config, selector)?, + None => CommandEnv::for_action(config)?, + }; + // Compare actual worktree identity, not whether a selector was supplied: + // --branch @ and a path naming the current worktree retain normal cleanup. + // A bare invocation has no current worktree and cannot require a cd. + let source_is_current = invoking_repo.current_worktree().root()? == env.worktree_path; + let result = merge_in(opts, &env, source_is_current); + if source_is_current { + result + } else { + result.inspect_err(|_| { + // Typed Git diagnostics render without anyhow context. Keep the + // source visible even for rebase conflicts and interruptions. + eprintln!( + "{}", + info_message(cformat!( + "Merge source @ {}", + format_path_for_display(&env.worktree_path) + )) + ); + }) + } +} + +fn merge_in( + opts: MergeOptions<'_>, + env: &CommandEnv, + source_is_current: bool, +) -> anyhow::Result<()> { let json_mode = opts.format == crate::cli::SwitchFormat::Json; let MergeOptions { target, @@ -156,15 +202,6 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { stage, .. } = opts; - - // Load config once, run LLM setup prompt if committing, then reuse config - let mut config = UserConfig::load().context("Failed to load config")?; - if flags.commit.unwrap_or(true) { - // One-time LLM setup prompt (errors logged internally; don't block merge) - let _ = crate::output::prompt_commit_generation(&mut config); - } - - let env = CommandEnv::for_action(config)?; let repo = &env.repo; let config = &env.config; // Cache current worktree for multiple queries @@ -262,7 +299,7 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { // Where `post-merge` / `post-remove` / `post-switch` run: the target // branch's worktree if it exists, else the primary worktree. Mirrors // `finish_after_merge`'s destination resolution. (Config is resolved from - // the invoking worktree, not here — see `approve_merge_plan`.) + // the source worktree, not here — see `approve_merge_plan`.) let destination_path = match &target_worktree_path { Some(path) => path.clone(), None => repo.home_path()?, @@ -300,6 +337,7 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { &project_id, verify, remove_requested, + remove_requested && source_is_current, will_create_commit, yes, )?; @@ -379,7 +417,8 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { // --no-hooks vs declined-approval distinction. let squashed = if squash_enabled { matches!( - super::step::handle_squash( + super::step::squash::handle_squash_in( + env, Some(&target_branch), yes, commit_hooks, @@ -397,7 +436,7 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { let rebased = if rebase { // Auto-rebase onto target matches!( - super::step::handle_rebase(Some(&target_branch))?, + super::step::handle_rebase(repo, Some(&target_branch))?, super::step::RebaseResult::Rebased { .. } ) } else { @@ -409,7 +448,7 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { .run_command(&["rev-parse", "--verify", "--end-of-options", &target_ref])? .trim() .to_string(); - let source_sha = repo + let source_sha = current_wt .run_command(&["rev-parse", "--verify", "HEAD"])? .trim() .to_string(); @@ -446,19 +485,25 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { }); if !ff { // Create a merge commit on the target branch via commit-tree + update-ref - handle_no_ff_merge(Some(&target_branch), operations, ¤t_branch)?; + handle_no_ff_merge(repo, Some(&target_branch), operations, ¤t_branch)?; } else { // Fast-forward push to target branch - handle_push(Some(&target_branch), PushKind::MergeFastForward, operations)?; + handle_push( + repo, + Some(&target_branch), + PushKind::MergeFastForward, + operations, + )?; } let removed = finish_after_merge( repo, config, - &env, + env, &mut announcer, FinishAfterMergeArgs { current_branch: ¤t_branch, + source_is_current, target_branch: &target_branch, target_worktree_path: target_worktree_path.as_deref(), remove, diff --git a/src/commands/repository_ext.rs b/src/commands/repository_ext.rs index 530e29b63..a5e5db5dd 100644 --- a/src/commands/repository_ext.rs +++ b/src/commands/repository_ext.rs @@ -82,6 +82,7 @@ pub trait RepositoryCliExt { &self, target_worktree: Option<&PathBuf>, target_branch: &str, + source_sha: &str, ) -> anyhow::Result<()>; /// Check if HEAD is a linear extension of the target branch. @@ -441,6 +442,7 @@ impl RepositoryCliExt for Repository { &self, target_worktree: Option<&PathBuf>, target_branch: &str, + source_sha: &str, ) -> anyhow::Result<()> { let Some(wt_path) = target_worktree else { return Ok(()); @@ -458,7 +460,7 @@ impl RepositoryCliExt for Repository { return Ok(()); } - let push_files = self.changed_files(target_branch, "HEAD")?; + let push_files = self.changed_files(target_branch, source_sha)?; let wt_files = parse_porcelain_z(&wt_status_output); let overlapping: Vec = push_files @@ -480,8 +482,12 @@ impl RepositoryCliExt for Repository { } fn is_rebased_onto(&self, target: &str) -> anyhow::Result { + let head = self + .current_worktree() + .run_command(&["rev-parse", "HEAD"])?; + let head = head.trim(); // Orphan branches have no common ancestor, so they can't be "rebased onto" target - let Some(merge_base) = self.merge_base("HEAD", target)? else { + let Some(merge_base) = self.merge_base(head, target)? else { return Ok(false); }; // `merge_base` peels an annotated tag to the commit it points at; a bare @@ -509,7 +515,7 @@ impl RepositoryCliExt for Repository { "rev-list", "--merges", "--end-of-options", - &format!("{}..HEAD", target), + &format!("{target}..{head}"), ])? .trim() .to_string(); diff --git a/src/commands/step/rebase.rs b/src/commands/step/rebase.rs index 760fdc256..f5df65ef3 100644 --- a/src/commands/step/rebase.rs +++ b/src/commands/step/rebase.rs @@ -16,8 +16,8 @@ pub enum RebaseResult { } /// Handle shared rebase workflow (used by `wt step rebase` and `wt merge`) -pub fn handle_rebase(target: Option<&str>) -> anyhow::Result { - let repo = Repository::current()?; +pub fn handle_rebase(repo: &Repository, target: Option<&str>) -> anyhow::Result { + let wt = repo.current_worktree(); // Refuse before reading ancestry: a worktree stopped mid-rebase has HEAD // detached on a linear extension of the target, so the up-to-date check @@ -40,10 +40,10 @@ pub fn handle_rebase(target: Option<&str>) -> anyhow::Result { } // Check if this is a fast-forward or true rebase + let head_sha = wt.run_command(&["rev-parse", "HEAD"])?.trim().to_string(); let merge_base = repo - .merge_base("HEAD", &integration_target)? + .merge_base(&head_sha, &integration_target)? .context("Cannot rebase: no common ancestor with target branch")?; - let head_sha = repo.run_command(&["rev-parse", "HEAD"])?.trim().to_string(); let is_fast_forward = merge_base == head_sha; // Only show progress for true rebases (fast-forwards are instant) @@ -57,7 +57,7 @@ pub fn handle_rebase(target: Option<&str>) -> anyhow::Result { // `--no-update-refs` overrides `rebase.updateRefs`, which would also move // other local branches stacked in the rebased range; a worktree's rebase // rewrites only its own branch. - let rebase_result = repo.run_command(&[ + let rebase_result = wt.run_command(&[ "rebase", "--no-update-refs", "--end-of-options", diff --git a/src/commands/step/squash.rs b/src/commands/step/squash.rs index dff89cccf..78f68ef34 100644 --- a/src/commands/step/squash.rs +++ b/src/commands/step/squash.rs @@ -81,6 +81,28 @@ pub fn handle_squash( let _ = crate::output::prompt_commit_generation(&mut config); let env = CommandEnv::for_action(config)?; + handle_squash_in( + &env, + target, + yes, + hooks, + stage, + announcer, + pre_approved_guidance, + ) +} + +/// Run the squash in the caller's already-selected environment. Reusing it +/// keeps the source worktree and the approved project-config cache consistent. +pub fn handle_squash_in( + env: &CommandEnv, + target: Option<&str>, + yes: bool, + hooks: HookGate, + stage: Option, + announcer: &mut HookAnnouncer<'_>, + pre_approved_guidance: PreApprovedGuidance, +) -> anyhow::Result { let repo = &env.repo; // Rewriting history under a half-finished operation is never what the user // meant, and mid-rebase HEAD is detached — so this runs ahead of the branch @@ -306,6 +328,7 @@ pub fn handle_squash( .unwrap_or("repo"); let commit_message = crate::llm::SquashInputs { + worktree: &wt, target_branch: &span_target, merge_base: &merge_base, commit_details: &commit_details, @@ -538,6 +561,7 @@ fn preview_squash( let staging_index = temp_index.as_ref(); let inputs = crate::llm::SquashInputs { + worktree: &wt, target_branch: &span_target, merge_base: &merge_base, commit_details: &commit_details, diff --git a/src/commands/worktree/finish.rs b/src/commands/worktree/finish.rs index 96bf5a8a0..142b8fd0a 100644 --- a/src/commands/worktree/finish.rs +++ b/src/commands/worktree/finish.rs @@ -7,10 +7,10 @@ //! 1. Capture the feature worktree's path + commit BEFORE removal — afterward //! the worktree directory is gone, but post-merge hooks still need to //! reference it via Active template overrides. -//! 2. Decide whether to remove the feature worktree. Five conditions block +//! 2. Decide whether to remove the feature worktree. Six conditions block //! removal: `--no-remove`, on-target, primary-worktree, locked, and -//! default-branch. Otherwise `ensure_clean` gates removal and -//! `handle_remove_output` performs it (sharing the same code path as +//! default-branch, and nested worktrees. Otherwise `ensure_clean` gates removal and +//! `handle_merge_remove_output` performs it (sharing the staging path with //! `wt remove`). //! 3. Register the post-merge hook with the announcer. The caller owns //! `flush()` because it's a command-level lifecycle operation, not part of @@ -26,6 +26,7 @@ use std::path::Path; use worktrunk::HookType; use worktrunk::config::UserConfig; use worktrunk::git::{BranchDeletionMode, Repository}; +use worktrunk::path::format_path_for_display; use worktrunk::styling::{eprintln, info_message}; use super::types::{RemovalPlan, SharedBranchCheckout}; @@ -38,14 +39,12 @@ use crate::commands::repository_ext::{ live_sibling_checkout, }; use crate::commands::template_vars::TemplateVars; -use crate::output::{ - BackgroundFallbackMode, RemovalExecution, handle_remove_output, post_hook_display_path, - pre_hook_display_path, -}; +use crate::output::{handle_merge_remove_output, post_hook_display_path, pre_hook_display_path}; /// Inputs to [`finish_after_merge`]. Owned by the caller; this struct just /// bundles them so the function signature stays readable. pub struct FinishAfterMergeArgs<'a> { + pub source_is_current: bool, pub current_branch: &'a str, pub target_branch: &'a str, pub target_worktree_path: Option<&'a Path>, @@ -74,6 +73,7 @@ pub fn finish_after_merge( ) -> anyhow::Result { let FinishAfterMergeArgs { current_branch, + source_is_current, target_branch, target_worktree_path, remove, @@ -136,6 +136,16 @@ pub fn finish_after_merge( }; eprintln!("{}", info_message(msg)); false + } else if let Some(nested) = worktrunk::git::remove::nested_worktree(repo, &env.worktree_path)? + { + eprintln!( + "{}", + info_message(format!( + "Worktree preserved (contains worktree @ {})", + format_path_for_display(&nested) + )) + ); + false } else { // Phase 3: reject removing default branch (merge always uses SafeDelete). check_not_default_branch(repo, current_branch, &BranchDeletionMode::SafeDelete)?; @@ -182,7 +192,7 @@ pub fn finish_after_merge( let remove_result = RemovalPlan::Worktree { main_path: destination_path.clone(), worktree_path: worktree_root, - changed_directory: true, + changed_directory: source_is_current, branch_name: Some(current_branch.to_string()), deletion_mode, target_branch: display_target, @@ -194,13 +204,7 @@ pub fn finish_after_merge( // The fate is dropped: merge's own reporting (`removed` in the JSON // blob, the removal messages) doesn't itemize the branch, and the // handler has already narrated any retention. - handle_remove_output( - &remove_result, - RemovalExecution::Background(BackgroundFallbackMode::Detached), - plan, - false, - announcer, - )?; + handle_merge_remove_output(&remove_result, plan, announcer)?; true }; @@ -218,7 +222,7 @@ pub fn finish_after_merge( &destination_path, yes, ); - let display_path = if removed { + let display_path = if removed && source_is_current { post_hook_display_path(&destination_path) } else { pre_hook_display_path(&destination_path) diff --git a/src/commands/worktree/push.rs b/src/commands/worktree/push.rs index 1b10cb0b5..8d21b7c84 100644 --- a/src/commands/worktree/push.rs +++ b/src/commands/worktree/push.rs @@ -112,9 +112,11 @@ struct MergeContext { impl MergeContext { /// Resolve target, verify fast-forward, check conflicts, count commits, capture stats. - fn prepare(target: Option<&str>, operations: Option) -> anyhow::Result { - let repo = Repository::current()?; - + fn prepare( + repo: &Repository, + target: Option<&str>, + operations: Option, + ) -> anyhow::Result { // Refuse before reading ancestry: mid-rebase HEAD is detached partway // through the replay, and it *is* a linear extension of the target, so // the fast-forward check below passes and the push carries the target @@ -161,7 +163,11 @@ impl MergeContext { // Fast-forward check (target must be ancestor of HEAD). // target_tip is already a SHA; resolve HEAD to one too so the // ancestry probe hits the SHA-keyed cache directly. - let head_sha = repo.run_command(&["rev-parse", "HEAD"])?.trim().to_string(); + let head_sha = repo + .current_worktree() + .run_command(&["rev-parse", "HEAD"])? + .trim() + .to_string(); if !repo.is_ancestor_by_sha(&target_tip, &head_sha)? { let commits_formatted = repo .run_command(&[ @@ -169,7 +175,7 @@ impl MergeContext { "--color=always", "--graph", "--oneline", - &format!("HEAD..{}", target_tip), + &format!("{head_sha}..{target_tip}"), ])? .trim() .to_string(); @@ -185,7 +191,7 @@ impl MergeContext { // Refuse when uncommitted changes in the target worktree overlap the // push range. Non-overlapping changes stay in place: the two-tree // merge in `advance_target` carries them through untouched. - repo.ensure_no_target_conflicts(target_worktree_path.as_ref(), &target_branch)?; + repo.ensure_no_target_conflicts(target_worktree_path.as_ref(), &target_branch, &head_sha)?; // TODO(#3519 follow-up): when `target_branch` was behind its upstream // (see `Repository::span_upstream`), this count mixes the carried @@ -194,18 +200,18 @@ impl MergeContext { // overstates what the branch itself contributed. Splitting them needs // a carried-count threaded through `MergeContext`; deferred as // cosmetic. - let commit_count = repo.count_commits(&target_branch, "HEAD")?; + let commit_count = repo.count_commits(&target_branch, &head_sha)?; let stats_summary = if commit_count > 0 { repo.current_worktree() - .prepare_commit_diff(&target_branch, "HEAD") + .prepare_commit_diff(&target_branch, &head_sha) .stats_summary() } else { Vec::new() }; Ok(Self { - repo, + repo: repo.clone(), target_branch, target_worktree_path, target_tip, @@ -255,12 +261,12 @@ impl MergeContext { "--graph", "--oneline", "--end-of-options", - &format!("{}..HEAD", self.target_branch), + &format!("{}..{}", self.target_branch, self.head_sha), ])?; eprintln!("{}", format_with_gutter(&log_output, None)); // Diff statistics - crate::commands::show_diffstat(&self.repo, &self.target_branch, "HEAD")?; + crate::commands::show_diffstat(&self.repo, &self.target_branch, &self.head_sha)?; Ok(()) } @@ -485,11 +491,12 @@ fn advance_target( /// two-tree merge carries them in place, and [`MergeContext::prepare`] already /// refused any that overlap the push range. pub fn handle_push( + repo: &Repository, target: Option<&str>, kind: PushKind, operations: Option, ) -> anyhow::Result { - let ctx = MergeContext::prepare(target, operations)?; + let ctx = MergeContext::prepare(repo, target, operations)?; ctx.show_progress(kind.verb_progressive(), "", operations)?; @@ -534,11 +541,12 @@ pub fn handle_push( /// The source may be rebased or may retain an explicitly preserved /// merge-shaped graph. pub fn handle_no_ff_merge( + repo: &Repository, target: Option<&str>, operations: Option, feature_branch: &str, ) -> anyhow::Result { - let ctx = MergeContext::prepare(target, operations)?; + let ctx = MergeContext::prepare(repo, target, operations)?; ctx.show_progress("Merging", " (--no-ff)", operations)?; diff --git a/src/git/remove.rs b/src/git/remove.rs index 5fe3535e6..8b673559d 100644 --- a/src/git/remove.rs +++ b/src/git/remove.rs @@ -4,8 +4,9 @@ //! //! - [`stage_worktree_removal`] — the ordered prelude every removal path runs //! in the foreground before the worktree directory stops existing: the -//! dirty-worktree gate, the fsmonitor stop, then the rename into trash. It -//! owns the gate, so it is the one place removal's data safety is decided. +//! dirty-worktree gate, the fsmonitor stop, then the +//! rename into trash. It owns the gates, so it is the one place removal's +//! data safety is decided. //! - [`remove_worktree_with_cleanup`] — that prelude, plus the direct-removal //! fallback and branch deletion, run to completion synchronously. //! @@ -100,8 +101,11 @@ use std::path::{Path, PathBuf}; use std::time::Duration; +use anyhow::Context as _; + use crate::git::repository::WorkingTree; use crate::git::{ErrorExt, GitError, IntegrationReason, Repository, WorktreeInfo, path_dir_name}; +use crate::path::format_path_for_display; use crate::shell_exec::Cmd; use crate::styling::{eprintln, format_with_gutter, warning_message}; use crate::utils::epoch_now; @@ -454,7 +458,7 @@ pub fn remove_worktree_with_cleanup( /// /// # Errors /// -/// The ownership check, the lock check, and the dirty-worktree gate error. A +/// The ownership, lock, and dirty-worktree gates error. A /// failed rename is reported as `None`, not an error, and the daemon stop is /// best-effort throughout. pub fn stage_worktree_removal( @@ -517,6 +521,40 @@ pub fn stage_worktree_removal( Ok(rename_into_trash(repo, worktree_path, &git_dir)) } +/// Find a registered worktree contained in `worktree_path`, using a fresh +/// registry read. Ignored nested worktrees are invisible to `ensure_clean`, +/// but renaming or removing their parent would destroy their independent work. +/// Canonical paths catch registrations made through symlinks; a missing entry +/// has no directory to protect, while other resolution errors fail closed. +/// +/// Called only by merge cleanup: once while deciding whether to remove, then +/// again after approved pre-remove hooks and before the final removal gates. +/// Keep this live registry read out of `stage_worktree_removal`: batch prune +/// staging must not wait for another removal's registry teardown (#3954). +pub fn nested_worktree(repo: &Repository, worktree_path: &Path) -> anyhow::Result> { + let root = + dunce::canonicalize(worktree_path).context("Failed to resolve worktree for removal")?; + let fresh_repo = Repository::at(repo.discovery_path())?; + for worktree in fresh_repo.list_worktrees()? { + let path = match dunce::canonicalize(&worktree.path) { + Ok(path) => path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).with_context(|| { + format!( + "Failed to resolve worktree @ {}", + format_path_for_display(&worktree.path) + ) + }); + } + }; + if path != root && path.starts_with(&root) { + return Ok(Some(path)); + } + } + Ok(None) +} + /// Rename a worktree into `/wt/trash/` and prune git metadata. /// /// Returns `Some(staged_path)` on success, `None` if the rename failed. The @@ -771,6 +809,60 @@ mod tests { assert!(worktree_path.exists()); } + #[test] + fn nested_worktree_reads_fresh_topology() { + let mut test = TestRepo::with_initial_commit(); + let source = test.add_worktree("source"); + let repo = Repository::at(test.root_path()).unwrap(); + // Warm the cache before adding another checkout: the guard must read + // topology after planning, independently of that cache. + repo.list_worktrees().unwrap(); + let late = test.add_worktree_at_path("late", &source.join("late")); + assert_eq!(nested_worktree(&repo, &source).unwrap(), Some(late)); + } + + #[cfg(unix)] + #[test] + fn nested_worktree_resolves_symlinked_parent() { + let mut test = TestRepo::with_initial_commit(); + let source = test.add_worktree("source"); + let nested = test.add_worktree_at_path("nested", &source.join("nested")); + let alias = test.root_path().parent().unwrap().join("source-alias"); + std::os::unix::fs::symlink(&source, &alias).unwrap(); + let repo = Repository::at(test.root_path()).unwrap(); + assert_eq!(nested_worktree(&repo, &alias).unwrap(), Some(nested)); + } + + #[test] + fn nested_worktree_skips_missing_checkout() { + let mut test = TestRepo::with_initial_commit(); + let source = test.add_worktree("source"); + let nested = test.add_worktree_at_path("nested", &source.join("nested")); + std::fs::rename(&nested, test.root_path().parent().unwrap().join("saved")).unwrap(); + let repo = Repository::at(test.root_path()).unwrap(); + assert_eq!(nested_worktree(&repo, &source).unwrap(), None); + } + + #[cfg(unix)] + #[test] + fn nested_worktree_refuses_unresolvable_checkout() { + let mut test = TestRepo::with_initial_commit(); + let source = test.add_worktree("source"); + let nested = test.add_worktree_at_path("nested", &source.join("nested")); + let saved = test.root_path().parent().unwrap().join("saved"); + std::fs::rename(&nested, &saved).unwrap(); + // A symlink loop is an actual resolution error, not evidence that the + // registered checkout is absent. Refuse cleanup rather than skip it. + std::os::unix::fs::symlink(&nested, &nested).unwrap(); + let repo = Repository::at(test.root_path()).unwrap(); + let err = nested_worktree(&repo, &source).unwrap_err(); + assert!( + err.to_string().contains("Failed to resolve worktree @"), + "{err:?}" + ); + assert!(saved.join(".git").exists()); + } + /// Registry serialization starts after the fast-path rename, so worktree /// staging can overlap while metadata teardown remains exclusive. #[test] diff --git a/src/git/repository/integration.rs b/src/git/repository/integration.rs index 4480134b7..2ab7fe6e1 100644 --- a/src/git/repository/integration.rs +++ b/src/git/repository/integration.rs @@ -812,7 +812,10 @@ impl Repository { return Ok(None); }; let target_sha = self.resolve_to_commit_sha(target_branch)?; - match self.merge_base("HEAD", &upstream)? { + let head = self + .current_worktree() + .run_command(&["rev-parse", "HEAD"])?; + match self.merge_base(head.trim(), &upstream)? { Some(fork_point) if !self.is_ancestor_by_sha(&fork_point, &target_sha)? => { Ok(Some(upstream)) } diff --git a/src/llm.rs b/src/llm.rs index 75a6418d4..8c3fd0908 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -5,9 +5,7 @@ use std::fmt; use std::path::Path; use std::sync::Arc; use worktrunk::config::CommitGenerationConfig; -use worktrunk::git::{ - CommandError, CommitMessageDetail, ErrorExt, Repository, TempIndex, WorkingTree, -}; +use worktrunk::git::{CommandError, CommitMessageDetail, ErrorExt, TempIndex, WorkingTree}; use worktrunk::shell_exec::{Cmd, ShellConfig}; use minijinja::Environment; @@ -843,6 +841,7 @@ pub(crate) fn build_commit_prompt( /// whole set to `build`, and the three call sites pass the same values in the /// same order, which is exactly where a transposed pair goes unnoticed. pub(crate) struct SquashInputs<'a> { + pub worktree: &'a WorkingTree<'a>, pub target_branch: &'a str, pub merge_base: &'a str, pub commit_details: &'a [CommitMessageDetail], @@ -899,8 +898,7 @@ impl SquashInputs<'_> { /// The diff spans everything the one resulting commit records — the commits /// since `merge_base` plus any working-tree changes folded in with them. pub(crate) fn prompt(&self) -> anyhow::Result { - let repo = Repository::current()?; - let wt = repo.current_worktree(); + let wt = self.worktree; // Diff `merge_base` against the index, because the index is what the // squash commits: `handle_squash` stages the working tree before @@ -914,7 +912,7 @@ impl SquashInputs<'_> { // matches `HEAD` and the two spans are the same diff, so this needs no // second path. It also matches the stats `handle_squash` prints for the // same commit. - let squashed = staged_diff(&wt, self.staging_index, self.merge_base); + let squashed = staged_diff(wt, self.staging_index, self.merge_base); let diff_output = squashed.capture(["--patch"])?; let diff_stat = squashed.capture(["--stat"])?; diff --git a/src/main.rs b/src/main.rs index ba68d8492..d6a381a2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -311,12 +311,12 @@ fn handle_step_command( format, .. } => { + let repo = Repository::current()?; let result = if no_ff { - let repo = Repository::current()?; let current_branch = repo.require_current_branch("step push --no-ff")?; - handle_no_ff_merge(target.as_deref(), None, ¤t_branch)? + handle_no_ff_merge(&repo, target.as_deref(), None, ¤t_branch)? } else { - handle_push(target.as_deref(), PushKind::Standalone, None)? + handle_push(&repo, target.as_deref(), PushKind::Standalone, None)? }; if format == SwitchFormat::Json { let PushResult { @@ -341,7 +341,7 @@ fn handle_step_command( Ok(()) } StepCommand::Rebase { target, format } => { - let result = handle_rebase(target.as_deref())?; + let result = handle_rebase(&Repository::current()?, target.as_deref())?; if format == SwitchFormat::Json { let output = match &result { RebaseResult::Rebased { @@ -899,6 +899,7 @@ fn handle_merge_command(args: MergeArgs, yes: bool) -> anyhow::Result<()> { } handle_merge(MergeOptions { target: args.target.as_deref(), + branch: args.branch.as_deref(), flags: MergeFlagOverrides::from_cli(&args), yes, stage: args.stage, diff --git a/src/output/handlers.rs b/src/output/handlers.rs index 6db516762..3d2f0eafd 100644 --- a/src/output/handlers.rs +++ b/src/output/handlers.rs @@ -1245,6 +1245,37 @@ pub fn handle_remove_output( hook_plan: &ApprovedHookPlan, quiet: bool, announcer: &mut HookAnnouncer<'_>, +) -> anyhow::Result { + handle_remove_output_inner(plan, execution, hook_plan, quiet, false, announcer) +} + +/// Merge may select a source containing another checkout, including the +/// invoking worktree. Recheck its topology after pre-remove hooks, before the +/// removal core's final safety gates. This is merge-specific: ordinary remove +/// and batch prune retain their concurrent staging without a live registry +/// read before each rename. +pub fn handle_merge_remove_output( + plan: &RemovalPlan, + hook_plan: &ApprovedHookPlan, + announcer: &mut HookAnnouncer<'_>, +) -> anyhow::Result { + handle_remove_output_inner( + plan, + RemovalExecution::Background(BackgroundFallbackMode::Detached), + hook_plan, + false, + true, + announcer, + ) +} + +fn handle_remove_output_inner( + plan: &RemovalPlan, + execution: RemovalExecution, + hook_plan: &ApprovedHookPlan, + quiet: bool, + protect_nested_worktrees: bool, + announcer: &mut HookAnnouncer<'_>, ) -> anyhow::Result { match plan { RemovalPlan::Worktree { @@ -1272,6 +1303,7 @@ pub fn handle_remove_output( branch_checked_out_at: branch_checked_out_at.as_ref(), hook_plan, execution, + protect_nested_worktrees, }, announcer, ), @@ -1808,6 +1840,7 @@ struct WorktreeRemovalContext<'a> { /// no `ProjectConfig` snapshot to thread. hook_plan: &'a ApprovedHookPlan, execution: RemovalExecution, + protect_nested_worktrees: bool, } impl WorktreeRemovalContext<'_> { @@ -2102,11 +2135,23 @@ fn handle_removed_worktree_output( execute_pre_remove_hooks_if_needed(&repo, &ctx)?; } - // No re-validation after `pre-remove` hooks: the pre-rename `ensure_clean` - // in the removal core catches a hook-dirtied worktree, and the branch - // deletion re-decides against fresh refs (`delete_branch_if_safe`'s CAS) - // — one mechanism per guarantee. `ctx.integration_reason` / - // `ctx.target_branch` carry the planning-time verdict for display. + // This read may wait for a registry teardown. Keep it before the final + // ownership/lock/dirty gates in the removal core, so edits made during + // the wait are checked before staging. Also precede the cd directive: + // refusing cleanup must not move the invoking shell. + if ctx.protect_nested_worktrees + && let Some(nested) = worktrunk::git::remove::nested_worktree(&repo, ctx.worktree_path)? + { + anyhow::bail!(cformat!( + "Cannot remove worktree @ {}; contains worktree @ {}", + format_path_for_display(ctx.worktree_path), + format_path_for_display(&nested) + )); + } + + // The pre-rename `ensure_clean` catches a hook-dirtied worktree, and the + // branch deletion re-decides against fresh refs (`delete_branch_if_safe`'s + // CAS). The planning-time integration verdict is only for display. // TUI (picker) path: the removal runs in a background thread while skim // owns the terminal, so no messages, no spinner, no `cd` directive (the diff --git a/src/output/mod.rs b/src/output/mod.rs index 1badb2e81..2551b298d 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -45,7 +45,7 @@ pub(crate) use global::{ // Re-export output handlers pub(crate) use handlers::{ BackgroundFallbackMode, DirectivePassthrough, RemovalExecution, execute_shell_command, - execute_user_command, handle_remove_output, handle_switch_output, + execute_user_command, handle_merge_remove_output, handle_remove_output, handle_switch_output, retained_unmerged_branch_messages, }; // Re-export shell integration functions diff --git a/tests/integration_tests/help.rs b/tests/integration_tests/help.rs index 75721de26..a27be2dd8 100644 --- a/tests/integration_tests/help.rs +++ b/tests/integration_tests/help.rs @@ -303,10 +303,7 @@ fn test_help_without_detectable_width() { /// Tests --help-description outputs the meta description for docs frontmatter. #[rstest] #[case("switch", "Switch to a worktree; create if needed.")] -#[case( - "merge", - "Merge current branch into the target branch. Squash & rebase" -)] +#[case("merge", "Merge a branch into the target branch. Squash & rebase")] #[case("hook", "Run configured hooks.")] fn test_help_description(#[case] cmd: &str, #[case] expected_prefix: &str) { let output = wt_command() diff --git a/tests/integration_tests/merge_source.rs b/tests/integration_tests/merge_source.rs new file mode 100644 index 000000000..177fc6187 --- /dev/null +++ b/tests/integration_tests/merge_source.rs @@ -0,0 +1,687 @@ +//! Source selection must apply to the whole merge, without borrowing the +//! invoking worktree's index, hooks, HEAD, or shell directory changes. + +use crate::common::{ + SLEEP_FOR_ABSENCE_CHECK, TestRepo, repo, wait_for_file_content, wait_for_worktree_removed, +}; +use path_slash::PathExt as _; +use rstest::rstest; +use std::fs; +use std::path::Path; + +fn git_in(repo: &TestRepo, path: &Path, args: &[&str]) -> String { + let output = repo + .git_command() + .current_dir(path) + .args(args.iter().copied()) + .run() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +#[rstest] +#[case(false, false)] +#[case(true, false)] +#[case(false, true)] +#[case(true, true)] +fn test_merge_source_pipeline_preserves_invoker( + mut repo: TestRepo, + #[case] no_squash: bool, + #[case] no_ff: bool, +) { + let source = repo.add_feature(); + repo.commit_in_worktree(&source, "second.txt", "second", "Second source commit"); + fs::write(source.join("dirty-source.txt"), "source pending work").unwrap(); + repo.commit_in_worktree(repo.root_path(), "target.txt", "target", "Advance target"); + let target_before = repo.git_output(&["rev-parse", "main"]); + let caller = repo.add_worktree("caller"); + fs::write(caller.join("staged.txt"), "caller's staged work").unwrap(); + repo.run_git_in(&caller, &["add", "staged.txt"]); + fs::write(caller.join("untracked.txt"), "caller's untracked work").unwrap(); + let caller_head = git_in(&repo, &caller, &["rev-parse", "HEAD"]); + let caller_index = git_in(&repo, &caller, &["write-tree"]); + let caller_status = git_in(&repo, &caller, &["status", "--porcelain"]); + let directive = caller.parent().unwrap().join("directive"); + fs::write(&directive, "").unwrap(); + + let mut command = repo.wt_command(); + command + .current_dir(&caller) + .args([ + "merge", + "--branch", + "feature", + "--yes", + "--no-hooks", + "--format=json", + ]) + .env("WORKTRUNK_DIRECTIVE_CD_FILE", &directive); + if no_squash { + command.arg("--no-squash"); + } + if no_ff { + command.arg("--no-ff"); + } + let output = command.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["branch"], "feature"); + assert_eq!(result["target"], "main"); + assert_eq!(result["committed"], no_squash); + assert_eq!(result["squashed"], !no_squash); + assert_eq!(result["rebased"], true); + assert_eq!(result["removed"], true); + wait_for_worktree_removed(&source); + assert_eq!( + repo.git_output(&["rev-list", "--count", &format!("{target_before}..main")]), + ((if no_squash { 3 } else { 1 }) + usize::from(no_ff)).to_string() + ); + for (name, expected) in [ + ("feature.txt", "feature content"), + ("second.txt", "second"), + ("dirty-source.txt", "source pending work"), + ("target.txt", "target"), + ] { + assert_eq!( + fs::read_to_string(repo.root_path().join(name)).unwrap(), + expected + ); + } + assert_eq!(git_in(&repo, &caller, &["rev-parse", "HEAD"]), caller_head); + assert_eq!(git_in(&repo, &caller, &["write-tree"]), caller_index); + assert_eq!( + git_in(&repo, &caller, &["status", "--porcelain"]), + caller_status + ); + assert_eq!( + fs::read_to_string(caller.join("untracked.txt")).unwrap(), + "caller's untracked work" + ); + assert_eq!(fs::read_to_string(directive).unwrap(), ""); +} + +#[rstest] +fn test_merge_source_uses_source_hooks_without_switching(mut repo: TestRepo) { + let source = repo.add_feature(); + let caller = repo.add_worktree("caller"); + fs::create_dir_all(caller.join(".config")).unwrap(); + fs::write(caller.join(".config/wt.toml"), "pre-merge = 'exit 91'\n").unwrap(); + let pre = caller.parent().unwrap().join("pre-merge"); + let post = caller.parent().unwrap().join("post-merge"); + let removed = caller.parent().unwrap().join("post-remove"); + let switched = caller.parent().unwrap().join("post-switch"); + fs::create_dir_all(source.join(".config")).unwrap(); + fs::write( + source.join(".config/wt.toml"), + format!( + r#" +pre-merge = "printf '%s' '{{{{ branch }}}}:{{{{ worktree_path }}}}' > '{}'" +post-merge = "printf '%s' '{{{{ branch }}}}:{{{{ worktree_path }}}}:{{{{ target }}}}' > '{}'" +post-remove = "printf '%s' '{{{{ branch }}}}' > '{}'" +post-switch = "echo switched > '{}'" +"#, + pre.to_slash_lossy(), + post.to_slash_lossy(), + removed.to_slash_lossy(), + switched.to_slash_lossy() + ), + ) + .unwrap(); + repo.run_git_in(&source, &["add", ".config/wt.toml"]); + repo.run_git_in(&source, &["commit", "-m", "Configure source hooks"]); + + let output = repo + .wt_command() + .current_dir(&caller) + .args(["merge", "--branch", "feature", "--yes", "--no-commit"]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + wait_for_file_content(&post); + wait_for_file_content(&removed); + assert_eq!( + fs::read_to_string(pre).unwrap(), + format!( + "feature:{}", + worktrunk::path::to_posix_path(&source.to_string_lossy()) + ) + ); + assert_eq!( + fs::read_to_string(post).unwrap(), + format!( + "feature:{}:main", + worktrunk::path::to_posix_path(&source.to_string_lossy()) + ) + ); + assert_eq!(fs::read_to_string(removed).unwrap(), "feature"); + // Successful background hooks prove the pipeline had a chance to run. + std::thread::sleep(SLEEP_FOR_ABSENCE_CHECK); + assert!(!switched.exists()); + assert!(!String::from_utf8_lossy(&output.stderr).contains("post-switch")); +} + +#[rstest] +#[case(false)] +#[case(true)] +fn test_merge_source_selected_by_path_or_current_alias(mut repo: TestRepo, #[case] current: bool) { + let source = repo.add_feature(); + let directive = source.parent().unwrap().join("directive"); + fs::write(&directive, "").unwrap(); + let cwd = if current { + source.as_path() + } else { + repo.root_path() + }; + let selector = if current { + "@".to_string() + } else { + source.to_string_lossy().into_owned() + }; + let output = repo + .wt_command() + .current_dir(cwd) + .args([ + "merge", + "--branch", + &selector, + "--no-commit", + "--no-hooks", + "--yes", + ]) + .env("WORKTRUNK_DIRECTIVE_CD_FILE", &directive) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + wait_for_worktree_removed(&source); + let cd = fs::read_to_string(directive).unwrap(); + if current { + assert_eq!(cd.trim(), repo.root_path().to_slash_lossy()); + } else { + assert!(cd.is_empty()); + } +} + +#[rstest] +fn test_merge_source_missing_worktree_is_non_mutating(mut repo: TestRepo) { + repo.add_feature(); + repo.create_branch("no-checkout"); + let before = repo.git_output(&["show-ref"]); + let output = repo + .wt_command() + .args(["merge", "--branch", "no-checkout", "--yes"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_eq!(repo.git_output(&["show-ref"]), before); +} + +#[rstest] +fn test_merge_source_conflict_stays_in_source(mut repo: TestRepo) { + let source = repo.add_feature(); + repo.commit_in_worktree(&source, "conflict.txt", "source", "Source conflict"); + repo.commit_in_worktree( + repo.root_path(), + "conflict.txt", + "target", + "Target conflict", + ); + let before = repo.git_output(&["rev-parse", "main"]); + let output = repo + .wt_command() + .args([ + "merge", + "--branch", + "feature", + "--no-commit", + "--no-hooks", + "--yes", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + use ansi_str::AnsiStr; + let stderr = String::from_utf8_lossy(&output.stderr); + let source_line = stderr + .lines() + .find(|line| line.contains("Merge source @")) + .unwrap(); + insta::assert_snapshot!(source_line.ansi_strip(), @"○ Merge source @ _REPO_.feature"); + assert!(git_in(&repo, &source, &["status", "--porcelain"]).contains("AA conflict.txt")); + assert_eq!(repo.git_output(&["rev-parse", "main"]), before); + assert_eq!(repo.git_output(&["status", "--porcelain"]), ""); + assert!(source.exists()); +} + +#[rstest] +fn test_merge_source_respects_dirty_target(mut repo: TestRepo) { + let source = repo.add_feature(); + fs::write( + repo.root_path().join("feature.txt"), + "untracked target work", + ) + .unwrap(); + let before = repo.git_output(&["rev-parse", "main"]); + let output = repo + .wt_command() + .args([ + "merge", + "--branch", + "feature", + "--no-commit", + "--no-hooks", + "--yes", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_eq!(repo.git_output(&["rev-parse", "main"]), before); + assert_eq!( + fs::read_to_string(repo.root_path().join("feature.txt")).unwrap(), + "untracked target work" + ); + assert!(source.exists()); +} + +#[rstest] +fn test_merge_source_scrubs_inherited_git_context(mut repo: TestRepo) { + let source = repo.add_feature(); + repo.commit_in_worktree(&source, "second.txt", "second", "Second source commit"); + repo.commit_in_worktree(repo.root_path(), "target.txt", "target", "Advance target"); + let caller = repo.add_worktree("caller"); + let gitdir = git_in(&repo, &caller, &["rev-parse", "--absolute-git-dir"]); + let caller_head = git_in(&repo, &caller, &["rev-parse", "HEAD"]); + let output = repo + .wt_command() + .current_dir(&caller) + .args([ + "merge", + "--branch", + "feature", + "--no-hooks", + "--no-remove", + "--yes", + ]) + .env("GIT_DIR", &gitdir) + .env("GIT_WORK_TREE", &caller) + .env("GIT_INDEX_FILE", Path::new(&gitdir).join("index")) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + repo.git_output(&["rev-parse", "main"]), + git_in(&repo, &source, &["rev-parse", "HEAD"]) + ); + assert_eq!(git_in(&repo, &caller, &["rev-parse", "HEAD"]), caller_head); + assert_eq!(git_in(&repo, &caller, &["status", "--porcelain"]), ""); + assert_eq!(repo.git_output(&["status", "--porcelain"]), ""); + assert!(source.exists()); +} + +#[rstest] +fn test_merge_source_squash_prompt_reads_source_index(mut repo: TestRepo) { + let source = repo.add_feature(); + repo.commit_in_worktree(&source, "second.txt", "second", "Second source commit"); + fs::write(source.join("pending.txt"), "selected-source-content").unwrap(); + let caller = repo.add_worktree("caller"); + fs::write(caller.join("caller.txt"), "invoking-worktree-content").unwrap(); + repo.run_git_in(&caller, &["add", "caller.txt"]); + let prompt = caller.parent().unwrap().join("squash-prompt"); + let command = format!( + "cat > '{}'; echo 'Commit selected source'", + prompt.to_slash_lossy() + ); + repo.write_test_config(&format!( + "[commit.generation]\ncommand = {}\n", + toml::Value::String(command) + )); + let output = repo + .wt_command() + .current_dir(&caller) + .args([ + "merge", + "--branch", + "feature", + "--no-remove", + "--no-hooks", + "--yes", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let prompt = fs::read_to_string(prompt).unwrap(); + assert!(prompt.contains("selected-source-content"), "{prompt}"); + assert!(!prompt.contains("invoking-worktree-content"), "{prompt}"); + assert_eq!( + repo.git_output(&["log", "-1", "--format=%s", "main"]), + "Commit selected source" + ); + assert_eq!( + git_in(&repo, &caller, &["status", "--porcelain"]), + "A caller.txt" + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +fn test_merge_source_refuses_dirty_or_detached_source(mut repo: TestRepo, #[case] detached: bool) { + let source = repo.add_feature(); + if detached { + repo.run_git_in(&source, &["checkout", "--detach"]); + } else { + fs::write(source.join("pending.txt"), "uncommitted source work").unwrap(); + } + let before = repo.git_output(&["show-ref"]); + let output = repo + .wt_command() + .args([ + "merge", + "--branch", + source.to_str().unwrap(), + "--no-commit", + "--yes", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_eq!(repo.git_output(&["show-ref"]), before); + assert!(source.exists()); + if !detached { + assert_eq!( + fs::read_to_string(source.join("pending.txt")).unwrap(), + "uncommitted source work" + ); + } +} + +#[rstest] +fn test_merge_source_locked_worktree_is_preserved(mut repo: TestRepo) { + let source = repo.add_feature(); + repo.run_git(&["worktree", "lock", source.to_str().unwrap()]); + let output = repo + .wt_command() + .args([ + "merge", + "--branch", + "feature", + "--no-commit", + "--no-hooks", + "--yes", + "--format=json", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["removed"], false); + assert_eq!( + repo.git_output(&["rev-parse", "main"]), + git_in(&repo, &source, &["rev-parse", "HEAD"]) + ); + assert!(source.join("feature.txt").exists()); +} + +#[rstest] +fn test_merge_source_conflict_check_ignores_inherited_caller_head(mut repo: TestRepo) { + repo.commit_in_worktree(repo.root_path(), "common.txt", "base", "Common base"); + let source = repo.add_feature(); + let caller = repo.add_worktree("caller"); + repo.commit_in_worktree(&caller, "common.txt", "caller", "Caller-only change"); + fs::write( + repo.root_path().join("common.txt"), + "uncommitted target work", + ) + .unwrap(); + let gitdir = git_in(&repo, &caller, &["rev-parse", "--absolute-git-dir"]); + let output = repo + .wt_command() + .current_dir(&caller) + .args([ + "merge", + "--branch", + "feature", + "--no-commit", + "--no-hooks", + "--no-remove", + "--yes", + ]) + .env("GIT_DIR", gitdir) + .env("GIT_WORK_TREE", &caller) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + repo.git_output(&["rev-parse", "main"]), + git_in(&repo, &source, &["rev-parse", "HEAD"]) + ); + assert_eq!( + fs::read_to_string(repo.root_path().join("common.txt")).unwrap(), + "uncommitted target work" + ); +} + +#[rstest] +#[case(true)] +#[case(false)] +fn test_merge_source_preserves_nested_worktree(mut repo: TestRepo, #[case] invoke_nested: bool) { + repo.commit_in_worktree( + repo.root_path(), + ".gitignore", + ".worktrees/\n", + "Ignore nested worktrees", + ); + let source = repo.add_feature(); + let nested = repo.add_worktree_at_path("nested", &source.join(".worktrees/nested")); + fs::write(nested.join("staged.txt"), "staged nested work").unwrap(); + repo.run_git_in(&nested, &["add", "staged.txt"]); + fs::write(nested.join("precious.txt"), "untracked nested work").unwrap(); + let before = git_in(&repo, &nested, &["status", "--porcelain"]); + let nested_head = git_in(&repo, &nested, &["rev-parse", "HEAD"]); + assert_eq!(git_in(&repo, &source, &["status", "--porcelain"]), ""); + let cwd = if invoke_nested { + nested.as_path() + } else { + repo.root_path() + }; + let output = repo + .wt_command() + .current_dir(cwd) + .args([ + "merge", + "--branch", + "feature", + "--no-commit", + "--no-hooks", + "--yes", + "--format=json", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["removed"], false); + assert!(String::from_utf8_lossy(&output.stderr).contains("contains worktree")); + assert_eq!( + repo.git_output(&["rev-parse", "main"]), + git_in(&repo, &source, &["rev-parse", "HEAD"]) + ); + assert_eq!(git_in(&repo, &nested, &["status", "--porcelain"]), before); + assert_eq!(git_in(&repo, &nested, &["rev-parse", "HEAD"]), nested_head); + assert_eq!( + fs::read_to_string(nested.join("precious.txt")).unwrap(), + "untracked nested work" + ); +} + +#[rstest] +#[case(Some("feature"))] +#[case(Some("@"))] +#[case(None)] +fn test_merge_source_rechecks_nested_worktree_after_pre_remove( + mut repo: TestRepo, + #[case] selector: Option<&str>, +) { + repo.commit_in_worktree( + repo.root_path(), + ".gitignore", + ".worktrees/\n", + "Ignore nested worktrees", + ); + let source = repo.add_feature(); + fs::create_dir_all(source.join(".config")).unwrap(); + fs::write(source.join(".config/wt.toml"), r#"pre-remove = "git worktree add --detach .worktrees/late && echo precious > .worktrees/late/precious.txt" +"#).unwrap(); + repo.run_git_in(&source, &["add", ".config/wt.toml"]); + repo.run_git_in(&source, &["commit", "-m", "Add pre-remove hook"]); + let directive = repo.home_path().join("directive"); + fs::write(&directive, "").unwrap(); + let mut command = repo.wt_command(); + command + .args(["merge", "--no-commit", "--yes"]) + .env("WORKTRUNK_DIRECTIVE_CD_FILE", &directive); + if selector != Some("feature") { + command.current_dir(&source); + } + if let Some(selector) = selector { + command.args(["--branch", selector]); + } + let output = command.output().unwrap(); + assert_eq!(fs::read_to_string(directive).unwrap(), ""); + assert!( + !output.status.success(), + "cleanup must refuse a new nested worktree: {output:?}" + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("contains worktree")); + let nested = source.join(".worktrees/late"); + assert_eq!( + fs::read_to_string(nested.join("precious.txt")) + .unwrap() + .trim(), + "precious" + ); + assert_eq!( + repo.git_output(&["rev-parse", "main"]), + git_in(&repo, &source, &["rev-parse", "HEAD"]) + ); + assert!(git_in(&repo, &nested, &["status", "--porcelain"]).contains("precious.txt")); +} + +#[rstest] +#[case(false)] +#[case(true)] +fn test_merge_source_current_bare_worktree_emits_directory_change(#[case] explicit_source: bool) { + let mut repo = TestRepo::bare(); + repo.write_test_config(""); + let main = repo.root_path().parent().unwrap().join("repo.main"); + repo.run_git(&[ + "worktree", + "add", + "--orphan", + "-b", + "main", + main.to_str().unwrap(), + ]); + repo.commit_in_worktree(&main, "initial.txt", "initial", "Initial commit"); + let source = repo.add_feature(); + let directive = main.parent().unwrap().join("directive"); + fs::write(&directive, "").unwrap(); + let mut command = repo.wt_command(); + command + .current_dir(&source) + .args(["merge", "main", "--no-commit", "--no-hooks", "--yes"]) + .env("WORKTRUNK_DIRECTIVE_CD_FILE", &directive); + if explicit_source { + command.args(["--branch", "@"]); + } + let output = command.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + wait_for_worktree_removed(&source); + assert_eq!( + fs::read_to_string(directive).unwrap().trim(), + main.to_slash_lossy() + ); +} + +/// A topology read can wait behind another registry teardown. A write made +/// during that read must still be seen by the final dirty-worktree gate. +#[cfg(unix)] +#[rstest] +#[case(false)] +#[case(true)] +fn test_merge_source_checks_dirt_after_final_topology_read( + mut repo: TestRepo, + #[case] source_is_current: bool, +) { + use std::os::unix::fs::PermissionsExt; + + let source = repo.add_feature(); + let phase = repo.home_path().join("pre-remove-ran"); + let reached = repo.home_path().join("topology-read-ran"); + let late_file = source.join("late-untracked.txt"); + fs::create_dir_all(source.join(".config")).unwrap(); + let hook = format!( + "touch {}", + shell_escape::unix::escape(phase.to_string_lossy()) + ); + fs::write( + source.join(".config/wt.toml"), + format!("pre-remove = {}\n", serde_json::to_string(&hook).unwrap()), + ) + .unwrap(); + repo.run_git_in(&source, &["add", ".config/wt.toml"]); + repo.run_git_in(&source, &["commit", "-m", "Add pre-remove marker"]); + + let wrapper = repo.home_path().join("git-wrapper"); + fs::create_dir_all(&wrapper).unwrap(); + let real_git = which::which("git").unwrap(); + let script = format!( + r#"#!/bin/sh +if [ "$1 $2" = 'worktree list' ] && [ -f {phase} ] && [ ! -f {reached} ]; then + printf '%s' 'written during topology read' > {late_file} + touch {reached} +fi +exec {real_git} "$@" +"#, + phase = shell_escape::unix::escape(phase.to_string_lossy()), + reached = shell_escape::unix::escape(reached.to_string_lossy()), + late_file = shell_escape::unix::escape(late_file.to_string_lossy()), + real_git = shell_escape::unix::escape(real_git.to_string_lossy()), + ); + let wrapper_git = wrapper.join("git"); + fs::write(&wrapper_git, script).unwrap(); + fs::set_permissions(&wrapper_git, fs::Permissions::from_mode(0o755)).unwrap(); + let mut path = vec![wrapper]; + path.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap())); + let mut command = repo.wt_command(); + command + .env("PATH", std::env::join_paths(path).unwrap()) + .args([ + "merge", + "--branch", + if source_is_current { "@" } else { "feature" }, + "--no-commit", + "--yes", + ]); + if source_is_current { + command.current_dir(&source); + } + let output = command.output().unwrap(); + assert!( + reached.exists(), + "final topology recheck was not reached: {output:?}" + ); + assert!( + !output.status.success(), + "late write must block cleanup: {output:?}" + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("uncommitted changes")); + assert_eq!( + fs::read_to_string(&late_file).unwrap(), + "written during topology read" + ); + assert_eq!( + repo.git_output(&["rev-parse", "main"]), + git_in(&repo, &source, &["rev-parse", "HEAD"]) + ); +} diff --git a/tests/integration_tests/mod.rs b/tests/integration_tests/mod.rs index d7a379897..1f92fd4db 100644 --- a/tests/integration_tests/mod.rs +++ b/tests/integration_tests/mod.rs @@ -39,6 +39,7 @@ pub mod list_config; pub mod list_layout; pub mod list_progressive; pub mod merge; +pub mod merge_source; pub mod nushell_default_config_dir; pub mod output_system_guard; pub mod packaged_assets; diff --git a/tests/snapshots/integration__integration_tests__help__help_md_merge.snap b/tests/snapshots/integration__integration_tests__help__help_md_merge.snap index 028a300e2..c968c06c8 100644 --- a/tests/snapshots/integration__integration_tests__help__help_md_merge.snap +++ b/tests/snapshots/integration__integration_tests__help__help_md_merge.snap @@ -42,7 +42,7 @@ info: success: true exit_code: 0 ----- stdout ----- -wt merge - Merge current branch into the target branch +wt merge - Merge a branch into the target branch Squash & rebase, fast-forward the target branch, remove the worktree. @@ -55,6 +55,9 @@ Arguments: Defaults to default branch. Options: + -b, --branch + Branch to merge from (defaults to current worktree) + --no-squash Skip commit squashing @@ -109,7 +112,7 @@ Global Options: -y, --yes Skip approval prompts -Unlike `git merge`, this merges the current branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The target defaults to the default branch. +Unlike `git merge`, this merges the source branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The source defaults to the current branch; the target defaults to the default branch. ## Examples @@ -138,6 +141,15 @@ Merge to a different branch: $ wt merge develop ``` +Merge another worktree's branch without leaving the current worktree: + +```bash +$ wt merge --branch feature/auth +$ wt merge develop --branch feature/auth +``` + +The source branch must have a checked-out worktree. Commit, squash, rebase, and hooks use that worktree and its project configuration. Cleanup removes the source worktree; the invoking worktree stays in place. A rebase conflict is left in the source worktree to resolve or abort. + Keep the worktree after merging: ```bash @@ -178,7 +190,7 @@ $ wt merge --no-commit --no-rebase 4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](/hook/). 5. **Merge** — Fast-forward merge to the target branch ([`wt step push`](/step/#wt-step-push)). With `--no-ff`, a merge commit is created instead — semi-linear history after the default rebase, while explicit `--no-rebase` preserves the graph produced by earlier steps before adding the merge commit. Non-fast-forward merges are rejected. 6. **Pre-remove hooks** — Hooks run before removing worktree. Failures abort. -7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, or locked, the worktree is preserved. +7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch, in the primary worktree, locked, or containing another registered worktree, the worktree is preserved. 8. **Post-remove + post-merge hooks** — Run in background after cleanup. Use `--no-commit` to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless `--no-rebase` is passed. Combining both flags preserves the exact source graph and requires the target to be its ancestor. Useful after preparing commits manually with `wt step commit`. Requires a clean working tree. diff --git a/tests/snapshots/integration__integration_tests__help__help_md_root.snap b/tests/snapshots/integration__integration_tests__help__help_md_root.snap index 61afbad99..84d6daa16 100644 --- a/tests/snapshots/integration__integration_tests__help__help_md_root.snap +++ b/tests/snapshots/integration__integration_tests__help__help_md_root.snap @@ -49,7 +49,7 @@ Commands: switch Switch to a worktree; create if needed list List worktrees and their status remove Remove worktree; delete branch if merged - merge Merge current branch into the target branch + merge Merge a branch into the target branch step Run individual operations hook Run configured hooks config Manage user & project configs diff --git a/tests/snapshots/integration__integration_tests__help__help_merge_long.snap b/tests/snapshots/integration__integration_tests__help__help_merge_long.snap index 14876477c..3e00c5a59 100644 --- a/tests/snapshots/integration__integration_tests__help__help_merge_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_merge_long.snap @@ -42,7 +42,7 @@ info: success: true exit_code: 0 ----- stdout ----- -wt merge - Merge current branch into the target branch +wt merge - Merge a branch into the target branch Squash & rebase, fast-forward the target branch, remove the worktree. @@ -55,6 +55,9 @@ Usage: wt merge [OPTIONS] [TARGET] Defaults to default branch. Options: + -b, --branch  + Branch to merge from (defaults to current worktree) + --no-squash Skip commit squashing @@ -109,7 +112,7 @@ Usage: wt merge [OPTIONS] [TARGET] -y, --yes Skip approval prompts -Unlike git merge, this merges the current branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The target defaults to the default branch. +Unlike git merge, this merges the source branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The source defaults to the current branch; the target defaults to the default branch. Examples @@ -132,6 +135,13 @@ Merge to a different branch:   wt merge develop +Merge another worktree's branch without leaving the current worktree: + +  wt merge --branch feature/auth +  wt merge develop --branch feature/auth + +The source branch must have a checked-out worktree. Commit, squash, rebase, and hooks use that worktree and its project configuration. Cleanup removes the source worktree; the invoking worktree stays in place. A rebase conflict is left in the source worktree to resolve or abort. + Keep the worktree after merging:   wt merge --no-remove @@ -162,7 +172,7 @@ Preserve the exact clean commit graph and tip: 4. Pre-merge hooks — Hooks run after rebase, before merge. Failures abort. See wt hook. 5. Merge — Fast-forward merge to the target branch (wt step push). With --no-ff, a merge commit is created instead — semi-linear history after the default rebase, while explicit --no-rebase preserves the graph produced by earlier steps before adding the merge commit. Non-fast-forward merges are rejected. 6. Pre-remove hooks — Hooks run before removing worktree. Failures abort. -7. Cleanup — Removes the worktree and branch. Use --no-remove to keep the worktree. When already on the target branch, in the primary worktree, or locked, the worktree is preserved. +7. Cleanup — Removes the worktree and branch. Use --no-remove to keep the worktree. When already on the target branch, in the primary worktree, locked, or containing another registered worktree, the worktree is preserved. 8. Post-remove + post-merge hooks — Run in background after cleanup. Use --no-commit to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless --no-rebase is passed. Combining both flags preserves the exact source graph and requires the target to be its ancestor. Useful after preparing commits manually with wt step commit. Requires a clean working tree. diff --git a/tests/snapshots/integration__integration_tests__help__help_merge_short.snap b/tests/snapshots/integration__integration_tests__help__help_merge_short.snap index c8e4cdcdb..44913da42 100644 --- a/tests/snapshots/integration__integration_tests__help__help_merge_short.snap +++ b/tests/snapshots/integration__integration_tests__help__help_merge_short.snap @@ -42,7 +42,7 @@ info: success: true exit_code: 0 ----- stdout ----- -wt merge - Merge current branch into the target branch +wt merge - Merge a branch into the target branch Usage: wt merge [OPTIONS] [TARGET] @@ -50,13 +50,14 @@ Usage: wt merge [OPTIONS] [TARGET] [TARGET] Target branch Options: - --no-squash Skip commit squashing - --no-commit Skip commit and squash - --no-rebase Skip rebase; require the target to fast-forward to the resulting tip - --no-remove Keep worktree after merge - --no-ff Create a merge commit (no fast-forward) - --stage  What to stage before committing [default: all] [possible values: all, tracked, none] - -h, --help Print help (see more with '--help') + -b, --branch  Branch to merge from (defaults to current worktree) + --no-squash Skip commit squashing + --no-commit Skip commit and squash + --no-rebase Skip rebase; require the target to fast-forward to the resulting tip + --no-remove Keep worktree after merge + --no-ff Create a merge commit (no fast-forward) + --stage  What to stage before committing [default: all] [possible values: all, tracked, none] + -h, --help Print help (see more with '--help') Automation: --no-hooks Skip hooks diff --git a/tests/snapshots/integration__integration_tests__help__help_no_args.snap b/tests/snapshots/integration__integration_tests__help__help_no_args.snap index b6cef5439..bee43648d 100644 --- a/tests/snapshots/integration__integration_tests__help__help_no_args.snap +++ b/tests/snapshots/integration__integration_tests__help__help_no_args.snap @@ -48,7 +48,7 @@ Usage: wt [OPTIONS] [COMMAND] switch Switch to a worktree; create if needed list List worktrees and their status remove Remove worktree; delete branch if merged - merge Merge current branch into the target branch + merge Merge a branch into the target branch step Run individual operations hook Run configured hooks config Manage user & project configs diff --git a/tests/snapshots/integration__integration_tests__help__help_root_long.snap b/tests/snapshots/integration__integration_tests__help__help_root_long.snap index 6641595ce..b28571f79 100644 --- a/tests/snapshots/integration__integration_tests__help__help_root_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_root_long.snap @@ -49,7 +49,7 @@ Usage: wt [OPTIONS] [COMMAND] switch Switch to a worktree; create if needed list List worktrees and their status remove Remove worktree; delete branch if merged - merge Merge current branch into the target branch + merge Merge a branch into the target branch step Run individual operations hook Run configured hooks config Manage user & project configs diff --git a/tests/snapshots/integration__integration_tests__help__help_root_short.snap b/tests/snapshots/integration__integration_tests__help__help_root_short.snap index 84c749403..8cc5b107f 100644 --- a/tests/snapshots/integration__integration_tests__help__help_root_short.snap +++ b/tests/snapshots/integration__integration_tests__help__help_root_short.snap @@ -49,7 +49,7 @@ Usage: wt [OPTIONS] [COMMAND] switch Switch to a worktree; create if needed list List worktrees and their status remove Remove worktree; delete branch if merged - merge Merge current branch into the target branch + merge Merge a branch into the target branch step Run individual operations hook Run configured hooks config Manage user & project configs