trusty-mpm 1.7.4

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
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
//! Decommission's in-project worktree removal, and its `--force` policy (#7660).
//!
//! Why: `tm sessions decommission` refused every freshly provisioned workspace,
//! because tm's own provisioning leaves ` M .gitignore`, `?? .claude/settings.json`,
//! `?? .claude/settings.json.bak` and `?? CLAUDE.md` behind, and the dirty-tree
//! guard counts all four as unsaved work. It also exited 0 while declining, so a
//! script saw success and the directory stayed on disk.
//! What: [`remove_in_project_worktree`] — the dirty-gated removal step that used
//! to sit inline in `decommission_with_root_checked` — now returns a
//! [`WorkspaceVerdict`] that carries WHY a workspace was kept, and honours
//! [`ProvisioningDirt::Discard`], under which the four provisioning paths are
//! excused only in the exact state provisioning leaves them
//! ([`is_provisioning_entry`]). Unpushed commits, any other modified or
//! untracked file, an edit to a tracked provisioning path, nested-repository
//! work, and every check that cannot complete still keep the workspace.
//! When its excuse is needed, `--force` acts only on a linked worktree tm
//! provably created that nothing locks ([`force_blocker`]); on a clean tree it
//! is never stricter than no flag. A workspace tm never removes — the shared
//! main checkout a launch-on-main session runs in — is kept with a named
//! reason ([`unowned_kept_reason`]): a by-design note without `--force`, and a
//! refusal that makes the CLI exit non-zero only under `--force`.
//! Test: `force_decommission_removes_a_provisioning_only_worktree`,
//! `force_decommission_keeps_an_edited_tracked_claude_md`,
//! `force_decommission_keeps_a_gitignore_with_a_non_provisioning_line`,
//! `force_decommission_still_refuses_user_work`,
//! `force_decommission_still_refuses_unpushed_commits`,
//! `force_decommission_removes_nothing_when_the_dirty_check_cannot_complete`,
//! `decommission_reports_why_it_kept_a_provisioned_worktree`.

use std::path::{Path, PathBuf};

use tracing::warn;

use super::decommission::{
    GIT_WORKTREE_REMOVE_TIMEOUT, WORKTREE_SENTINEL_FILE, WorktreeRemoval,
    remove_session_worktree_guarded,
};
use super::record::{ManagedSessionId, SessionRecord};
use super::worktree_ownership::SentinelOwner;
use super::worktree_ownership_location::{
    OwnerReadError, admin_sentinel_path, legacy_sentinel_path, read_sentinel_owner_strict,
};
use super::worktree_safety::{inspect_dirt, inspect_dirt_excusing};

/// The paths tm's own provisioning writes into a workspace (#7660).
///
/// Why: these are the four entries the issue's `git status --porcelain` showed
/// on a workspace no user had touched. Nothing else is excused: a broader list
/// would let `--force` discard work.
pub(crate) const PROVISIONING_FILES: [&str; 4] = [
    ".gitignore",
    ".claude/settings.json",
    ".claude/settings.json.bak",
    "CLAUDE.md",
];

/// Whether decommission may discard tm's own provisioning dirt (#7660).
///
/// Why: a two-variant enum rather than a `force: bool`, for the reason
/// [`super::DirtyWorktreePolicy`] gives — a swapped positional bool next to the
/// existing `check_foreign_claim` flag would silently invert a data-safety gate.
/// Test: `force_decommission_removes_a_provisioning_only_worktree`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProvisioningDirt {
    /// Any dirt keeps the workspace (the default).
    #[default]
    Refuse,
    /// Dirt made only of [`PROVISIONING_FILES`] does not keep the workspace.
    Discard,
}

/// What a full decommission did with the workspace, and why (#7660).
///
/// Why: `(SessionRecord, bool)` could say THAT a workspace stayed but not WHY,
/// so the CLI could neither exit non-zero nor name what blocked the removal.
/// What: the tombstoned record, whether the directory was removed, and — only
/// when decommission had a removal candidate and kept it — the reason.
/// `#[non_exhaustive]` (owner ruling 2026-09-24): a field added later is not
/// a semver break; only this crate constructs it.
/// Test: `decommission_reports_why_it_kept_a_provisioned_worktree`.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DecommissionReport {
    /// The tombstoned record.
    pub record: SessionRecord,
    /// `true` only when the directory was removed by this call.
    pub workspace_removed: bool,
    /// Why the workspace was kept; `None` when it was removed or is absent.
    /// #7660: a workspace tm never removes (a main checkout, a local-path or
    /// adopted directory) is a refusal here only under `--force`.
    pub workspace_kept_reason: Option<String>,
    /// #7660: why a plain decommission kept a workspace tm never removes (a
    /// main checkout, a local-path or adopted directory). Not a failure.
    pub workspace_kept_by_design: Option<String>,
}

/// What [`remove_in_project_worktree`] did (#7660).
#[derive(Debug, Clone, Default)]
pub(super) struct WorkspaceVerdict {
    /// `true` only when the worktree was removed.
    pub removed: bool,
    /// Why the worktree was kept, when it was.
    pub kept_reason: Option<String>,
}

/// The provisioning paths `--force` excuses only while git does not track them
/// (#7660). A tracked one showing ` M` is an edit someone made, not a write
/// provisioning did, so it is never excused.
const UNTRACKED_PROVISIONING_FILES: [&str; 3] = [
    ".claude/settings.json",
    ".claude/settings.json.bak",
    "CLAUDE.md",
];

/// Is this `git status --porcelain` line in `ws` one of tm's provisioning
/// files, in exactly the state provisioning leaves it (#7660)?
///
/// Why: `--force` is followed by `git worktree remove --force`, which destroys
/// whatever it excused. A repository that tracks `CLAUDE.md` shows an agent's
/// edit to it as ` M CLAUDE.md`, and excusing that by path alone discarded it.
/// What: the three [`UNTRACKED_PROVISIONING_FILES`] are excused only as `??`.
/// `.gitignore` is excused as ` M` only when its unstaged diff adds nothing
/// but the lines provisioning writes and removes nothing, and as `??` only
/// when every line of it is such a line. Anything else — staged, deleted,
/// renamed, conflicted, or an unreadable diff — is not excused.
/// Test: `provisioning_entry_matches_only_the_four_paths_in_provisioning_states`,
/// `force_decommission_keeps_an_edited_tracked_claude_md`,
/// `force_decommission_keeps_a_gitignore_with_a_non_provisioning_line`,
/// `force_decommission_removes_a_tree_with_an_untracked_scaffold_gitignore`,
/// `force_decommission_keeps_an_untracked_gitignore_with_a_user_line`.
pub(crate) fn is_provisioning_entry(ws: &Path, line: &str) -> bool {
    let (Some(status), Some(path)) = (line.get(..3), line.get(3..)) else {
        return false;
    };
    match (status, path.trim()) {
        // #7660: `.gitignore` is checked line by line, never by path alone.
        (" M ", ".gitignore") => gitignore_diff_is_provisioning(ws),
        ("?? ", ".gitignore") => std::fs::read_to_string(ws.join(".gitignore"))
            .is_ok_and(|body| body.lines().all(is_provisioning_gitignore_line)),
        ("?? ", path) => UNTRACKED_PROVISIONING_FILES.contains(&path),
        _ => false,
    }
}

/// Whether `line` is one provisioning writes into `.gitignore` (#7660): a
/// blank line, a managed-block marker, or a managed path.
fn is_provisioning_gitignore_line(line: &str) -> bool {
    use crate::core::scaffold_gitignore::{
        SCAFFOLD_GITIGNORE_BEGIN, SCAFFOLD_GITIGNORE_END, SCAFFOLD_IGNORED_PATHS,
    };
    line.trim().is_empty()
        || line == SCAFFOLD_GITIGNORE_BEGIN
        || line == SCAFFOLD_GITIGNORE_END
        || SCAFFOLD_IGNORED_PATHS.contains(&line)
}

/// Whether `ws`'s unstaged `.gitignore` diff only ADDS provisioning lines
/// (#7660).
///
/// What: `git diff -U0` of the working tree against the index. Every `+` line
/// must pass [`is_provisioning_gitignore_line`]; any `-` line, any line that is
/// not a diff header, or a diff that cannot be read answers `false`. Headers
/// are recognised only before the first `@@` hunk.
/// Test: `gitignore_body_line_shaped_like_a_header_is_not_excused`,
/// `force_decommission_keeps_the_tree_when_the_gitignore_diff_cannot_be_read`.
fn gitignore_diff_is_provisioning(ws: &Path) -> bool {
    let args = [
        "diff",
        "--no-color",
        "--no-ext-diff",
        "--no-textconv",
        "-U0",
        "--",
        ".gitignore",
    ];
    let Ok(diff) = super::worktree_safety::git_stdout(ws, &args) else {
        return false;
    };
    let mut added = 0usize;
    let mut in_hunk = false;
    for line in diff.lines() {
        // #7660: `---`/`+++` are headers only before the first hunk; inside
        // one, `+++ x` is the added line `++ x`.
        if !in_hunk {
            if line.starts_with("@@ ") {
                in_hunk = true;
                continue;
            }
            if line.starts_with("diff --git ")
                || line.starts_with("index ")
                || line.starts_with("--- ")
                || line.starts_with("+++ ")
            {
                continue;
            }
            return false;
        }
        if line.starts_with("@@ ") || line.starts_with("\\ ") {
            continue;
        }
        match line.strip_prefix('+') {
            Some(body) if is_provisioning_gitignore_line(body) => added += 1,
            _ => return false,
        }
    }
    added > 0
}

/// Remove an in-project session worktree unless it holds work (#4344, #7660).
///
/// Why: see the module doc. Moved out of `decommission_with_root_checked`
/// unchanged except for the `policy` excuse and the returned reason.
/// What: runs [`keep_reason`] on a blocking thread — [`inspect_dirt`], and
/// only when that finds dirt under [`ProvisioningDirt::Discard`],
/// [`force_blocker`] then [`inspect_dirt_excusing`] with
/// [`is_provisioning_entry`]. Any dirt, a blocker, a
/// panicked check or a failed check keeps the worktree and returns the reason;
/// a clean answer removes it through [`remove_session_worktree_guarded`]. Under
/// `Discard` its guard re-asks the same questions immediately before
/// `git worktree remove --force`. An absent path is neither removed nor kept.
/// Test: `force_decommission_removes_a_provisioning_only_worktree`,
/// `force_decommission_removes_nothing_when_the_dirty_check_cannot_complete`,
/// `decommission_reports_why_it_kept_a_provisioned_worktree`.
pub(super) async fn remove_in_project_worktree(
    id: &ManagedSessionId,
    ws: &Path,
    policy: ProvisioningDirt,
) -> WorkspaceVerdict {
    // #7660: nothing on disk is nothing kept — an absent tree is not a refusal.
    if std::fs::symlink_metadata(ws).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) {
        return WorkspaceVerdict::default();
    }
    let ws_for_check = ws.to_path_buf();
    let owner = *id;
    let kept = tokio::task::spawn_blocking(move || keep_reason(&ws_for_check, &owner, policy))
        .await
        .unwrap_or_else(|e| {
            // Fail-safe: a panicked check is dirty, never a green light.
            Some(format!("the dirty-tree check panicked: {e}"))
        });
    if let Some(reason) = kept {
        warn!(
            id = %id, workspace = %ws.display(), reason = %reason,
            "decommission: refusing to remove worktree; leaving it on disk (the record \
             is still tombstoned)"
        );
        return WorkspaceVerdict {
            removed: false,
            kept_reason: Some(reason),
        };
    }
    // #1845 item 4: a hung git must not stall the executor.
    let ws_clone = ws.to_path_buf();
    let join = tokio::task::spawn_blocking(move || {
        // #7660: a forced removal re-asks every question — provenance, lock and
        // dirt — inside the audit window; the default path is unchanged.
        let guard = || match policy {
            ProvisioningDirt::Refuse => None,
            ProvisioningDirt::Discard => keep_reason(&ws_clone, &owner, policy),
        };
        // #7885: name the route in the audit line.
        remove_session_worktree_guarded(
            &ws_clone,
            "session decommission: the session ended and its tree is clean",
            &guard,
        )
    });
    let outcome = match tokio::time::timeout(GIT_WORKTREE_REMOVE_TIMEOUT, join).await {
        Ok(Ok(outcome)) => outcome,
        Ok(Err(e)) => WorktreeRemoval::Kept(format!("the removal task panicked: {e}")),
        Err(_elapsed) => WorktreeRemoval::Kept(format!(
            "git worktree remove did not finish within {}s; the worktree may require manual \
             cleanup",
            GIT_WORKTREE_REMOVE_TIMEOUT.as_secs()
        )),
    };
    // #4732: the reason comes back FROM the remover.
    if let Some(reason) = outcome.reason() {
        warn!(id = %id, workspace = %ws.display(), "decommission: worktree left on disk — {reason}");
    }
    WorkspaceVerdict {
        removed: outcome.removed(),
        kept_reason: outcome.reason().map(str::to_string),
    }
}

/// Why `ws` must be kept under `policy`, or `None` when it may be removed
/// (#7660).
///
/// What: a tree the plain dirt check passes may be removed under either
/// policy — `--force` is never stricter than no flag (#7660 critic round 3).
/// Only when `--force`'s excuse is needed does [`force_blocker`] run: it acts
/// only on a worktree tm provably created and nothing locks. Then the dirt
/// `policy` does not excuse keeps it, named file by file. `id` is the session
/// being decommissioned.
/// Test: `force_decommission_is_never_stricter_than_plain_on_a_clean_tree`,
/// `force_decommission_keeps_a_worktree_without_the_sentinel`.
fn keep_reason(ws: &Path, id: &ManagedSessionId, policy: ProvisioningDirt) -> Option<String> {
    let plain = inspect_dirt(ws)?;
    if policy == ProvisioningDirt::Refuse {
        return Some(kept_for_dirt(ws, &plain.reason, policy));
    }
    if let Some(blocker) = force_blocker(ws, id) {
        return Some(format!("--force declined: {blocker}; nothing was removed"));
    }
    inspect_dirt_excusing(ws, &|line| is_provisioning_entry(ws, line))
        .map(|d| kept_for_dirt(ws, &d.reason, policy))
}

/// The untracked provisioning files `--force` excuses by path alone, never by
/// content, so an edit to one is lost with the worktree. See #8540.
const CONTENT_UNCHECKED_FILES: [&str; 2] = [".claude/settings.json", "CLAUDE.md"];

/// The operator-facing reason a dirty worktree was kept (#7660).
///
/// Test: `decommission_refusal_warns_force_discards_untracked_claude_md_edits`,
/// `decommission_refusal_warning_names_a_single_untracked_file`,
/// `decommission_refusal_omits_the_warning_without_untracked_claude_files`.
fn kept_for_dirt(ws: &Path, reason: &str, policy: ProvisioningDirt) -> String {
    let files = PROVISIONING_FILES.join(", ");
    let entries = dirty_entries(ws);
    // #7660: name what blocked the removal, not only how many entries did.
    let named = blocking_entries(ws, &entries, policy);
    match policy {
        ProvisioningDirt::Refuse => format!(
            "the dirty-tree guard kept it ({reason}{named}). If the only changes are tm's own \
             provisioning files ({files}), re-run with --force to remove it; --force never \
             discards other changes or unpushed commits{}",
            force_discard_warning(&entries)
        ),
        ProvisioningDirt::Discard => format!(
            "--force excused tm's provisioning files ({files}), but the dirty-tree guard \
             still kept it ({reason}{named}); --force never discards that"
        ),
    }
}

/// How many dirty entries [`blocking_entries`] names before it summarises.
const NAMED_ENTRIES_CAP: usize = 10;

/// `": <entry>, <entry>"` for the [`dirty_entries`] that keep `ws` under
/// `policy`, or `""` when there are none to name (#7660).
///
/// Why: "1 uncommitted/untracked file(s)" tells an operator THAT `--force`
/// refused, not what to look at.
/// What: display only — the keep/remove decision was already made by
/// [`keep_reason`], so a failed status read only drops the list. Lists the
/// entries [`is_provisioning_entry`] does not excuse under `Discard` (every
/// entry under `Refuse`).
/// Test: `force_decommission_still_refuses_user_work`.
fn blocking_entries(ws: &Path, entries: &[String], policy: ProvisioningDirt) -> String {
    let entries: Vec<&str> = entries
        .iter()
        .map(String::as_str)
        .filter(|line| policy == ProvisioningDirt::Refuse || !is_provisioning_entry(ws, line))
        .collect();
    if entries.is_empty() {
        return String::new();
    }
    let mut named = entries
        .iter()
        .take(NAMED_ENTRIES_CAP)
        .map(|l| l.trim())
        .collect::<Vec<_>>()
        .join(", ");
    if entries.len() > NAMED_ENTRIES_CAP {
        named.push_str(&format!(", and {} more", entries.len() - NAMED_ENTRIES_CAP));
    }
    format!(": {named}")
}

/// `ws`'s per-file `git status --porcelain` lines, without the ownership
/// sentinel and `.trusty-mpm/`, which the dirty-tree guard accounts for
/// itself; empty when status cannot be read (display only, #7660).
fn dirty_entries(ws: &Path) -> Vec<String> {
    let args = [
        "status",
        "--porcelain",
        "--untracked-files=all",
        "--ignore-submodules=none",
    ];
    let Ok(status) = super::worktree_safety::git_stdout(ws, &args) else {
        return Vec::new();
    };
    status
        .lines()
        .filter(|line| !line.trim().is_empty())
        .filter(|line| {
            let path = line.get(3..).unwrap_or("").trim();
            path != WORKTREE_SENTINEL_FILE
                && path != ".trusty-mpm"
                && !path.starts_with(".trusty-mpm/")
        })
        .map(str::to_string)
        .collect()
}

/// The warning a plain refusal appends when `entries` hold an untracked file
/// `--force` would discard unread, or `""` when they hold none (#7660).
///
/// Why: the owner's interim ruling for 1.7.3 — `--force` excuses these files
/// by path, so notes a user appended to an untracked `CLAUDE.md` are lost.
// See #8540
fn force_discard_warning(entries: &[String]) -> String {
    let named: Vec<&str> = CONTENT_UNCHECKED_FILES
        .into_iter()
        .filter(|file| entries.iter().any(|line| line == &format!("?? {file}")))
        .collect();
    // #7660: an English list — "A" or "A and B" — with a matching pronoun.
    let (list, pronoun) = match named.as_slice() {
        [] => return String::new(),
        [one] => ((*one).to_string(), "it"),
        [init @ .., last] => (format!("{} and {last}", init.join(", ")), "them"),
    };
    format!(
        ". WARNING: --force deletes the untracked {list} with the worktree, including any edits \
         made to {pronoun}; copy out anything you added there first"
    )
}

/// Why `--force` may not act on `ws` for session `id`, or `None` when tm
/// provably created it for `id` as a linked worktree that nothing locks
/// (#7660).
///
/// Why: `--force` is followed by `git worktree remove --force`, which destroys
/// the files it excused. It is only safe on a tree tm itself provisioned for a
/// session — never on a repository's main checkout, a tree tm cannot vouch for,
/// or one a `git worktree lock` protects.
/// What: three probes, each failing closed with a named reason: (1) the
/// ownership marker is present and names no other owner
/// ([`ownership_blocker`]); (2) `git rev-parse`
/// reports `ws` as its own worktree root whose git dir differs from the common
/// dir — a linked worktree, not a main checkout; (3) that git dir holds no
/// `locked` file ([`lock_blocker`]).
/// Test: `force_decommission_keeps_a_worktree_without_the_sentinel`,
/// `force_decommission_keeps_a_main_checkout_under_the_worktrees_dir`,
/// `force_decommission_keeps_a_worktree_git_cannot_resolve`,
/// `force_decommission_keeps_a_locked_worktree`,
/// `lock_blocker_fails_closed_when_the_lock_state_cannot_be_read`,
/// `force_decommission_keeps_a_worktree_another_session_owns`.
pub(crate) fn force_blocker(ws: &Path, id: &ManagedSessionId) -> Option<String> {
    if let Some(reason) = ownership_blocker(ws, id) {
        return Some(reason);
    }
    match linked_worktree_git_dir(ws) {
        Ok(git_dir) => lock_blocker(&git_dir),
        Err(reason) => Some(reason),
    }
}

/// Why `ws`'s ownership marker gives `--force` no licence, or `None` when it
/// proves tm created the tree for session `id` (#7660 critic round 3, #8511).
///
/// Why: since #8511 provisioning writes the marker into the git admin dir
/// only, so a check of the legacy in-tree path alone refused nearly every
/// tm-created worktree.
/// What: any marker location — legacy or admin — that exists as anything but
/// a regular file blocks, because [`read_sentinel_owner_strict`] follows
/// symlinks. Then that strict reader decides: a marker naming `id`, or an
/// owner-less (pre-#3649) one, passes; a marker naming another session or an
/// agent blocks and names that owner; no marker, an unreadable one, a corrupt
/// one, or an unresolvable `.git` blocks.
/// Test: `force_decommission_honours_an_admin_dir_marker`,
/// `force_decommission_keeps_a_worktree_another_session_owns`,
/// `force_decommission_keeps_a_worktree_an_agent_owns`,
/// `force_decommission_removes_a_worktree_its_own_session_owns`,
/// `force_decommission_honours_a_legacy_in_tree_marker`,
/// `force_decommission_keeps_a_worktree_whose_marker_is_a_symlink`,
/// `force_decommission_keeps_a_worktree_whose_marker_is_corrupt`,
/// `force_decommission_keeps_a_worktree_whose_sentinel_cannot_be_read`.
fn ownership_blocker(ws: &Path, id: &ManagedSessionId) -> Option<String> {
    let unproven = |reason: String| Some(format!("cannot prove tm created it: {reason}"));
    for marker in std::iter::once(legacy_sentinel_path(ws)).chain(admin_sentinel_path(ws)) {
        if std::fs::symlink_metadata(&marker).is_ok_and(|meta| !meta.is_file()) {
            return unproven(format!(
                "its ownership sentinel {} is not a regular file",
                marker.display()
            ));
        }
    }
    match read_sentinel_owner_strict(ws) {
        // #7660 critic round 3: the marker must name this session, or no one.
        Ok(Some(SentinelOwner::Unknown)) => None,
        Ok(Some(SentinelOwner::Known(owner, _))) if owner == *id => None,
        Ok(Some(SentinelOwner::Known(owner, _))) => Some(format!(
            "its ownership sentinel names session {owner}, not {id}; --force never removes \
             another session's worktree"
        )),
        Ok(Some(SentinelOwner::Agent(agent, _))) => Some(format!(
            "its ownership sentinel names agent {}, not session {id}; --force never removes \
             an agent's worktree",
            agent.agent_id
        )),
        Ok(None) => unproven(format!(
            "it carries no ownership sentinel (neither in its git admin dir nor as \
             {WORKTREE_SENTINEL_FILE})"
        )),
        Err(OwnerReadError::Unreadable { path, reason }) => unproven(format!(
            "its ownership sentinel could not be read ({}): {reason}",
            path.display()
        )),
        Err(OwnerReadError::Corrupt { path, reason }) => unproven(format!(
            "its ownership sentinel {} is corrupt: {reason}",
            path.display()
        )),
    }
}

/// The git dir of `ws` when `ws` is the root of a LINKED worktree (#7660).
///
/// What: one `git rev-parse --path-format=absolute --show-toplevel --git-dir
/// --git-common-dir` through the hardened [`super::worktree_safety::git_stdout`].
/// A failed or malformed answer, a toplevel other than `ws`, and a git dir
/// equal to the common dir (a main checkout) are each an `Err` naming why.
/// Test: `force_decommission_keeps_a_main_checkout_under_the_worktrees_dir`,
/// `force_decommission_keeps_a_worktree_git_cannot_resolve`,
/// `force_blocker_refuses_a_directory_that_is_not_a_worktree_root`.
fn linked_worktree_git_dir(ws: &Path) -> Result<PathBuf, String> {
    let args = [
        "rev-parse",
        "--path-format=absolute",
        "--show-toplevel",
        "--git-dir",
        "--git-common-dir",
    ];
    let out = super::worktree_safety::git_stdout(ws, &args)
        .map_err(|e| format!("cannot prove it is a tm-created linked worktree: {e}"))?;
    let lines: Vec<&str> = out.lines().collect();
    let [top, git_dir, common] = lines.as_slice() else {
        return Err(format!(
            "cannot prove it is a tm-created linked worktree: unexpected `git rev-parse` \
             output {out:?}"
        ));
    };
    let resolve = |p: &Path| {
        std::fs::canonicalize(p).map_err(|e| {
            format!(
                "cannot prove it is a tm-created linked worktree: {} could not be resolved: {e}",
                p.display()
            )
        })
    };
    if resolve(Path::new(top))? != resolve(ws)? {
        return Err(format!(
            "it is not a worktree root (git reports the root as {top})"
        ));
    }
    let git_dir = resolve(Path::new(git_dir))?;
    if git_dir == resolve(Path::new(common))? {
        return Err(
            "it is a repository's main checkout, not a linked worktree tm created; a main \
             checkout is never removed"
                .to_string(),
        );
    }
    Ok(git_dir)
}

/// Why a worktree whose git dir is `git_dir` is locked, or `None` when it is
/// not (#7660).
///
/// What: `git worktree lock` writes `<git-dir>/locked`. Present — locked, with
/// its reason when readable; absent — unlocked; any other stat error fails
/// closed, since an unreadable lock state is never proof of no lock.
/// Test: `force_decommission_keeps_a_locked_worktree`,
/// `lock_blocker_fails_closed_when_the_lock_state_cannot_be_read`.
pub(crate) fn lock_blocker(git_dir: &Path) -> Option<String> {
    let lock = git_dir.join("locked");
    match std::fs::symlink_metadata(&lock) {
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
        Ok(_) => {
            let why = std::fs::read_to_string(&lock).unwrap_or_default();
            let why = why.trim();
            Some(if why.is_empty() {
                "it is locked (`git worktree lock`)".to_string()
            } else {
                format!("it is locked (`git worktree lock`: {why})")
            })
        }
        Err(e) => Some(format!(
            "its lock state could not be read ({}): {e}",
            lock.display()
        )),
    }
}

/// Why decommission keeps a workspace tm never removes, or `None` when nothing
/// is on disk (#7660).
///
/// Why: a launch-on-main session's workspace is the project's shared managed
/// checkout (`launch_on_main.rs`, `workspace_owned: false`), and a local-path
/// or adopted session's is the user's own directory. Decommission keeps both by
/// design, but said nothing, so the CLI exited 0 and `--force` was silently
/// ignored.
/// What: one line naming what the workspace is — a main checkout (`.git` is a
/// directory), a worktree tm did not create (`.git` is a file), or another
/// directory — and, under `--force`, that the flag does not apply.
/// Test: `unowned_kept_reason_names_a_main_checkout`,
/// `session_decommission_routed_keeps_the_shared_main_checkout_under_force`,
/// `session_decommission_routed_says_why_it_kept_the_main_checkout`.
pub(crate) fn unowned_kept_reason(ws: &Path, policy: ProvisioningDirt) -> Option<String> {
    if std::fs::symlink_metadata(ws).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) {
        return None;
    }
    let kind = match std::fs::symlink_metadata(ws.join(".git")) {
        Ok(meta) if meta.is_dir() => {
            "a repository's main checkout, shared by the project's sessions"
        }
        Ok(_) => "a git worktree tm did not create for this session",
        Err(_) => "a local-path or adopted directory tm did not create",
    };
    let force = match policy {
        ProvisioningDirt::Refuse => "",
        ProvisioningDirt::Discard => "; --force does not apply to it",
    };
    Some(format!(
        "kept by design: {} is {kind}, and decommission never deletes it{force}",
        ws.display()
    ))
}

#[cfg(test)]
#[path = "decommission_force_tests.rs"]
mod decommission_force_tests;