Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/commands/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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()?;
Expand Down
7 changes: 5 additions & 2 deletions src/git/repository/working_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
19 changes: 19 additions & 0 deletions tests/integration_tests/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =============================================================================
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading