trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
//! Integration test: `decommission` removes a REAL on-disk git worktree (#1806).
//!
//! Why: `session_manager/tests.rs` is at the 1500-SLOC test cap; this
//! #1806-specific coverage lives here so neither file grows past its limit.
//! Keeping it in a focused sibling also makes the fix easy to locate. Uses
//! `FakeTmuxDriver` from the sibling `tests` module, mirroring the pattern
//! established by `backfill_tests.rs`.
//! What: creates a real git base clone + a real `git worktree add` under
//! `<base>/.worktrees/<id>`, decommissions the session via the public
//! `SessionManager` API, and asserts the worktree directory, git worktree
//! metadata, and branch ref are all gone.
//! Test: this file IS the test module; run with `cargo test -p trusty-mpm`.

use super::decommission::WORKTREE_SENTINEL_FILE;
use super::manager::SessionManager;
use super::record::{ManagedSessionId, ManagedSessionState};
use super::tests::FakeTmuxDriver;

/// `decommission` on an in-project (`workspace_owned = false`) session whose
/// workspace is a REAL `git worktree` removes the worktree directory from
/// disk AND prunes the git worktree metadata + branch ref from the base clone
/// (#1806).
///
/// Why: #1806 reported that `tm session decommission` tombstones the record
/// but leaves the on-disk worktree directory behind, requiring a manual
/// `git worktree remove`. The `workspace_owned = false` + `.worktrees/`
/// exception (added for #1840/#1845) is unit-tested for the free function
/// `remove_session_worktree` in `decommission.rs`, but no test previously
/// exercised the FULL round trip through the public `SessionManager` API
/// with a real git repository — this closes that verification gap.
/// What: creates a real git base clone (`git init` + one empty commit), adds
/// a real `git worktree add` under `<base>/.worktrees/<id>` with the SM
/// ownership sentinel file (mirroring `create_session_worktree`), creates a
/// session record pointing at it with `workspace_owned = false`, calls
/// `decommission_with_root`, and asserts: (1) `workspace_removed` is true;
/// (2) the worktree directory is gone from disk; (3) `git worktree list`
/// on the base clone no longer references the removed path; (4) the session
/// branch ref was deleted.
/// Test: this function IS the test.
#[tokio::test]
async fn manager_decommission_removes_real_git_worktree() {
    let dir = crate::test_support::hermetic_temp_dir();
    // #8935: the record's pane proves the live session is its own.
    let fake = super::tests::fake_with_pane();
    let mgr = SessionManager::new(dir.path(), fake)
        .await
        .expect("manager");

    // ── Real git base clone: init + one empty commit (needed for `worktree add`) ──
    let base_dir = crate::test_support::hermetic_temp_dir();
    let base = base_dir.path().to_path_buf();
    let git_init_ok = std::process::Command::new("git")
        .arg("init")
        .current_dir(&base)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !git_init_ok {
        eprintln!("manager_decommission_removes_real_git_worktree: git unavailable, skipping");
        return;
    }
    let _ = std::process::Command::new("git")
        .args([
            "-C",
            base.to_str().unwrap(),
            "config",
            "user.email",
            "ci@test.invalid",
        ])
        .status();
    let _ = std::process::Command::new("git")
        .args(["-C", base.to_str().unwrap(), "config", "user.name", "CI"])
        .status();
    let commit_ok = std::process::Command::new("git")
        .args([
            "-C",
            base.to_str().unwrap(),
            "commit",
            "--allow-empty",
            "-m",
            "init",
        ])
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    if !commit_ok {
        eprintln!("manager_decommission_removes_real_git_worktree: git commit failed, skipping");
        return;
    }

    // ── Give the base clone a remote and push (the #4400-style dirty gate) ──
    // Without ANY remote, `worktree_safety::inspect_dirt`'s unpushed-commit
    // check treats every commit as unpushed (there is nothing to exclude via
    // `--not --remotes`), which would make this "clean happy path" fixture
    // spuriously dirty and refuse removal. Push to a real bare remote so the
    // ONLY thing under test in this function is the ordinary clean-removal
    // path; the dirty-refusal path has its own fixture/test below.
    push_to_bare_remote(&base);

    // ── Real `git worktree add` under <base>/.worktrees/<session-name> ───────
    // Branch is `session/<session_name>` (issue #2032 fix) — this MUST mirror
    // the exact convention `create_session_worktree`/`worktree_branch_for`
    // use in production, or this fixture would silently re-encode the very
    // bug #2032 fixed (a pre-#2032 version of this fixture used the bare,
    // unprefixed name here, which happened to match `decommission.rs`'s
    // then-buggy `git branch -D <leaf>` and masked the missing-prefix bug).
    let session_name = "test-session-1806";
    let branch_name = crate::core::worktree_naming::worktree_branch_for(session_name);
    let worktrees_dir = base.join(".worktrees");
    std::fs::create_dir_all(&worktrees_dir).unwrap();
    let worktree_path = worktrees_dir.join(session_name);
    let add_ok = std::process::Command::new("git")
        .args([
            "-C",
            base.to_str().unwrap(),
            "worktree",
            "add",
            "-b",
            &branch_name,
        ])
        .arg(&worktree_path)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    assert!(add_ok, "git worktree add must succeed in this test fixture");

    // Write the SM ownership sentinel, mirroring `create_session_worktree`.
    std::fs::write(worktree_path.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");

    // ── Create the session record: in-project worktree, workspace_owned=false ──
    let record = mgr
        .create_with_id(
            ManagedSessionId::new(),
            "task".into(),
            Some(worktree_path.clone()),
            None,
            Some(worktree_path.clone()),
            None,
            None,
            crate::runtime::RuntimeKind::default(),
            false,
            false, // owned: false — in-project worktree, not a full clone
        )
        .await
        .expect("create");

    // The managed root is irrelevant for the unowned/worktree branch, but the
    // API requires one — point it at an unrelated temp dir.
    let managed_root = crate::test_support::hermetic_temp_dir();
    let (tombstone, workspace_removed) = mgr
        .decommission_with_root(&record.id, managed_root.path(), None)
        .await
        .expect("decommission");

    assert!(
        workspace_removed,
        "workspace_removed must be true for an in-project git worktree (#1806)"
    );
    assert_eq!(tombstone.state, ManagedSessionState::Decommissioned);
    assert!(
        !worktree_path.exists(),
        "the worktree directory must be removed from disk after decommission (#1806)"
    );

    // `git worktree list` on the base clone must no longer reference the path.
    let list_out = std::process::Command::new("git")
        .args(["-C", base.to_str().unwrap(), "worktree", "list"])
        .output()
        .expect("git worktree list");
    let list_stdout = String::from_utf8_lossy(&list_out.stdout);
    assert!(
        !list_stdout.contains(session_name),
        "git worktree list must not reference the removed worktree; got: {list_stdout}"
    );

    // The session branch ref (`session/<name>`, #2032) must have been deleted.
    let branch_out = std::process::Command::new("git")
        .args([
            "-C",
            base.to_str().unwrap(),
            "branch",
            "--list",
            &branch_name,
        ])
        .output()
        .expect("git branch --list");
    let branch_stdout = String::from_utf8_lossy(&branch_out.stdout);
    assert!(
        branch_stdout.trim().is_empty(),
        "the session branch ref must be deleted after decommission; got: {branch_stdout}"
    );
}

/// Push `repo`'s current `HEAD` to a fresh bare remote and fetch it back.
///
/// Why: `worktree_safety::inspect_dirt`'s unpushed-commit check treats a
/// repository with NO remote at all as fully unpushed (there is nothing for
/// `--not --remotes` to exclude), which would make a "clean happy path"
/// fixture built with a bare `git init` + one commit spuriously dirty. Tests
/// that need a genuinely CLEAN worktree call this immediately after the base
/// clone's first commit.
/// What: `git init --bare` a throwaway remote, `remote add origin`, `push
/// origin HEAD`, `fetch origin` — panics with git's stderr on any failure.
/// Test: `manager_decommission_removes_real_git_worktree`.
fn push_to_bare_remote(repo: &std::path::Path) {
    let remote_dir = crate::test_support::hermetic_temp_dir();
    let remote = remote_dir.path().to_path_buf();
    let remote_str = remote.to_str().expect("utf8 remote path");
    let repo_str = repo.to_str().expect("utf8 repo path");

    let init_ok = std::process::Command::new("git")
        .args(["init", "--bare"])
        .current_dir(&remote)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    assert!(init_ok, "git init --bare must succeed for the test remote");

    for args in [
        vec!["-C", repo_str, "remote", "add", "origin", remote_str],
        vec!["-C", repo_str, "push", "origin", "HEAD"],
        vec!["-C", repo_str, "fetch", "origin"],
    ] {
        let ok = std::process::Command::new("git")
            .args(&args)
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        assert!(ok, "git {args:?} must succeed");
    }
}

/// `decommission` refuses to remove an in-project worktree that holds unsaved
/// (uncommitted/untracked) work — the data-loss fix.
///
/// Why: before this fix, `remove_session_worktree` ran `git worktree remove
/// --force` (falling back to `fs::remove_dir_all`) with NO dirty check at
/// all. `tm sessions prune --state stopped` calls `decommission` for every
/// matching record, so a stopped session with uncommitted/untracked work in
/// its in-project worktree was silently destroyed. This reuses
/// `worktree_safety::inspect_dirt` (the same check the orphan-worktree sweep
/// uses) to gate removal.
/// What: builds a real, clean, pushed git worktree (so the ONLY dirt is the
/// file this test adds), drops an untracked file into it, then decommissions.
/// Asserts: (1) `workspace_removed` is `false`; (2) the record still
/// tombstones to `Decommissioned` (consistent with every other "skip
/// removal" branch in `decommission_with_root`); (3) the worktree directory
/// AND the untracked file both survive on disk; (4) the tombstone record
/// KEEPS `workspace_path` pointing at the retained directory (#4344 review) —
/// nulling it here would strand the retained work with nothing but a
/// transient warn! log line as a trail back to it.
/// Test: this function IS the test.
#[tokio::test]
async fn manager_decommission_refuses_dirty_worktree() {
    let dir = crate::test_support::hermetic_temp_dir();
    // #8935: the record's pane proves the live session is its own.
    let fake = super::tests::fake_with_pane();
    let mgr = SessionManager::new(dir.path(), fake)
        .await
        .expect("manager");

    let fixture = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
    let session_name = "test-session-dirty";
    let worktree_path = fixture.add_worktree(session_name);

    // Write the SM ownership sentinel, mirroring `create_session_worktree`.
    std::fs::write(worktree_path.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");

    // Dirty the worktree: an untracked file `git status` will report. This is
    // the ONLY source of dirt — the fixture's base repo is already pushed.
    std::fs::write(
        worktree_path.join("uncommitted-work.txt"),
        b"unsaved work\n",
    )
    .expect("write uncommitted file");

    let record = mgr
        .create_with_id(
            ManagedSessionId::new(),
            "task".into(),
            Some(worktree_path.clone()),
            None,
            Some(worktree_path.clone()),
            None,
            None,
            crate::runtime::RuntimeKind::default(),
            false,
            false, // owned: false — in-project worktree, not a full clone
        )
        .await
        .expect("create");

    let managed_root = crate::test_support::hermetic_temp_dir();
    let (tombstone, workspace_removed) = mgr
        .decommission_with_root(&record.id, managed_root.path(), None)
        .await
        .expect("decommission");

    assert!(
        !workspace_removed,
        "workspace_removed must be false when the worktree holds unsaved work"
    );
    assert_eq!(tombstone.state, ManagedSessionState::Decommissioned);
    assert!(
        worktree_path.exists(),
        "a dirty worktree must survive on disk — decommission must refuse to delete it"
    );
    assert!(
        worktree_path.join("uncommitted-work.txt").exists(),
        "the uncommitted file itself must survive the refused decommission"
    );
    assert_eq!(
        tombstone.workspace_path.as_deref(),
        Some(worktree_path.as_path()),
        "workspace_path must survive on the tombstone when removal was refused \
         (#4344 review) — it is the only durable pointer back to the retained work"
    );
}

/// `tm sessions prune --state stopped` (via `prune_managed`) surfaces a
/// dirty-worktree refusal through its own report, instead of printing the
/// same `decommissioned` line whether or not the worktree was actually
/// removed (#4344 review).
///
/// Why: `prune_managed` discarded the `bool` half of `decommission`'s
/// `(SessionRecord, bool)` return, and `PruneAction` had no variant for
/// "refused, worktree retained" — so `tm sessions prune --state stopped`
/// gave no visible signal, at the ONE surface an operator actually reads,
/// that a worktree was silently kept dirty on disk instead of removed.
/// What: seeds a `Stopped` record (via the real `stop()` teardown, mirroring
/// how a genuinely stopped session gets there) pointing at a real,
/// dirtied in-project worktree, runs
/// `prune_managed(PruneFilter::Stopped, …)`, and asserts the single
/// returned row is `PruneAction::DecommissionedWorktreeRetained` with
/// `retained_workspace_path` set to the worktree's path — plus the usual
/// on-disk survival assertions.
/// Test: this function IS the test.
#[tokio::test]
async fn prune_reports_dirty_worktree_retained() {
    let dir = crate::test_support::hermetic_temp_dir();
    let fake = FakeTmuxDriver::new();
    // #8935: a teardown kills only a session proved to hold the record's pane.
    *fake.pane_id_override.lock().unwrap() = Some("%1".into());
    let mgr = SessionManager::new(dir.path(), fake)
        .await
        .expect("manager");

    let fixture = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
    let session_name = "test-session-prune-dirty";
    let worktree_path = fixture.add_worktree(session_name);
    std::fs::write(worktree_path.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");
    std::fs::write(
        worktree_path.join("uncommitted-work.txt"),
        b"unsaved work\n",
    )
    .expect("write uncommitted file");

    let record = mgr
        .create_with_id(
            ManagedSessionId::new(),
            "task".into(),
            Some(worktree_path.clone()),
            None,
            Some(worktree_path.clone()),
            None,
            None,
            crate::runtime::RuntimeKind::default(),
            false,
            false, // owned: false — in-project worktree, not a full clone
        )
        .await
        .expect("create");

    // Transition to Stopped exactly like a real "runtime exited" session —
    // `prune --state stopped` only ever targets genuinely Stopped records.
    mgr.stop(&record.id).await.expect("stop");

    let outcome = mgr
        .prune_managed(
            crate::session_manager::PruneFilter::Stopped,
            false,
            false,
            None,
            None,
        )
        .await
        .expect("prune stopped");

    assert_eq!(
        outcome.count(),
        1,
        "the dirty stopped session is the only candidate"
    );
    let pruned = &outcome.sessions[0];
    assert_eq!(
        pruned.action,
        crate::session_manager::PruneAction::DecommissionedWorktreeRetained,
        "a dirty in-project worktree must report as retained, not a plain decommission; got {:?}",
        pruned.action
    );
    assert_eq!(
        pruned.retained_workspace_path.as_deref(),
        Some(worktree_path.as_path()),
        "the prune report must echo back WHERE the retained work lives"
    );

    // The usual on-disk guarantees still hold at the prune surface too.
    assert!(
        worktree_path.exists(),
        "the dirty worktree must survive a prune sweep"
    );
    assert!(
        worktree_path.join("uncommitted-work.txt").exists(),
        "the uncommitted file must survive a prune sweep"
    );
}

// ─────────────────────────────────────────────────────────────────────────
// #4732: git declining is a REFUSAL, not a failure to work around.
//
// Every test below fails against the pre-#4732 remover, which fell through to
// `std::fs::remove_dir_all` on ANY non-zero git exit and on any failure to
// resolve the owning checkout. The two "cleans up" tests are the other half of
// the contract: the one legitimate fallback case must still work, or this fix
// would have traded a data-loss bug for a leak.
// ─────────────────────────────────────────────────────────────────────────

use super::decommission::remove_session_worktree;
use super::worktree_git_fixture::{GitWorktreeFixture, deny_all};

/// A worktree whose admin directory was removed out of band must be preserved
/// (#4732).
///
/// Why: `git rev-parse` from inside it answers `not a git repository: (null)`,
/// which `registry_root_for` reports as `None` — and the remover read that
/// `None` as "a plain directory nothing claims". The working tree, including
/// work that was never committed anywhere, is entirely intact. This is the
/// state ~70 worktrees on this machine were left in on 2026-07-21.
#[test]
fn remove_refuses_a_stale_worktree_pointer() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("stale-pointer-e2e-4732");
    std::fs::write(wt.join("precious.txt"), "never committed\n").expect("write precious file");
    std::fs::remove_dir_all(fx.repo.join(".git").join("worktrees")).expect("drop admin dir");

    let outcome = remove_session_worktree(
        &wt,
        "test",
        crate::session_manager::DirtyWorktreePolicy::Skip,
    );
    assert!(
        wt.join("precious.txt").exists(),
        "a stale pointer must not cost the working tree: {outcome:?}"
    );
    assert!(
        !outcome.removed(),
        "and must report NOT removed: {outcome:?}"
    );
}

/// A `.git` git cannot read is a worktree, not an absent one (#4732).
#[test]
fn remove_refuses_an_unreadable_git_entry() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("unreadable-git-e2e-4732");
    std::fs::write(wt.join("precious.txt"), "never committed\n").expect("write precious file");
    let _restore = deny_all(&wt.join(".git"));

    let outcome = remove_session_worktree(
        &wt,
        "test",
        crate::session_manager::DirtyWorktreePolicy::Skip,
    );
    assert!(
        wt.join("precious.txt").exists(),
        "an unreadable .git must not cost the working tree: {outcome:?}"
    );
    assert!(
        !outcome.removed(),
        "and must report NOT removed: {outcome:?}"
    );
}

/// `is not a .git file` is git validating and declining, not git reporting an
/// empty directory (#4732).
#[test]
fn remove_refuses_a_worktree_with_a_broken_git_file() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("broken-git-e2e-4732");
    std::fs::write(wt.join("precious.txt"), "never committed\n").expect("write precious file");
    std::fs::write(wt.join(".git"), "gitdir: /nonexistent/xyz\n").expect("corrupt .git");

    let outcome = remove_session_worktree(
        &wt,
        "test",
        crate::session_manager::DirtyWorktreePolicy::Skip,
    );
    assert!(
        wt.join("precious.txt").exists(),
        "a broken .git must not cost the working tree: {outcome:?}"
    );
    assert!(
        !outcome.removed(),
        "and must report NOT removed: {outcome:?}"
    );
}

/// The surviving fallback, half one: a trusty-mpm-owned directory with no
/// repository above it at all (#4732).
///
/// Why: pinning this is what keeps the fix from silently becoming "never clean
/// anything up". Nothing can be claiming a directory outside every repository,
/// so there is no git state to protect and a direct removal is the only way to
/// reclaim it.
#[test]
fn remove_cleans_up_a_directory_no_repository_claims() {
    let tmp = crate::test_support::hermetic_temp_dir();
    let leftover = tmp.path().join("leftover-4732");
    std::fs::create_dir_all(&leftover).expect("mkdir");
    std::fs::write(leftover.join(WORKTREE_SENTINEL_FILE), b"").expect("write sentinel");

    let outcome = remove_session_worktree(
        &leftover,
        "test",
        crate::session_manager::DirtyWorktreePolicy::Skip,
    );
    assert!(outcome.removed(), "{outcome:?}");
    assert!(!leftover.exists(), "the leftover directory must be gone");
}

/// The surviving fallback, half two: a leftover inside a real repository whose
/// registry positively does not name it (#4732).
///
/// Why: this is the ordinary shape — a worktree git already pruned, or one
/// whose creation never completed registration. Git holds nothing, so the
/// directory is the only thing to clean up.
#[test]
fn remove_cleans_up_an_unregistered_leftover_inside_a_repo() {
    let fx = GitWorktreeFixture::new();
    let leftover = fx.repo.join(".worktrees").join("unregistered-e2e-4732");
    std::fs::create_dir_all(&leftover).expect("mkdir");

    let outcome = remove_session_worktree(
        &leftover,
        "test",
        crate::session_manager::DirtyWorktreePolicy::Skip,
    );
    assert!(outcome.removed(), "{outcome:?}");
    assert!(!leftover.exists(), "the leftover directory must be gone");
}

/// The happy path is unchanged: git removes a healthy worktree, and the ref
/// cleanup still runs behind it (#4732 regression guard).
#[test]
fn remove_still_removes_a_healthy_worktree() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("healthy-4732");

    let outcome = remove_session_worktree(
        &wt,
        "test",
        crate::session_manager::DirtyWorktreePolicy::Skip,
    );
    assert!(outcome.removed(), "{outcome:?}");
    assert!(!wt.exists(), "the worktree directory must be gone");

    let listed = std::process::Command::new("git")
        .arg("-C")
        .arg(&fx.repo)
        .args(["worktree", "list", "--porcelain"])
        .output()
        .expect("git worktree list");
    assert!(
        !String::from_utf8_lossy(&listed.stdout).contains("healthy-4732"),
        "the registry entry must be pruned too"
    );
    let branches = std::process::Command::new("git")
        .arg("-C")
        .arg(&fx.repo)
        .args(["branch", "--list", "session/healthy-4732"])
        .output()
        .expect("git branch --list");
    assert!(
        String::from_utf8_lossy(&branches.stdout).trim().is_empty(),
        "the session branch must be deleted too"
    );
}

// ─────────────────────────────────────────────────────────────────────────
// #5949: the bookkeeping repair lives below every caller
// ─────────────────────────────────────────────────────────────────────────

/// Build a real checkout at `dir` with one commit. Returns `false` when git is
/// unavailable, matching the skip convention the tests above already use.
///
/// Why: the #5949 tests assert on what git itself reports, so a mocked git
/// would test nothing.
/// What: `git init` plus a committed `README.md`, with the identity and signing
/// config every sandboxed commit needs.
/// Test: `decommission_prunes_the_base_repo_worktree_registry`.
fn init_repo_with_commit(dir: &std::path::Path) -> bool {
    let git = |args: &[&str]| {
        std::process::Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    };
    std::fs::create_dir_all(dir).expect("create repo dir");
    if !git(&["init", "--quiet"]) {
        return false;
    }
    assert!(git(&["config", "user.email", "ci@test.invalid"]));
    assert!(git(&["config", "user.name", "CI"]));
    assert!(git(&["config", "commit.gpgsign", "false"]));
    std::fs::write(dir.join("README.md"), "seed\n").expect("write README");
    assert!(git(&["add", "README.md"]));
    assert!(git(&["commit", "--quiet", "-m", "seed"]));
    // #8663: pushed, so an owned workspace's decommission sees no unpushed
    // commit and removes it.
    let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("repo");
    let remote = dir.with_file_name(format!("{name}-remote.git"));
    let bare = std::process::Command::new("git")
        .args(["init", "--quiet", "--bare"])
        .arg(&remote)
        .status()
        .is_ok_and(|s| s.success());
    assert!(bare, "fixture: bare remote");
    assert!(git(&["remote", "add", "origin", &remote.to_string_lossy()]));
    assert!(git(&["push", "--quiet", "origin", "HEAD"]));
    assert!(git(&["fetch", "--quiet", "origin"]));
    true
}

/// `git -C <dir> worktree list --porcelain`, as a string.
fn worktree_list(dir: &std::path::Path) -> String {
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(["worktree", "list", "--porcelain"])
        .output()
        .expect("git worktree list");
    String::from_utf8_lossy(&out.stdout).into_owned()
}

/// #5949: decommissioning an SM-OWNED workspace that git registers as a worktree
/// leaves the base checkout's registry clean.
///
/// Why: the owned branch removes the directory with `remove_dir_all`, which git
/// never learns about, so the entry survived in the base checkout's listing. The
/// repair existed only in the client layer, which the MCP tool and the idle
/// reaper never pass through — this asserts it from `SessionManager` itself, the
/// one function every caller reaches regardless of transport.
/// What: a real base checkout inside the managed root, a real registered
/// worktree under it, an owned record pointing at that worktree, then
/// `decommission_with_root`. Asserts the removal happened and that git no longer
/// lists the leaf. Against the pre-fix code the last assertion fails: the
/// directory is gone and the entry is still listed.
/// Test: this function IS the test.
#[tokio::test]
async fn decommission_prunes_the_base_repo_worktree_registry() {
    let dir = crate::test_support::hermetic_temp_dir();
    // #8935: the record's pane proves the live session is its own.
    let mgr = SessionManager::new(dir.path(), super::tests::fake_with_pane())
        .await
        .expect("manager");

    let managed_root = crate::test_support::hermetic_temp_dir();
    let base = managed_root.path().join("owner").join("repo");
    if !init_repo_with_commit(&base) {
        eprintln!("decommission_prunes_the_base_repo_worktree_registry: git unavailable, skipping");
        return;
    }

    let leaf = "tm-5949-session";
    let ws = base.join(".worktrees").join(leaf);
    let added = std::process::Command::new("git")
        .arg("-C")
        .arg(&base)
        .args([
            "worktree",
            "add",
            "--quiet",
            "-b",
            "session/tm-5949-session",
        ])
        .arg(&ws)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    assert!(added, "fixture: the worktree must be registered");
    assert!(
        worktree_list(&base).contains(leaf),
        "fixture invariant: git must list the worktree before decommission"
    );

    let record = mgr
        .create_with_id(
            ManagedSessionId::new(),
            "regression: #5949 in-process decommission".into(),
            Some(ws.clone()),
            None,
            Some(ws.clone()),
            None,
            None,
            crate::runtime::RuntimeKind::default(),
            false,
            // Owned: this is the branch that removes with `remove_dir_all` and
            // therefore the one that strands git's bookkeeping.
            true,
        )
        .await
        .expect("create");

    let (tombstone, workspace_removed) = mgr
        .decommission_with_root(&record.id, managed_root.path(), None)
        .await
        .expect("decommission");

    assert!(
        workspace_removed,
        "fixture invariant: an owned, existing workspace must be removed"
    );
    assert_eq!(tombstone.state, ManagedSessionState::Decommissioned);
    assert!(!ws.exists(), "the workspace must be gone: {}", ws.display());
    assert!(
        !worktree_list(&base).contains(leaf),
        "decommission left a stale worktree entry for {leaf} — the bookkeeping \
         repair did not run below the caller (#5949)"
    );
}

/// #5949: an owned workspace that IS its own checkout has no registry to repair.
///
/// Why: the repair resolves the checkout git says owns the workspace. For a
/// plain SM-provisioned clone that answer is the workspace itself, which is
/// about to be deleted — pruning there would only log a failure against a
/// directory that no longer exists.
/// What: an owned record pointing at a standalone checkout, asserted to select
/// no registry root; the worktree case is asserted to select the base checkout.
/// Test: this function IS the test.
#[tokio::test]
async fn registry_root_to_repair_ignores_a_standalone_owned_clone() {
    let dir = crate::test_support::hermetic_temp_dir();
    // #8935: the record's pane proves the live session is its own.
    let mgr = SessionManager::new(dir.path(), super::tests::fake_with_pane())
        .await
        .expect("manager");

    let managed_root = crate::test_support::hermetic_temp_dir();
    let clone = managed_root.path().join("owner").join("standalone");
    if !init_repo_with_commit(&clone) {
        eprintln!(
            "registry_root_to_repair_ignores_a_standalone_owned_clone: git unavailable, skipping"
        );
        return;
    }

    let standalone = mgr
        .create_with_id(
            ManagedSessionId::new(),
            "standalone clone".into(),
            Some(clone.clone()),
            None,
            Some(clone.clone()),
            None,
            None,
            crate::runtime::RuntimeKind::default(),
            false,
            true,
        )
        .await
        .expect("create standalone");
    assert!(
        super::decommission::registry_root_to_repair(&standalone).is_none(),
        "a checkout that owns itself has no external registry to repair"
    );

    let ws = clone.join(".worktrees").join("tm-5949-leaf");
    let added = std::process::Command::new("git")
        .arg("-C")
        .arg(&clone)
        .args(["worktree", "add", "--quiet", "-b", "session/tm-5949-leaf"])
        .arg(&ws)
        .status()
        .map(|s| s.success())
        .unwrap_or(false);
    assert!(added, "fixture: the worktree must be registered");

    let linked = mgr
        .create_with_id(
            ManagedSessionId::new(),
            "linked worktree".into(),
            Some(ws.clone()),
            None,
            Some(ws.clone()),
            None,
            None,
            crate::runtime::RuntimeKind::default(),
            false,
            true,
        )
        .await
        .expect("create linked");
    let resolved = super::decommission::registry_root_to_repair(&linked)
        .expect("a linked worktree resolves to its owning checkout");
    let expected = std::fs::canonicalize(&clone).unwrap_or(clone);
    assert_eq!(
        resolved, expected,
        "the repair must target the checkout git says owns the worktree"
    );
}