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
14 changes: 9 additions & 5 deletions docs/content/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,28 +50,32 @@ Preserve commit history (no squash):

{{ terminal(cmd="wt merge --no-squash") }}

Create a merge commit — semi-linear history:
Create a merge commit — rebased semi-linear history by default:

{{ terminal(cmd="wt merge --no-ff") }}

Skip committing/squashing (rebase still runs unless --no-rebase):

{{ terminal(cmd="wt merge --no-commit") }}

Preserve the exact clean commit graph and tip:

{{ terminal(cmd="wt merge --no-commit --no-rebase") }}

## Pipeline

`wt merge` runs these steps:

1. **Commit** — Pre-commit hooks run, then uncommitted changes are committed. Post-commit hooks run in background. Skipped when squashing (the default) — changes are staged during the squash step instead. With `--no-squash`, this is the only commit step.
2. **Squash** — Combines all commits since target into one (like GitHub's "Squash and merge"). Use `--stage` to control what gets staged: `all` (default), `tracked`, or `none`. A backup ref is saved to `refs/wt-backup/<branch>`. With `--no-squash`, individual commits are preserved.
3. **Rebase** — Rebases onto target if behind. Skipped if already up-to-date. Conflicts abort immediately.
3. **Rebase** — Rebases onto target if behind. Skipped if already up-to-date. Conflicts abort immediately. With `--no-rebase`, the graph produced by earlier commit/squash steps is preserved and the target must be able to fast-forward to its tip.
4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](@/hook.md).
5. **Merge** — Fast-forward merge to the target branch. With `--no-ff`, a merge commit is created instead — semi-linear history with rebased commits plus a merge commit. Non-fast-forward merges are rejected.
5. **Merge** — Fast-forward merge to the target branch. 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 or in the primary 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. Useful after preparing commits manually with `wt step commit`. Requires a clean working tree.
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.

## Local CI

Expand Down Expand Up @@ -116,7 +120,7 @@ Usage: <b><span class=c>wt merge</span></b> <span class=c>[OPTIONS]</span> <span
Skip commit and squash

<b><span class=c>--no-rebase</span></b>
Skip rebase (fail if not already rebased)
Skip rebase; require the target to fast-forward to the resulting tip

<b><span class=c>--no-remove</span></b>
Keep worktree after merge
Expand Down
16 changes: 11 additions & 5 deletions plugins/worktrunk/skills/worktrunk/reference/merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Preserve commit history (no squash):
$ wt merge --no-squash
```

Create a merge commit — semi-linear history:
Create a merge commit — rebased semi-linear history by default:

```bash
$ wt merge --no-ff
Expand All @@ -53,20 +53,26 @@ Skip committing/squashing (rebase still runs unless --no-rebase):
$ wt merge --no-commit
```

Preserve the exact clean commit graph and tip:

```bash
$ wt merge --no-commit --no-rebase
```

## Pipeline

`wt merge` runs these steps:

1. **Commit** — Pre-commit hooks run, then uncommitted changes are committed. Post-commit hooks run in background. Skipped when squashing (the default) — changes are staged during the squash step instead. With `--no-squash`, this is the only commit step.
2. **Squash** — Combines all commits since target into one (like GitHub's "Squash and merge"). Use `--stage` to control what gets staged: `all` (default), `tracked`, or `none`. A backup ref is saved to `refs/wt-backup/<branch>`. With `--no-squash`, individual commits are preserved.
3. **Rebase** — Rebases onto target if behind. Skipped if already up-to-date. Conflicts abort immediately.
3. **Rebase** — Rebases onto target if behind. Skipped if already up-to-date. Conflicts abort immediately. With `--no-rebase`, the graph produced by earlier commit/squash steps is preserved and the target must be able to fast-forward to its tip.
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. With `--no-ff`, a merge commit is created instead — semi-linear history with rebased commits plus a merge commit. Non-fast-forward merges are rejected.
5. **Merge** — Fast-forward merge to the target branch. 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 or in the primary 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. Useful after preparing commits manually with `wt step commit`. Requires a clean working tree.
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.

## Local CI

Expand Down Expand Up @@ -105,7 +111,7 @@ Options:
Skip commit and squash

--no-rebase
Skip rebase (fail if not already rebased)
Skip rebase; require the target to fast-forward to the resulting tip

--no-remove
Keep worktree after merge
Expand Down
16 changes: 11 additions & 5 deletions skills/worktrunk/reference/merge.md

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

16 changes: 11 additions & 5 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ pub(crate) struct MergeArgs {
#[arg(long, overrides_with = "no_rebase", hide = true)]
pub(crate) rebase: bool,

/// Skip rebase (fail if not already rebased)
/// Skip rebase; require the target to fast-forward to the resulting tip
#[arg(long = "no-rebase", overrides_with = "rebase")]
pub(crate) no_rebase: bool,

Expand Down Expand Up @@ -1375,7 +1375,7 @@ Preserve commit history (no squash):
$ wt merge --no-squash
```

Create a merge commit — semi-linear history:
Create a merge commit — rebased semi-linear history by default:

```console
$ wt merge --no-ff
Expand All @@ -1387,20 +1387,26 @@ Skip committing/squashing (rebase still runs unless --no-rebase):
$ wt merge --no-commit
```

Preserve the exact clean commit graph and tip:

```console
$ wt merge --no-commit --no-rebase
```

## Pipeline

`wt merge` runs these steps:

1. **Commit** — Pre-commit hooks run, then uncommitted changes are committed. Post-commit hooks run in background. Skipped when squashing (the default) — changes are staged during the squash step instead. With `--no-squash`, this is the only commit step.
2. **Squash** — Combines all commits since target into one (like GitHub's "Squash and merge"). Use `--stage` to control what gets staged: `all` (default), `tracked`, or `none`. A backup ref is saved to `refs/wt-backup/<branch>`. With `--no-squash`, individual commits are preserved.
3. **Rebase** — Rebases onto target if behind. Skipped if already up-to-date. Conflicts abort immediately.
3. **Rebase** — Rebases onto target if behind. Skipped if already up-to-date. Conflicts abort immediately. With `--no-rebase`, the graph produced by earlier commit/squash steps is preserved and the target must be able to fast-forward to its tip.
4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](@/hook.md).
5. **Merge** — Fast-forward merge to the target branch. With `--no-ff`, a merge commit is created instead — semi-linear history with rebased commits plus a merge commit. Non-fast-forward merges are rejected.
5. **Merge** — Fast-forward merge to the target branch. 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 or in the primary 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. Useful after preparing commits manually with `wt step commit`. Requires a clean working tree.
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.

## Local CI

Expand Down
18 changes: 14 additions & 4 deletions src/commands/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ use super::context::CommandEnv;
use super::flag_pair;
use super::hook_plan::{ApprovedHookPlan, HookPlanBuilder, execute_planned_hook};
use super::hooks::HookAnnouncer;
use super::repository_ext::RepositoryCliExt;
use super::template_vars::TemplateVars;
use super::worktree::{
FinishAfterMergeArgs, MergeOperations, PushKind, finish_after_merge, handle_no_ff_merge,
Expand Down Expand Up @@ -333,11 +332,22 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> {
super::step::RebaseResult::Rebased { .. }
)
} else {
// --no-rebase: verify already rebased, fail if not
if !repo.is_rebased_onto(&target_branch)? {
// --no-rebase preserves the graph produced by the commit/squash stages
// above. Merge commits in that graph are valid as long as the target
// can fast-forward to the final tip.
let target_ref = format!("refs/heads/{target_branch}");
let target_sha = repo
.run_command(&["rev-parse", "--verify", "--end-of-options", &target_ref])?
.trim()
.to_string();
let source_sha = repo
.run_command(&["rev-parse", "--verify", "HEAD"])?
.trim()
.to_string();
if !repo.is_ancestor_by_sha(&target_sha, &source_sha)? {
return Err(worktrunk::git::GitError::NotRebased { target_branch }.into());
}
false // Already rebased, no rebase occurred
false // Rebase skipped; the graph produced by earlier stages remains
};

// Run pre-merge checks unless --no-hooks was specified
Expand Down
8 changes: 6 additions & 2 deletions src/commands/worktree/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,10 @@ pub fn handle_push(
///
/// Uses git plumbing (`commit-tree` + `update-ref`) to create a merge commit
/// on the target branch without needing to check it out. This is safe because
/// rebase has already run, so the feature branch tree IS the correct merge result.
/// [`MergeContext::prepare`] verified that the target is an ancestor of the
/// feature tip, so the feature tree is the correct integration result. The
/// source may be rebased or may retain an explicitly preserved merge-shaped
/// graph.
///
/// If the target branch has a checked-out worktree, its working tree is synced
/// via `read-tree -m -u` after the ref update. We use a two-tree merge
Expand Down Expand Up @@ -380,7 +383,8 @@ pub fn handle_no_ff_merge(
}

// Create the merge commit using git plumbing.
// Since rebase has already run, HEAD's tree is the correct merge result.
// The target-is-ancestor check makes HEAD's tree the correct merge result,
// whether the source was rebased or explicitly preserved.
let tree = ctx
.repo
.run_command(&["rev-parse", "HEAD^{tree}"])?
Expand Down
24 changes: 24 additions & 0 deletions tests/integration_tests/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#![cfg(not(windows))]

use crate::common::{add_standard_env_redactions, wt_command};
use ansi_str::AnsiStr as _;
use insta::Settings;
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
Expand Down Expand Up @@ -54,6 +55,29 @@ fn snapshot_help(test_name: &str, args: &[&str]) {
});
}

#[test]
fn test_merge_help_describes_exact_shape_no_rebase() {
let output = wt_command()
.args(["merge", "--help"])
.output()
.expect("failed to run wt merge --help");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.ansi_strip();
assert!(
stdout.contains("Skip rebase; require the target to fast-forward to the resulting tip"),
"missing graph-preservation contract:\n{stdout}"
);
assert!(
stdout.contains("wt merge --no-commit --no-rebase"),
"missing exact-shape example:\n{stdout}"
);
assert!(
stdout.contains("explicit --no-rebase preserves the graph produced by earlier steps"),
"missing no-ff qualification:\n{stdout}"
);
}

// Root command (wt)
#[rstest]
#[case("help_root_short", "-h")]
Expand Down
Loading
Loading