Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions docs/content/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Preserve the exact clean commit graph and tip:

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.

`wt merge` targets the *local* default-branch ref and never fetches. When that ref has fallen behind its upstream — e.g. a primary checkout's `main` left behind `origin/main` — the merge is refused rather than folding already-upstream commits into it. Updating the local branch from its upstream lifts the refusal.

## Local CI

For personal projects, pre-merge hooks open up the possibility of a workflow with much faster iteration — an order of magnitude more small changes instead of fewer large ones.
Expand Down
2 changes: 2 additions & 0 deletions plugins/worktrunk/skills/worktrunk/reference/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ $ wt merge --no-commit --no-rebase

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.

`wt merge` targets the *local* default-branch ref and never fetches. When that ref has fallen behind its upstream — e.g. a primary checkout's `main` left behind `origin/main` — the merge is refused rather than folding already-upstream commits into it. Updating the local branch from its upstream lifts the refusal.

## Local CI

For personal projects, pre-merge hooks open up the possibility of a workflow with much faster iteration — an order of magnitude more small changes instead of fewer large ones.
Expand Down
2 changes: 2 additions & 0 deletions skills/worktrunk/reference/merge.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,8 @@ $ wt merge --no-commit --no-rebase

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.

`wt merge` targets the *local* default-branch ref and never fetches. When that ref has fallen behind its upstream — e.g. a primary checkout's `main` left behind `origin/main` — the merge is refused rather than folding already-upstream commits into it. Updating the local branch from its upstream lifts the refusal.

## Local CI

For personal projects, pre-merge hooks open up the possibility of a workflow with much faster iteration — an order of magnitude more small changes instead of fewer large ones.
Expand Down
14 changes: 14 additions & 0 deletions src/commands/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,20 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> {

// Get and validate target branch (must be a branch since we're updating it)
let target_branch = repo.require_target_branch(target)?;

// Data-safety guard (#3519): refuse to squash/rebase against a local target
// ref that has fallen behind its upstream. Doing so would fold commits
// already published on the upstream into a new commit on the local ref,
// corrupting it (and, if later pushed, duplicating upstream content under
// new SHAs). Local-only check — no fetch, consistent with local-first merge.
if let Some(upstream) = repo.merge_target_behind_upstream(&target_branch)? {
return Err(worktrunk::git::GitError::MergeTargetBehindUpstream {
target_branch: target_branch.clone(),
upstream,
}
.into());
}

// Worktree for target is optional: if present we use it for safety checks and as destination.
let target_worktree_path = repo.worktree_for_branch(&target_branch)?;
// Where `post-merge` / `post-remove` / `post-switch` run: the target
Expand Down
34 changes: 34 additions & 0 deletions src/git/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,18 @@ pub enum GitError {
target_branch: String,
error: String,
},
/// `wt merge` would squash/rebase against a *local* target ref that has
/// fallen behind its upstream, folding commits already published on the
/// upstream into a new commit on that local ref — corrupting it (#3519).
/// Detected locally, with no fetch (worktrunk is local-first): the target
/// branch has an upstream that resolves locally and
/// `merge-base(HEAD, upstream)` is not an ancestor of the local target, so
/// the merge span reaches commits the upstream already contains. Refused
/// rather than silently mutating a stale ref, per the Data Safety posture.
MergeTargetBehindUpstream {
target_branch: String,
upstream: String,
},

// Validation/other errors
NotInteractive,
Expand Down Expand Up @@ -726,6 +738,13 @@ impl GitError {
cformat!("Can't push to local <bold>{target_branch}</> branch")
}

GitError::MergeTargetBehindUpstream {
target_branch,
upstream,
} => cformat!(
"Local <bold>{target_branch}</> is behind <bold>{upstream}</> — merging would fold already-upstream commits into <bold>{target_branch}</>"
),

GitError::NotInteractive => {
"Cannot prompt for approval in non-interactive environment".to_string()
}
Expand Down Expand Up @@ -1191,6 +1210,21 @@ impl GitError {
write!(f, "{}", format_error_block(error_message(&title), error))
}

GitError::MergeTargetBehindUpstream {
target_branch,
upstream,
} => {
let title = self.title();
write!(
f,
"{}\n{}",
error_message(&title),
hint_message(cformat!(
"Update <underline>{target_branch}</> from <underline>{upstream}</> before merging, or specify a different target"
))
)
}

GitError::NotInteractive => {
let title = self.title();
let approvals_cmd = suggest_command("config", &["approvals", "add"], &[]);
Expand Down
39 changes: 39 additions & 0 deletions src/git/repository/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,45 @@ impl Repository {
self.rev_parse_commit(r)
}

/// Whether merging `HEAD` into the local `target_branch` ref would fold in
/// commits already reachable from the target's upstream — the #3519
/// stale-local-default-branch hazard.
///
/// `wt merge` squashes/rebases the span `merge-base(target, HEAD)..HEAD`
/// and fast-forwards the *local* `target` ref to the result. When that
/// local ref is behind its upstream (e.g. the primary checkout's `main` is
/// behind `origin/main`) and `HEAD` descends from the newer upstream tip,
/// the span sweeps in commits already published upstream, folding them into
/// a new commit and leaving the local ref diverged from its upstream.
///
/// Detected locally, with no fetch (worktrunk is local-first): fires when
/// the target branch has a configured upstream and `merge-base(HEAD,
/// upstream)` is not an ancestor of the local `target` ref — i.e. the merge
/// span reaches commits the upstream already contains. Quiet on a
/// legitimately-diverged local target (where the span is only the feature's
/// own commits, so the fork point is still an ancestor of the local target)
/// and on an orphan feature with no shared history (nothing upstream to
/// re-fold). Returns the upstream's short name (e.g. `origin/main`) for the
/// caller's error; `None` when there's no upstream or the span is clean.
pub fn merge_target_behind_upstream(
&self,
target_branch: &str,
) -> anyhow::Result<Option<String>> {
// `upstream()` already excludes a `[gone]` upstream, so a `Some` value
// is a live remote-tracking ref that `merge_base` resolves locally — no
// fetch, and no separate existence check.
let Some(upstream) = self.branch(target_branch).upstream()? else {
return Ok(None);
};
let target_sha = self.resolve_to_commit_sha(target_branch)?;
match self.merge_base("HEAD", &upstream)? {
Some(fork_point) if !self.is_ancestor_by_sha(&fork_point, &target_sha)? => {
Ok(Some(upstream))
}
_ => Ok(None),
}
}

/// Check if a branch is integrated into a target.
///
/// Combines [`compute_integration_lazy()`] and [`check_integration()`], and
Expand Down
103 changes: 103 additions & 0 deletions tests/integration_tests/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4400,6 +4400,109 @@ fn test_merge_removes_branch_when_local_main_diverged_from_upstream(
);
}

/// Data-safety regression (#3519): `wt merge` must refuse to squash/rebase
/// against a *local* target ref that has fallen behind its upstream. Otherwise
/// the merge span `merge-base(local-main, HEAD)..HEAD` sweeps in commits already
/// on `origin/main` and folds them into a new squash commit on the local ref —
/// corrupting the primary checkout's `main` and, if later pushed, duplicating
/// upstream content under new SHAs.
///
/// Contrast with `test_merge_removes_branch_when_local_main_diverged_from_upstream`:
/// there the feature forks from the *shared base*, so the span is only the
/// feature's own commit and the merge legitimately succeeds. Here the feature
/// forks from the newer `origin/main` tip, so the span reaches already-upstream
/// commits and the merge must fail rather than mutate the stale ref.
#[rstest]
fn test_merge_refuses_stale_local_default_behind_upstream(
#[from(repo_with_remote)] repo: TestRepo,
) {
let remote_path = repo.remote_path().unwrap().to_path_buf();

// Advance origin/main by two commits (stand-ins for already-merged PRs)
// from a second clone, without touching the primary's local main.
let github_sim = repo.home_path().join("github-sim");
repo.run_git_in(
repo.home_path(),
&["clone", remote_path.to_str().unwrap(), "github-sim"],
);
for (file, msg) in [
("upstream-1.txt", "Upstream PR 1 (already merged)"),
("upstream-2.txt", "Upstream PR 2 (already merged)"),
] {
fs::write(github_sim.join(file), "upstream").unwrap();
repo.run_git_in(&github_sim, &["add", file]);
repo.run_git_in(&github_sim, &["commit", "-m", msg]);
}
repo.run_git_in(&github_sim, &["push", "origin", "main"]);

// Primary fetches origin (so origin/main is known locally) but leaves its
// local main stale — behind origin/main by the two upstream commits.
repo.run_git(&["fetch", "origin"]);
let stale_main = repo.git_output(&["rev-parse", "main"]);
assert!(
!repo
.git_command()
.args(["merge-base", "--is-ancestor", "origin/main", "main"])
.run()
.unwrap()
.status
.success(),
"setup: local main should be behind origin/main"
);

// Create a feature worktree off the *newer* origin/main tip (mirrors
// `wt switch --create feature --base origin/main`) with one real commit.
let feature_wt = repo.root_path().parent().unwrap().join("repo.feature");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
feature_wt.to_str().unwrap(),
"origin/main",
]);
fs::write(feature_wt.join("feature.txt"), "feature").unwrap();
repo.run_git_in(&feature_wt, &["add", "feature.txt"]);
repo.run_git_in(
&feature_wt,
&["commit", "-m", "the one real feature commit"],
);

// Merge must fail rather than fold the two upstream commits into main.
let output = repo
.wt_command()
.args(["merge", "main", "--yes", "--no-hooks"])
.current_dir(&feature_wt)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"merge must refuse a stale local default branch\nstderr:\n{stderr}",
);
assert!(
stderr.contains("is behind"),
"error should explain the stale local target\nstderr:\n{stderr}",
);

// The local main ref must be untouched — not fast-forwarded, not squashed.
assert_eq!(
repo.git_output(&["rev-parse", "main"]),
stale_main,
"local main must not be mutated by a refused merge",
);
// The feature branch must survive a refused merge (no removal).
assert!(
repo.git_command()
.args(["rev-parse", "--verify", "--quiet", "refs/heads/feature"])
.run()
.unwrap()
.status
.success(),
"feature branch must survive a refused merge",
);
}

/// Approval-boundary TOCTOU regression: a `post-merge` command that enters the
/// invoking worktree's `.config/wt.toml` only via the rebase — after the gate
/// froze the plan — must not run.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ info:
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"
Expand Down Expand Up @@ -173,6 +174,8 @@ $ wt merge --no-commit --no-rebase

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.

`wt merge` targets the *local* default-branch ref and never fetches. When that ref has fallen behind its upstream — e.g. a primary checkout's `main` left behind `origin/main` — the merge is refused rather than folding already-upstream commits into it. Updating the local branch from its upstream lifts the refusal.

## Local CI

For personal projects, pre-merge hooks open up the possibility of a workflow with much faster iteration — an order of magnitude more small changes instead of fewer large ones.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ info:
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"
Expand Down Expand Up @@ -157,6 +158,8 @@ Preserve the exact clean commit graph and tip:

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.

wt merge targets the *local* default-branch ref and never fetches. When that ref has fallen behind its upstream — e.g. a primary checkout's main left behind origin/main — the merge is refused rather than folding already-upstream commits into it. Updating the local branch from its upstream lifts the refusal.

Local CI

For personal projects, pre-merge hooks open up the possibility of a workflow with much faster iteration — an order of magnitude more small changes instead of fewer large ones.
Expand Down
Loading