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
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
//! Unit tests for the #4091 dirty-worktree guard in `worktree_safety.rs`.
//!
//! Why: these exercise [`super::inspect_dirt`] in isolation against REAL git
//! repositories built under `tempfile::tempdir()` — never a live worktree —
//! so each "dirty" definition (modified tracked, untracked-not-ignored,
//! committed-but-unpushed) and each fail-safe error path is pinned
//! independently of the reclaim path that consumes them. The wired-up
//! behaviour is covered separately by the `#4091` tests in
//! `prune_orphan_tests`.
//! What: the clean/pushed baseline, the three dirty definitions, the
//! ignored-files carve-out, the non-git-directory classification, and the
//! error-means-dirty invariant.
//! Test: this file IS the test module; run with `cargo test -p trusty-mpm`.

use super::*;
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;

/// The default policy must be the SAFE one. If this ever flips, every caller
/// that relies on `..Default::default()` silently gains the power to destroy
/// uncommitted work.
#[test]
fn dirty_policy_defaults_to_skip() {
    assert_eq!(
        DirtyWorktreePolicy::default(),
        DirtyWorktreePolicy::Skip,
        "the default dirty policy must never be ForceDiscard"
    );
}

/// Baseline: a worktree with no local edits, whose HEAD is already on a
/// remote-tracking ref, is PROVABLY clean — the guard must return `None` so
/// reclamation still works. Without this the guard would be a permanent leak.
#[test]
fn inspect_dirt_clean_pushed_worktree_is_none() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("clean");
    assert!(
        inspect_dirt(&wt).is_none(),
        "a clean, fully-pushed worktree must be reclaimable; got {:?}",
        inspect_dirt(&wt)
    );
}

/// Definition 1a of dirty: a MODIFIED TRACKED file.
#[test]
fn inspect_dirt_reports_modified_tracked_file() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("modified");
    std::fs::write(wt.join("README.md"), "edited but never committed\n").unwrap();

    let dirt = inspect_dirt(&wt).expect("a modified tracked file must read as dirty");
    assert_eq!(dirt.dirty_files, 1, "reason was: {}", dirt.reason);
    assert_eq!(dirt.unpushed_commits, 0);
    assert_eq!(dirt.path, wt);
}

/// Definition 1b of dirty: an UNTRACKED, non-ignored file. Untracked work is
/// the most easily lost kind — it exists nowhere but that directory.
#[test]
fn inspect_dirt_reports_untracked_file() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("untracked");
    std::fs::write(wt.join("notes.md"), "hand-written, never added\n").unwrap();

    let dirt = inspect_dirt(&wt).expect("an untracked file must read as dirty");
    assert_eq!(dirt.dirty_files, 1, "reason was: {}", dirt.reason);
}

/// The carve-out that keeps the guard usable: IGNORED files (build artefacts)
/// are not work, and must not pin a worktree as dirty forever. A `target/`
/// directory in every checkout would otherwise make the guard refuse
/// everything, which is indistinguishable from having no reclamation at all.
#[test]
fn inspect_dirt_ignores_gitignored_files() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("ignored");
    std::fs::write(wt.join(".gitignore"), "build-output/\n").unwrap();
    std::fs::create_dir_all(wt.join("build-output")).unwrap();
    std::fs::write(wt.join("build-output").join("artifact.bin"), "junk\n").unwrap();
    // Commit the .gitignore itself so only the IGNORED path remains on disk.
    let commit = std::process::Command::new("git")
        .arg("-C")
        .arg(&wt)
        .args(["add", ".gitignore"])
        .output()
        .unwrap();
    assert!(commit.status.success());
    let commit = std::process::Command::new("git")
        .arg("-C")
        .arg(&wt)
        .args(["commit", "-m", "ignore build output"])
        .output()
        .unwrap();
    assert!(commit.status.success());

    // The commit itself is unpushed, so the worktree IS dirty — but for the
    // commit, not for the ignored artefact. Assert on the file count.
    let dirt = inspect_dirt(&wt).expect("the new commit is unpushed, so this is dirty");
    assert_eq!(
        dirt.dirty_files, 0,
        "ignored build output must not count as uncommitted work; reason: {}",
        dirt.reason
    );
    assert_eq!(dirt.unpushed_commits, 1, "reason: {}", dirt.reason);
}

/// trusty-mpm's OWN untracked ownership sentinel must not count as work.
///
/// Why this test exists: it caught a real self-inflicted bug. Every managed
/// worktree carries a `.trusty-mpm-worktree` sentinel, and most host projects
/// do not gitignore it — so counting it marked EVERY managed worktree
/// permanently dirty, which would have shipped a guard that silently disabled
/// all reclamation.
#[test]
fn inspect_dirt_excludes_own_sentinel() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("sentinel-only");
    GitWorktreeFixture::stamp_reclaimable_sentinel(&wt);

    assert!(
        inspect_dirt(&wt).is_none(),
        "trusty-mpm's own sentinel must not make a worktree dirty; got {:?}",
        inspect_dirt(&wt)
    );
}

/// The two DISPOSABLE `.trusty-mpm/` artefacts — and only those — must not
/// count as work. The daemon rewrites both on every snapshot.
#[test]
fn inspect_dirt_excludes_untracked_trusty_mpm_dir() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("scrollback");
    std::fs::create_dir_all(wt.join(".trusty-mpm")).unwrap();
    std::fs::write(wt.join(".trusty-mpm").join("scrollback.txt"), "pane dump\n").unwrap();
    std::fs::write(
        wt.join(".trusty-mpm").join("last-instructions.md"),
        "brief\n",
    )
    .unwrap();

    assert!(
        inspect_dirt(&wt).is_none(),
        "the daemon's own scrollback artefacts must not make a worktree dirty; got {:?}",
        inspect_dirt(&wt)
    );
}

/// THE REGRESSION THIS ROUND EXISTS FOR: hand-rescued source parked under
/// `.trusty-mpm/` must read as work.
///
/// Why this exact shape: on 2026-07-27 the live session worktree
/// `.base/.worktrees/2eb72dca-…` held
/// `.trusty-mpm/wip-backup-20260727-jira/` containing twelve files of
/// uncommitted `trusty-git-analytics` source, deliberately preserved by an
/// earlier rescue operation. The first cut of this guard excused the whole
/// `.trusty-mpm/` subtree by prefix, so `dirty_files` stayed 0, `inspect_dirt`
/// returned `None`, and `git worktree remove --force` would have destroyed it
/// silently. The carve-out is now an allowlist of two exact paths.
#[test]
fn inspect_dirt_counts_wip_backup_under_trusty_mpm() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("wip-backup");
    let wip = wt.join(".trusty-mpm").join("wip-backup-20260727-jira");
    std::fs::create_dir_all(wip.join("crates").join("foo").join("src")).unwrap();
    std::fs::write(
        wip.join("crates").join("foo").join("src").join("main.rs"),
        "fn main() { /* rescued, uncommitted */ }\n",
    )
    .unwrap();
    // The disposable artefacts sit alongside it, exactly as they do live.
    std::fs::write(wt.join(".trusty-mpm").join("scrollback.txt"), "pane dump\n").unwrap();

    let dirt = inspect_dirt(&wt).expect("rescued source under .trusty-mpm/ must read as dirty");
    assert_eq!(
        dirt.dirty_files, 1,
        "the wip-backup tree must count and the scrollback must not; reason: {}",
        dirt.reason
    );
}

/// Pause snapshots under `.trusty-mpm/sessions/` are what `/tm-session-resume`
/// reads back, and this PR's own skill change tells operators to trust the
/// pause path. They are not disposable.
#[test]
fn inspect_dirt_counts_pause_snapshots_under_trusty_mpm() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("pause-snapshots");
    std::fs::create_dir_all(wt.join(".trusty-mpm").join("sessions")).unwrap();
    std::fs::write(
        wt.join(".trusty-mpm").join("sessions").join("s-01.md"),
        "# paused session\n",
    )
    .unwrap();

    let dirt = inspect_dirt(&wt).expect("pause snapshots must read as dirty");
    assert!(dirt.dirty_files >= 1, "reason: {}", dirt.reason);
}

/// The SAME `.trusty-mpm/` content must read as dirty when the project
/// GITIGNORES it, which is how this repo's `main` spells it (`.gitignore:49`,
/// `.trusty-mpm/*`). Plain `git status` cannot see it at all in that spelling —
/// only the per-file `--ignored=matching` pass can. Both spellings are live in
/// the fleet simultaneously, on different branches of this one repo.
#[test]
fn inspect_dirt_counts_gitignored_trusty_mpm_work() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("ignored-tm");
    std::fs::write(wt.join(".gitignore"), ".trusty-mpm/*\n").unwrap();
    GitWorktreeFixture::commit_all_and_push(&wt, "gitignore .trusty-mpm");
    std::fs::create_dir_all(wt.join(".trusty-mpm").join("wip-backup-x")).unwrap();
    std::fs::write(
        wt.join(".trusty-mpm").join("wip-backup-x").join("main.rs"),
        "fn main() {}\n",
    )
    .unwrap();

    // Prove the premise: plain porcelain really is blind to it.
    let plain = std::process::Command::new("git")
        .arg("-C")
        .arg(&wt)
        .args(["status", "--porcelain"])
        .output()
        .unwrap();
    assert!(
        String::from_utf8_lossy(&plain.stdout).trim().is_empty(),
        "premise broken: plain porcelain should not see the gitignored subtree"
    );

    let dirt = inspect_dirt(&wt).expect("gitignored .trusty-mpm/ work must still read as dirty");
    assert!(dirt.dirty_files >= 1, "reason: {}", dirt.reason);
}

/// The counterweight: a TRACKED file under `.trusty-mpm/` that someone edited
/// IS real work, and the bookkeeping exclusion must not swallow it. The
/// exclusion is scoped to untracked entries precisely so this stays protected.
#[test]
fn inspect_dirt_counts_tracked_trusty_mpm_edit() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("tracked-config");
    std::fs::create_dir_all(wt.join(".trusty-mpm")).unwrap();
    std::fs::write(wt.join(".trusty-mpm").join("INSTRUCTIONS.md"), "v1\n").unwrap();
    for args in [
        vec!["add", ".trusty-mpm/INSTRUCTIONS.md"],
        vec!["commit", "-m", "track instructions"],
    ] {
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(&wt)
            .args(&args)
            .output()
            .unwrap();
        assert!(out.status.success(), "{args:?}: {:?}", out);
    }
    std::fs::write(
        wt.join(".trusty-mpm").join("INSTRUCTIONS.md"),
        "v2 edited\n",
    )
    .unwrap();

    let dirt = inspect_dirt(&wt).expect("a tracked edit is real work, wherever it lives");
    assert_eq!(
        dirt.dirty_files, 1,
        "a tracked .trusty-mpm/ edit must still count; reason: {}",
        dirt.reason
    );
}

/// #4166: a tracked file DELETED from `.trusty-mpm/` must be counted by
/// exactly one of the two passes, and before this it was counted by neither.
///
/// Why: pass 1 skips every `.trusty-mpm/`-scoped status line on the promise
/// that pass 2 owns them, and pass 2 returned `Ok(0)` without running git
/// whenever the directory was absent. Deleting the last file under
/// `.trusty-mpm/` removes the directory too, so the deletion fell through the
/// handover and `dirty_files` read 0 — the broken invariant, not a large loss:
/// for a registered worktree both the index and the object store live outside
/// the candidate, so nothing unique dies.
#[test]
fn inspect_dirt_counts_a_deleted_tracked_file_under_trusty_mpm() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("deleted-tm");
    std::fs::create_dir_all(wt.join(".trusty-mpm")).unwrap();
    std::fs::write(wt.join(".trusty-mpm").join("notes.md"), "v1\n").unwrap();
    // Pushed, so the commit itself is not the dirt under test.
    GitWorktreeFixture::commit_all_and_push(&wt, "track notes under .trusty-mpm");
    std::fs::remove_dir_all(wt.join(".trusty-mpm")).unwrap();

    // Premise: the directory really is gone, which is what used to short-circuit
    // pass 2 before it ever asked git.
    assert!(
        !wt.join(".trusty-mpm").exists(),
        "premise broken: the whole directory should be gone"
    );

    let dirt =
        inspect_dirt(&wt).expect("a deleted tracked file under .trusty-mpm/ must read as dirty");
    assert_eq!(
        dirt.dirty_files, 1,
        "the deletion must be counted by pass 2; reason: {}",
        dirt.reason
    );
}

/// Definition 2 of dirty: a COMMITTED but UNPUSHED commit. This looks safe —
/// the work is committed — but `remove_session_worktree` deletes the
/// `session/<leaf>` branch after removal, so the commit loses its last
/// reachable ref.
#[test]
fn inspect_dirt_reports_unpushed_commit() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("unpushed");
    GitWorktreeFixture::commit_unpushed(&wt);

    let dirt = inspect_dirt(&wt).expect("an unpushed commit must read as dirty");
    assert_eq!(dirt.dirty_files, 0, "reason: {}", dirt.reason);
    assert_eq!(dirt.unpushed_commits, 1, "reason: {}", dirt.reason);
}

/// THE CRITICAL: a parent worktree containing a GITIGNORED nested worktree
/// holding uncommitted work must read as DIRTY.
///
/// Why: `.gitignore:40` on this repo's `main` ignores `.claude/worktrees/`, the
/// exact shape `find_orphaned_worktrees` location 5 exists to walk. The nested
/// worktrees carry no ownership sentinel, so the #3649 gate routes them to
/// `owner_unknown` and REFUSES to delete them — and then the sweep would delete
/// the parent they live inside, having certified it clean. The guard was
/// bypassable by deleting its own parent.
#[test]
fn inspect_dirt_reports_nested_gitignored_worktree() {
    let fx = GitWorktreeFixture::new();
    let parent = fx.add_worktree("parent");
    std::fs::write(parent.join(".gitignore"), ".claude/worktrees/\n").unwrap();
    GitWorktreeFixture::commit_all_and_push(&parent, "ignore agent worktrees");
    let nested = fx.add_nested_worktree(&parent, ".claude/worktrees", "agentA");
    std::fs::write(nested.join("patch.rs"), "// agent work, never committed\n").unwrap();

    // Prove the premise: the parent's own porcelain really is blind to it.
    let plain = std::process::Command::new("git")
        .arg("-C")
        .arg(&parent)
        .args(["status", "--porcelain"])
        .output()
        .unwrap();
    assert!(
        String::from_utf8_lossy(&plain.stdout).trim().is_empty(),
        "premise broken: plain porcelain should not see the gitignored nested worktree"
    );

    let dirt = inspect_dirt(&parent)
        .expect("a nested worktree holding uncommitted work must make the parent dirty");
    assert!(
        dirt.reason.contains("nested git worktree/repository"),
        "unexpected reason: {}",
        dirt.reason
    );
}

/// The same hole, but via an UNREGISTERED repository — an independent clone
/// dropped into a gitignored directory. `git worktree list` has never heard of
/// it, so only the on-disk scan can find it.
#[test]
fn inspect_dirt_reports_unregistered_nested_repo_in_ignored_dir() {
    let fx = GitWorktreeFixture::new();
    let parent = fx.add_worktree("with-clone");
    std::fs::write(parent.join(".gitignore"), "scratch/\n").unwrap();
    GitWorktreeFixture::commit_all_and_push(&parent, "ignore scratch");
    let inner = parent.join("scratch").join("side-project");
    std::fs::create_dir_all(&inner).unwrap();
    for args in [vec!["init", "--initial-branch=main"], vec!["status"]] {
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(&inner)
            .args(&args)
            .output()
            .unwrap();
        assert!(out.status.success(), "{args:?}: {out:?}");
    }
    std::fs::write(inner.join("work.rs"), "// never committed anywhere\n").unwrap();

    let dirt = inspect_dirt(&parent)
        .expect("an unregistered nested repo holding work must make the parent dirty");
    assert!(
        dirt.reason.contains("nested git worktree/repository"),
        "unexpected reason: {}",
        dirt.reason
    );
}

/// The counterweight, and it matters as much as the finding: a nested worktree
/// that is itself CLEAN and pushed must NOT pin the parent.
///
/// Why: without this, any session that ever spawned an agent worktree would be
/// permanently unreclaimable, which is the "guard that silently disables
/// reclamation" failure mode — indistinguishable from having no sweep at all.
#[test]
fn inspect_dirt_allows_clean_nested_worktree() {
    let fx = GitWorktreeFixture::new();
    let parent = fx.add_worktree("clean-parent");
    std::fs::write(parent.join(".gitignore"), ".claude/worktrees/\n").unwrap();
    GitWorktreeFixture::commit_all_and_push(&parent, "ignore agent worktrees");
    let nested = fx.add_nested_worktree(&parent, ".claude/worktrees", "agentClean");
    std::fs::write(nested.join("done.rs"), "// finished agent work\n").unwrap();
    GitWorktreeFixture::commit_all_and_push(&nested, "agent work, pushed");

    assert!(
        inspect_dirt(&parent).is_none(),
        "a clean, pushed nested worktree must not pin the parent; got {:?}",
        inspect_dirt(&parent)
    );
}

/// A dirty nested worktree sitting BEHIND a clean one must still be found.
///
/// Why this test exists: it pins a real bug. The first cut of the scan used
/// `?` on the per-root inspection, so the first CLEAN nested root returned
/// `None` for the whole scan and every root after it went unexamined — a parent
/// with one finished agent worktree and one in-flight one read CLEAN. Roots are
/// visited in sorted order, so `aaa-clean` is checked before `zzz-dirty`.
#[test]
fn inspect_dirt_keeps_scanning_past_a_clean_nested_worktree() {
    let fx = GitWorktreeFixture::new();
    let parent = fx.add_worktree("two-nested");
    std::fs::write(parent.join(".gitignore"), ".claude/worktrees/\n").unwrap();
    GitWorktreeFixture::commit_all_and_push(&parent, "ignore agent worktrees");

    let clean = fx.add_nested_worktree(&parent, ".claude/worktrees", "aaa-clean");
    std::fs::write(clean.join("done.rs"), "// finished\n").unwrap();
    GitWorktreeFixture::commit_all_and_push(&clean, "finished, pushed");

    let dirty = fx.add_nested_worktree(&parent, ".claude/worktrees", "zzz-dirty");
    std::fs::write(dirty.join("wip.rs"), "// in flight, never committed\n").unwrap();

    let dirt = inspect_dirt(&parent)
        .expect("a dirty nested worktree behind a clean one must still make the parent dirty");
    assert!(
        dirt.reason.contains("zzz-dirty"),
        "the reason must name the nested worktree that is actually dirty; got: {}",
        dirt.reason
    );
}

/// Documents the residual, so it is a decision rather than an accident: the
/// nested-repo scan does NOT descend into disposable build directories, so a
/// repository inside `target/` is invisible. Skipping them by name is what
/// keeps the scan affordable across ~95 candidates.
#[test]
fn inspect_dirt_does_not_scan_disposable_build_dirs() {
    let fx = GitWorktreeFixture::new();
    let parent = fx.add_worktree("disposable");
    std::fs::write(parent.join(".gitignore"), "target/\n").unwrap();
    GitWorktreeFixture::commit_all_and_push(&parent, "ignore target");
    let buried = parent.join("target").join("debug").join("checkout");
    std::fs::create_dir_all(buried.join(".git")).unwrap();
    std::fs::write(buried.join("thing.rs"), "// inside target/\n").unwrap();

    assert!(
        inspect_dirt(&parent).is_none(),
        "target/ is deliberately not scanned; got {:?}",
        inspect_dirt(&parent)
    );
}

/// HIGH-1: `status.showUntrackedFiles=no` makes bare `git status --porcelain`
/// exit 0 with EMPTY output while untracked work sits on disk. Its natural home
/// is the SHARED `.base/.git/config` every worktree in the sweep reads, so one
/// line there would blind the guard fleet-wide. The mode is pinned on the
/// command line, which beats every config file.
#[test]
fn inspect_dirt_survives_hostile_show_untracked_files_config() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("hostile-untracked");
    std::fs::write(wt.join("notes.md"), "hand-written, never added\n").unwrap();
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(&wt)
        .args(["config", "status.showUntrackedFiles", "no"])
        .output()
        .unwrap();
    assert!(out.status.success());

    // Prove the premise: unpinned porcelain really does go silent.
    let plain = std::process::Command::new("git")
        .arg("-C")
        .arg(&wt)
        .args(["status", "--porcelain"])
        .output()
        .unwrap();
    assert!(
        String::from_utf8_lossy(&plain.stdout).trim().is_empty(),
        "premise broken: the hostile config should have silenced plain porcelain"
    );

    let dirt = inspect_dirt(&wt).expect("a hostile config must not be able to hide untracked work");
    assert_eq!(dirt.dirty_files, 1, "reason: {}", dirt.reason);
}

/// The same class of hijack via `core.excludesFile`: a user-global excludes
/// file naming the work hides it exactly as `showUntrackedFiles=no` does.
/// Pinned empty on the command line.
#[test]
fn inspect_dirt_survives_hostile_global_excludes_file() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("hostile-excludes");
    let excludes = fx.repo.join("hostile-excludes.txt");
    std::fs::write(&excludes, "secret-work.txt\n").unwrap();
    std::fs::write(wt.join("secret-work.txt"), "REAL WORK\n").unwrap();
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(&wt)
        .args(["config", "core.excludesFile"])
        .arg(&excludes)
        .output()
        .unwrap();
    assert!(out.status.success());

    let plain = std::process::Command::new("git")
        .arg("-C")
        .arg(&wt)
        .args(["status", "--porcelain"])
        .output()
        .unwrap();
    assert!(
        String::from_utf8_lossy(&plain.stdout).trim().is_empty(),
        "premise broken: the excludes file should have hidden the work"
    );

    let dirt = inspect_dirt(&wt).expect("a hostile excludes file must not hide untracked work");
    assert_eq!(dirt.dirty_files, 1, "reason: {}", dirt.reason);
}

/// MEDIUM: `decommission` force-deletes `session/<leaf>` derived from the
/// DIRECTORY NAME, not from `HEAD`. When HEAD has been switched off that branch
/// — routine; the live `.base/.worktrees/2eb72dca-…` sits on `fix/4061-…` —
/// HEAD reads fully pushed while the branch about to be destroyed is not.
#[test]
fn inspect_dirt_reports_unpushed_session_branch_when_head_moved() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("moved-head");
    GitWorktreeFixture::commit_unpushed(&wt);
    // Switch HEAD off session/moved-head onto the pushed base commit.
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(&wt)
        .args(["switch", "--detach", "origin/main"])
        .output()
        .unwrap();
    assert!(out.status.success(), "{out:?}");

    let dirt = inspect_dirt(&wt)
        .expect("an unpushed commit on the branch decommission deletes must read as dirty");
    assert_eq!(
        dirt.unpushed_commits, 1,
        "HEAD is pushed but session/moved-head is not; reason: {}",
        dirt.reason
    );
}

/// The `.base/.worktrees/<session-id>` shape — the DOMINANT population of the
/// sweep — has its branch named BARE by `provisioner/workspace.rs:803`
/// (`session_id.to_string()`), not `session/<id>`. Checking only the prefixed
/// spelling left this leg inert exactly where most worktrees live.
///
/// Deliberately conservative: `branch -D session/<id>` currently MISSES the
/// bare branch, so it survives removal today. The divergence is a live
/// pre-existing bug that will be repaired on one side or the other, and if it
/// is repaired on the `remove_session_worktree` side then bare branches start
/// being destroyed. A guard that goes silently inert because somebody fixed an
/// unrelated naming bug is the failure mode this module exists to prevent.
#[test]
fn inspect_dirt_reports_unpushed_bare_leaf_branch_when_head_moved() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("bare-branch");
    // Re-point the worktree at a BARE branch named after the leaf, the way the
    // provisioner names it, then commit on it and switch HEAD away.
    for args in [
        vec!["switch", "-c", "bare-branch"],
        vec!["branch", "-D", "session/bare-branch"],
    ] {
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(&wt)
            .args(&args)
            .output()
            .unwrap();
        assert!(out.status.success(), "{args:?}: {out:?}");
    }
    GitWorktreeFixture::commit_unpushed(&wt);
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(&wt)
        .args(["switch", "--detach", "origin/main"])
        .output()
        .unwrap();
    assert!(out.status.success(), "{out:?}");

    let dirt = inspect_dirt(&wt)
        .expect("an unpushed commit on the bare leaf-named branch must read as dirty");
    assert_eq!(
        dirt.unpushed_commits, 1,
        "HEAD is pushed but the bare `bare-branch` is not; reason: {}",
        dirt.reason
    );
}

/// The counterweight to the test above: when the session branch IS the
/// checked-out branch, its commits must be counted ONCE, not twice.
#[test]
fn inspect_dirt_does_not_double_count_the_checked_out_session_branch() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("single-count");
    GitWorktreeFixture::commit_unpushed(&wt);

    let dirt = inspect_dirt(&wt).expect("an unpushed commit must read as dirty");
    assert_eq!(
        dirt.unpushed_commits, 1,
        "the same commit must not be counted by both legs; reason: {}",
        dirt.reason
    );
}

/// MEDIUM: `GIT_DIR` + `GIT_WORK_TREE` pointing at a clean repository make
/// `git -C <candidate> status` answer for THAT repository — a false CLEAN. The
/// daemon inherits whatever environment launched it. Asserted on the command
/// itself rather than by mutating process-global environment, which is racy
/// under a parallel test runner.
#[test]
fn git_command_strips_repository_redirecting_env() {
    let cmd = super::git_command(std::path::Path::new("/tmp"), &["status"]);
    let removed: Vec<&str> = cmd
        .get_envs()
        .filter(|(_, v)| v.is_none())
        .filter_map(|(k, _)| k.to_str())
        .collect();
    for key in [
        "GIT_DIR",
        "GIT_WORK_TREE",
        "GIT_INDEX_FILE",
        "GIT_CONFIG_COUNT",
    ] {
        assert!(
            removed.contains(&key),
            "{key} must be removed from the child environment; removed: {removed:?}"
        );
    }
}

/// #6391 REGRESSION: the REMOVAL is subject to the same redirect its gates are
/// hardened against, and until #6391 it was the one call that was not.
///
/// Why: `git worktree` resolves its repository from `GIT_DIR` ahead of `-C`.
/// Measured directly: with `GIT_DIR`/`GIT_WORK_TREE` naming another repository,
/// `git -C <repo> worktree list` lists that OTHER repository's worktrees, and
/// operating on one of `<repo>`'s own worktrees exits 128 with
/// `is not a working tree`. Every gate ahead of the removal goes through
/// [`super::git_command`] and so examines the real worktree; the removal used a
/// bare `Command::new("git")` and did not. The result is a reap that passes
/// every gate, deletes nothing, and reports the refusal to a `warn!` no test
/// reads — `agent_worktree_reap_tests::await_gone` then panics with
/// `was never removed` and no reason (#6391, and the `is not a working tree`
/// text #6099 recorded).
///
/// Against the pre-fix body this fails outright: a bare command removes no
/// variables at all, so `removed` is empty.
#[test]
fn worktree_remove_command_strips_repository_redirecting_env() {
    let cmd = super::worktree_remove_command(
        std::path::Path::new("/r"),
        std::path::Path::new("/r/.claude/worktrees/agent-x"),
    );
    let removed: Vec<&str> = cmd
        .get_envs()
        .filter(|(_, v)| v.is_none())
        .filter_map(|(k, _)| k.to_str())
        .collect();
    for key in [
        "GIT_DIR",
        "GIT_WORK_TREE",
        "GIT_INDEX_FILE",
        "GIT_COMMON_DIR",
        "GIT_CONFIG_COUNT",
    ] {
        assert!(
            removed.contains(&key),
            "{key} must be removed from the removal's child environment; removed: {removed:?}"
        );
    }
}

/// The hardening must not reorder the command: `--force` still precedes the
/// target, and the target is still the last argument.
///
/// Why: [`super::git_command`] appends its own pinned globals and `-C <dir>`
/// before the caller's arguments, so routing the removal through it is only
/// correct if the subcommand and its operand still land in that order. A
/// silently reordered `git worktree remove <path> --force` is a different
/// command.
#[test]
fn worktree_remove_command_ends_in_the_target_path() {
    let target = std::path::Path::new("/r/.claude/worktrees/agent-x");
    let cmd = super::worktree_remove_command(std::path::Path::new("/r"), target);
    let args: Vec<String> = cmd
        .get_args()
        .map(|a| a.to_string_lossy().into_owned())
        .collect();
    assert!(
        args.ends_with(&[
            "worktree".to_string(),
            "remove".to_string(),
            "--force".to_string(),
            target.display().to_string(),
        ]),
        "the removal must end in `worktree remove --force <path>`; args: {args:?}"
    );
    assert!(
        args.windows(2).any(|w| w[0] == "-C" && w[1] == "/r"),
        "the repository root must still be named by `-C`; args: {args:?}"
    );
}

/// Every git invocation must carry the pins, not just the ones someone
/// remembered — that is the point of routing them through one builder.
#[test]
fn git_command_pins_untracked_and_excludes_config() {
    let cmd = super::git_command(std::path::Path::new("/tmp"), &["status"]);
    let args: Vec<String> = cmd
        .get_args()
        .map(|a| a.to_string_lossy().into_owned())
        .collect();
    for pin in [
        "core.excludesFile=",
        "status.showUntrackedFiles=normal",
        "core.quotePath=false",
    ] {
        assert!(
            args.iter().any(|a| a == pin),
            "`{pin}` must be pinned on every git call; args: {args:?}"
        );
    }
}

/// FAIL-SAFE: a path git cannot examine at all reads as DIRTY, never as clean.
/// An error on the check must never become a green light to delete.
#[test]
fn inspect_dirt_treats_missing_path_as_dirty() {
    let tmp = tempfile::tempdir().unwrap();
    let missing = tmp.path().join("does").join("not").join("exist");
    let dirt = inspect_dirt(&missing).expect("an unexaminable path must read as dirty");
    assert!(
        dirt.reason.contains("unreadable") || dirt.reason.contains("not a git worktree"),
        "unexpected reason: {}",
        dirt.reason
    );
}

/// A plain directory that is NOT a git worktree root but holds files cannot be
/// assessed by git at all — and the fail-safe answer is DIRTY. This also pins
/// the identity check: the directory sits INSIDE a real checkout, so a naive
/// `git status` would have reported the ENCLOSING repo's (clean) status and
/// wrongly approved deletion.
#[test]
fn inspect_dirt_treats_non_worktree_with_files_as_dirty() {
    let fx = GitWorktreeFixture::new();
    let plain = fx.repo.join(".worktrees").join("not-a-worktree");
    std::fs::create_dir_all(&plain).unwrap();
    std::fs::write(plain.join("leftover.txt"), "who knows\n").unwrap();

    let dirt = inspect_dirt(&plain).expect("a non-worktree dir holding files must read as dirty");
    assert_eq!(dirt.dirty_files, 1, "reason: {}", dirt.reason);
    assert!(
        dirt.reason.contains("not a git worktree root"),
        "unexpected reason: {}",
        dirt.reason
    );
}

/// The counterweight to the test above: an EMPTY leftover shell (nothing but
/// the ownership sentinel) is provably free of work and stays reclaimable.
/// This is the #1838 case the orphan sweep exists for — one project grew 94 of
/// these — so the guard must not turn it into a permanent leak.
#[test]
fn inspect_dirt_allows_empty_non_git_leftover() {
    let tmp = tempfile::tempdir().unwrap();
    let shell = tmp.path().join(".worktrees").join("empty-shell");
    std::fs::create_dir_all(&shell).unwrap();
    std::fs::write(
        shell.join(super::super::decommission::WORKTREE_SENTINEL_FILE),
        "{}",
    )
    .unwrap();

    assert!(
        inspect_dirt(&shell).is_none(),
        "an empty leftover shell must stay reclaimable; got {:?}",
        inspect_dirt(&shell)
    );
}

// ── #6507: a squash-merged branch is not unsaved work ────────────────────────

/// Run one git command in `dir`, asserting it succeeded (#6507 tests).
fn git_must(dir: &Path, args: &[&str]) {
    let out = Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .output()
        .unwrap_or_else(|e| panic!("`git {}` could not be run: {e}", args.join(" ")));
    assert!(
        out.status.success(),
        "`git {}` failed: {}",
        args.join(" "),
        String::from_utf8_lossy(&out.stderr)
    );
}

/// 🔴 #7889: a branch whose work landed on the REMOTE — under an `-r2` name,
/// squash-merged on GitHub — is spared by gate 6 only because this checkout's
/// `refs/remotes/origin/main` predates the merge.
///
/// Why: the #6507 discount is evidence-based and correct, and it reads that
/// evidence from a ref nothing in the reclaim path ever updates. Observed
/// 2026-09-14: `prune-worktrees --merged-prs --force` spared
/// `agent-a2e6bc07b2ac613b4` at gate 6 with "1 unpushed commit(s)" although its
/// content had squash-merged as PR #7909, and a fleet run reclaimed 0 of ~40
/// worktrees for the same reason. Fails on `0f2bd5134`, where nothing fetches
/// and the second assertion still counts the commit as unsaved.
#[test]
fn worktree_7889_a_stale_origin_main_no_longer_spares_a_squash_merged_branch() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("agent-a2e6bc07b");
    fx.land_on_the_remote_only(&wt, "landed.rs");

    // The observed shape, pinned: the work IS on the remote, and this checkout
    // cannot see it, so gate 6 reports unsaved work.
    let dirt = inspect_dirt(&wt).expect("the stale-ref state must reproduce the refusal");
    assert_eq!(
        dirt.unpushed_commits, 1,
        "the fixture must leave exactly the one commit gate 6 miscounted: {dirt:?}"
    );

    crate::session_manager::worktree_landing_refresh::refresh_landing_refs(&wt)
        .expect("the fixture's origin is reachable");

    assert!(
        inspect_dirt(&wt).is_none(),
        "once the landing ref is current the squash clears the branch; got {:?}",
        inspect_dirt(&wt)
    );
}

/// The #6507 regression. A branch whose PR squash-merged, and whose remote
/// branch was then deleted, holds no work a removal could destroy — its patch
/// is on `origin/main` under a different SHA. Before this fix
/// `rev-list --count HEAD --not --remotes` returned 1 for exactly that state,
/// gate 6 of the merged-PR reclaim reported "holds unsaved work", and
/// `tm session prune-worktrees --merged-prs --force` reclaimed 0 on a
/// squash-merging repository.
///
/// FAIL-OPEN CHECK: reporting clean when work is unsaved is the one direction
/// this module may not get wrong, so the discount is per-commit and
/// evidence-based — `git cherry` marking that commit `-` against a remote
/// landing branch. It is never "the upstream ref is gone, therefore fine": the
/// sibling test below keeps a genuinely unpushed commit on the same fixture
/// refused.
#[test]
fn inspect_dirt_clears_a_squash_merged_branch_whose_upstream_was_pruned() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("squashed");
    fx.squash_merge_to_origin(&wt, "landed.rs");

    // The pre-fix state, pinned so a regression stays legible: the commit
    // really is unreachable from every remote ref.
    let reachability = git_stdout(&wt, &["rev-list", "--count", "HEAD", "--not", "--remotes"])
        .expect("rev-list must run");
    assert_eq!(
        reachability.trim(),
        "1",
        "the fixture must reproduce the pruned-upstream shape this bug needs"
    );

    assert!(
        inspect_dirt(&wt).is_none(),
        "a squash-merged branch holds no unsaved work; got {:?}",
        inspect_dirt(&wt)
    );
}

/// A squash of TWO commits clears too — the shape that survived the first fix.
///
/// Why: `git cherry` compares patch ids one commit at a time, and a squash
/// carries the UNION of the branch's patches, so a two-commit branch matches
/// nothing. Live on 2026-09-03: PR #6705 squashed `37540d9cb` and `4c8e11de8`
/// into `7933d406d`, `git cherry refs/remotes/origin/main HEAD` marked both
/// `+`, and gate 6 reported "2 unpushed commit(s)" for a fully landed worktree.
#[test]
fn inspect_dirt_clears_a_two_commit_squash_merge() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("squashed-two");
    fx.squash_merge_commits_to_origin(&wt, &["first.rs", "second.rs"]);

    // The pre-fix state, pinned: both commits are unreachable from every remote
    // ref AND neither matches a landed commit's patch id on its own.
    let reachability = git_stdout(&wt, &["rev-list", "--count", "HEAD", "--not", "--remotes"])
        .expect("rev-list must run");
    assert_eq!(
        reachability.trim(),
        "2",
        "the fixture must reproduce a divergence of more than one commit"
    );
    let cherry =
        git_stdout(&wt, &["cherry", "refs/remotes/origin/main", "HEAD"]).expect("cherry must run");
    assert!(
        !cherry.contains("- "),
        "the per-commit comparison must find NO match — that is the whole bug; got {cherry}"
    );

    assert!(
        inspect_dirt(&wt).is_none(),
        "a branch whose two commits squash-merged as one holds no unsaved work; got {:?}",
        inspect_dirt(&wt)
    );
}

/// The control for the two-commit fix: work added AFTER the squash still counts.
///
/// Why: the aggregate comparison discounts a whole divergence at once, so it
/// has to stop discounting the moment the divergence stops matching.
#[test]
fn inspect_dirt_reports_a_commit_added_after_a_two_commit_squash_merge() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("squashed-two-plus");
    fx.squash_merge_commits_to_origin(&wt, &["first.rs", "second.rs"]);

    std::fs::write(wt.join("never-landed.rs"), "work that exists only here\n").unwrap();
    git_must(&wt, &["add", "never-landed.rs"]);
    git_must(&wt, &["commit", "-m", "feat: never pushed anywhere"]);

    let dirt = inspect_dirt(&wt).expect("a commit added after the squash must still read as dirty");
    assert_eq!(
        dirt.unpushed_commits, 3,
        "an aggregate that no longer matches discounts nothing; reason was: {}",
        dirt.reason
    );
}

/// `squashed_divergence` arm 1 — NO MERGE BASE keeps the raw count (#6507).
///
/// Why: every arm of the aggregate probe has to fail toward "still unsaved", and
/// this is the arm reached whenever the branch shares no ancestor with a landing
/// branch. A probe that treated an unanswerable comparison as a match would
/// discount commits that exist nowhere else.
#[test]
fn inspect_dirt_keeps_the_raw_count_when_there_is_no_merge_base() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("orphan-6507");
    git_must(&wt, &["checkout", "--orphan", "unrelated-6507"]);
    git_must(&wt, &["rm", "-rf", "."]);
    std::fs::write(wt.join("only-here.rs"), "work that shares no ancestor\n").unwrap();
    git_must(&wt, &["add", "only-here.rs"]);
    git_must(&wt, &["commit", "-m", "feat: unrelated root commit"]);

    // The precondition this arm needs: git can name no common ancestor at all.
    assert!(
        git_stdout(&wt, &["merge-base", "refs/remotes/origin/main", "HEAD"]).is_err(),
        "the fixture must reproduce a history with no merge base"
    );

    let dirt = inspect_dirt(&wt).expect("an unrelated root commit is unsaved work");
    assert_eq!(
        dirt.unpushed_commits, 1,
        "an unanswerable comparison discounts nothing; reason was: {}",
        dirt.reason
    );
}

/// `squashed_divergence` arm 2 — an EMPTY DIVERGENCE DIFF keeps the raw count
/// (#6507).
///
/// Why: a branch that adds a file and then removes it has two real commits and
/// no aggregate diff, so there is no patch to compare and no path to narrow
/// candidates by. The probe returns before it lists a single candidate, and
/// those two commits stay counted — they exist nowhere but this worktree.
#[test]
fn inspect_dirt_keeps_the_raw_count_when_the_divergence_has_no_diff() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("netzero-6507");
    std::fs::write(wt.join("ephemeral.rs"), "added, then taken away\n").unwrap();
    git_must(&wt, &["add", "ephemeral.rs"]);
    git_must(&wt, &["commit", "-m", "feat: add a file"]);
    git_must(&wt, &["rm", "ephemeral.rs"]);
    git_must(&wt, &["commit", "-m", "fix: remove it again"]);

    // The precondition: two commits whose AGGREGATE diff is empty, so the probe
    // has no patch to match and no path to narrow the candidate list by.
    let names = git_stdout(
        &wt,
        &["diff", "--name-only", "refs/remotes/origin/main", "HEAD"],
    )
    .expect("diff must run");
    assert!(
        names.trim().is_empty(),
        "the divergence must cancel out; got {names}"
    );

    let dirt = inspect_dirt(&wt).expect("two commits that landed nowhere are unsaved work");
    assert_eq!(
        dirt.unpushed_commits, 2,
        "an empty aggregate proves nothing landed; reason was: {}",
        dirt.reason
    );
}

/// `squashed_divergence` arm 3 — a squash BEYOND the candidate window keeps the
/// raw count (#6507).
///
/// Why: the probe compares at most `SQUASH_CANDIDATE_MAX` (25) landing commits
/// that touch a path the branch touched. This pins the boundary exactly: 25
/// newer commits on `main` touch `first.rs`, so the squash is the 26th and the
/// window never lists it. Missing it costs a reclaim, never a deletion.
#[test]
fn inspect_dirt_keeps_the_raw_count_when_the_squash_falls_outside_the_window() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("windowed-6507");
    fx.squash_merge_commits_to_origin(&wt, &["first.rs", "second.rs"]);

    // `first.rs` is the probe path — the first name the branch's aggregate diff
    // lists — so commits touching it are what fill the window.
    for i in 0..25 {
        std::fs::write(fx.repo.join("first.rs"), format!("later edit {i}\n")).unwrap();
        git_must(&fx.repo, &["add", "first.rs"]);
        git_must(
            &fx.repo,
            &["commit", "-m", &format!("chore: later edit {i}")],
        );
    }
    git_must(&fx.repo, &["push", "origin", "main"]);
    git_must(&fx.repo, &["fetch", "--prune", "origin"]);

    let dirt =
        inspect_dirt(&wt).expect("a squash the window cannot reach is not proof the work landed");
    assert_eq!(
        dirt.unpushed_commits, 2,
        "an exhausted window discounts nothing; reason was: {}",
        dirt.reason
    );
}

/// The control the #6507 fix may never break: a commit that landed NOWHERE is
/// still unsaved work, in the same worktree, beside one that did land.
#[test]
fn inspect_dirt_still_reports_a_genuinely_unpushed_commit_beside_a_squashed_one() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("mixed");
    fx.squash_merge_to_origin(&wt, "landed.rs");

    std::fs::write(wt.join("never-landed.rs"), "work that exists only here\n").unwrap();
    git_must(&wt, &["add", "never-landed.rs"]);
    git_must(&wt, &["commit", "-m", "feat: never pushed anywhere"]);

    let dirt = inspect_dirt(&wt).expect("an unlanded commit must still read as dirty");
    assert_eq!(
        dirt.unpushed_commits, 1,
        "only the squash-merged commit may be discounted; reason was: {}",
        dirt.reason
    );
}

/// The discount is applied PER COMMIT, never as a difference of counts.
///
/// Why this needs its own fixture: `git cherry`'s output range is
/// `<base>..<tip>`, which can hold commits the rev-list never counted. Here the
/// first commit is reachable from `origin/landed-elsewhere`, so it is not a
/// candidate — and its patch is also on `origin/main`, so cherry marks it `-`.
/// A naive `candidates - landed.len()` therefore reports 1 - 1 = 0 and calls the
/// worktree clean, discarding the SECOND commit, which exists nowhere but this
/// directory. The other two #6507 tests pass under that naive form; this one
/// does not.
#[test]
fn inspect_dirt_discounts_only_the_commits_the_query_counted() {
    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("two-refs");
    fx.land_patch_and_keep_the_remote_branch(&wt, "landed.rs", "landed-elsewhere");

    std::fs::write(wt.join("only-here.rs"), "exists nowhere else\n").unwrap();
    git_must(&wt, &["add", "only-here.rs"]);
    git_must(&wt, &["commit", "-m", "feat: exists nowhere else"]);

    // The fixture's own precondition: exactly ONE candidate (the second
    // commit), and a cherry range that also marks the first one landed.
    let counted = git_stdout(&wt, &["rev-list", "--count", "HEAD", "--not", "--remotes"])
        .expect("rev-list must run");
    assert_eq!(
        counted.trim(),
        "1",
        "the fixture must count only the commit that landed nowhere"
    );
    let cherry = git_stdout(&wt, &["cherry", "refs/remotes/origin/main", "HEAD"])
        .expect("git cherry must run");
    assert_eq!(
        cherry.lines().filter(|l| l.starts_with("- ")).count(),
        1,
        "the cherry range must also hold a landed commit the rev-list never counted: {cherry}"
    );

    let dirt = inspect_dirt(&wt).expect("the second commit is unsaved work");
    assert_eq!(
        dirt.unpushed_commits, 1,
        "a landed commit outside the counted set must not discount one inside it; reason was: {}",
        dirt.reason
    );
}

/// A repository with no remote landing branch discounts NOTHING — the patch
/// comparison has no base, and an unanswerable comparison must leave the raw
/// count exactly where it was.
#[test]
fn inspect_dirt_discounts_nothing_without_a_remote_landing_branch() {
    let tmp = tempfile::tempdir().unwrap();
    let repo = std::fs::canonicalize(tmp.path()).unwrap().join("solo");
    std::fs::create_dir_all(&repo).unwrap();
    git_must(&repo, &["init", "--initial-branch=main"]);
    git_must(&repo, &["config", "user.email", "ci@test.invalid"]);
    git_must(&repo, &["config", "user.name", "CI"]);
    git_must(&repo, &["config", "commit.gpgsign", "false"]);
    std::fs::write(repo.join("only.rs"), "local\n").unwrap();
    git_must(&repo, &["add", "only.rs"]);
    git_must(&repo, &["commit", "-m", "local only"]);

    assert!(
        landing_bases(&repo).is_empty(),
        "a repository with no remotes offers no landing base"
    );
    let dirt = inspect_dirt(&repo).expect("a commit on no remote is unsaved work");
    assert_eq!(dirt.unpushed_commits, 1, "reason was: {}", dirt.reason);
}

// ── #7185: every reclaim path agrees about the harness marker, git included ──

/// Run `git -C <dir> <args>` and return whether it succeeded, plus its stderr.
fn git_try(dir: &Path, args: &[&str]) -> (bool, String) {
    let out = Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .output()
        .unwrap_or_else(|e| panic!("`git {}` could not be run: {e}", args.join(" ")));
    (
        out.status.success(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
    )
}

/// 🔴 REGRESSION (#7185): a tree carrying ONLY the harness ownership marker
/// reads clean on BOTH reclaim decisions, and plain `git worktree remove` can
/// only agree once the marker is cleared.
///
/// Why: #7212 made [`count_dirty_files`] and the ADR-0057 removal guard's
/// `dirty_entries` one answer, and that answer excuses the marker. It does not
/// bind git. `git worktree remove` applies its own clean check, which counts
/// every untracked entry — so the `tm pr merge` cleanup path, which deliberately
/// never forces, was authorised by tm and refused by git on every worktree in a
/// project that does not gitignore the marker. This pins all three answers in
/// one place so they cannot drift apart again.
#[test]
fn a_worktree_holding_only_the_harness_marker_is_removable_by_plain_git() {
    use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};
    use crate::session_manager::worktree_ownership::take_worktree_sentinel;

    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("marker-only");
    GitWorktreeFixture::stamp_reclaimable_sentinel(&wt);

    // Decision 1 — the reclaim sweep and `tm pr merge`'s cleanup probe.
    assert!(
        inspect_dirt(&wt).is_none(),
        "the harness marker is not unsaved work; got {:?}",
        inspect_dirt(&wt)
    );
    // Decision 2 — the ADR-0057 `git worktree remove` guard.
    assert_eq!(
        GitAndGhProbe
            .dirty_entries(&wt)
            .expect("status must be readable"),
        0,
        "both reclaim paths must read the marker-only tree as clean"
    );

    // Git's own answer, which neither decision above can change.
    let (removed, stderr) = git_try(&fx.repo, &["worktree", "remove", wt.to_str().unwrap()]);
    assert!(
        !removed && stderr.contains("untracked files"),
        "git itself counts the untracked marker — if this ever stops being true \
         the clearing step below is no longer needed; stderr: {stderr}"
    );

    let taken = take_worktree_sentinel(&wt).expect("the harness marker must be clearable");
    assert!(
        taken.is_some_and(|b| !b.is_empty()),
        "taking the marker must hand back the bytes a failed removal would restore"
    );
    let (removed, stderr) = git_try(&fx.repo, &["worktree", "remove", wt.to_str().unwrap()]);
    assert!(
        removed,
        "clearing the marker must let a NON-forced removal succeed; stderr: {stderr}"
    );
}

/// 🔴 The excusal covers that one name and nothing else: real work beside the
/// marker still refuses, and clearing the marker never touches it.
#[test]
fn clearing_the_marker_leaves_every_other_file_alone() {
    use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};
    use crate::session_manager::worktree_ownership::{
        restore_worktree_sentinel, take_worktree_sentinel,
    };

    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("marker-plus-work");
    GitWorktreeFixture::stamp_reclaimable_sentinel(&wt);
    // One modified tracked file and one untracked source file, beside the marker.
    std::fs::write(wt.join("README.md"), "edited\n").expect("modify tracked file");
    let untracked = wt.join("scratch.rs");
    std::fs::write(&untracked, "fn main() {}\n").expect("write untracked source");

    let dirt = inspect_dirt(&wt).expect("real work beside the marker is still unsaved work");
    assert_eq!(dirt.dirty_files, 2, "reason was: {}", dirt.reason);
    assert_eq!(
        GitAndGhProbe
            .dirty_entries(&wt)
            .expect("status must be readable"),
        2,
        "the removal guard must count the same two files"
    );

    let taken = take_worktree_sentinel(&wt)
        .expect("clearing must succeed")
        .expect("the marker was there, so its bytes come back");
    assert!(
        untracked.exists() && wt.join("README.md").exists(),
        "clearing the marker must remove the marker and nothing else"
    );
    // An absent marker is the state this exists to reach, so a second take is a
    // no-op that reports nothing to restore.
    assert!(
        take_worktree_sentinel(&wt)
            .expect("a second take must be a no-op")
            .is_none()
    );

    // The round trip a failed removal relies on: the bytes go back verbatim.
    restore_worktree_sentinel(&wt, &taken).expect("restore must succeed");
    assert_eq!(
        std::fs::read(wt.join(super::super::decommission::WORKTREE_SENTINEL_FILE))
            .expect("read restored marker"),
        taken,
        "the restore must be byte-faithful"
    );
    // And the restored marker is still excused by both decisions.
    assert_eq!(
        GitAndGhProbe
            .dirty_entries(&wt)
            .expect("status must be readable"),
        2,
        "a restored marker must not become work"
    );
}

/// 🔴 #7914: the count ADR-0057's PR-less admission grants on, against real
/// git.
///
/// Why: the guard's policy tests fabricate this number, so nothing else proves
/// the query spelling answers what the admission claims it answers. All three
/// states are walked in one fixture because the transitions are the contract:
/// a fresh worktree is the shape an owner is told to delete, a local commit is
/// the shape that must keep denying, and pushing it is the ff-into-a-landed-
/// sibling shape the admission exists for.
#[test]
fn local_only_commits_counts_only_what_no_origin_ref_has() {
    use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};

    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("landed-history");

    assert_eq!(
        GitAndGhProbe
            .local_only_commits(&wt)
            .expect("a fresh worktree must be countable"),
        0,
        "a worktree branched off a pushed tip holds no commit origin lacks"
    );

    GitWorktreeFixture::commit_unpushed(&wt);
    assert_eq!(
        GitAndGhProbe
            .local_only_commits(&wt)
            .expect("a committed worktree must be countable"),
        1,
        "a commit on no origin ref is exactly what must keep denying"
    );

    // A second commit, so `commit_all_and_push` has something to stage — it
    // pushes the local-only one along with it.
    std::fs::write(wt.join("landed.txt"), "landed\n").expect("write a file to land");
    GitWorktreeFixture::commit_all_and_push(&wt, "land it");
    assert_eq!(
        GitAndGhProbe
            .local_only_commits(&wt)
            .expect("a pushed worktree must be countable"),
        0,
        "once the commits are on origin the tree is the only place for nothing"
    );
}

/// 🔴 REGRESSION (#7914, critic round 1): a branch deleted on the remote behind
/// this worktree's back must stop vouching for its commits.
///
/// Why: `--remotes=origin` reads `refs/remotes/origin/*`, a LOCAL cache. A
/// delete performed anywhere but this worktree — `gh pr close --delete-branch`,
/// the web UI, another clone — leaves that ref naming a commit the remote no
/// longer has, and the admission then granted a removal of the only surviving
/// copy. The second clone here is what makes the staleness real: nothing
/// updates this worktree's refs, so only the refresh inside `local_only_commits`
/// can catch it. Fails against `798a257e7`, which counts 0 and admits.
#[test]
fn local_only_commits_reprunes_a_branch_deleted_behind_this_worktrees_back() {
    use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};

    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("deleted-upstream");
    std::fs::write(wt.join("work.txt"), "real work\n").expect("write work");
    GitWorktreeFixture::commit_all_and_push(&wt, "work nobody else has");
    assert_eq!(
        GitAndGhProbe
            .local_only_commits(&wt)
            .expect("a pushed worktree must be countable"),
        0,
        "the pushed branch vouches for the commit while it exists"
    );

    // The delete happens in a SECOND clone, so no push or fetch in `wt` can
    // have updated its remote-tracking refs.
    let origin = git_stdout(&wt, &["remote", "get-url", "origin"]).expect("origin url");
    let clone = fx.repos_root.join("second-clone");
    let (ok, stderr) = git_try(
        &fx.repos_root,
        &["clone", "-q", origin.trim(), clone.to_str().expect("utf8")],
    );
    assert!(ok, "the second clone must succeed; stderr: {stderr}");
    let (ok, stderr) = git_try(
        &clone,
        &[
            "push",
            "-q",
            "origin",
            "--delete",
            "session/deleted-upstream",
        ],
    );
    assert!(ok, "the remote delete must succeed; stderr: {stderr}");

    assert_eq!(
        GitAndGhProbe
            .local_only_commits(&wt)
            .expect("the count must still be answerable"),
        1,
        "a ref the remote no longer has must stop vouching — this worktree now holds the \
         only copy of that commit"
    );
}

/// 🔴 #7914, critic round 1, failure path: an origin that cannot be reached
/// makes the count UNANSWERABLE, never zero.
///
/// Why: the refresh is what makes the refs trustworthy, so a refresh that did
/// not happen must not leave the stale refs granting. `Err` is what the policy
/// reads as "this admission does not apply" — see
/// `an_unanswerable_local_only_count_never_admits`, which pins that it then
/// falls through to the merged-PR route rather than granting.
#[test]
fn local_only_commits_cannot_be_answered_when_origin_is_unreachable() {
    use crate::core::worktree_removal_facts::{GitAndGhProbe, WorktreeRemovalProbe};

    let fx = GitWorktreeFixture::new();
    let wt = fx.add_worktree("dead-origin");
    let gone = fx.repos_root.join("no-such-remote.git");
    let (ok, stderr) = git_try(
        &wt,
        &["remote", "set-url", "origin", gone.to_str().expect("utf8")],
    );
    assert!(
        ok,
        "pointing origin at nothing must succeed; stderr: {stderr}"
    );

    let answer = GitAndGhProbe.local_only_commits(&wt);
    let err = answer.expect_err("an unreachable origin must not produce a trusted count");
    assert!(
        err.contains("could not be refreshed"),
        "the failure must name the refresh, not a count: {err}"
    );
}