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
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 lags its upstream — e.g. a primary checkout's `main` left behind `origin/main` — a branch based on the newer upstream tip is measured, squashed, and rebased against the upstream (so already-upstream commits are never folded into the squash), and the final fast-forward carries the local ref through the already-fetched upstream commits by their real SHAs. `wt step squash` and `wt step rebase` measure the same way. A local target that has *diverged* from its upstream — its own commits and behind — cannot fast-forward, so the merge is refused until the target is reconciled.

## 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 lags its upstream — e.g. a primary checkout's `main` left behind `origin/main` — a branch based on the newer upstream tip is measured, squashed, and rebased against the upstream (so already-upstream commits are never folded into the squash), and the final fast-forward carries the local ref through the already-fetched upstream commits by their real SHAs. `wt step squash` and `wt step rebase` measure the same way. A local target that has *diverged* from its upstream — its own commits and behind — cannot fast-forward, so the merge is refused until the target is reconciled.

## 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 lags its upstream — e.g. a primary checkout's `main` left behind `origin/main` — a branch based on the newer upstream tip is measured, squashed, and rebased against the upstream (so already-upstream commits are never folded into the squash), and the final fast-forward carries the local ref through the already-fetched upstream commits by their real SHAs. `wt step squash` and `wt step rebase` measure the same way. A local target that has *diverged* from its upstream — its own commits and behind — cannot fast-forward, so the merge is refused until the target is reconciled.

## 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
46 changes: 46 additions & 0 deletions src/commands/merge.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::path::Path;

use anyhow::Context;
use color_print::cformat;
use worktrunk::HookType;
use worktrunk::config::{MergeConfig, UserConfig};
use worktrunk::git::Repository;
Expand Down Expand Up @@ -204,6 +205,51 @@ 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)?;

// #3519: when the branch is based past the local target into the target's
// upstream, the rewrite steps measure against the upstream (see
// `span_upstream`) and the final fast-forward carries the local target
// through the upstream commits by their real SHAs. That fast-forward exists
// only while the local target is strictly behind its upstream; a target
// that has *diverged* — its own commits AND behind — can never fast-forward
// to this branch, so refuse up front with the real reason rather than
// failing late in the push step. Local-only check — no fetch.
if let Some(upstream) = repo.span_upstream(&target_branch)? {
let target_sha = repo
.run_command(&[
"rev-parse",
"--verify",
"--end-of-options",
&format!("refs/heads/{target_branch}"),
])?
.trim()
.to_string();
let upstream_sha = repo
.run_command(&["rev-parse", "--verify", "--end-of-options", &upstream])?
.trim()
.to_string();
if repo.is_ancestor_by_sha(&target_sha, &upstream_sha)? {
let carried = repo.count_commits(&target_branch, &upstream)?;
let (commit_text, pronoun) = if carried == 1 {
("commit", "it")
} else {
("commits", "them")
};
eprintln!(
"{}",
info_message(cformat!(
"Local <bold>{target_branch}</> is {carried} {commit_text} behind <bold>{upstream}</>; the merge fast-forwards through {pronoun}"
))
);
} else {
return Err(worktrunk::git::GitError::MergeTargetDivergedFromUpstream {
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
7 changes: 7 additions & 0 deletions src/commands/step/rebase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ pub fn handle_rebase(target: Option<&str>) -> anyhow::Result<RebaseResult> {

// Get and validate target ref (any commit-ish for rebase)
let integration_target = repo.require_target_ref(target)?;
// #3519: when the branch's history extends past the local target into the
// target's upstream, rebase onto the upstream instead — replaying the span
// onto the stale local ref would duplicate commits the upstream already
// contains under new SHAs.
let integration_target = repo
.span_upstream(&integration_target)?
.unwrap_or(integration_target);

// Check if already up-to-date (linear extension of target, no merge commits)
if repo.is_rebased_onto(&integration_target)? {
Expand Down
24 changes: 17 additions & 7 deletions src/commands/step/squash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ pub fn handle_squash(

// Get and validate target ref (any commit-ish for merge-base calculation)
let integration_target = repo.require_target_ref(target)?;
// #3519: when the branch's history extends past the local target into the
// target's upstream, measure the squash against the upstream — so commits
// already published there are never folded into the squash commit.
let span_target = repo
.span_upstream(&integration_target)?
.unwrap_or_else(|| integration_target.clone());
let template_vars = TemplateVars::new().with_target(&integration_target);

// Auto-stage changes before running pre-commit hooks so both beta and merge paths behave identically
Expand Down Expand Up @@ -169,7 +175,7 @@ pub fn handle_squash(

// Get merge base with target branch (required for squash)
let merge_base = repo
.merge_base("HEAD", &integration_target)?
.merge_base("HEAD", &span_target)?
.context("Cannot squash: no common ancestor with target branch")?;

// Count commits since merge base
Expand All @@ -182,7 +188,7 @@ pub fn handle_squash(
// Handle different scenarios
if commit_count == 0 && !has_staged {
// No commits and no staged changes - nothing to squash
return Ok(SquashResult::NoCommitsAhead(integration_target));
return Ok(SquashResult::NoCommitsAhead(span_target));
}

if commit_count == 1 && !has_staged {
Expand Down Expand Up @@ -251,7 +257,7 @@ pub fn handle_squash(

// Create safety backup before potentially destructive reset if there are working tree changes
if has_staged {
let backup_message = format!("{} → {} (squash)", current_branch, integration_target);
let backup_message = format!("{} → {} (squash)", current_branch, span_target);
let sha = wt.create_safety_backup(&backup_message)?;
eprintln!("{}", hint_message(format!("Backup created @ {sha}")));
}
Expand All @@ -275,7 +281,7 @@ pub fn handle_squash(
.unwrap_or("repo");

let commit_message = crate::llm::generate_squash_message(
&integration_target,
&span_target,
&merge_base,
&commit_details,
&current_branch,
Expand Down Expand Up @@ -363,12 +369,16 @@ fn preview_squash(target: Option<&str>, dry_run: bool, yes: bool) -> anyhow::Res
let commit_config = config.commit_generation(project_id.as_deref());

let integration_target = repo.require_target_ref(target)?;
// #3519: preview against the same upstream-aware span the real squash uses.
let span_target = repo
.span_upstream(&integration_target)?
.unwrap_or(integration_target);

let wt = repo.current_worktree();
let current_branch = wt.branch()?.unwrap_or_else(|| "HEAD".to_string());

let merge_base = repo
.merge_base("HEAD", &integration_target)?
.merge_base("HEAD", &span_target)?
.context("Cannot generate squash message: no common ancestor with target branch")?;

let range = format!("{}..HEAD", merge_base);
Expand All @@ -385,7 +395,7 @@ fn preview_squash(target: Option<&str>, dry_run: bool, yes: bool) -> anyhow::Res
let project_append = resolve_template_for_preview(&ctx, &commit_config, dry_run)?;

let prompt = crate::llm::build_squash_prompt(
&integration_target,
&span_target,
&merge_base,
&commit_details,
&current_branch,
Expand All @@ -398,7 +408,7 @@ fn preview_squash(target: Option<&str>, dry_run: bool, yes: bool) -> anyhow::Res
return Ok(());
}
let message = crate::llm::generate_squash_message(
&integration_target,
&span_target,
&merge_base,
&commit_details,
&current_branch,
Expand Down
32 changes: 32 additions & 0 deletions src/git/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,16 @@ pub enum GitError {
target_branch: String,
error: String,
},
/// `wt merge` requires the local target ref to fast-forward to the merge
/// result. When the target has both fallen behind its upstream and grown
/// its own commits — diverged — while the branch is based past it on the
/// upstream side, no fast-forward exists in any mode: the target's own
/// commits first need reconciling with the upstream (#3519). Detected
/// locally, with no fetch, before any rewrite or approval prompt.
MergeTargetDivergedFromUpstream {
target_branch: String,
upstream: String,
},

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

GitError::MergeTargetDivergedFromUpstream {
target_branch,
upstream,
} => cformat!(
"Local <bold>{target_branch}</> has diverged from <bold>{upstream}</> — can't fast-forward to a branch based on <bold>{upstream}</>"
),

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

GitError::MergeTargetDivergedFromUpstream {
target_branch,
upstream,
} => {
let title = self.title();
write!(
f,
"{}\n{}",
error_message(&title),
hint_message(cformat!(
"Reconcile <underline>{target_branch}</> with <underline>{upstream}</> (rebase or merge its local commits), or specify a different target"
))
)
}

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

/// The target's upstream, when the current branch's history extends past
/// the local `target_branch` ref into it — the #3519 stale-local-target
/// topology.
///
/// The rewrite steps (`wt step squash`, `wt step rebase`, and `wt merge`
/// through them) measure "the branch's own commits" as
/// `merge-base(target, HEAD)..HEAD`. When the local target ref lags its
/// upstream (e.g. the primary checkout's `main` left behind `origin/main`)
/// and the branch was built on the newer upstream tip, that span sweeps in
/// commits the upstream already contains — folding (squash) or replaying
/// (rebase) them would duplicate published history under new SHAs. Callers
/// measure against the returned upstream instead, which keeps the span to
/// the branch's own commits; `wt merge`'s final fast-forward then carries
/// the local target through the upstream commits by their real SHAs.
///
/// Detected locally, with no fetch (worktrunk is local-first): returns
/// `Some(upstream)` (short name, e.g. `origin/main`) 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 local-target span would
/// reach commits the upstream already has. `None` — measure against the
/// local target as usual — for a legitimately-diverged local target whose
/// span holds only the branch's own commits (the fork point is still an
/// ancestor of the local target), an orphan branch with no shared history,
/// a non-branch target (an explicit `origin/main` has no upstream of its
/// own), or a target with no upstream at all.
pub fn span_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
Loading
Loading