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
//! The pure decisions behind `tm pr cleanup` (#7275).
//!
//! Why: every mutating step of the cleanup is guarded by a judgment made from
//! command output — is this PR merged, does the remote still list the branch,
//! which worktrees hold this head, which local branches point at it. Keeping
//! those judgments as pure functions of scripted text is what makes the
//! destructive command they authorise testable without running it.
//!
//! What: [`PrView`] and [`merge_refusal`] (step 1), [`WorktreeEntry`] /
//! [`parse_worktree_list`] / [`worktree_targets`] (step 3),
//! [`agent_branches_at`] (step 4), and [`StepLine`] — the one-line-per-step
//! result shape the whole command reports in.
//!
//! Test: the sibling `tests.rs` — `merge_refusal_*`, `parse_worktree_list_*`,
//! `worktree_targets_*`, `agent_branches_at_*`.

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

use serde::{Deserialize, Serialize};

/// The prefix of a branch the harness mints for a dispatched agent's worktree.
///
/// Why: those branches are named after the agent, not the workstream, so
/// nothing else in the delivery chain deletes them; they accumulate one per
/// dispatch. They are safe to delete exactly when the PR that carried their
/// commits has merged, which is the only moment this command runs.
/// Test: `agent_branches_at_matches_only_the_agent_prefix`.
pub const AGENT_BRANCH_PREFIX: &str = "worktree-agent-";

/// The `gh pr view --json` fields cleanup decides from.
///
/// Why: one read answers the whole gate — `state` is the refusal, and
/// `headRefName` / `headRefOid` are what every later step matches against, so
/// no second round trip can observe a different PR than the one that was
/// judged.
/// What: every field defaulted, so a payload missing one still parses.
/// Test: `merge_refusal_accepts_merged`, `merge_refusal_names_the_state`.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct PrView {
    /// `MERGED`, `OPEN` or `CLOSED`.
    #[serde(default)]
    pub state: String,
    /// The head branch name, without a `refs/heads/` prefix.
    #[serde(default, rename = "headRefName")]
    pub head_ref_name: String,
    /// The head commit the PR was merged from.
    #[serde(default, rename = "headRefOid")]
    pub head_ref_oid: String,
    /// The squash commit on the base branch, when GitHub reports one.
    #[serde(default, rename = "mergeCommit")]
    pub merge_commit: Option<MergeCommit>,
    /// The base branch the PR merged into — `origin/<this>` is what a sibling's
    /// content is checked against (#7275 round-N siblings).
    #[serde(default, rename = "baseRefName")]
    pub base_ref_name: String,
}

/// The `mergeCommit` sub-object of a `gh pr view --json` payload.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct MergeCommit {
    /// The squash commit's SHA.
    #[serde(default)]
    pub oid: String,
}

/// Why this PR may NOT be cleaned up, or `None` when it may.
///
/// Why: cleanup deletes a remote branch, several worktrees and several local
/// branches, and `git branch -D` is only defensible because the PR is known
/// MERGED — a squash merge leaves the branch looking unmerged to git, so the
/// ordinary `-d` safety net reports nothing useful here. That makes the state
/// check the load-bearing gate, and the reason it refuses must name what it
/// actually saw so an operator is not left guessing between "not merged yet"
/// and "no such PR".
/// What: refuses on any `state` other than `MERGED` (case-insensitive), and on
/// a payload that names neither a head branch nor a head OID — with nothing to
/// match on, every later step would silently find no targets and report a
/// clean sweep it never performed.
/// Test: `merge_refusal_accepts_merged`, `merge_refusal_names_the_state`,
/// `merge_refusal_rejects_a_payload_with_no_head`.
pub fn merge_refusal(view: &PrView, pr: u64) -> Option<String> {
    let state = view.state.trim();
    if !state.eq_ignore_ascii_case("MERGED") {
        let seen = if state.is_empty() { "<absent>" } else { state };
        return Some(format!(
            "#{pr} is {seen}, not MERGED — cleanup deletes branches and worktrees and runs only \
             after a merge is confirmed"
        ));
    }
    if view.head_ref_name.trim().is_empty() && view.head_ref_oid.trim().is_empty() {
        return Some(format!(
            "#{pr} reports MERGED but names neither a head branch nor a head commit, so nothing \
             identifies what to clean up"
        ));
    }
    None
}

/// One entry of `git worktree list --porcelain`.
///
/// Test: `parse_worktree_list_reads_branch_and_head`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeEntry {
    /// The worktree's directory.
    pub path: PathBuf,
    /// The commit its HEAD points at, lowercase hex, or empty when detached
    /// with no reported HEAD.
    pub head: String,
    /// The branch it has checked out, short form, or `None` when detached.
    pub branch: Option<String>,
}

/// Parse `git worktree list --porcelain` into entries.
///
/// Why: the porcelain form is the only listing that reports the branch and the
/// HEAD alongside the path; the human form collapses them into one line that
/// cannot be split reliably when a path contains a space.
/// What: a `worktree <path>` line opens a record, `HEAD <oid>` and
/// `branch refs/heads/<name>` fill it, and a blank line closes it. A `bare`
/// record has no path of interest and is dropped, as is any record whose
/// `worktree` line was absent.
/// Test: `parse_worktree_list_reads_branch_and_head`,
/// `parse_worktree_list_drops_the_bare_record`,
/// `parse_worktree_list_keeps_a_detached_worktree`.
pub fn parse_worktree_list(porcelain: &str) -> Vec<WorktreeEntry> {
    let mut out = Vec::new();
    let mut path: Option<PathBuf> = None;
    let mut head = String::new();
    let mut branch: Option<String> = None;
    let mut bare = false;

    let flush = |path: &mut Option<PathBuf>,
                 head: &mut String,
                 branch: &mut Option<String>,
                 bare: &mut bool,
                 out: &mut Vec<WorktreeEntry>| {
        if let Some(p) = path.take()
            && !*bare
        {
            out.push(WorktreeEntry {
                path: p,
                head: std::mem::take(head),
                branch: branch.take(),
            });
        }
        head.clear();
        *branch = None;
        *bare = false;
    };

    for line in porcelain.lines() {
        let line = line.trim_end();
        if line.is_empty() {
            flush(&mut path, &mut head, &mut branch, &mut bare, &mut out);
        } else if let Some(p) = line.strip_prefix("worktree ") {
            flush(&mut path, &mut head, &mut branch, &mut bare, &mut out);
            path = Some(PathBuf::from(p.trim()));
        } else if let Some(oid) = line.strip_prefix("HEAD ") {
            head = oid.trim().to_ascii_lowercase();
        } else if let Some(r) = line.strip_prefix("branch ") {
            branch = Some(short_branch(r.trim()).to_string());
        } else if line == "bare" {
            bare = true;
        }
    }
    flush(&mut path, &mut head, &mut branch, &mut bare, &mut out);
    out
}

/// A `refs/heads/foo` ref as `foo`; anything else unchanged.
fn short_branch(r: &str) -> &str {
    r.strip_prefix("refs/heads/").unwrap_or(r)
}

/// A branch name with a trailing review-round suffix removed.
///
/// Why (#7275, round-N siblings): an engineer's second review round lands on
/// `<branch>-r2`, and `version-control` pushes that onto the PR's own head
/// name — so the PR merges as `<branch>` while `<branch>-r2` and its
/// `worktree-agent-*` tree survive with no merged pull request carrying their
/// name. Reducing both spellings to the same stem is what lets one rule relate
/// them, in either direction: the PR's head may be the suffixed one.
/// What: strips a trailing `-r<digits>`; anything else is returned unchanged.
/// Test: `strip_round_suffix_removes_only_a_numbered_round`.
pub fn strip_round_suffix(name: &str) -> &str {
    let Some((stem, round)) = name.rsplit_once("-r") else {
        return name;
    };
    if stem.is_empty() || round.is_empty() || !round.bytes().all(|b| b.is_ascii_digit()) {
        return name;
    }
    stem
}

/// Does `name` belong to the pull request whose head branch is `head`?
///
/// Why: cleanup must reclaim the round-N siblings the merge made obsolete
/// WITHOUT widening into a machine-wide sweep — `tm session prune-worktrees
/// --merged-prs` already does the unscoped thing, and doing it again here would
/// let one PR's cleanup delete another PR's tree. Relating names by their
/// round-stripped stem keeps the scope to this pull request's own branches.
/// What: true when both names reduce to the same stem under
/// [`strip_round_suffix`] — so `foo`, `foo-r2` and `foo-r3` all belong to each
/// other, and nothing else does. An empty `head` matches nothing.
/// Test: `is_pr_branch_relates_round_siblings`,
/// `is_pr_branch_rejects_an_unrelated_branch`.
pub fn is_pr_branch(head: &str, name: &str) -> bool {
    let head = head.trim();
    let name = name.trim();
    if head.is_empty() || name.is_empty() {
        return false;
    }
    strip_round_suffix(head) == strip_round_suffix(name)
}

/// The worktrees this PR's merge makes obsolete.
///
/// Why: an agent's tree is found either by the branch it has checked out or —
/// when the harness left it detached, which is the shape after a rebase — by
/// the commit its HEAD sits on. Matching only the branch missed the detached
/// half and left those trees on disk with no automated route to reclaiming
/// them.
/// What: every entry whose branch belongs to this PR under [`is_pr_branch`] —
/// the head branch itself and its round-N siblings — or whose HEAD equals
/// `head_oid`, EXCEPT the checkout at `repo_root`: cleanup runs from the main
/// checkout, which is never its own target however its HEAD happens to sit.
/// Comparison on the OID is case-insensitive and requires a non-empty OID, so
/// two entries with no reported HEAD never match each other.
/// Test: `worktree_targets_matches_branch_and_detached_head`,
/// `worktree_targets_never_returns_the_main_checkout`,
/// `worktree_targets_ignores_an_empty_head_oid`,
/// `worktree_targets_includes_a_round_sibling`,
/// `cleanup_8489_a_symlinked_spelling_of_the_checkout_is_still_the_checkout`.
pub fn worktree_targets<'a>(
    entries: &'a [WorktreeEntry],
    branch: &str,
    head_oid: &str,
    repo_root: &Path,
) -> Vec<&'a WorktreeEntry> {
    let branch = branch.trim();
    let oid = head_oid.trim();
    entries
        .iter()
        // #8489: git and the caller may spell one directory two ways.
        .filter(|e| !same_tree(&e.path, repo_root))
        .filter(|e| {
            let by_branch = e.branch.as_deref().is_some_and(|b| is_pr_branch(branch, b));
            let by_head = !oid.is_empty() && e.head.eq_ignore_ascii_case(oid);
            by_branch || by_head
        })
        .collect()
}

/// Do two spellings name one directory (#8489)?
///
/// Why: `git worktree list` reports the path git recorded, while `repo_root`
/// comes from `--show-toplevel` or a registry entry's cwd, and a symlink or
/// case difference between them made the running checkout look like a target.
/// What: plain equality, else `trusty_common::identifies_same_path`.
fn same_tree(a: &Path, b: &Path) -> bool {
    a == b || trusty_common::identifies_same_path(a, b)
}

/// The checkout cleanup runs in, when it holds this PR's branch (#8489).
///
/// Why: [`worktree_targets`] never returns the checkout the git commands run
/// in, and the worktree step then reported "no worktree holds X" while that
/// checkout still had X checked out — so `git branch -D` failed one step later.
/// What: every entry naming `repo_root` whose checked-out branch is one step 4
/// deletes — a branch belonging to the PR under [`is_pr_branch`], or a
/// [`AGENT_BRANCH_PREFIX`] branch sitting on `head_oid` (#8489 round 2).
/// Test: `cleanup_8489_names_the_checkout_it_runs_from_when_that_holds_the_head`,
/// `cleanup_8489_a_symlinked_spelling_of_the_checkout_is_still_the_checkout`,
/// `cleanup_8489_a_running_checkout_on_an_agent_branch_at_the_head_is_kept`.
pub(crate) fn holders_run_from<'a>(
    entries: &'a [WorktreeEntry],
    branch: &str,
    head_oid: &str,
    repo_root: &Path,
) -> Vec<&'a WorktreeEntry> {
    let oid = head_oid.trim();
    entries
        .iter()
        .filter(|e| same_tree(&e.path, repo_root))
        .filter(|e| {
            e.branch.as_deref().is_some_and(|b| {
                is_pr_branch(branch, b)
                    || (b.starts_with(AGENT_BRANCH_PREFIX)
                        && !oid.is_empty()
                        && e.head.eq_ignore_ascii_case(oid))
            })
        })
        .collect()
}

/// The report line for a holder [`holders_run_from`] found (#8489).
///
/// `head_only` keeps the recovery inside a merge-chained run's scope (#8301):
/// that run reaches only the head branch, so it never points at the wider
/// `tm pr cleanup <n>`. The recovery names `git switch --detach`, which works
/// in any worktree; `git switch main` fails in an agent tree because the main
/// checkout already holds `main`.
/// Test: `cleanup_8489_head_only_recovery_stays_in_scope`,
/// `cleanup_8489_names_the_checkout_it_runs_from_when_that_holds_the_head`.
pub(crate) fn kept_run_from(entry: &WorktreeEntry, pr: u64, head_only: bool) -> String {
    let branch = entry.branch.as_deref().unwrap_or_default();
    let recovery = if head_only {
        format!(
            "run `git switch --detach` in it, then `git branch -D {branch}` — this merge's \
             cleanup reaches only {branch}"
        )
    } else {
        format!(
            "run `git switch --detach` in it, then `git branch -D {branch}`; or run \
             `tm pr cleanup {pr}` from the main checkout"
        )
    };
    format!(
        "{}: holds {branch} and was kept — cleanup runs its git commands in this checkout and \
         never removes it; {recovery}",
        entry.path.display()
    )
}

/// The standing worktree that has `branch` checked out, if any (#8489).
///
/// Why: git refuses `git branch -D` on a branch any worktree holds, and the
/// refusal used to stop step 4 before the branches after it were deleted.
/// What: the first entry of `standing` whose branch equals `branch`.
/// Test: `cleanup_8489_local_branch_skips_the_held_head_and_deletes_the_rest`.
pub(crate) fn holder_of<'a>(
    standing: &'a [WorktreeEntry],
    branch: &str,
) -> Option<&'a WorktreeEntry> {
    let branch = branch.trim();
    standing
        .iter()
        .find(|e| e.branch.as_deref().map(str::trim) == Some(branch))
}

/// The step-4 report text for a branch [`holder_of`] found held (#8489).
pub(crate) fn kept_branch(branch: &str, holder: &WorktreeEntry) -> String {
    format!(
        "kept {branch}: the worktree at {} has it checked out, and git refuses to delete a \
         branch a worktree holds",
        holder.path.display()
    )
}

/// The step-4 report text when step 3 could not read the worktree listing.
///
/// Test: `cleanup_8489_a_failed_worktree_listing_deletes_no_branch`.
pub(crate) const HOLDERS_UNKNOWN: &str = "no branch deleted — the worktree listing was not read, \
     so which branches a worktree holds is unknown (#8489)";

/// The report line for a worktree listing that named no tree at all (#8489).
///
/// Why: git lists the checkout it runs in on every successful call, so an
/// empty parse means the listing was not read, never that nothing holds the
/// branch.
/// Test: `cleanup_8489_an_empty_worktree_listing_is_inconclusive`.
pub(crate) fn inconclusive_listing(branch: &str) -> String {
    format!(
        "`git worktree list --porcelain` named no worktree, not even this checkout — the \
         lookup is inconclusive, so whether a worktree holds {branch} is unknown"
    )
}

/// Split cleanup targets into the PR head's own trees and the rest (#8301).
///
/// Why: `tm pr merge` removed a worktree the caller never named because it sat
/// on the merged head commit. A merge names exactly one branch, so a
/// merge-chained cleanup removes only the tree that has that branch checked
/// out; everything else [`worktree_targets`] matched is left for an explicit
/// `tm pr cleanup <n>`.
/// What: `(named, left)` — `named` holds the entries whose checked-out branch
/// equals `head` exactly; `left` holds the rest. An empty `head` names nothing.
/// Test: `cleanup_8301_head_only_leaves_an_unnamed_tree_at_the_head_commit`.
pub fn split_head_only<'a>(
    targets: Vec<&'a WorktreeEntry>,
    head: &str,
) -> (Vec<&'a WorktreeEntry>, Vec<&'a WorktreeEntry>) {
    let head = head.trim();
    targets
        .into_iter()
        .partition(|e| !head.is_empty() && e.branch.as_deref().map(str::trim) == Some(head))
}

/// The report line for trees a head-only cleanup did not touch (#8301).
pub fn left_in_place(left: &[&WorktreeEntry], pr: u64) -> String {
    let paths: Vec<String> = left.iter().map(|e| e.path.display().to_string()).collect();
    format!(
        "left in place, not this PR's head worktree: {} — `tm pr cleanup {pr}` reclaims them",
        paths.join(", ")
    )
}

/// The local branches this pull request owns, head and round-N siblings.
///
/// Why: the sibling branch is the other half of what a round-2 fix leaves
/// behind — the worktree is one, `<branch>-r2` in `git branch` is the other,
/// and deleting only the tree leaves a branch nothing will ever delete.
/// What: reads `git branch --format='%(refname:short) %(objectname)'` output
/// and returns the names [`is_pr_branch`] relates to `head`, head first so the
/// caller deletes it before its siblings.
/// Test: `pr_branches_lists_head_then_siblings`,
/// `pr_branches_ignores_an_unrelated_branch`.
pub fn pr_branches(listing: &str, head: &str) -> Vec<String> {
    let head = head.trim();
    let mut out: Vec<String> = listing
        .lines()
        .filter_map(|l| l.split_whitespace().next())
        .filter(|name| is_pr_branch(head, name))
        .map(str::to_string)
        .collect();
    out.sort_by_key(|n| (n != head, n.clone()));
    out
}

/// The `worktree-agent-*` branches whose tip is this PR's head commit.
///
/// Why: the harness mints one of these per dispatched agent and nothing else
/// deletes them. Requiring the tip to EQUAL the merged head is what keeps the
/// match narrow: an agent branch that has moved on since the merge carries
/// commits the PR never contained, and deleting it would discard them.
/// What: reads `git branch --format='%(refname:short) %(objectname)'` output;
/// returns the short names carrying [`AGENT_BRANCH_PREFIX`] whose object name
/// matches `head_oid` case-insensitively. An empty `head_oid` matches nothing.
/// Test: `agent_branches_at_matches_only_the_agent_prefix`,
/// `agent_branches_at_ignores_a_branch_that_moved_on`,
/// `agent_branches_at_returns_nothing_for_an_empty_oid`.
pub fn agent_branches_at(listing: &str, head_oid: &str) -> Vec<String> {
    let oid = head_oid.trim();
    if oid.is_empty() {
        return Vec::new();
    }
    listing
        .lines()
        .filter_map(|line| {
            let mut parts = line.split_whitespace();
            let name = parts.next()?;
            let object = parts.next()?;
            (name.starts_with(AGENT_BRANCH_PREFIX) && object.eq_ignore_ascii_case(oid))
                .then(|| name.to_string())
        })
        .collect()
}

/// The outcome of one cleanup step.
///
/// Why: the owner's ruling is that cleanup is deterministic unless it errors,
/// so a step has exactly two outcomes and there is deliberately no third
/// "warning" variant — a step that could not do its job is a failure, and
/// "there was nothing to do" is a success carrying that detail.
/// Test: `step_line_renders_ok_and_failed`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum StepStatus {
    /// The step did its job, or found nothing left to do.
    Ok,
    /// The step could not do its job.
    Failed,
}

/// One printed line of a cleanup run.
///
/// Test: `step_line_renders_ok_and_failed`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct StepLine {
    /// The step's stable name, e.g. `worktree`.
    pub step: &'static str,
    /// Whether it succeeded.
    pub status: StepStatus,
    /// What it did, or why it could not.
    pub detail: String,
}

impl StepLine {
    /// A step that did its job.
    pub fn ok(step: &'static str, detail: impl Into<String>) -> Self {
        Self {
            step,
            status: StepStatus::Ok,
            detail: detail.into(),
        }
    }

    /// A step that could not do its job.
    pub fn failed(step: &'static str, detail: impl Into<String>) -> Self {
        Self {
            step,
            status: StepStatus::Failed,
            detail: detail.into(),
        }
    }

    /// Whether this line reports a failure.
    pub fn is_failure(&self) -> bool {
        self.status == StepStatus::Failed
    }

    /// The one line an operator sees.
    ///
    /// Test: `step_line_renders_ok_and_failed`.
    pub fn render(&self) -> String {
        let tag = match self.status {
            StepStatus::Ok => "ok",
            StepStatus::Failed => "FAILED",
        };
        format!("{}: {tag} — {}", self.step, self.detail)
    }
}