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
//! Pre-deletion safety checks for an orphaned-worktree reclaim candidate
//! (#3649 `git worktree list` cross-check, extended #4091 dirty-tree guard).
//!
//! Why: before #4091 the reclaim path's ENTIRE safety model was "who owned
//! this directory" — the #3649 ownership sentinel plus the
//! [`git_worktree_list_agrees`] cross-check. Neither asks the one question an
//! operator actually cares about: *is there unsaved work in here*. A worktree
//! with a KNOWN owner that passed the owner-terminal + grace-window gates was
//! force-deleted (`git worktree remove --force`, falling back to
//! `fs::remove_dir_all`) with uncommitted edits still in it, silently. Worse,
//! `decommission::remove_session_worktree` also runs
//! `git branch -D session/<leaf>` on success, so commits made on the
//! worktree's own branch and never pushed lose their last reachable ref too —
//! committed work is genuinely destroyed by a reclaim, not merely detached.
//! Ownership answers "may I delete this"; it must not also have to answer "is
//! this safe to delete".
//! What: [`DirtyWorktreePolicy`] (skip vs. explicit force-discard),
//! [`DirtyWorktree`] (a reportable skip record), [`inspect_dirt`] (the check
//! itself), and [`git_worktree_list_agrees`] (moved here verbatim from
//! `prune.rs`, which is at its 500-SLOC cap — these are the same category of
//! check and belong together).
//!
//! FAIL-SAFE DIRECTION: every error path in this module resolves to DIRTY.
//! A git subprocess that cannot be spawned, exits non-zero, or emits an
//! unparsable count, and a directory that cannot be read, all mean "this
//! check could not prove the directory is safe to delete" — which must never
//! become a green light to delete. That inverts the whole point.
//!
//! # The rule, stated once (#4118 review round)
//!
//! `git status` answers "what does git see", and the two most valuable things
//! in a session worktree are precisely the things it does NOT see: work hidden
//! behind `.gitignore`, and work hidden behind this guard's own carve-outs.
//! Both were live on this machine on 2026-07-27, on DIFFERENT branches of the
//! same repo — `main` gitignores `.claude/worktrees/` and `.trusty-mpm/*`
//! (`.gitignore:40,49`), while the older branch checked out in
//! `.base/.worktrees/2eb72dca-…` gitignores neither, so the same content
//! arrives as `??` lines there and is invisible here. A guard that closed only
//! one of those would be sound on one branch and useless on the other, so this
//! module never reasons from "is it gitignored". It asks five questions:
//!
//! 1. **Does git report anything?** — [`STATUS_ARGS`], with every
//! config knob that could silence it pinned on the command line.
//! 2. **Is anything committed but not on a remote?** — on `HEAD` *and* on the
//! `session/<leaf>` branch that `decommission` force-deletes.
//! 3. **Is there a nested repository holding unsaved work?** — registered
//! worktrees from `git worktree list --porcelain` (exact), plus a bounded
//! walk of gitignored subtrees for repositories git has never heard of,
//! bare and non-bare alike. Each nested root is then put through questions
//! 1, 2 and 4 in its own right.
//! 4. **Is there anything under `.trusty-mpm/` that is not one of two
//! disposable artefacts?** — accounted per-file by pathspec, covering the
//! gitignored and untracked spellings alike.
//! 5. **Is there a high-value gitignored file?** (#4166) — a short list of
//! names that are unrecoverable if deleted (`.env*`, `*.bak`, `*.orig`),
//! found by the same walk as question 3. See
//! `super::worktree_nested::is_high_value_ignored`.
//!
//! # What this rule still CANNOT see (residual risk)
//!
//! - **Gitignored loose files outside `.trusty-mpm/` whose names are not on the
//! #4166 list** — a scratch note in an ignored directory, a `.mcp.json.local`.
//! Not counted HERE: on this repo's own 31 session worktrees, treating every
//! non-disposable ignored entry as dirt would flag `.claude/` in 30 of 31. Since
//! #8534 a separate gate counts them before removal, excusing harness and build
//! output: `super::worktree_ignored_output`, run by the shared remover, the
//! agent reap and the `tm pr cleanup` probe.
//! - **Anything under a disposable build directory** (`target/`,
//! `node_modules/`, …), including a nested repository inside one — see
//! `super::worktree_nested::DISPOSABLE_DIR_NAMES`.
//! - **A repository whose root this walk cannot recognise.** Question 3 knows
//! two shapes: a `.git` entry, and the `HEAD` + `objects/` + `refs/` triple a
//! bare repository has instead (#4166 — before that, a bare repo was invisible
//! and a candidate holding one read CLEAN). A repository laid out as neither —
//! a `.git` FILE pointing somewhere unusual, a bare repo missing a marker — is
//! still not seen.
//! - **`.git/info/exclude`**, which is per-repo and on-disk and cannot be
//! overridden from the command line the way `core.excludesFile` can.
//! - **The stash — but only for the CANDIDATE ITSELF, and this distinction
//! matters.** A session worktree's stash lives in the shared `.base/.git`
//! *outside* the candidate, so it survives removal and is genuinely out of
//! scope here. A **self-contained nested clone** is the opposite case: its
//! `refs/stash` and reflog sit inside the directory being deleted and die
//! with it, so [`super::worktree_nested`] counts them. The earlier blanket
//! claim that "the stash is repo-level, not per-worktree" was true of the
//! top-level candidate and false of a nested clone — the same conflation
//! that produced the round-3 CRITICAL.
//! - **Symlinked subtrees** — not followed, because `remove_dir_all` deletes
//! the link and not its target, so no work is at risk there.
//! - **A repository nested inside a gitignored subtree OF a nested repository**
//! — the scan stops descending at the first repository root it finds.
//! - **The bare-branch population, partially.** `count_session_branch_unpushed`
//! covers both `session/<leaf>` and the bare `<leaf>` spelling. Since #4165
//! every producer emits `session/<leaf>`; the bare arm covers worktrees
//! provisioned by an older build, whose branch `decommission` still misses.
//! - **`DirtyWorktreePolicy::ForceDiscard`**, which destroys all of the above
//! by explicit operator opt-in.
//!
//! Test: `worktree_safety_tests`.
use HashSet;
use ;
use Command;
use ;
use Serialize;
/// The bookkeeping directory trusty-mpm writes into every managed workspace.
const TRUSTY_MPM_DIR: &str = ".trusty-mpm";
/// The ONLY two artefacts under [`TRUSTY_MPM_DIR`] this guard treats as
/// disposable (#4118 gap review).
///
/// Why: the first cut of this guard excused the whole `.trusty-mpm/` subtree on
/// the grounds that it is "the scrollback-snapshot directory". It is not. That
/// subtree ALSO holds `sessions/` pause snapshots (written by
/// `trusty-common`'s `catchup::pause`, read back by `/tm-session-resume`),
/// config checkpoints, and — observed live on 2026-07-27 in
/// `.base/.worktrees/2eb72dca-…/.trusty-mpm/wip-backup-20260727-jira/` — twelve
/// files of hand-rescued, uncommitted Rust source. A prefix match excused all
/// of it, so `dirty_files` stayed 0 and the worktree read CLEAN.
/// What: an ALLOWLIST of exact repo-relative paths, not a prefix. `scrollback`
/// is a pane dump the daemon rewrites on every snapshot;
/// `last-instructions.md` is a regenerated copy of the PM brief. Everything
/// else under `.trusty-mpm/` — named or not, tracked, untracked, or gitignored
/// — counts as work.
/// Test: `inspect_dirt_counts_wip_backup_under_trusty_mpm`,
/// `inspect_dirt_counts_pause_snapshots_under_trusty_mpm`,
/// `inspect_dirt_excludes_untracked_trusty_mpm_dir`.
const DISPOSABLE_TRUSTY_MPM_FILES: & = &;
/// Git global options pinned on EVERY invocation this module makes (#4118).
///
/// Why: `git status --porcelain` does not report "all work". It reports all
/// work git has been CONFIGURED to show, and three of those config keys can
/// silence it outright. Proven on git 2.54.0 against throwaway repos:
/// `status.showUntrackedFiles=no` makes `git status --porcelain` exit 0 with
/// EMPTY output while untracked work sits on disk; a `core.excludesFile`
/// naming that work hides it the same way. The natural home for either is the
/// SHARED `.base/.git/config` that all ~95 worktrees in the sweep read, so one
/// line there would blind the guard fleet-wide. A work-destroying gate must not
/// be steerable by configuration it does not control.
/// What: `--no-optional-locks` (never write to a candidate's index just to
/// inspect it), plus `-c` pins for every key that changes what status reports
/// or how it spells a path. `-c` beats every config file, so this is
/// deterministic regardless of system/global/repo config. NOT pinned: aliases
/// (git refuses to let an alias shadow a built-in command — verified) and
/// `status.branch` (verified to have no effect on `--porcelain` v1 output).
/// Test: `inspect_dirt_survives_hostile_show_untracked_files_config`,
/// `inspect_dirt_survives_hostile_global_excludes_file`.
const GIT_PINNED_GLOBAL_ARGS: & = &;
/// Environment variables that point git at a DIFFERENT repository (#4118).
///
/// Why: `git -C <candidate> status` obeys `GIT_DIR`/`GIT_WORK_TREE` over its
/// own `-C`. The daemon inherits its environment from whatever launched it, and
/// a `GIT_DIR` + `GIT_WORK_TREE` pair naming a CLEAN repository makes every
/// candidate report clean — verified: with both set, status on a repo holding
/// an untracked file exits 0 with empty output. Most other combinations fail
/// toward DIRTY, but "most" is not a safety property.
/// What: removed from the child environment so git always resolves the
/// repository from `-C <candidate>` alone. `GIT_CONFIG_COUNT` is stripped too
/// because env-injected config would otherwise compete with
/// [`GIT_PINNED_GLOBAL_ARGS`].
/// Test: `git_command_strips_repository_redirecting_env`.
///
/// `pub(crate)` since #6391 so a failing worktree removal can name which of
/// these the ambient environment carried, instead of reporting only that the
/// directory is still there.
pub const GIT_ENV_REDIRECTS: & = &;
/// Working-tree status for the candidate itself.
// #8572: `pub(crate)` so the main-checkout HEAD-switch guard reads dirt the same way.
pub const STATUS_ARGS: & = &;
/// [`STATUS_ARGS`] with every untracked file listed on its own line (#7660).
const STATUS_ARGS_PER_FILE: & = &;
/// Per-file accounting of `.trusty-mpm/`, scoped by pathspec (#4118).
///
/// Why: `--untracked-files=normal` COLLAPSES an untracked directory to a single
/// `?? .trusty-mpm/` line, which makes scrollback indistinguishable from
/// rescued source — the exact discrimination the allowlist needs. `-uall` plus
/// `--ignored=matching` restores per-path detail AND covers the case where the
/// project gitignores `.trusty-mpm/*` (this repo's `main`) so the subtree is
/// invisible to plain status altogether. Both spellings are live in the fleet
/// right now, so the guard must not depend on either.
/// What: scoped by the `-- .trusty-mpm` pathspec so the expensive `-uall`
/// recursion is confined to one small directory instead of the whole checkout.
const TRUSTY_MPM_STATUS_ARGS: & = &;
/// What the reclaim path does when a candidate holds unsaved work (#4091).
///
/// Why: the default must be impossible to trigger accidentally — an ordinary
/// `/tm-session-pause` (which prunes by DEFAULT, `prune_worktrees` unwrapping
/// to `true`) must never be able to discard uncommitted work. Modelling the
/// override as its own two-variant enum rather than a `force: bool` keeps it
/// from being positionally confused with the `dry_run: bool` that already sits
/// next to it in [`super::prune::SessionManager::prune_orphaned_worktrees`](crate::session_manager::SessionManager::prune_orphaned_worktrees)'s
/// signature, where a swapped argument would mean "really delete, and discard
/// dirty work" instead of "preview".
/// What: [`Skip`](Self::Skip) (the [`Default`]) refuses to remove a dirty
/// candidate and reports it; [`ForceDiscard`](Self::ForceDiscard) removes it
/// anyway, and is reachable ONLY from the explicit `discard_dirty` flag on the
/// `prune-worktrees` HTTP route / `tm session prune-worktrees --discard-dirty`.
/// Test: `dirty_policy_defaults_to_skip`.
/// A reclaim candidate that was skipped because it holds unsaved work (#4091).
///
/// Why: "never silently" is half the fix. A skipped worktree that only shows
/// up as a log line is invisible to the MCP caller, the HTTP client, and the
/// `/tm-session-pause` skill — so the operator sweeping ~95 worktrees would
/// have no idea which ones still hold work. This is the structured, wire-
/// serializable form that every surface returns.
/// What: the candidate `path`, a human-readable `reason`, and the two counts
/// behind that reason — `dirty_files` (working-tree entries reported by
/// `git status --porcelain`, i.e. modified/staged tracked files PLUS
/// untracked-but-not-ignored files) and `unpushed_commits`.
/// Test: `inspect_dirt_reports_modified_tracked_file`,
/// `inspect_dirt_reports_untracked_file`, `inspect_dirt_reports_unpushed_commit`.
/// Does this reclaim candidate hold work that removal would destroy (#4091)?
///
/// Why: this is the check the reclaim path never had. It is deliberately the
/// LAST gate — it runs only on candidates the #3649 ownership gate has already
/// approved, so it is additive and cannot weaken that guard.
/// What: returns `None` only when the directory is PROVABLY free of unsaved
/// work; `Some(DirtyWorktree)` in every other case, including every error.
/// "Dirty" means any of:
/// 1. [`count_dirty_files`] finds a working-tree entry — modified or staged
/// tracked files, untracked-but-not-ignored files, and anything under
/// `.trusty-mpm/` that is not one of two disposable artefacts;
/// 2. [`count_unpushed`] finds a commit that is on no remote, on `HEAD` or on
/// the `session/<leaf>` branch `remove_session_worktree` force-deletes;
/// 3. [`super::worktree_nested::nested_dirt`] finds a nested repository
/// holding unsaved work, or a high-value gitignored file (#4166), either
/// way regardless of gitignore;
/// 4. the directory is not a git worktree ROOT at all and is not empty — see
/// [`non_git_dirt`];
/// 5. any check errored — see the module-level FAIL-SAFE note.
///
/// The worktree-root identity check is not incidental: running
/// `git status` inside a plain directory that happens to sit INSIDE a checkout
/// would report the ENCLOSING repository's status, which is both wrong and
/// misleading, so the toplevel must canonicalize to the candidate itself.
/// Test: `inspect_dirt_clean_pushed_worktree_is_none`,
/// `inspect_dirt_reports_modified_tracked_file`,
/// `inspect_dirt_reports_untracked_file`,
/// `inspect_dirt_reports_unpushed_commit`,
/// `inspect_dirt_treats_missing_path_as_dirty`,
/// `inspect_dirt_treats_non_worktree_with_files_as_dirty`,
/// `inspect_dirt_allows_empty_non_git_leftover`.
/// A predicate over one `git status --porcelain` line (#7660).
pub type ExcuseEntry<'a> = &'a dyn Fn ;
/// [`inspect_dirt`], with the working-tree entries `excuse` accepts left
/// uncounted (#7660).
///
/// Why: `tm sessions decommission --force` removes a workspace that is dirty
/// only from tm's own provisioning files. Every other question this guard asks
/// — unpushed commits, nested repositories, `.trusty-mpm/` work, the root
/// identity check, and every fail-safe error arm — must still be asked, so the
/// excuse is threaded through this one function rather than re-implemented.
/// What: identical to [`inspect_dirt`] except that pass one of
/// [`count_dirty_files`] lists untracked files one by one and skips a line
/// `excuse` returns `true` for.
/// Test: `force_decommission_removes_a_provisioning_only_worktree`,
/// `force_decommission_still_refuses_user_work`,
/// `force_decommission_still_refuses_unpushed_commits`.
pub
/// [`inspect_dirt`], with the nested-repository scan switchable off.
///
/// Why: the nested scan inspects each nested root by calling back into this
/// function. Running the scan again from inside a nested root would recurse
/// without bound, and buys nothing — [`nested_repo_roots`] already enumerates
/// registered worktrees at EVERY depth in one flat pass.
/// What: `scan_nested = false` answers only questions 1, 2 and 4 for `path`.
/// Test: `inspect_dirt_reports_nested_gitignored_worktree`.
pub
/// The body of [`inspect_dirt_at`] and [`inspect_dirt_excusing`] (#7660).
/// Count working-tree entries that represent WORK, in two passes (#4118).
///
/// Why: one `git status` cannot both stay cheap on a whole checkout and stay
/// per-file precise inside `.trusty-mpm/`. `-unormal` collapses untracked
/// directories (cheap, but `?? .trusty-mpm/` hides what is underneath) and
/// `-uall` does not (precise, but recurses the entire tree). Splitting by
/// pathspec buys the precision exactly where the allowlist needs it.
/// What: pass one counts every `-unormal` entry except the ownership sentinel
/// and anything rooted at `.trusty-mpm/`; pass two hands `.trusty-mpm/` to
/// [`trusty_mpm_dirt`], which sees ignored and untracked content alike.
/// Test: `inspect_dirt_counts_wip_backup_under_trusty_mpm`,
/// `inspect_dirt_excludes_own_sentinel`.
///
/// `pub(crate)` since #7185 so the ADR-0057 removal guard asks this ONE
/// question instead of its own plain `git status --porcelain` count, which
/// counted the harness's own ownership marker as the agent's unsaved work.
pub
/// [`count_dirty_files`], skipping the pass-one lines `excuse` accepts (#7660).
/// The repo-relative path of a `git status --porcelain` v1 line.
///
/// Why: every line is `XY <path>`, so the path starts at byte 3. A rename is
/// `R <old> -> <new>`; this returns the whole `<old> -> <new>` span, which no
/// caller matches against an allowlist — a rename INTO an excused path
/// therefore fails the match and COUNTS, which is the fail-safe direction.
/// What: the trimmed remainder of the line, or `""` for a malformed one (which
/// then matches no allowlist entry and is counted).
/// Is this repo-relative path rooted at the `.trusty-mpm/` bookkeeping dir?
///
/// Why: the ownership sentinel is `.trusty-mpm-worktree`, a SIBLING whose name
/// shares the prefix. Matching on a bare `.trusty-mpm` prefix would route the
/// sentinel into the per-file pass, where it is not on the allowlist and would
/// be counted — marking every managed worktree dirty forever.
/// What: true for `.trusty-mpm`, `.trusty-mpm/` and anything beneath it; false
/// for `.trusty-mpm-worktree`.
/// Test: `inspect_dirt_excludes_own_sentinel`.
/// Count entries under `.trusty-mpm/` that are not disposable bookkeeping.
///
/// Why: see [`DISPOSABLE_TRUSTY_MPM_FILES`]. This is the leg that closes the
/// live `wip-backup-20260727-jira/` hole, and it must work whether the subtree
/// is gitignored (invisible to plain status) or untracked (collapsed by plain
/// status) — hence [`TRUSTY_MPM_STATUS_ARGS`].
/// What: one count per reported entry that is not on the allowlist. A quoted
/// path is an ERROR, not a silently-unmatched one: if the guard cannot spell
/// the path it cannot decide the path is disposable.
/// Test: `inspect_dirt_counts_wip_backup_under_trusty_mpm`,
/// `inspect_dirt_counts_pause_snapshots_under_trusty_mpm`,
/// `inspect_dirt_counts_a_deleted_tracked_file_under_trusty_mpm`.
/// Classify a candidate that is NOT a git worktree root (#4091).
///
/// Why: the orphan sweep's original purpose (#1838) is reclaiming the leftover
/// `.worktrees/<id>` SHELLS that accumulate when `git worktree remove` never
/// ran or half-ran — one project grew 94 of them. Those shells carry no git
/// metadata, so no git-based dirty check can speak to them, and treating every
/// one as dirty would neuter reclamation entirely (the guard must not become a
/// permanent leak). But a non-git directory that still holds FILES is exactly
/// the case we cannot assess, and the fail-safe direction is to keep it.
/// What: `None` when the directory holds nothing but the trusty-mpm ownership
/// sentinel (an empty shell — provably no work); `Some` otherwise, including
/// when the directory cannot be read at all.
/// Test: `inspect_dirt_allows_empty_non_git_leftover`,
/// `inspect_dirt_treats_non_worktree_with_files_as_dirty`,
/// `inspect_dirt_treats_missing_path_as_dirty`.
/// Does `path` canonicalize to its own git worktree toplevel?
///
/// Why: `git -C <dir> status` happily answers for an ENCLOSING repository when
/// `dir` is merely nested inside one, so an identity check is required before
/// any status output can be attributed to the candidate itself.
/// What: `Ok(true)` when `git rev-parse --show-toplevel` canonicalizes to the
/// same path as `path`; `Ok(false)` when it resolves elsewhere; `Err` when git
/// cannot answer (not a repository, git missing, spawn failure).
/// Test: `inspect_dirt_treats_non_worktree_with_files_as_dirty` (the nested
/// plain-directory case), `inspect_dirt_clean_pushed_worktree_is_none`.
// #8318: `pub(crate)` so adoption's branch release asks the same identity question.
pub
/// Count commits on `HEAD` that are not yet safely on a remote (#4091).
///
/// Why: `decommission::remove_session_worktree` deletes the worktree's
/// `session/<leaf>` branch after a successful removal, so a commit that exists
/// only on that branch loses its last reachable ref and becomes garbage-
/// collectable. "Committed" is therefore NOT the same as "safe".
/// What: when the checked-out branch has an upstream, counts
/// `<upstream>..HEAD`. With NO upstream configured, counts commits reachable
/// from `HEAD` but from no remote-tracking ref at all
/// (`rev-list --count HEAD --not --remotes`) — which subsumes the
/// "not on `origin/main`" rule (a commit absent from `origin/main` AND every
/// other remote ref is counted) while not falsely flagging a branch that WAS
/// pushed under its own name but never had upstream tracking configured. In a
/// repository with no remotes at all, `--remotes` expands to nothing and every
/// commit counts as unpushed — the correct, conservative answer.
/// Reachability alone is not the question a SQUASH-merge repository can answer
/// (#6507). A squash merge replays the branch's changes as ONE NEW commit on
/// `main`, so the branch tip is never an ancestor of it, and
/// `gh pr merge --squash --delete-branch` then removes the remote branch that
/// made those commits reachable. `rev-list --count HEAD --not --remotes` counts
/// them all as unpushed, gate 6 of the merged-PR reclaim reports "holds unsaved
/// work", and `prune-worktrees --merged-prs` reclaims nothing on a repository
/// that squash-merges every PR. [`count_unlanded`] subtracts the commits whose
/// PATCH is provably already on a remote landing branch — per commit for a
/// one-commit branch, and per whole divergence for the two-or-more-commit
/// branch a squash collapses into a patch none of them matches
/// ([`squashed_divergence`]).
/// Test: `inspect_dirt_reports_unpushed_commit`,
/// `inspect_dirt_clean_pushed_worktree_is_none`,
/// `inspect_dirt_clears_a_squash_merged_branch_whose_upstream_was_pruned`,
/// `inspect_dirt_clears_a_two_commit_squash_merge`.
/// The most unpushed-looking commits this guard will patch-compare (#6507).
///
/// Why: the no-upstream arm of [`count_unpushed`] is
/// `rev-list HEAD --not --remotes`, which in a repository with no remote refs
/// at all expands to the WHOLE history — tens of thousands of commits here. The
/// patch comparison is worth running over a branch's divergence and never over
/// a history, so past this many candidates the raw count is returned unchanged.
/// That is the fail-closed direction: more unpushed commits, never fewer.
/// What: 200, comfortably above any real branch's divergence.
/// Test: `inspect_dirt_clears_a_squash_merged_branch_whose_upstream_was_pruned`
/// exercises the comparing path; the cap only ever restores today's behaviour.
const LANDED_PROBE_MAX_CANDIDATES: usize = 200;
/// Ref patterns naming a branch a merge can LAND on (#6507).
///
/// Why: `git cherry` takes exactly one upstream, so the comparison needs a
/// concrete ref rather than `--remotes`. A squash merge lands on a remote's
/// default branch, and these three patterns name it: `HEAD` when the remote's
/// symbolic default is known locally (every `git clone` sets it), and the two
/// conventional spellings for a repository built by `git init` + `git remote
/// add`, which sets no `HEAD`. A pattern that matches nothing contributes
/// nothing, so an unusual default branch simply leaves the raw count in place.
/// Test: `inspect_dirt_clears_a_squash_merged_branch_whose_upstream_was_pruned`.
const LANDING_BASE_PATTERNS: & = &;
/// How many distinct landing bases one call will compare against (#6507).
///
/// Why: each base costs one `git cherry` per tip, and a machine with several
/// remotes would otherwise pay for all of them on every dirty check.
const LANDING_BASE_LIMIT: usize = 4;
/// Count the commits in one rev-list query that are NOT already on a remote,
/// by patch rather than by reachability (#6507).
///
/// Why: see [`count_unpushed`]. The subtraction is per-COMMIT and never a
/// difference of counts: `git cherry`'s output range is `<base>..<tip>`, which
/// can hold commits this query never counted, so subtracting its `-` total
/// would discount a genuinely unsaved commit against an unrelated match.
/// What: runs `count_args` for the number and `list_args` for the same
/// commits' SHAs, then drops those [`landed_on_a_remote`] proved equivalent to
/// a commit already on a landing branch. Every failure arm keeps the raw count:
/// an unanswerable patch comparison must never lower it.
/// Test: `inspect_dirt_clears_a_squash_merged_branch_whose_upstream_was_pruned`,
/// `inspect_dirt_still_reports_a_genuinely_unpushed_commit_beside_a_squashed_one`.
/// The SHAs reachable from `tips` whose patch is already on a landing branch
/// (#6507).
///
/// Why: this is the only evidence that turns "unreachable from every remote"
/// into "already landed". `git cherry <base> <tip>` marks a commit `-` when its
/// patch id matches one on `base` — which is exactly what a squash merge
/// produces — and `+` otherwise.
/// What: the `-` SHAs, unioned over every [`landing_bases`] base and every tip,
/// plus whatever [`squashed_divergence`] proves landed as ONE squash commit —
/// which is the only thing that clears a branch of two or more commits.
/// An empty set on every failure path, which leaves the caller's count intact.
///
/// Residual, stated rather than hidden: an EMPTY commit has an empty patch id,
/// so two unrelated empty commits read as equivalent and one could be
/// discounted. An empty commit carries no file content, so nothing recoverable
/// is at risk. `git cherry` also skips merge commits, which therefore never
/// appear here and stay counted — the conservative direction.
///
/// The same residual runs through [`squashed_divergence`], one size larger
/// (#6507). An exact patch-id match is CONTENT equivalence, not merge
/// provenance: a landing commit whose diff is byte-identical to the branch's
/// whole divergence discounts that divergence, whether or not it is the squash
/// of it. The window keeps that narrow — the match has to land inside the
/// [`SQUASH_CANDIDATE_MAX`] commits on the base that touch a path the branch
/// touched — and a byte-identical diff means the branch's content is already on
/// the landing branch either way, so nothing unique to the worktree is at risk.
/// Test: `inspect_dirt_clears_a_squash_merged_branch_whose_upstream_was_pruned`,
/// `inspect_dirt_clears_a_two_commit_squash_merge`.
/// How many landing-branch commits one squash probe patch-compares (#6507).
///
/// Why: the probe narrows candidates by a path the branch actually touched, so
/// the true squash commit is always in the list; this only bounds how far back
/// a match is looked for when many commits touch that path. A miss keeps the
/// raw count, so the cap can only ever over-report.
/// Test: `inspect_dirt_keeps_the_raw_count_when_the_squash_falls_outside_the_window`
/// pins the boundary — 25 newer commits on the path hide the squash.
const SQUASH_CANDIDATE_MAX: usize = 25;
/// The commits in `<base>..<tip>` when the branch's WHOLE divergence is already
/// on `base` as ONE squash commit (#6507).
///
/// Why: `git cherry` — the #6528 fix — compares commits pairwise, so it clears
/// a branch that squash-merged from a SINGLE commit and nothing else. Live on
/// 2026-09-03: PR #6705 squashed two commits (`37540d9cb`, `4c8e11de8`) into
/// `7933d406d`, neither commit's patch id matched, and gate 6 of the merged-PR
/// reclaim reported "0 uncommitted/untracked file(s), 2 unpushed commit(s)" for
/// a worktree whose work had fully landed. A squash's patch is the UNION of the
/// branch's commits, so the branch's aggregate diff is the only thing that can
/// match it.
/// What: `git diff <merge-base> <tip>` reduced to one patch id, compared
/// against the patch ids of up to [`SQUASH_CANDIDATE_MAX`] commits on `base`
/// that touch a path the branch touched. An exact match means every commit in
/// `<merge-base>..<tip>` landed, and those SHAs are returned; anything else —
/// no merge base, an empty diff, no candidate, a git failure — returns nothing
/// and leaves the caller's count untouched.
///
/// The match is exact and aggregate, so a commit added AFTER the squash changes
/// the aggregate diff and nothing is discounted: the fail-closed direction.
/// Test: `inspect_dirt_clears_a_two_commit_squash_merge`,
/// `inspect_dirt_reports_a_commit_added_after_a_two_commit_squash_merge`, and
/// one per early return (#6507) —
/// `inspect_dirt_keeps_the_raw_count_when_there_is_no_merge_base`,
/// `inspect_dirt_keeps_the_raw_count_when_the_divergence_has_no_diff`,
/// `inspect_dirt_keeps_the_raw_count_when_the_squash_falls_outside_the_window`.
/// `git patch-id --stable` over the patch a git command prints (#6507).
///
/// Why: patch equivalence is the only signal that survives a squash, and
/// `git patch-id` is the only thing that computes it. It reads a patch STREAM
/// and emits one `<patch-id> <commit-id>` line per patch in it, so a single
/// pair of subprocesses covers both `git diff` (one line, no commit) and
/// `git show <sha>…` (one line per commit).
/// What: runs `git <args>`, feeds its stdout to `git patch-id --stable`, and
/// parses the reply. Every failure — the first command, the spawn, a non-zero
/// exit — yields an empty vector, which discounts nothing.
/// Test: `inspect_dirt_clears_a_two_commit_squash_merge`.
/// Remote refs a squash merge could have landed on, deduplicated by commit
/// (#6507).
///
/// Why: `refs/remotes/origin/HEAD` and `refs/remotes/origin/main` usually name
/// the SAME commit, and comparing against both would double the subprocess cost
/// for one answer.
/// What: one `for-each-ref` over [`LANDING_BASE_PATTERNS`], keeping the first
/// ref for each distinct object and at most [`LANDING_BASE_LIMIT`] of them. An
/// empty vector when git cannot be asked, which discounts nothing.
/// Test: `inspect_dirt_clears_a_squash_merged_branch_whose_upstream_was_pruned`.
/// Count unpushed commits on the `session/<leaf>` branch `decommission` deletes.
///
/// Why: [`count_unpushed`] inspects `HEAD`, but
/// `decommission::remove_session_worktree` step 3 force-deletes
/// `session/<leaf>` derived from the DIRECTORY NAME, not from `HEAD`. Switching
/// a session worktree off its own branch is routine here — the live
/// `.base/.worktrees/2eb72dca-…` sits on `fix/4061-…`, not `session/2eb72dca-…`
/// — and in that state HEAD is fully pushed while the session branch is not.
/// Reproduced end-to-end on git 2.54.0: after committing on `session/sess` and
/// switching to `main`, `rev-list --count HEAD --not --remotes` returns 0 while
/// the branch still carries the commit. HEAD-only counting therefore returned a
/// false CLEAN for the one branch about to be force-deleted.
/// What: BOTH spellings of "the branch named after this directory" are checked
/// — `refs/heads/session/<leaf>` and the bare `refs/heads/<leaf>`. Every
/// producer now emits the prefixed spelling: `worktree_branch_for` backs
/// `inproject::create_session_worktree`, `remove_session_worktree`'s
/// `branch -D`, and — since #4165 — the session-worktree creation path,
/// which used to name the branch bare and so leaked one branch per session.
///
/// The bare check stays for the population already on disk. Worktrees
/// provisioned before #4165 still carry a bare branch, `branch -D
/// session/<uuid>` still misses it, and it still survives removal — so
/// counting it here over-reports rather than under-reports, which is the
/// direction this module errs in deliberately. Drop the bare arm only once
/// no pre-#4165 worktree remains; a guard that goes quietly inert because
/// somebody repaired a naming bug is the failure mode this module exists to
/// prevent.
///
/// 0 when neither ref exists (`for-each-ref` exits 0 with empty output, so
/// absence is distinguishable from a spawn failure, which stays an `Err`).
/// Otherwise the commits on those branches reachable from no remote AND not
/// already counted via `HEAD` — excluding `HEAD` is what stops the common
/// "branch IS the checked-out branch" case from being counted twice, and
/// passing both refs to ONE `rev-list` stops a commit shared by both spellings
/// from being counted twice either.
/// Test: `inspect_dirt_reports_unpushed_session_branch_when_head_moved`,
/// `inspect_dirt_reports_unpushed_bare_leaf_branch_when_head_moved`,
/// `inspect_dirt_does_not_double_count_the_checked_out_session_branch`.
pub
/// Run `git -C <dir> <args>` and return stdout, or an error string.
///
/// Why: every check in this module needs the same "ran, exited zero, gave me
/// stdout" contract, and every deviation from it must surface as an `Err` the
/// caller turns into DIRTY rather than being swallowed.
/// What: non-zero exit, spawn failure and a timeout (#8306) all become `Err`
/// carrying the command and git's own stderr; stdout is lossily decoded.
/// Test: `inspect_dirt_treats_missing_path_as_dirty`,
/// `a_wedged_git_is_killed_within_the_ceiling`.
pub
/// Build a `git` invocation that cannot be steered by ambient config or
/// environment (#4118).
///
/// Why: EVERY git call in this module has to be immune to the two hijacks that
/// turn a dirty worktree into a clean report — config keys that suppress
/// output ([`GIT_PINNED_GLOBAL_ARGS`]) and environment variables that point git
/// at a different repository ([`GIT_ENV_REDIRECTS`]). Centralising it here is
/// what makes "every git call" true rather than aspirational; a future call
/// site cannot forget.
/// What: `git <pinned globals> -C <dir> <args>`, with the redirect variables
/// removed from the child environment. Built on
/// [`trusty_common::git::command`] (#7171) so every call through this
/// function — the crate's single hardened git entry point — also carries
/// `-c maintenance.auto=false -c gc.auto=0`; this module's callers run per
/// worktree, repeatedly, across every worktree in the fleet, which is exactly
/// the pattern that turned into 41 detached `git maintenance run --auto`
/// repacks in one incident.
/// Test: `git_command_strips_repository_redirecting_env`,
/// `git_command_pins_untracked_and_excludes_config`.
pub
/// Build the one `git worktree remove --force` invocation this crate runs
/// (#6391).
///
/// Why: `git worktree` resolves its repository from `GIT_DIR` ahead of its own
/// `-C`, so an ambient redirect aims the removal at a DIFFERENT repository than
/// the one every gate before it just examined. Those gates all route through
/// [`git_command`], which strips [`GIT_ENV_REDIRECTS`]; the removal did not,
/// because each of its two call sites spelt the command out with a bare
/// `Command::new("git")`. That left the one call that actually deletes as the
/// only unhardened one. What an operator sees is a reap in which every gate
/// passes, `git` then exits 128 with `is not a working tree` (the text #6099
/// recorded), the refusal reaches nothing but a `warn!`, and the directory is
/// still on disk. A git hook exports `GIT_DIR` and `GIT_INDEX_FILE` to
/// everything it launches, so a daemon or test run descended from one inherits
/// exactly that redirect.
/// What: [`git_command`] plus `worktree remove --force <path>`. `path` is passed
/// as a `Path` rather than a `&str` so a non-UTF-8 name is not coerced lossily
/// (#1840).
/// Test: `worktree_remove_command_strips_repository_redirecting_env`,
/// `worktree_remove_command_ends_in_the_target_path`.
pub
/// Decide whether a reclaim candidate may be removed, and say so out loud.
///
/// Why: the reclaim path has to make this decision TWICE — once while
/// classifying candidates and again immediately before each removal (#4118
/// TOCTOU) — and `prune.rs` sits against its 500-SLOC cap. Both call sites also
/// have to log identically, or a reader of the daemon log cannot tell which
/// pass spared a worktree.
/// What: `Some(dirt)` means "do not remove this, and record it"; `None` means
/// "proceed". Under [`DirtyWorktreePolicy::ForceDiscard`] a dirty candidate
/// returns `None` after a `warn!` naming exactly what is about to be destroyed.
/// Test: `prune_orphaned_worktrees_rechecks_dirt_immediately_before_removal`.
pub
/// What the #4091 dirty-tree gate decided for one candidate (#8782).
///
/// Why: [`dirt_blocks_removal`] answers `None` both for a clean tree and for a
/// dirty one `--discard-dirty` is about to destroy, so the prune preview told
/// the operator "no unsaved work" for exactly the trees that would lose it.
/// What: `Clean`; `Blocks` — keep it and report it; `Discards` — remove it
/// anyway under [`DirtyWorktreePolicy::ForceDiscard`], carrying what is lost.
pub
/// [`dirt_blocks_removal`], keeping the dirt a force-discard destroys (#8782).
///
/// Test: `the_orphan_preview_names_unsaved_work_that_discard_dirty_destroys`.
pub
/// Best-effort cross-check: does `git worktree list` on the checkout owning
/// `candidate` agree that `candidate` is a real, currently-registered git
/// worktree (#3649)?
///
/// Why: the sentinel + store-ownerless checks establish WHO owned this
/// directory and whether that owner is provably gone, but neither confirms
/// git's OWN bookkeeping still recognises the path as a worktree at all — a
/// belt-and-suspenders safety net against deleting a directory that merely
/// LOOKS like a worktree (e.g. its git worktree entry was already pruned by
/// something else, or the shape matched by coincidence). A disagreement is
/// treated conservatively: skip rather than delete.
/// What (rebuilt git-native, #4207): asks the CANDIDATE'S OWN repository —
/// `git -C <candidate> worktree list --porcelain` — rather than a repository
/// guessed from the candidate's grandparent directory. The grandparent rule
/// was an inference, not a fact, and it was wrong in both directions: for
/// `<repo>/.claude/worktrees/<name>` it named `<repo>/.claude`, which is not a
/// repository at all, and for the fourteen worktrees living under
/// `<repo>/.base/.worktrees/` but REGISTERED to the parent repo `<repo>` it
/// named `.base`, which correctly disowned them — so this check returned
/// `false` for every one of them and the sweep skipped them conservatively,
/// forever. Git resolves the owning repository from the candidate itself, so
/// the answer no longer depends on where the worktree is parked.
///
/// Returns `true` (agree — deletion may proceed, subject to the caller's other
/// checks) when the probe cannot be answered at all: this check is an
/// ADDITIONAL safety net on top of the sentinel/store checks, not a
/// replacement for them, so a missing `git` binary or a transient failure
/// never blocks a deletion those checks already approved. It is emphatically
/// NOT the fail-safe gate; that role belongs to [`inspect_dirt`], which fails
/// toward DIRTY. Returns `true` only when `candidate`'s canonicalized path
/// appears among the registered worktrees.
///
/// The exception is a TIMEOUT (#8306): git was running and did not answer, so
/// something in this worktree is wedged. That answers `false` — disagree, keep
/// the tree — never the "unanswerable" `true`.
/// Test: `git_worktree_list_agrees_true_for_real_worktree`,
/// `git_worktree_list_agrees_false_for_untracked_dir`,
/// `git_worktree_list_agrees_true_for_worktree_registered_to_parent_repo`
/// (#4207 — fails against the grandparent rule),
/// `a_timed_out_worktree_list_disagrees`.
pub