From 4f2d106c26bc53412ae50bd4b230539562133e98 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 24 Jul 2026 18:24:36 -0700 Subject: [PATCH] fix(merge): refuse unresolved conflicts in merge's own name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wt merge` inherited the index gate from whichever step it delegated to, so it answered `Cannot squash` (via `wt step squash`) or `Cannot commit` (via the direct-commit path) for a command the user typed as `merge`. Its sibling operation gate already ran at merge's entry in merge's own name; the index gate is the one that hadn't been hoisted. Moving it next to `ensure_no_operation_in_progress("merge")` restores the pattern — each entry point gates in its own name — and refuses ahead of the approval prompts instead of partway through the flow. `--no-commit` changes which error it hits: the dirty-tree check used to answer "commit your changes", which is the wrong advice when the changes are conflict markers. Co-Authored-By: Claude Opus 5 --- src/commands/merge.rs | 9 ++-- src/git/repository/working_tree.rs | 7 ++- tests/integration_tests/merge.rs | 19 +++++++ ...__merge__merge_refuses_unmerged_paths.snap | 52 +++++++++++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 tests/snapshots/integration__integration_tests__merge__merge_refuses_unmerged_paths.snap diff --git a/src/commands/merge.rs b/src/commands/merge.rs index 58f969765c..f67a2c65cb 100644 --- a/src/commands/merge.rs +++ b/src/commands/merge.rs @@ -167,10 +167,16 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { let env = CommandEnv::for_action(config)?; let repo = &env.repo; let config = &env.config; + // Cache current worktree for multiple queries + let current_wt = repo.current_worktree(); // Ahead of the branch check: mid-rebase and mid-bisect both detach HEAD, so // an unguarded merge blames the detached HEAD and points at `git switch`, // which abandons the operation instead of finishing it. repo.ensure_no_operation_in_progress("merge")?; + // Merge stages on the user's behalf through the commit and squash steps, so + // it takes their index gate here: in its own name, and ahead of the + // approval prompts rather than partway through the flow. + current_wt.ensure_no_unmerged_paths("merge")?; // Merge requires being on a branch (can't merge from detached HEAD) let current_branch = env.require_branch("merge")?.to_string(); @@ -187,9 +193,6 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { } = flags.resolve(&resolved.merge); let stage_mode = stage.unwrap_or(resolved.commit.stage()); - // Cache current worktree for multiple queries - let current_wt = repo.current_worktree(); - // Validate --no-commit: requires clean working tree if !commit { let dirty_files = current_wt.dirty_files()?; diff --git a/src/git/repository/working_tree.rs b/src/git/repository/working_tree.rs index 18e8b67128..97fba0c21c 100644 --- a/src/git/repository/working_tree.rs +++ b/src/git/repository/working_tree.rs @@ -512,12 +512,15 @@ impl<'a> WorkingTree<'a> { /// Fail when the index still holds unresolved conflicts. /// /// A precondition for the commands that stage on the user's behalf - /// (`wt step commit`, `wt step squash`). `git add -A` collapses an - /// unmerged path's three stages into one entry, so it resolves the + /// (`wt step commit`, `wt step squash`, `wt merge`). `git add -A` collapses + /// an unmerged path's three stages into one entry, so it resolves the /// conflict as far as the index is concerned while the file on disk still /// holds `<<<<<<<` markers — and it takes git's own refusal to commit /// with it. Asking before staging is what keeps the markers out of a /// commit. + /// + /// `action` names the command the user typed, so each entry point gates in + /// its own name rather than in the name of a step it delegates to. pub fn ensure_no_unmerged_paths(&self, action: &str) -> anyhow::Result<()> { let files = self.unmerged_paths()?; if files.is_empty() { diff --git a/tests/integration_tests/merge.rs b/tests/integration_tests/merge.rs index d3ea8072b7..5c9041ae72 100644 --- a/tests/integration_tests/merge.rs +++ b/tests/integration_tests/merge.rs @@ -3589,6 +3589,25 @@ fn test_step_commit_refuses_unmerged_paths(mut repo: TestRepo) { ); } +/// `wt merge` stages through the commit and squash steps, so it needs the same +/// index gate — under its own name. Delegating the refusal to the step would +/// answer `wt merge` with "Cannot squash". +#[rstest] +fn test_merge_refuses_unmerged_paths(mut repo: TestRepo) { + let feature_wt = stop_feature_on_conflicted_stash_pop(&mut repo); + let head_before = repo.head_sha_in(&feature_wt); + + assert_cmd_snapshot!( + "merge_refuses_unmerged_paths", + make_snapshot_cmd(&repo, "merge", &["--yes"], Some(&feature_wt)) + ); + assert_eq!( + repo.head_sha_in(&feature_wt), + head_before, + "the refusal must leave HEAD alone; merging here would commit the conflict markers" + ); +} + // ============================================================================= // JSON output tests // ============================================================================= diff --git a/tests/snapshots/integration__integration_tests__merge__merge_refuses_unmerged_paths.snap b/tests/snapshots/integration__integration_tests__merge__merge_refuses_unmerged_paths.snap new file mode 100644 index 0000000000..bc6d4d25c2 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__merge__merge_refuses_unmerged_paths.snap @@ -0,0 +1,52 @@ +--- +source: tests/integration_tests/merge.rs +info: + program: wt + args: + - merge + - "--yes" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_PARENT_SHELL: "" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Cannot merge: 1 path with unresolved conflicts +  conflict.txt