Skip to main content

release_kit/
worktree.rs

1//! Worktree hygiene after squash merges: the pure half.
2//!
3//! A linked worktree seats one branch, and the forge's squash merge
4//! retires that branch the same way it retires a bare one — so the
5//! worktrees need the same post-merge cleanup, resting on the same
6//! merged-request proof. This module holds the pure half of the
7//! `rk worktree` family: the sibling-path derivation, the fail-closed
8//! parser over `git worktree list --porcelain -z`, and the guard order
9//! that keeps a worktree out of the candidate set. Spawning stays in the
10//! handler, exactly as `crate::branches` declares for the branch half.
11
12use camino::{Utf8Path, Utf8PathBuf};
13
14use crate::branches::{Branch, Class, PROTECTED_PREFIX};
15
16/// The Conventional Commit types the branch grammar's first form admits,
17/// mirroring [`crate::landing::BRANCH_GRAMMAR`]'s alternation.
18const BRANCH_TYPES: [&str; 11] = [
19    "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
20];
21
22/// Whether a branch name matches the landed grammar.
23///
24/// The same anchored
25/// language [`crate::landing::BRANCH_GRAMMAR`] states as an extended
26/// regular expression, hand-rolled here because the convention admits no
27/// regex dependency for one pattern. Necessary, not sufficient: it admits
28/// names git itself refuses, so `rk worktree add` follows it with
29/// `git check-ref-format --branch`.
30#[must_use]
31pub fn matches_grammar(branch: &str) -> bool {
32    // release[-/].+ — any non-empty remainder, as the regex dot admits.
33    if let Some(rest) = branch.strip_prefix("release") {
34        if let Some(line) = rest.strip_prefix(['-', '/']) {
35            if !line.is_empty() {
36                return true;
37            }
38        }
39    }
40    // <type>/<slug> with the slug over [A-Za-z0-9._/-]+.
41    if let Some((kind, slug)) = branch.split_once('/') {
42        if BRANCH_TYPES.contains(&kind)
43            && !slug.is_empty()
44            && slug
45                .chars()
46                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-'))
47        {
48            return true;
49        }
50    }
51    issue_form(branch)
52}
53
54/// The issue-linked form: `([0-9]+|[A-Z][A-Z0-9]+-[0-9]+)-<slug>` with
55/// the slug over `[A-Za-z0-9._-]+`.
56fn issue_form(branch: &str) -> bool {
57    let slug_ok = |slug: &str| {
58        !slug.is_empty()
59            && slug
60                .chars()
61                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
62    };
63    // [0-9]+-<slug>: the digit run stops at the first non-digit, which
64    // must be the separating hyphen — the classes are disjoint there, so
65    // maximal munch is exact.
66    let digits = branch
67        .find(|c: char| !c.is_ascii_digit())
68        .unwrap_or(branch.len());
69    if digits >= 1 {
70        if let Some(slug) = branch[digits..].strip_prefix('-') {
71            if slug_ok(slug) {
72                return true;
73            }
74        }
75    }
76    // [A-Z][A-Z0-9]+-[0-9]+-<slug>.
77    if !branch.starts_with(|c: char| c.is_ascii_uppercase()) {
78        return false;
79    }
80    let key = branch[1..]
81        .find(|c: char| !(c.is_ascii_uppercase() || c.is_ascii_digit()))
82        .map_or(branch.len(), |offset| offset + 1);
83    if key < 2 {
84        return false;
85    }
86    let Some(rest) = branch[key..].strip_prefix('-') else {
87        return false;
88    };
89    let number = rest
90        .find(|c: char| !c.is_ascii_digit())
91        .unwrap_or(rest.len());
92    if number < 1 {
93        return false;
94    }
95    rest[number..].strip_prefix('-').is_some_and(slug_ok)
96}
97
98/// The branch name flattened for a directory: every `/` becomes `-`.
99///
100/// Not injective — `feat/a-b` and `feat-a/b` collide — so every caller
101/// that creates checks for collision and refuses; none suffixes silently.
102#[must_use]
103pub fn flatten(branch: &str) -> String {
104    branch.replace('/', "-")
105}
106
107/// The repository's layout: the main worktree's path, its parent, and
108/// its basename as the project name the sibling paths compose with.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Layout {
111    /// The main worktree's path.
112    pub main: Utf8PathBuf,
113    /// The directory the sibling worktrees land in.
114    pub parent: Utf8PathBuf,
115    /// The main worktree's basename, the project half of a sibling name.
116    pub project: String,
117}
118
119impl Layout {
120    /// The layout of a parsed inventory: the first record is the main
121    /// worktree — git documents the ordering — and [`parse_worktrees`]
122    /// already refused an inventory whose first record is not one.
123    ///
124    /// # Errors
125    ///
126    /// The detail of a main worktree the sibling convention cannot
127    /// compose with: no parent directory, or no basename.
128    pub fn of(worktrees: &[Worktree]) -> Result<Self, String> {
129        let main = worktrees
130            .first()
131            .ok_or_else(|| "the worktree inventory is empty".to_owned())?;
132        let parent = main
133            .path
134            .parent()
135            .ok_or_else(|| format!("the main worktree {} has no parent directory", main.path))?
136            .to_owned();
137        let project = main
138            .path
139            .file_name()
140            .ok_or_else(|| format!("the main worktree {} has no basename", main.path))?
141            .to_owned();
142        Ok(Self {
143            main: main.path.clone(),
144            parent,
145            project,
146        })
147    }
148}
149
150/// The canonical worktree path for a branch: `<parent>/<project>@<flat>`.
151#[must_use]
152pub fn derived_path(layout: &Layout, branch: &str) -> Utf8PathBuf {
153    layout
154        .parent
155        .join(format!("{}@{}", layout.project, flatten(branch)))
156}
157
158/// One worktree as `git worktree list --porcelain -z` reports it.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct Worktree {
161    /// The worktree's path.
162    pub path: Utf8PathBuf,
163    /// The full object name at HEAD.
164    pub head: String,
165    /// The checked-out branch's short name; `None` when detached.
166    pub branch: Option<String>,
167    /// Whether the record is the bare repository itself.
168    pub bare: bool,
169    /// The lock reason, where locked (empty string for a bare lock).
170    pub locked: Option<String>,
171    /// Git's own prunable note, where the directory is missing.
172    pub prunable: Option<String>,
173}
174
175/// One record under construction, folded attribute by attribute.
176#[derive(Debug, Default)]
177struct Partial {
178    path: Option<Utf8PathBuf>,
179    head: Option<String>,
180    branch: Option<String>,
181    bare: bool,
182    detached: bool,
183    locked: Option<String>,
184    prunable: Option<String>,
185}
186
187impl Partial {
188    const fn is_empty(&self) -> bool {
189        self.path.is_none()
190            && self.head.is_none()
191            && self.branch.is_none()
192            && !self.bare
193            && !self.detached
194            && self.locked.is_none()
195            && self.prunable.is_none()
196    }
197
198    /// Close one record: every required attribute present, or the reason.
199    fn close(self) -> Result<Worktree, String> {
200        let path = self
201            .path
202            .ok_or_else(|| "a worktree record carries no path".to_owned())?;
203        // A bare record carries no HEAD; every checked-out worktree does.
204        let head = match (self.head, self.bare) {
205            (Some(head), _) => head,
206            (None, true) => String::new(),
207            (None, false) => return Err(format!("the record for {path} carries no HEAD")),
208        };
209        if !self.bare && self.branch.is_none() && !self.detached {
210            return Err(format!(
211                "the record for {path} names neither a branch nor a detached HEAD"
212            ));
213        }
214        Ok(Worktree {
215            path,
216            head,
217            branch: self.branch,
218            bare: self.bare,
219            locked: self.locked,
220            prunable: self.prunable,
221        })
222    }
223}
224
225/// Parse `git worktree list --porcelain -z`.
226///
227/// NUL-terminated attribute
228/// lines, an empty token closing each record, the attributes `worktree`,
229/// `HEAD`, `branch refs/heads/<name>` (shortened here), `bare`,
230/// `detached`, `locked [reason]`, and `prunable [reason]`.
231///
232/// # Errors
233///
234/// The detail of what could not be trusted: a first record that is not a
235/// complete main worktree, a record missing its required attributes, an
236/// unknown attribute shape, or a path that is not UTF-8 — each refuses
237/// the whole inventory before any verb acts on a partial one. A bare
238/// main record is refused by name: the sibling convention has no parent
239/// checkout to compose with, and no verb here operates on a bare
240/// repository. Destructive verbs sit on this parser, and nothing ever
241/// inspects `.git/worktrees/` directly; this is the one reader.
242pub fn parse_worktrees(bytes: &[u8]) -> Result<Vec<Worktree>, String> {
243    let mut worktrees = Vec::new();
244    let mut partial = Partial::default();
245    for token in bytes.split(|byte| *byte == 0) {
246        if token.is_empty() {
247            if !partial.is_empty() {
248                worktrees.push(std::mem::take(&mut partial).close()?);
249            }
250            continue;
251        }
252        let line = std::str::from_utf8(token)
253            .map_err(|_| "a worktree record carries a path that is not UTF-8".to_owned())?;
254        let (attribute, value) = line
255            .split_once(' ')
256            .map_or((line, None), |(attribute, value)| (attribute, Some(value)));
257        match (attribute, value) {
258            ("worktree", Some(path)) => partial.path = Some(Utf8PathBuf::from(path)),
259            ("HEAD", Some(head)) => partial.head = Some(head.to_owned()),
260            ("branch", Some(reference)) => {
261                partial.branch = Some(
262                    reference
263                        .strip_prefix("refs/heads/")
264                        .unwrap_or(reference)
265                        .to_owned(),
266                );
267            }
268            ("bare", None) => partial.bare = true,
269            ("detached", None) => partial.detached = true,
270            ("locked", reason) => partial.locked = Some(reason.unwrap_or("").to_owned()),
271            ("prunable", reason) => partial.prunable = Some(reason.unwrap_or("").to_owned()),
272            _ => {
273                return Err(format!(
274                    "the worktree inventory carries an attribute this binary does not know: {line}"
275                ));
276            }
277        }
278    }
279    if !partial.is_empty() {
280        // A truncated stream: the last record never closed.
281        return Err("the worktree inventory ends mid-record".to_owned());
282    }
283    let Some(main) = worktrees.first() else {
284        return Err("the worktree inventory is empty".to_owned());
285    };
286    if main.bare {
287        return Err(
288            "the repository is bare; the sibling convention has no main checkout to compose with"
289                .to_owned(),
290        );
291    }
292    if main.prunable.is_some() {
293        return Err(format!(
294            "the first record, {}, is not a complete main worktree",
295            main.path
296        ));
297    }
298    Ok(worktrees)
299}
300
301/// What `rk worktree prune` says about one linked worktree.
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum WtClass {
304    /// Guarded out, with the reason: the main checkout, a seat in use,
305    /// locked, detached, a protected branch, dirty, or a live upstream.
306    Kept {
307        /// Why the worktree stays.
308        reason: String,
309    },
310    /// Its branch's upstream is gone and no guard held: a candidate.
311    Candidate,
312    /// Confirmed / Unconfirmed / Unknown — the judgments from
313    /// [`crate::branches::Class`], produced by the same predicate.
314    Judged(Class),
315    /// A registered record whose directory is missing and which is not
316    /// locked: `git worktree prune --expire now` territory, never a
317    /// removal.
318    Stale,
319}
320
321/// Judge the last-moment re-observation of one confirmed worktree:
322/// `None` clears the removal, `Some(reason)` keeps it.
323///
324/// Verification
325/// authorized only the state it saw, so the fresh record must still be
326/// the same resource — present, unlocked, its directory standing, and
327/// seating the very branch the merge proof named; a seat that switched
328/// branches keeps, because the proof would otherwise authorize removing
329/// a different resource. The caller passes `None` for a record the fresh
330/// inventory no longer carries, and keeps on its own when the inventory
331/// itself could not be read — an unobservable state clears nothing.
332#[must_use]
333pub fn reobservation(seat: Option<&Worktree>, branch: &str) -> Option<String> {
334    let Some(seat) = seat else {
335        return Some("the worktree record vanished".to_owned());
336    };
337    if seat.locked.is_some() {
338        return Some("a lock arrived".to_owned());
339    }
340    if seat.prunable.is_some() {
341        return Some("the directory vanished".to_owned());
342    }
343    if seat.branch.as_deref() != Some(branch) {
344        return Some(format!("the seat switched off {branch}"));
345    }
346    None
347}
348
349/// Classify one worktree for the prune report.
350///
351/// The guards run in order
352/// and the first one holds; the order is load-bearing — a missing
353/// directory takes no `status` call and is commonly also detached, so the
354/// stale arm precedes the detached one by construction, and a lock is
355/// kept unconditionally, missing directory included. The caller applies
356/// this within the reportable set (stale records and gone-upstream
357/// worktrees); the main-worktree and live-upstream arms stay as
358/// belt-and-braces for a caller that hands it anything else.
359///
360/// `seats` are the paths whose worktrees are in use — the caller's own
361/// seat and the target's current worktree, both, independently. `dirty`
362/// is the handler's `git status --porcelain` probe, run only for a
363/// worktree whose directory exists; untracked files count.
364#[must_use]
365pub fn classify(
366    worktree: &Worktree,
367    branch: Option<&Branch>,
368    layout: &Layout,
369    seats: &[&Utf8Path],
370    trunk: &str,
371    dirty: bool,
372) -> WtClass {
373    if worktree.path == layout.main {
374        return WtClass::Kept {
375            reason: "the main checkout".to_owned(),
376        };
377    }
378    if seats.iter().any(|seat| **seat == worktree.path) {
379        return WtClass::Kept {
380            reason: "a seat in use".to_owned(),
381        };
382    }
383    if let Some(reason) = &worktree.locked {
384        return WtClass::Kept {
385            reason: if reason.is_empty() {
386                "locked".to_owned()
387            } else {
388                format!("locked: {reason}")
389            },
390        };
391    }
392    if worktree.prunable.is_some() {
393        return WtClass::Stale;
394    }
395    let Some(name) = &worktree.branch else {
396        return WtClass::Kept {
397            reason: "detached HEAD".to_owned(),
398        };
399    };
400    if name == trunk || name.starts_with(PROTECTED_PREFIX) {
401        return WtClass::Kept {
402            reason: "a protected branch".to_owned(),
403        };
404    }
405    // The join fails closed, and before the state probes: a worktree
406    // whose branch observation is missing is never guessed into a
407    // candidate, and its dirt reading is noise — a seat whose ref
408    // vanished reads unborn.
409    let Some(branch) = branch else {
410        return WtClass::Kept {
411            reason: format!("no branch observation covers {name}"),
412        };
413    };
414    if dirty {
415        return WtClass::Kept {
416            reason: "uncommitted changes".to_owned(),
417        };
418    }
419    if !branch.gone {
420        return WtClass::Kept {
421            reason: "the upstream is live or unset".to_owned(),
422        };
423    }
424    WtClass::Candidate
425}
426
427#[cfg(test)]
428mod tests {
429    #![allow(clippy::expect_used)]
430
431    use camino::{Utf8Path, Utf8PathBuf};
432
433    use super::{Layout, Worktree, WtClass, classify, derived_path, flatten, parse_worktrees};
434    use crate::branches::Branch;
435
436    /// The hand-rolled matcher speaks the one grammar: on a spread of
437    /// admitted and refused names it agrees with `grep -E` over
438    /// [`crate::landing::BRANCH_GRAMMAR`], the const the hook block
439    /// renders — so the two validators cannot drift apart silently.
440    #[test]
441    fn the_matcher_agrees_with_the_one_branch_grammar() {
442        let cases = [
443            ("feat/oauth-login", true),
444            ("fix/PROJ-412-empty-csv", true),
445            ("guides/release", false),
446            ("chore/deps/bump", true),
447            ("feat/", false),
448            ("412-empty-csv", true),
449            ("PROJ-412-empty-csv", true),
450            ("A-1-x", false),
451            ("AB-1-x", true),
452            ("412-", false),
453            ("release/1.2", true),
454            ("release-1.2", true),
455            ("release-", false),
456            ("release", false),
457            ("master", false),
458            ("worktree-session", false),
459            ("feature/x", false),
460            ("123", false),
461        ];
462        for (name, expected) in cases {
463            assert_eq!(
464                super::matches_grammar(name),
465                expected,
466                "matcher disagrees on {name}"
467            );
468            let grepped = std::process::Command::new("sh")
469                .args([
470                    "-c",
471                    &format!(
472                        "printf %s \"$1\" | grep -Eq \"{}\"",
473                        crate::landing::BRANCH_GRAMMAR
474                    ),
475                    "sh",
476                    name,
477                ])
478                .status()
479                .expect("grep runs");
480            assert_eq!(
481                grepped.success(),
482                expected,
483                "the regex itself disagrees on {name}"
484            );
485        }
486    }
487
488    /// Flattening replaces every slash; the collision pair derives equal —
489    /// documented, refused at `add`, never suffixed.
490    #[test]
491    fn a_branch_flattens_into_a_sibling_directory_name() {
492        assert_eq!(flatten("feat/oauth-login"), "feat-oauth-login");
493        assert_eq!(flatten("guides/release/x"), "guides-release-x");
494        assert_eq!(flatten("plain"), "plain");
495        assert_eq!(
496            flatten("feat/a-b"),
497            flatten("feat-a/b"),
498            "flattening is not injective; add refuses the collision by name"
499        );
500        let layout = Layout {
501            main: Utf8PathBuf::from("/srv/checkouts/widget"),
502            parent: Utf8PathBuf::from("/srv/checkouts"),
503            project: "widget".into(),
504        };
505        assert_eq!(
506            derived_path(&layout, "feat/oauth-login"),
507            Utf8PathBuf::from("/srv/checkouts/widget@feat-oauth-login")
508        );
509    }
510
511    /// A porcelain stream, NUL-separated, with an empty token closing each
512    /// record.
513    fn stream(records: &[&[&str]]) -> Vec<u8> {
514        let mut bytes = Vec::new();
515        for record in records {
516            for line in *record {
517                bytes.extend_from_slice(line.as_bytes());
518                bytes.push(0);
519            }
520            bytes.push(0);
521        }
522        bytes
523    }
524
525    /// Complete records parse — main, linked, detached, locked with a
526    /// reason, prunable — and each untrustworthy shape refuses with the
527    /// reason named.
528    #[test]
529    fn porcelain_parsing_refuses_what_it_cannot_trust() {
530        let parsed = parse_worktrees(&stream(&[
531            &[
532                "worktree /srv/checkouts/widget",
533                "HEAD aaaa",
534                "branch refs/heads/master",
535            ],
536            &[
537                "worktree /srv/checkouts/widget@feat-x",
538                "HEAD bbbb",
539                "branch refs/heads/feat/x",
540            ],
541            &[
542                "worktree /srv/checkouts/widget-probe",
543                "HEAD cccc",
544                "detached",
545            ],
546            &[
547                "worktree /srv/checkouts/widget-held",
548                "HEAD dddd",
549                "branch refs/heads/feat/held",
550                "locked a running agent",
551            ],
552            &[
553                "worktree /srv/checkouts/widget-gone",
554                "HEAD eeee",
555                "branch refs/heads/feat/gone",
556                "prunable gitdir file points to non-existent location",
557            ],
558        ]))
559        .expect("a complete inventory parses");
560        assert_eq!(parsed.len(), 5);
561        assert_eq!(parsed[0].branch.as_deref(), Some("master"));
562        assert_eq!(parsed[1].branch.as_deref(), Some("feat/x"));
563        assert_eq!(parsed[2].branch, None);
564        assert_eq!(parsed[3].locked.as_deref(), Some("a running agent"));
565        assert!(parsed[4].prunable.is_some());
566        let layout = Layout::of(&parsed).expect("the layout resolves");
567        assert_eq!(layout.parent, Utf8PathBuf::from("/srv/checkouts"));
568        assert_eq!(layout.project, "widget");
569
570        let truncated = stream(&[&["worktree /srv/checkouts/widget", "HEAD aaaa"]]);
571        let truncated = &truncated[..truncated.len() - 2];
572        assert!(
573            parse_worktrees(truncated)
574                .expect_err("a truncated stream refuses")
575                .contains("mid-record")
576        );
577        assert!(
578            parse_worktrees(&stream(&[&["worktree /srv/x", "branch refs/heads/master"]]))
579                .expect_err("a record without a HEAD refuses")
580                .contains("no HEAD")
581        );
582        assert!(
583            parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa"]]))
584                .expect_err("neither branch nor detached refuses")
585                .contains("neither a branch nor a detached HEAD")
586        );
587        assert!(
588            parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa", "gitdir /y"]]))
589                .expect_err("an unknown attribute refuses")
590                .contains("does not know")
591        );
592        assert!(
593            parse_worktrees(&stream(&[&["worktree /srv/bare.git", "bare"]]))
594                .expect_err("a bare main record refuses by name")
595                .contains("bare")
596        );
597        assert!(
598            parse_worktrees(&stream(&[&[
599                "worktree /srv/x",
600                "HEAD aaaa",
601                "branch refs/heads/x",
602                "prunable gone",
603            ]]))
604            .expect_err("a prunable first record is no main worktree")
605            .contains("main worktree")
606        );
607        let mut invalid = b"worktree /srv/\xff\0HEAD aaaa\0branch refs/heads/x\0\0".to_vec();
608        assert!(
609            parse_worktrees(&invalid)
610                .expect_err("a non-UTF-8 path refuses")
611                .contains("not UTF-8")
612        );
613        invalid.clear();
614        assert!(
615            parse_worktrees(&invalid).is_err(),
616            "an empty inventory refuses"
617        );
618    }
619
620    fn fixture(path: &str, branch: Option<&str>) -> Worktree {
621        Worktree {
622            path: Utf8PathBuf::from(path),
623            head: "aaaa".into(),
624            branch: branch.map(str::to_owned),
625            bare: false,
626            locked: None,
627            prunable: None,
628        }
629    }
630
631    fn observation(name: &str, gone: bool) -> Branch {
632        Branch {
633            name: name.into(),
634            tip: "aaaa".into(),
635            upstream: Some(format!("origin/{name}")),
636            gone,
637            worktree: None,
638        }
639    }
640
641    /// The last-moment re-observation fails closed: a vanished record, a
642    /// fresh lock, a vanished directory, and a seat that switched off the
643    /// confirmed branch each keep; only the very resource verification
644    /// saw clears the removal.
645    #[test]
646    fn a_reobservation_clears_only_the_verified_resource() {
647        let seat = fixture("/srv/widget@feat-x", Some("feat/x"));
648        assert_eq!(super::reobservation(Some(&seat), "feat/x"), None);
649        assert!(
650            super::reobservation(None, "feat/x").is_some_and(|reason| reason.contains("vanished"))
651        );
652        let locked = Worktree {
653            locked: Some(String::new()),
654            ..seat.clone()
655        };
656        assert!(
657            super::reobservation(Some(&locked), "feat/x")
658                .is_some_and(|reason| reason.contains("lock"))
659        );
660        let gone = Worktree {
661            prunable: Some("gone".into()),
662            ..seat.clone()
663        };
664        assert!(
665            super::reobservation(Some(&gone), "feat/x")
666                .is_some_and(|reason| reason.contains("directory"))
667        );
668        let switched = Worktree {
669            branch: Some("feat/other".into()),
670            ..seat.clone()
671        };
672        assert!(
673            super::reobservation(Some(&switched), "feat/x")
674                .is_some_and(|reason| reason.contains("switched")),
675            "a merge proof authorizes no other resource"
676        );
677        let detached = Worktree {
678            branch: None,
679            ..seat
680        };
681        assert!(super::reobservation(Some(&detached), "feat/x").is_some());
682    }
683
684    /// The nine guards hold in order: main, seat, locked (missing
685    /// directory included), stale before detached, detached, protected,
686    /// dirty, live upstream, candidate.
687    #[test]
688    fn classification_guards_hold_in_order() {
689        let layout = Layout {
690            main: Utf8PathBuf::from("/srv/widget"),
691            parent: Utf8PathBuf::from("/srv"),
692            project: "widget".into(),
693        };
694        let seat = Utf8Path::new("/srv/widget@feat-seat");
695        let seats: &[&Utf8Path] = &[seat];
696        let gone = observation("feat/x", true);
697        let keep = |worktree: &Worktree, branch: Option<&Branch>, dirty: bool| {
698            classify(worktree, branch, &layout, seats, "master", dirty)
699        };
700
701        assert_eq!(
702            keep(&fixture("/srv/widget", Some("master")), None, false),
703            WtClass::Kept {
704                reason: "the main checkout".into()
705            }
706        );
707        assert_eq!(
708            keep(
709                &fixture("/srv/widget@feat-seat", Some("feat/x")),
710                Some(&gone),
711                false
712            ),
713            WtClass::Kept {
714                reason: "a seat in use".into()
715            }
716        );
717        let locked_missing = Worktree {
718            locked: Some(String::new()),
719            prunable: Some("gone".into()),
720            ..fixture("/srv/widget@feat-x", Some("feat/x"))
721        };
722        assert_eq!(
723            keep(&locked_missing, Some(&gone), false),
724            WtClass::Kept {
725                reason: "locked".into()
726            },
727            "a lock is kept unconditionally, missing directory included"
728        );
729        let stale_detached = Worktree {
730            prunable: Some("gone".into()),
731            ..fixture("/srv/widget@feat-x", None)
732        };
733        assert_eq!(
734            keep(&stale_detached, None, false),
735            WtClass::Stale,
736            "a missing directory precedes the detached arm by construction"
737        );
738        assert_eq!(
739            keep(&fixture("/srv/widget-probe", None), None, false),
740            WtClass::Kept {
741                reason: "detached HEAD".into()
742            }
743        );
744        assert_eq!(
745            keep(
746                &fixture("/srv/widget@release-1.2", Some("release/1.2")),
747                Some(&observation("release/1.2", true)),
748                false
749            ),
750            WtClass::Kept {
751                reason: "a protected branch".into()
752            }
753        );
754        assert_eq!(
755            keep(
756                &fixture("/srv/widget@feat-x", Some("feat/x")),
757                Some(&gone),
758                true
759            ),
760            WtClass::Kept {
761                reason: "uncommitted changes".into()
762            }
763        );
764        assert_eq!(
765            keep(&fixture("/srv/widget@feat-x", Some("feat/x")), None, true),
766            WtClass::Kept {
767                reason: "no branch observation covers feat/x".into()
768            },
769            "a missing observation keeps by name, before the dirt reading"
770        );
771        assert_eq!(
772            keep(
773                &fixture("/srv/widget@feat-x", Some("feat/x")),
774                Some(&observation("feat/x", false)),
775                false
776            ),
777            WtClass::Kept {
778                reason: "the upstream is live or unset".into()
779            }
780        );
781        assert_eq!(
782            keep(
783                &fixture("/srv/widget@feat-x", Some("feat/x")),
784                Some(&gone),
785                false
786            ),
787            WtClass::Candidate
788        );
789    }
790}