workmux 0.1.261

An opinionated workflow tool that orchestrates git worktrees and tmux
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
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
use anyhow::{Context, Result, anyhow};

use crate::{cmd, git};
use tracing::{debug, info};

use super::cleanup::{self, get_worktree_mode};
use super::context::WorkflowContext;
use super::types::MergeResult;

/// Merge a branch into the target branch and clean up
#[allow(clippy::too_many_arguments)]
pub fn merge(
    name: &str,
    into_branch: Option<&str>,
    ignore_uncommitted: bool,
    rebase: bool,
    squash: bool,
    keep: bool,
    no_verify: bool,
    no_hooks: bool,
    notification: bool,
    context: &WorkflowContext,
) -> Result<MergeResult> {
    info!(
        name = name,
        into = into_branch,
        ignore_uncommitted,
        rebase,
        squash,
        keep,
        no_verify,
        no_hooks,
        "merge:start"
    );

    // Change CWD to main worktree to prevent errors if the command is run from within
    // the worktree that is about to be deleted.
    context.chdir_to_main_worktree()?;

    // Smart resolution: try handle first, then branch name
    let (worktree_path, branch_to_merge) = git::find_worktree(name).map_err(|_| {
        anyhow!(
            "Worktree '{}' not found. Use 'workmux list' to see available worktrees.",
            name
        )
    })?;

    if context.is_main_worktree(&worktree_path) {
        return Err(anyhow!(
            "Cannot merge branch '{}' because it is checked out in the main worktree at '{}'. \
            Create a linked worktree for '{}' first.",
            branch_to_merge,
            context.main_worktree_root.display(),
            branch_to_merge
        ));
    }

    // The handle is the basename of the worktree directory (used for tmux operations)
    let handle = worktree_path
        .file_name()
        .and_then(std::ffi::OsStr::to_str)
        .ok_or_else(|| {
            anyhow!(
                "Could not derive handle from worktree path: {}",
                worktree_path.display()
            )
        })?;

    // Capture metadata before cleanup removes it.
    let mode = get_worktree_mode(handle);
    let attachment = git::get_worktree_attachment_in(handle, Some(&context.execution_dir));

    debug!(
        name = name,
        handle = handle,
        branch = branch_to_merge,
        path = %worktree_path.display(),
        "merge:worktree resolved"
    );

    // Determine the target branch:
    // 1. Use explicit --into if provided
    // 2. Otherwise, check if branch has a stored base (from workmux add)
    // 3. Fall back to main_branch
    let detected_base: Option<String> = if into_branch.is_some() {
        None // User explicitly specified target, no auto-detection needed
    } else {
        match git::get_branch_base(&branch_to_merge) {
            Ok(base) => {
                // Verify the base branch still exists locally.
                if git::local_branch_exists(&base)? {
                    info!(
                        branch = %branch_to_merge,
                        base = %base,
                        "merge:auto-detected base branch"
                    );
                    Some(base)
                } else {
                    info!(
                        branch = %branch_to_merge,
                        base = %base,
                        "merge:base branch not found locally, defaulting to main"
                    );
                    None
                }
            }
            Err(_) => {
                debug!(
                    branch = %branch_to_merge,
                    "merge:no base config found, defaulting to main"
                );
                None
            }
        }
    };

    let target_branch = into_branch
        .map(|s| s.to_string())
        .or(detected_base)
        .unwrap_or_else(|| context.main_branch.clone());
    let target_branch = target_branch.as_str();

    // Resolve the worktree path and window handle for the TARGET branch.
    // We prioritize finding an existing worktree for the target branch to support
    // workflows where 'main' is checked out in a linked worktree (issue #29).
    let (target_worktree_path, target_window_name) = match git::get_worktree_path(target_branch) {
        Ok(path) => {
            // Target is checked out in a worktree (could be main root or a linked worktree)
            if path == context.main_worktree_root {
                // It's in the main root. Use the main branch name as the window handle.
                (path, context.main_branch.clone())
            } else {
                // It's in a linked worktree. Use the directory name as the handle.
                let handle = path
                    .file_name()
                    .and_then(|s| s.to_str())
                    .ok_or_else(|| anyhow!("Invalid worktree path for target branch"))?
                    .to_string();
                (path, handle)
            }
        }
        Err(_) => {
            // Target branch is NOT checked out anywhere.
            // We fallback to using the main worktree root to perform the merge.
            debug!(
                target = target_branch,
                "merge:target branch has no worktree, using main worktree"
            );
            (
                context.main_worktree_root.clone(),
                context.main_branch.clone(),
            )
        }
    };

    // Handle changes in the source worktree
    // Only check for unstaged/untracked when worktree will be deleted (!keep)
    // With --keep, the worktree persists so no data loss risk
    let has_unstaged = !keep && git::has_unstaged_changes(&worktree_path)?;
    let has_untracked = !keep && git::has_untracked_files(&worktree_path)?;

    if (has_unstaged || has_untracked) && !ignore_uncommitted {
        let mut issues = Vec::new();
        if has_unstaged {
            issues.push("unstaged changes");
        }
        if has_untracked {
            issues.push("untracked files (will be lost)");
        }
        return Err(anyhow!(
            "Worktree for '{}' has {}. Please stage or stash them, or use --ignore-uncommitted.",
            branch_to_merge,
            issues.join(" and ")
        ));
    }

    let had_staged_changes = git::has_staged_changes(&worktree_path)?;
    if had_staged_changes && !ignore_uncommitted {
        // Commit using git's editor (respects $EDITOR or git config)
        info!(path = %worktree_path.display(), "merge:committing staged changes");
        git::commit_with_editor(&worktree_path).context("Failed to commit staged changes")?;
    }

    if branch_to_merge == target_branch {
        return Err(anyhow!(
            "Cannot merge branch '{}' into itself.",
            branch_to_merge
        ));
    }
    debug!(
        branch = %branch_to_merge,
        target = target_branch,
        "merge:target branch resolved"
    );

    // Safety check: Abort if the target worktree has uncommitted tracked changes.
    // Untracked files are allowed; git will fail safely if they collide with merged files.
    if git::has_tracked_changes(&target_worktree_path)? {
        return Err(anyhow!(
            "Target worktree ({}) has uncommitted changes. Please commit or stash them before merging.",
            target_worktree_path.display()
        ));
    }

    // Explicitly switch the target worktree to the target branch.
    // This ensures that if we are reusing the main worktree for a feature branch merge,
    // it is checked out to the correct branch.
    git::switch_branch_in_worktree(&target_worktree_path, target_branch)?;

    // Run pre-merge hooks after all validations pass but before any merge operations begin.
    // Skip hooks if --no-verify or --no-hooks flag is passed.
    if !no_verify
        && !no_hooks
        && let Some(hooks) = &context.config.pre_merge
        && !hooks.is_empty()
    {
        info!(count = hooks.len(), "merge:running pre-merge hooks");

        let abs_worktree_path = worktree_path
            .canonicalize()
            .unwrap_or_else(|_| worktree_path.clone());
        let abs_project_root = context
            .main_worktree_root
            .canonicalize()
            .unwrap_or_else(|_| context.main_worktree_root.clone());
        let worktree_path_str = abs_worktree_path.to_string_lossy();
        let project_root_str = abs_project_root.to_string_lossy();

        let hook_env = [
            ("WORKMUX_HANDLE", handle),
            ("WM_BRANCH_NAME", branch_to_merge.as_str()),
            ("WM_TARGET_BRANCH", target_branch),
            ("WM_WORKTREE_PATH", worktree_path_str.as_ref()),
            ("WM_PROJECT_ROOT", project_root_str.as_ref()),
            ("WM_HANDLE", handle),
        ];

        for command in hooks {
            cmd::shell_command_with_env(
                context.config.hook_shell.as_deref(),
                command,
                &worktree_path,
                &hook_env,
            )
            .with_context(|| format!("Pre-merge hook failed: '{}'", command))?;
        }
    }

    // Helper closure to generate the error message for merge conflicts
    let conflict_err = |branch: &str| -> anyhow::Error {
        let retry_cmd = if into_branch.is_some() {
            format!("workmux merge {} --into {}", branch, target_branch)
        } else {
            format!("workmux merge {}", branch)
        };
        anyhow!(
            "Merge failed due to conflicts. Target worktree kept clean.\n\n\
            To resolve, update your branch in worktree at {}:\n\
              git rebase {}  (recommended)\n\
            Or:\n\
              git merge {}\n\n\
            After resolving conflicts, retry: {}",
            worktree_path.display(),
            target_branch,
            target_branch,
            retry_cmd
        )
    };

    if rebase {
        // Rebase the feature branch on top of target inside its own worktree.
        // This is where conflicts will be detected.
        println!("Rebasing '{}' onto '{}'...", branch_to_merge, target_branch);
        info!(
            branch = %branch_to_merge,
            base = target_branch,
            "merge:rebase start"
        );
        git::rebase_branch_onto_base(&worktree_path, target_branch).with_context(|| {
            format!(
                "Rebase failed, likely due to conflicts.\n\n\
                Please resolve them manually inside the worktree at '{}'.\n\
                Then, run 'git rebase --continue' to proceed or 'git rebase --abort' to cancel.",
                worktree_path.display()
            )
        })?;

        // After a successful rebase, merge into target. This will be a fast-forward.
        git::merge_in_worktree(&target_worktree_path, &branch_to_merge)
            .context("Failed to merge rebased branch. This should have been a fast-forward.")?;
        info!(branch = %branch_to_merge, "merge:fast-forward complete");
    } else if squash {
        // Perform the squash merge. This stages all changes from the feature branch but does not commit.
        if let Err(e) = git::merge_squash_in_worktree(&target_worktree_path, &branch_to_merge) {
            info!(branch = %branch_to_merge, error = %e, "merge:squash merge failed, resetting target worktree");
            // Best effort to reset; ignore failure as the user message is the priority.
            let _ = git::reset_hard(&target_worktree_path);
            return Err(conflict_err(&branch_to_merge));
        }

        // Prompt the user to provide a commit message for the squashed changes.
        println!("Staged squashed changes. Please provide a commit message in your editor.");
        git::commit_with_editor(&target_worktree_path)
            .context("Failed to commit squashed changes. You may need to commit them manually.")?;
        info!(branch = %branch_to_merge, "merge:squash merge committed");
    } else {
        // Default merge commit workflow
        if let Err(e) = git::merge_in_worktree(&target_worktree_path, &branch_to_merge) {
            info!(branch = %branch_to_merge, error = %e, "merge:standard merge failed, aborting merge in target worktree");
            // Best effort to abort; ignore failure as the user message is the priority.
            let _ = git::abort_merge_in_worktree(&target_worktree_path);
            return Err(conflict_err(&branch_to_merge));
        }
        info!(branch = %branch_to_merge, "merge:standard merge complete");
    }

    // Show notification before cleanup or early return (--keep),
    // since cleanup may kill the window and terminate this process
    if notification {
        show_notification(&format!(
            "Merged '{}' into '{}'",
            branch_to_merge, target_branch
        ));
    }

    // Skip cleanup when keep behavior is enabled
    if keep {
        info!(branch = %branch_to_merge, "merge:skipping cleanup");
        return Ok(MergeResult {
            branch_merged: branch_to_merge,
            main_branch: target_branch.to_string(),
            had_staged_changes,
            cleanup_scheduled: false,
            cleanup_error: None,
        });
    }

    // Always force cleanup after a successful merge
    info!(branch = %branch_to_merge, "merge:cleanup start");
    let cleanup_result = match cleanup::cleanup(
        context,
        &branch_to_merge,
        handle,
        &worktree_path,
        cleanup::CleanupOptions {
            force: true,
            keep_branch: false,
            no_hooks,
            show_hook_output: true,
        },
    ) {
        Ok(result) => result,
        Err(error) => {
            return Ok(MergeResult {
                branch_merged: branch_to_merge,
                main_branch: target_branch.to_string(),
                had_staged_changes,
                cleanup_scheduled: false,
                cleanup_error: Some(error),
            });
        }
    };

    let cleanup_scheduled = cleanup_result.deferred_cleanup.is_some();
    let cleanup_error = if attachment.manages_mux() {
        cleanup::navigate_to_target_and_close(
            context.mux.as_ref(),
            &context.prefix,
            &target_window_name,
            handle,
            &cleanup_result,
            mode,
        )
        .err()
    } else {
        None
    };

    Ok(MergeResult {
        branch_merged: branch_to_merge,
        main_branch: target_branch.to_string(),
        had_staged_changes,
        cleanup_scheduled,
        cleanup_error,
    })
}

/// Shows a system notification on macOS or Linux
fn show_notification(message: &str) {
    #[cfg(target_os = "macos")]
    {
        use mac_notification_sys::{Notification, set_application};
        // Set application to Terminal to use its icon
        if let Err(e) = set_application("com.apple.Terminal") {
            tracing::debug!("Failed to set notification application: {:?}", e);
        }
        if let Err(e) = Notification::default()
            .title("workmux")
            .message(message)
            .send()
        {
            tracing::debug!("Failed to send notification: {:?}", e);
        }
    }

    #[cfg(not(target_os = "macos"))]
    {
        if let Err(e) = notify_rust::Notification::new()
            .summary("workmux")
            .body(message)
            .show()
        {
            tracing::debug!("Failed to send notification: {:?}", e);
        }
    }
}