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