1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
use std::path::Path;
use anyhow::Context;
use worktrunk::HookType;
use worktrunk::config::{Approvals, MergeConfig, UserConfig};
use worktrunk::git::Repository;
use worktrunk::styling::{eprintln, info_message};
use super::command_approval::approve_command_batch;
use super::command_executor::FailureStrategy;
use super::commit::{CommitOptions, HookGate};
use super::context::CommandEnv;
use super::hooks::{HookAnnouncer, execute_hook};
use super::project_config::{
ApprovableCommand, collect_commands_for_hooks, collect_remove_hook_commands,
};
use super::repository_ext::RepositoryCliExt;
use super::template_vars::TemplateVars;
use super::worktree::{
FinishAfterMergeArgs, MergeOperations, PushKind, finish_after_merge, handle_no_ff_merge,
handle_push,
};
/// Tri-state CLI overrides for the six `wt merge` boolean flags. `None` =
/// fall through to effective config; `Some(b)` = user explicitly chose.
pub struct MergeFlagOverrides {
pub squash: Option<bool>,
pub commit: Option<bool>,
pub rebase: Option<bool>,
pub remove: Option<bool>,
pub ff: Option<bool>,
pub verify: Option<bool>,
}
impl MergeFlagOverrides {
pub fn from_cli(args: &crate::cli::MergeArgs) -> Self {
Self {
squash: crate::flag_pair(args.squash, args.no_squash),
commit: crate::flag_pair(args.commit, args.no_commit),
rebase: crate::flag_pair(args.rebase, args.no_rebase),
remove: crate::flag_pair(args.remove, args.no_remove),
ff: crate::flag_pair(args.ff, args.no_ff),
verify: crate::flag_pair(args.verify, args.no_hooks || args.no_verify),
}
}
/// Apply the override → effective-config → default-true chain.
pub fn resolve(&self, config: &MergeConfig) -> ResolvedMergeFlags {
ResolvedMergeFlags {
squash: self.squash.unwrap_or(config.squash()),
commit: self.commit.unwrap_or(config.commit()),
rebase: self.rebase.unwrap_or(config.rebase()),
remove: self.remove.unwrap_or(config.remove()),
ff: self.ff.unwrap_or(config.ff()),
verify: self.verify.unwrap_or(config.verify()),
}
}
}
pub struct ResolvedMergeFlags {
pub squash: bool,
pub commit: bool,
pub rebase: bool,
pub remove: bool,
pub ff: bool,
pub verify: bool,
}
/// Options for the merge command. `flags` carries tri-state CLI overrides for
/// the six boolean flags; `stage` is the same shape but for stage mode.
pub struct MergeOptions<'a> {
pub target: Option<&'a str>,
pub flags: MergeFlagOverrides,
pub yes: bool,
pub stage: Option<super::commit::StageMode>,
pub format: crate::cli::SwitchFormat,
}
/// Collect all commands that will be executed during merge, for batch approval.
///
/// Returns (commands, project_identifier). Each hook is approved against the
/// `.config/wt.toml` of the worktree it runs in (and is resolved from):
///
/// - `pre-commit` / `post-commit` / `pre-merge` → the feature worktree
/// (= `repo`'s cwd).
/// - `post-merge` → the merge destination (`destination_path`).
/// - `pre-remove` / `post-remove` / `post-switch` (when the feature worktree
/// is being removed) → resolved by [`collect_remove_hook_commands`], which
/// reads `pre-remove` from the feature worktree's own `.config/wt.toml`
/// and `post-remove` / `post-switch` from the destination — mirroring
/// `output::handlers::execute_pre_remove_hooks_if_needed`. No fallback
/// between worktrees — each `.config/wt.toml` stands alone.
fn collect_merge_commands(
repo: &Repository,
destination_path: &Path,
commit: bool,
verify: bool,
will_remove: bool,
squash_enabled: bool,
) -> anyhow::Result<(Vec<ApprovableCommand>, String)> {
let mut feature_hooks = Vec::new();
let mut destination_hooks = Vec::new();
// Pre-commit hooks run when a commit will actually be created
let will_create_commit = repo.current_worktree().is_dirty()? || squash_enabled;
if commit && verify && will_create_commit {
feature_hooks.push(HookType::PreCommit);
feature_hooks.push(HookType::PostCommit);
}
if verify {
feature_hooks.push(HookType::PreMerge);
destination_hooks.push(HookType::PostMerge);
}
let mut all_commands = Vec::new();
if let Some(cfg) = repo.load_project_config()? {
all_commands.extend(collect_commands_for_hooks(&cfg, &feature_hooks));
}
if !destination_hooks.is_empty() || (verify && will_remove) {
let destination_repo = Repository::at(destination_path)?;
if !destination_hooks.is_empty()
&& let Some(cfg) = destination_repo.load_project_config()?
{
all_commands.extend(collect_commands_for_hooks(&cfg, &destination_hooks));
}
if verify && will_remove {
let current_wt = repo.current_worktree();
let feature_path = current_wt.path();
// The feature worktree is removed; the user lands in the merge
// destination, so `post-switch` reads `destination_path`'s config.
all_commands.extend(collect_remove_hook_commands(
&[feature_path],
&[destination_path],
)?);
}
}
Ok((all_commands, repo.project_identifier()?))
}
pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> {
let json_mode = opts.format == crate::cli::SwitchFormat::Json;
let MergeOptions {
target,
flags,
yes,
stage,
..
} = opts;
// Load config once, run LLM setup prompt if committing, then reuse config
let mut config = UserConfig::load().context("Failed to load config")?;
if flags.commit.unwrap_or(true) {
// One-time LLM setup prompt (errors logged internally; don't block merge)
let _ = crate::output::prompt_commit_generation(&mut config);
}
let env = CommandEnv::for_action(config)?;
let repo = &env.repo;
let config = &env.config;
// Merge requires being on a branch (can't merge from detached HEAD)
let current_branch = env.require_branch("merge")?.to_string();
// Get effective settings (project-specific merged with global, defaults applied)
let resolved = env.resolved();
let ResolvedMergeFlags {
squash,
commit,
rebase,
remove,
ff,
verify,
} = 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()?;
if !dirty_files.is_empty() {
return Err(worktrunk::git::GitError::UncommittedChanges {
action: Some("merge with --no-commit".into()),
branch: Some(current_branch),
force_hint: false,
dirty_files,
}
.into());
}
}
// --no-commit implies --no-squash
let squash_enabled = squash && commit;
// Get and validate target branch (must be a branch since we're updating it)
let target_branch = repo.require_target_branch(target)?;
// 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 (and resolve their
// `.config/wt.toml`): the target branch's worktree if it exists, else the
// primary worktree. Mirrors `finish_after_merge`'s destination resolution.
let destination_path = match &target_worktree_path {
Some(path) => path.clone(),
None => repo.home_path()?,
};
// Quick check for command approval: will removal be attempted?
// The authoritative guard is prepare_merge_removal (shared with wt remove),
// but we need a lightweight answer here to decide whether to include
// pre-remove/post-remove hooks in the batch approval prompt.
let on_target = current_branch == target_branch;
let remove_requested = remove && !on_target;
// Collect and approve all commands upfront for batch permission request
let (all_commands, project_id) = collect_merge_commands(
repo,
&destination_path,
commit,
verify,
remove_requested,
squash_enabled,
)?;
// Approve all commands in a single batch (shows templates, not expanded values)
let approvals = Approvals::load().context("Failed to load approvals")?;
let approved = approve_command_batch(&all_commands, &project_id, &approvals, yes, false)?;
// Commit-phase gate uses the original `verify` (before the shadow below) so it can
// distinguish --no-hooks from declined-approval; CommitOptions and handle_squash
// need that distinction to suppress a duplicate "(--no-hooks)" line.
let commit_hooks = HookGate::from_approval(verify, approved);
// If commands were declined, skip hooks but continue with merge.
// Shadow verify to gate all subsequent hook execution (pre-merge, post-merge,
// pre-remove, post-switch) on approval.
let verify = if approved {
verify
} else {
eprintln!("{}", info_message("Commands declined, continuing merge"));
false
};
// One announcer for the whole command's background hooks: post-commit
// (from auto-commit or squash), post-remove + post-switch (from worktree
// removal), and post-merge share a single `◎ Running …` line flushed at
// the end.
let mut announcer = HookAnnouncer::new(repo, config, false);
// Handle uncommitted changes (skip if --no-commit) - track whether commit occurred
let committed = if commit && current_wt.is_dirty()? {
if squash_enabled {
false // Squash path handles staging and committing
} else {
let ctx = env.context(yes);
let mut options = CommitOptions::new(&ctx);
options.target_branch = Some(&target_branch);
options.hooks = commit_hooks;
options.stage_mode = stage_mode;
options.warn_about_untracked = stage_mode == super::commit::StageMode::All;
options.show_no_squash_note = true;
let _ = options.commit(&mut announcer)?;
true // Committed directly
}
} else {
false // No dirty changes or --no-commit
};
// Squash commits if enabled - track whether squashing occurred.
// Pass `commit_hooks` (not the shadowed `verify`) so handle_squash gets the
// --no-hooks vs declined-approval distinction.
let squashed = if squash_enabled {
matches!(
super::step::handle_squash(
Some(&target_branch),
yes,
commit_hooks,
Some(stage_mode),
&mut announcer,
)?,
super::step::SquashResult::Squashed { .. }
)
} else {
false
};
// Rebase onto target - track whether rebasing occurred
let rebased = if rebase {
// Auto-rebase onto target
matches!(
super::step::handle_rebase(Some(&target_branch))?,
super::step::RebaseResult::Rebased { .. }
)
} else {
// --no-rebase: verify already rebased, fail if not
if !repo.is_rebased_onto(&target_branch)? {
return Err(worktrunk::git::GitError::NotRebased { target_branch }.into());
}
false // Already rebased, no rebase occurred
};
// Run pre-merge checks unless --no-hooks was specified
// Do this after commit/squash/rebase to validate the final state that will be pushed
if verify {
let ctx = env.context(yes);
let mut vars = TemplateVars::new().with_target(&target_branch);
if let Some(p) = target_worktree_path.as_deref() {
vars = vars.with_target_worktree_path(p);
}
execute_hook(
&ctx,
HookType::PreMerge,
&vars.as_extra_vars(),
FailureStrategy::FailFast,
crate::output::pre_hook_display_path(ctx.worktree_path),
)?;
}
// Merge to target branch
let operations = Some(MergeOperations {
committed,
squashed,
rebased,
});
if !ff {
// Create a merge commit on the target branch via commit-tree + update-ref
let _ = handle_no_ff_merge(Some(&target_branch), operations, ¤t_branch)?;
} else {
// Fast-forward push to target branch
let _ = handle_push(Some(&target_branch), PushKind::MergeFastForward, operations)?;
}
let removed = finish_after_merge(
repo,
config,
&env,
&mut announcer,
FinishAfterMergeArgs {
current_branch: ¤t_branch,
target_branch: &target_branch,
target_worktree_path: target_worktree_path.as_deref(),
remove,
verify,
yes,
},
)?;
announcer.flush()?;
if json_mode {
let output = serde_json::json!({
"branch": current_branch,
"target": target_branch,
"committed": committed,
"squashed": squashed,
"rebased": rebased,
"removed": removed,
});
println!("{}", serde_json::to_string_pretty(&output)?);
}
Ok(())
}