git-worktree-manager 0.0.40

CLI tool integrating git worktree with AI coding assistants
Documentation
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
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
/// Batch cleanup of worktrees.
///
use console::style;
use std::path::Path;

use crate::constants::{format_config_key, path_age_days, CONFIG_KEY_BASE_BRANCH};
use crate::error::Result;
use crate::git;
use crate::messages;

use super::display::get_worktree_status;
use super::pr_cache::{PrCache, PrState};

/// Determine whether `branch` is merged, using the same two-step logic as
/// `gw list`:
///   1. PrCache (primary) — squash-merge aware, checks GitHub PR state.
///   2. `git branch --merged` (fallback) — only catches traditional merge
///      commits, but still useful when `gh` is not available.
///
/// Returns `Some(base_branch_name)` if merged, `None` otherwise. Returning
/// the resolved base lets the caller render an accurate "merged into <base>"
/// reason without re-deriving the base separately (which would risk silent
/// drift if the resolution logic ever changed in only one place).
///
/// The base branch is read from `branch.<name>.worktreeBase` git config; if
/// absent, `git::detect_default_branch` is used as the fallback.  The old
/// code silently skipped the merged check when the config key was missing,
/// which caused `gw clean --merged` to miss every squash-merged branch (the
/// live bug: `gw list` showed "merged" while `gw clean --merged` said "No
/// worktrees match").
pub(super) fn branch_is_merged(
    branch_name: &str,
    repo: &Path,
    pr_cache: &PrCache,
) -> Option<String> {
    // Determine base branch: git config first, repo default second.
    let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
    let base_branch = git::get_config(&base_key, Some(repo))
        .unwrap_or_else(|| git::detect_default_branch(Some(repo)));

    // Primary: cached GitHub PR state (squash-merge aware).
    if matches!(pr_cache.state(branch_name), Some(PrState::Merged)) {
        return Some(base_branch);
    }

    // Fallback: git branch --merged (traditional merge commits only).
    if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
        return Some(base_branch);
    }

    None
}

/// Batch cleanup of worktrees based on criteria.
pub fn clean_worktrees(
    no_cache: bool,
    merged: bool,
    older_than: Option<u64>,
    interactive: bool,
    dry_run: bool,
    force: bool,
) -> Result<()> {
    let repo = git::get_repo_root(None)?;

    // Must specify at least one criterion
    if !merged && older_than.is_none() && !interactive {
        eprintln!(
            "Error: Please specify at least one cleanup criterion:\n  \
             --merged, --older-than, or -i/--interactive"
        );
        return Ok(());
    }

    // Load the PR cache once at the top so the merged-check and the interactive
    // listing both share the same instance (no double fetch).
    let pr_cache = PrCache::load_or_fetch(&repo, no_cache);

    let mut to_delete: Vec<(String, String, String)> = Vec::new(); // (branch, path, reason)

    for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
        let mut should_delete = false;
        let mut reasons = Vec::new();

        // Check if merged — mirrors `gw list`'s merge-detection strategy:
        // PrCache first (squash-merge aware), git fallback second.
        if merged {
            if let Some(base_branch) = branch_is_merged(&branch_name, &repo, &pr_cache) {
                should_delete = true;
                reasons.push(format!("merged into {}", base_branch));
            }
        }

        // Check age
        if let Some(days) = older_than {
            if let Some(age) = path_age_days(&path) {
                let age_days = age as u64;
                if age_days >= days {
                    should_delete = true;
                    reasons.push(format!("older than {} days ({} days)", days, age_days));
                }
            }
        }

        if should_delete {
            to_delete.push((
                branch_name.clone(),
                path.to_string_lossy().to_string(),
                reasons.join(", "),
            ));
        }
    }

    // Interactive mode
    if interactive && to_delete.is_empty() {
        println!("{}\n", style("Available worktrees:").cyan().bold());
        let mut all_wt = Vec::new();
        // Reuse the already-loaded pr_cache instance (no second fetch).
        for (branch_name, path) in git::get_feature_worktrees(Some(&repo))? {
            let status = get_worktree_status(&path, &repo, Some(branch_name.as_str()), &pr_cache);
            println!("  [{:8}] {:<30} {}", status, branch_name, path.display());
            all_wt.push((branch_name, path.to_string_lossy().to_string()));
        }
        println!();
        println!("Enter branch names to delete (space-separated), or 'all' for all:");

        let mut input = String::new();
        std::io::stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.eq_ignore_ascii_case("all") {
            to_delete = all_wt
                .into_iter()
                .map(|(b, p)| (b, p, "user selected".to_string()))
                .collect();
        } else {
            let selected: Vec<&str> = input.split_whitespace().collect();
            to_delete = all_wt
                .into_iter()
                .filter(|(b, _)| selected.contains(&b.as_str()))
                .map(|(b, p)| (b, p, "user selected".to_string()))
                .collect();
        }

        if to_delete.is_empty() {
            println!("{}", style("No worktrees selected for deletion").yellow());
            return Ok(());
        }
    }

    // Skip worktrees that another session is actively using, unless --force.
    // This prevents `gw clean --merged` from wiping a worktree held open by
    // a Claude Code / shell / editor session. Users can pass --force to
    // ignore the busy gate.
    let mut busy_skipped: Vec<(
        String,
        Vec<crate::operations::busy::BusyInfo>,
        Vec<crate::operations::busy::BusyInfo>,
    )> = Vec::new();
    if !force {
        let mut kept: Vec<(String, String, String)> = Vec::with_capacity(to_delete.len());
        for (branch, path, reason) in to_delete.into_iter() {
            let (hard, soft) =
                crate::operations::busy::detect_busy_tiered(std::path::Path::new(&path));
            if hard.is_empty() && soft.is_empty() {
                kept.push((branch, path, reason));
            } else {
                busy_skipped.push((branch, hard, soft));
            }
        }
        to_delete = kept;
    }

    if !busy_skipped.is_empty() {
        println!(
            "{}",
            style(format!(
                "Skipping {} busy worktree(s) (use --force to override):",
                busy_skipped.len()
            ))
            .yellow()
        );
        for (branch, hard, soft) in &busy_skipped {
            eprint!(
                "{}",
                crate::operations::busy_messages::render_refusal(branch, hard, soft)
            );
        }
        println!();
    }

    if to_delete.is_empty() {
        println!(
            "{} No worktrees match the cleanup criteria\n",
            style("*").green().bold()
        );
        return Ok(());
    }

    // Show what will be deleted
    let prefix = if dry_run { "DRY RUN: " } else { "" };
    println!(
        "\n{}\n",
        style(format!("{}Worktrees to delete:", prefix))
            .yellow()
            .bold()
    );
    for (branch, path, reason) in &to_delete {
        println!("  - {:<30} ({})", branch, reason);
        println!("    Path: {}", path);
    }
    println!();

    if dry_run {
        println!(
            "{} Would delete {} worktree(s)",
            style("*").cyan().bold(),
            to_delete.len()
        );
        println!("Run without --dry-run to actually delete them");
        return Ok(());
    }

    // Delete worktrees
    let mut deleted = 0u32;
    for (branch, _, _) in &to_delete {
        println!("{}", style(format!("Deleting {}...", branch)).yellow());
        // clean already filtered out busy worktrees above (unless --force),
        // so at this point we pass allow_busy=true to skip the redundant
        // gate inside delete_worktree.
        match super::worktree::delete_worktree(Some(branch), false, false, true, true, None) {
            Ok(()) => {
                println!("{} Deleted {}", style("*").green().bold(), branch);
                deleted += 1;
            }
            Err(e) => {
                println!(
                    "{} Failed to delete {}: {}",
                    style("x").red().bold(),
                    branch,
                    e
                );
            }
        }
    }

    println!(
        "\n{}\n",
        style(messages::cleanup_complete(deleted)).green().bold()
    );

    // Prune stale metadata
    println!("{}", style("Pruning stale worktree metadata...").dim());
    let _ = git::git_command(&["worktree", "prune"], Some(&repo), false, false);
    println!("{}\n", style("* Prune complete").dim());

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    // The env-var lock and guard are defined in `super::super::test_env` so
    // this module and `pr_cache` share a single mutex. Without sharing, each
    // module's tests would hold a different lock and race on the same global
    // env vars (`GW_TEST_GH_JSON`, `GW_TEST_GH_FAIL`, `GW_TEST_CACHE_DIR`).
    use super::super::test_env::{env_lock, EnvGuard};

    fn init_git_repo(path: &std::path::Path) {
        for args in &[
            vec!["init", "-b", "main"],
            vec!["config", "user.name", "Test"],
            vec!["config", "user.email", "test@test.com"],
            vec!["config", "commit.gpgsign", "false"],
        ] {
            std::process::Command::new("git")
                .args(args)
                .current_dir(path)
                .output()
                .unwrap();
        }
    }

    // ──────────────────────────────────────────────────────────────────────
    // Case A: squash-merged branch detected via PrCache, worktreeBase MISSING.
    //
    // This is the live bug: `gw list` shows "merged", but the old
    // `gw clean --merged` skipped the check entirely when worktreeBase
    // was absent from git config.
    // ──────────────────────────────────────────────────────────────────────
    #[test]
    fn case_a_squash_merged_pr_cache_no_worktree_base() {
        let _g = env_lock();
        let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);

        // Inject a MERGED PR into the PrCache via the test env hook.
        std::env::set_var(
            "GW_TEST_GH_JSON",
            r#"[{"headRefName":"fix-squash-branch","state":"MERGED"}]"#,
        );
        let tmp_repo =
            std::path::PathBuf::from(format!("/tmp/gw-test-unit-a-{}", std::process::id()));
        let cache = PrCache::load_or_fetch(&tmp_repo, true);

        // Sanity: ensure the cache has the MERGED state before calling the predicate.
        assert_eq!(
            cache.state("fix-squash-branch"),
            Some(&super::super::pr_cache::PrState::Merged),
            "PrCache must report Merged for the test to be meaningful"
        );

        // Use a real tempdir as "repo" — worktreeBase is intentionally absent.
        let repo_dir = tempfile::tempdir().unwrap();
        let repo = repo_dir.path();
        init_git_repo(repo);

        let result = branch_is_merged("fix-squash-branch", repo, &cache);
        assert!(
            result.is_some(),
            "branch_is_merged must return Some(base) when PrCache reports MERGED, \
             even without a worktreeBase git config entry (the live bug)"
        );
    }

    // ──────────────────────────────────────────────────────────────────────
    // Case C: branch with no PR, not reachable from base, no worktreeBase.
    //         Predicate must return None (no false-positive).
    // ──────────────────────────────────────────────────────────────────────
    #[test]
    fn case_c_no_pr_not_merged_no_worktree_base() {
        let _g = env_lock();
        let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);

        // Empty cache — no PRs at all.
        std::env::set_var("GW_TEST_GH_FAIL", "1");
        let tmp_repo =
            std::path::PathBuf::from(format!("/tmp/gw-test-unit-c-{}", std::process::id()));
        let cache = PrCache::load_or_fetch(&tmp_repo, true);

        let repo_dir = tempfile::tempdir().unwrap();
        let repo = repo_dir.path();
        init_git_repo(repo);

        // Initial commit on main
        std::fs::write(repo.join("README.md"), "hi").unwrap();
        for args in &[vec!["add", "."], vec!["commit", "-m", "init"]] {
            std::process::Command::new("git")
                .args(args)
                .current_dir(repo)
                .env("GIT_AUTHOR_NAME", "Test")
                .env("GIT_AUTHOR_EMAIL", "test@test.com")
                .env("GIT_COMMITTER_NAME", "Test")
                .env("GIT_COMMITTER_EMAIL", "test@test.com")
                .output()
                .unwrap();
        }
        // Unmerged feature branch
        std::process::Command::new("git")
            .args(["checkout", "-b", "feat-unmerged"])
            .current_dir(repo)
            .output()
            .unwrap();
        std::fs::write(repo.join("feat.txt"), "work").unwrap();
        for args in &[vec!["add", "."], vec!["commit", "-m", "feat work"]] {
            std::process::Command::new("git")
                .args(args)
                .current_dir(repo)
                .env("GIT_AUTHOR_NAME", "Test")
                .env("GIT_AUTHOR_EMAIL", "test@test.com")
                .env("GIT_COMMITTER_NAME", "Test")
                .env("GIT_COMMITTER_EMAIL", "test@test.com")
                .output()
                .unwrap();
        }

        let result = branch_is_merged("feat-unmerged", repo, &cache);
        assert!(
            result.is_none(),
            "branch_is_merged must return None for an unmerged branch with no PR \
             and no worktreeBase config"
        );
    }

    // ──────────────────────────────────────────────────────────────────────
    // The reason string returned to the user must agree with the resolved
    // base branch — i.e., the helper returns the SAME base it used for the
    // git fallback. Pins single-source-of-truth so the "merged into <base>"
    // reason cannot silently disagree with the predicate's actual base.
    // ──────────────────────────────────────────────────────────────────────
    #[test]
    fn reason_base_matches_resolved_worktree_base_config() {
        let _g = env_lock();
        let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);

        std::env::set_var(
            "GW_TEST_GH_JSON",
            r#"[{"headRefName":"some-feature","state":"MERGED"}]"#,
        );
        let tmp_repo =
            std::path::PathBuf::from(format!("/tmp/gw-test-unit-reason-{}", std::process::id()));
        let cache = PrCache::load_or_fetch(&tmp_repo, true);

        let repo_dir = tempfile::tempdir().unwrap();
        let repo = repo_dir.path();
        init_git_repo(repo);

        // Set worktreeBase to a non-default value so we can tell the helper
        // honored it (instead of silently falling back to detect_default_branch).
        std::process::Command::new("git")
            .args(["config", "branch.some-feature.worktreeBase", "develop"])
            .current_dir(repo)
            .output()
            .unwrap();

        let result = branch_is_merged("some-feature", repo, &cache);
        assert_eq!(
            result.as_deref(),
            Some("develop"),
            "branch_is_merged must return the worktreeBase config value as the \
             resolved base, so the user-facing 'merged into <base>' reason \
             cannot drift from what the predicate actually checked"
        );
    }

    // ──────────────────────────────────────────────────────────────────────
    // PrCache OPEN must not mark a branch merged.
    // ──────────────────────────────────────────────────────────────────────
    #[test]
    fn pr_open_is_not_merged() {
        let _g = env_lock();
        let _env = EnvGuard::capture(&["GW_TEST_GH_JSON", "GW_TEST_GH_FAIL", "GW_TEST_CACHE_DIR"]);

        std::env::set_var(
            "GW_TEST_GH_JSON",
            r#"[{"headRefName":"feat-open","state":"OPEN"}]"#,
        );
        let tmp_repo =
            std::path::PathBuf::from(format!("/tmp/gw-test-unit-open-{}", std::process::id()));
        let cache = PrCache::load_or_fetch(&tmp_repo, true);

        let repo_dir = tempfile::tempdir().unwrap();
        let repo = repo_dir.path();
        init_git_repo(repo);

        let result = branch_is_merged("feat-open", repo, &cache);
        assert!(result.is_none(), "An OPEN PR must not be considered merged");
    }
}