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    local: Option<&crate::integrate::Entry>,
370) -> WtClass {
371    if worktree.path == layout.main {
372        return WtClass::Kept {
373            reason: "the main checkout".to_owned(),
374        };
375    }
376    if seats.iter().any(|seat| **seat == worktree.path) {
377        return WtClass::Kept {
378            reason: "a seat in use".to_owned(),
379        };
380    }
381    if let Some(reason) = &worktree.locked {
382        return WtClass::Kept {
383            reason: if reason.is_empty() {
384                "locked".to_owned()
385            } else {
386                format!("locked: {reason}")
387            },
388        };
389    }
390    if worktree.prunable.is_some() {
391        return WtClass::Stale;
392    }
393    let Some(name) = &worktree.branch else {
394        return WtClass::Kept {
395            reason: "detached HEAD".to_owned(),
396        };
397    };
398    if name == trunk || name.starts_with(PROTECTED_PREFIX) {
399        return WtClass::Kept {
400            reason: "a protected branch".to_owned(),
401        };
402    }
403    // The join fails closed, and before the state probes: a worktree
404    // whose branch observation is missing is never guessed into a
405    // candidate, and its dirt reading is noise — a seat whose ref
406    // vanished reads unborn.
407    let Some(branch) = branch else {
408        return WtClass::Kept {
409            reason: format!("no branch observation covers {name}"),
410        };
411    };
412    if dirty {
413        return WtClass::Kept {
414            reason: "uncommitted changes".to_owned(),
415        };
416    }
417    // Local evidence is a proof rather than a candidate signal, so it
418    // resolves here and asks no forge. It sits after every state guard:
419    // a dirty, locked, or in-use seat keeps whatever proved its branch.
420    if let Some(entry) = local {
421        return WtClass::Judged(Class::Confirmed {
422            proof: crate::branches::Proof::LocalIntegration(entry.trunk_commit.clone()),
423        });
424    }
425    if !branch.gone {
426        return WtClass::Kept {
427            reason: "the upstream is live or unset".to_owned(),
428        };
429    }
430    WtClass::Candidate
431}
432
433#[cfg(test)]
434mod tests {
435    use camino::{Utf8Path, Utf8PathBuf};
436
437    use super::{Layout, Worktree, WtClass, classify, derived_path, flatten, parse_worktrees};
438    use crate::branches::Branch;
439
440    /// The hand-rolled matcher speaks the one grammar: on a spread of
441    /// admitted and refused names it agrees with `grep -E` over
442    /// [`crate::landing::BRANCH_GRAMMAR`], the const the hook block
443    /// renders — so the two validators cannot drift apart silently.
444    #[test]
445    fn the_matcher_agrees_with_the_one_branch_grammar() {
446        let cases = [
447            ("feat/oauth-login", true),
448            ("fix/PROJ-412-empty-csv", true),
449            ("guides/release", false),
450            ("chore/deps/bump", true),
451            ("feat/", false),
452            ("412-empty-csv", true),
453            ("PROJ-412-empty-csv", true),
454            ("A-1-x", false),
455            ("AB-1-x", true),
456            ("412-", false),
457            ("release/1.2", true),
458            ("release-1.2", true),
459            ("release-", false),
460            ("release", false),
461            ("master", false),
462            ("worktree-session", false),
463            ("feature/x", false),
464            ("123", false),
465        ];
466        for (name, expected) in cases {
467            assert_eq!(
468                super::matches_grammar(name),
469                expected,
470                "matcher disagrees on {name}"
471            );
472            let grepped = std::process::Command::new(crate::probes::sh_bin())
473                .args([
474                    "-c",
475                    &format!(
476                        "printf %s \"$1\" | grep -Eq \"{}\"",
477                        crate::landing::BRANCH_GRAMMAR
478                    ),
479                    "sh",
480                    name,
481                ])
482                .status()
483                .expect("grep runs");
484            assert_eq!(
485                grepped.success(),
486                expected,
487                "the regex itself disagrees on {name}"
488            );
489        }
490    }
491
492    /// Flattening replaces every slash; the collision pair derives equal —
493    /// documented, refused at `add`, never suffixed.
494    #[test]
495    fn a_branch_flattens_into_a_sibling_directory_name() {
496        assert_eq!(flatten("feat/oauth-login"), "feat-oauth-login");
497        assert_eq!(flatten("guides/release/x"), "guides-release-x");
498        assert_eq!(flatten("plain"), "plain");
499        assert_eq!(
500            flatten("feat/a-b"),
501            flatten("feat-a/b"),
502            "flattening is not injective; add refuses the collision by name"
503        );
504        let layout = Layout {
505            main: Utf8PathBuf::from("/srv/checkouts/widget"),
506            parent: Utf8PathBuf::from("/srv/checkouts"),
507            project: "widget".into(),
508        };
509        assert_eq!(
510            derived_path(&layout, "feat/oauth-login"),
511            Utf8PathBuf::from("/srv/checkouts/widget@feat-oauth-login")
512        );
513    }
514
515    /// A porcelain stream, NUL-separated, with an empty token closing each
516    /// record.
517    fn stream(records: &[&[&str]]) -> Vec<u8> {
518        let mut bytes = Vec::new();
519        for record in records {
520            for line in *record {
521                bytes.extend_from_slice(line.as_bytes());
522                bytes.push(0);
523            }
524            bytes.push(0);
525        }
526        bytes
527    }
528
529    /// Complete records parse — main, linked, detached, locked with a
530    /// reason, prunable — and each untrustworthy shape refuses with the
531    /// reason named.
532    #[test]
533    fn porcelain_parsing_refuses_what_it_cannot_trust() {
534        let parsed = parse_worktrees(&stream(&[
535            &[
536                "worktree /srv/checkouts/widget",
537                "HEAD aaaa",
538                "branch refs/heads/master",
539            ],
540            &[
541                "worktree /srv/checkouts/widget@feat-x",
542                "HEAD bbbb",
543                "branch refs/heads/feat/x",
544            ],
545            &[
546                "worktree /srv/checkouts/widget-probe",
547                "HEAD cccc",
548                "detached",
549            ],
550            &[
551                "worktree /srv/checkouts/widget-held",
552                "HEAD dddd",
553                "branch refs/heads/feat/held",
554                "locked a running agent",
555            ],
556            &[
557                "worktree /srv/checkouts/widget-gone",
558                "HEAD eeee",
559                "branch refs/heads/feat/gone",
560                "prunable gitdir file points to non-existent location",
561            ],
562        ]))
563        .expect("a complete inventory parses");
564        assert_eq!(parsed.len(), 5);
565        assert_eq!(parsed[0].branch.as_deref(), Some("master"));
566        assert_eq!(parsed[1].branch.as_deref(), Some("feat/x"));
567        assert_eq!(parsed[2].branch, None);
568        assert_eq!(parsed[3].locked.as_deref(), Some("a running agent"));
569        assert!(parsed[4].prunable.is_some());
570        let layout = Layout::of(&parsed).expect("the layout resolves");
571        assert_eq!(layout.parent, Utf8PathBuf::from("/srv/checkouts"));
572        assert_eq!(layout.project, "widget");
573
574        let truncated = stream(&[&["worktree /srv/checkouts/widget", "HEAD aaaa"]]);
575        let truncated = &truncated[..truncated.len() - 2];
576        assert!(
577            parse_worktrees(truncated)
578                .expect_err("a truncated stream refuses")
579                .contains("mid-record")
580        );
581        assert!(
582            parse_worktrees(&stream(&[&["worktree /srv/x", "branch refs/heads/master"]]))
583                .expect_err("a record without a HEAD refuses")
584                .contains("no HEAD")
585        );
586        assert!(
587            parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa"]]))
588                .expect_err("neither branch nor detached refuses")
589                .contains("neither a branch nor a detached HEAD")
590        );
591        assert!(
592            parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa", "gitdir /y"]]))
593                .expect_err("an unknown attribute refuses")
594                .contains("does not know")
595        );
596        assert!(
597            parse_worktrees(&stream(&[&["worktree /srv/bare.git", "bare"]]))
598                .expect_err("a bare main record refuses by name")
599                .contains("bare")
600        );
601        assert!(
602            parse_worktrees(&stream(&[&[
603                "worktree /srv/x",
604                "HEAD aaaa",
605                "branch refs/heads/x",
606                "prunable gone",
607            ]]))
608            .expect_err("a prunable first record is no main worktree")
609            .contains("main worktree")
610        );
611        let mut invalid = b"worktree /srv/\xff\0HEAD aaaa\0branch refs/heads/x\0\0".to_vec();
612        assert!(
613            parse_worktrees(&invalid)
614                .expect_err("a non-UTF-8 path refuses")
615                .contains("not UTF-8")
616        );
617        invalid.clear();
618        assert!(
619            parse_worktrees(&invalid).is_err(),
620            "an empty inventory refuses"
621        );
622    }
623
624    fn fixture(path: &str, branch: Option<&str>) -> Worktree {
625        Worktree {
626            path: Utf8PathBuf::from(path),
627            head: "aaaa".into(),
628            branch: branch.map(str::to_owned),
629            bare: false,
630            locked: None,
631            prunable: None,
632        }
633    }
634
635    fn observation(name: &str, gone: bool) -> Branch {
636        Branch {
637            name: name.into(),
638            tip: "aaaa".into(),
639            upstream: Some(format!("origin/{name}")),
640            gone,
641            worktree: None,
642        }
643    }
644
645    /// The last-moment re-observation fails closed: a vanished record, a
646    /// fresh lock, a vanished directory, and a seat that switched off the
647    /// confirmed branch each keep; only the very resource verification
648    /// saw clears the removal.
649    #[test]
650    fn a_reobservation_clears_only_the_verified_resource() {
651        let seat = fixture("/srv/widget@feat-x", Some("feat/x"));
652        assert_eq!(super::reobservation(Some(&seat), "feat/x"), None);
653        assert!(
654            super::reobservation(None, "feat/x").is_some_and(|reason| reason.contains("vanished"))
655        );
656        let locked = Worktree {
657            locked: Some(String::new()),
658            ..seat.clone()
659        };
660        assert!(
661            super::reobservation(Some(&locked), "feat/x")
662                .is_some_and(|reason| reason.contains("lock"))
663        );
664        let gone = Worktree {
665            prunable: Some("gone".into()),
666            ..seat.clone()
667        };
668        assert!(
669            super::reobservation(Some(&gone), "feat/x")
670                .is_some_and(|reason| reason.contains("directory"))
671        );
672        let switched = Worktree {
673            branch: Some("feat/other".into()),
674            ..seat.clone()
675        };
676        assert!(
677            super::reobservation(Some(&switched), "feat/x")
678                .is_some_and(|reason| reason.contains("switched")),
679            "a merge proof authorizes no other resource"
680        );
681        let detached = Worktree {
682            branch: None,
683            ..seat
684        };
685        assert!(super::reobservation(Some(&detached), "feat/x").is_some());
686    }
687
688    /// The nine guards hold in order: main, seat, locked (missing
689    /// directory included), stale before detached, detached, protected,
690    /// dirty, live upstream, candidate.
691    #[test]
692    fn classification_guards_hold_in_order() {
693        let layout = Layout {
694            main: Utf8PathBuf::from("/srv/widget"),
695            parent: Utf8PathBuf::from("/srv"),
696            project: "widget".into(),
697        };
698        let seat = Utf8Path::new("/srv/widget@feat-seat");
699        let seats: &[&Utf8Path] = &[seat];
700        let gone = observation("feat/x", true);
701        let keep = |worktree: &Worktree, branch: Option<&Branch>, dirty: bool| {
702            classify(worktree, branch, &layout, seats, "master", dirty, None)
703        };
704
705        assert_eq!(
706            keep(&fixture("/srv/widget", Some("master")), None, false),
707            WtClass::Kept {
708                reason: "the main checkout".into()
709            }
710        );
711        assert_eq!(
712            keep(
713                &fixture("/srv/widget@feat-seat", Some("feat/x")),
714                Some(&gone),
715                false
716            ),
717            WtClass::Kept {
718                reason: "a seat in use".into()
719            }
720        );
721        let locked_missing = Worktree {
722            locked: Some(String::new()),
723            prunable: Some("gone".into()),
724            ..fixture("/srv/widget@feat-x", Some("feat/x"))
725        };
726        assert_eq!(
727            keep(&locked_missing, Some(&gone), false),
728            WtClass::Kept {
729                reason: "locked".into()
730            },
731            "a lock is kept unconditionally, missing directory included"
732        );
733        let stale_detached = Worktree {
734            prunable: Some("gone".into()),
735            ..fixture("/srv/widget@feat-x", None)
736        };
737        assert_eq!(
738            keep(&stale_detached, None, false),
739            WtClass::Stale,
740            "a missing directory precedes the detached arm by construction"
741        );
742        assert_eq!(
743            keep(&fixture("/srv/widget-probe", None), None, false),
744            WtClass::Kept {
745                reason: "detached HEAD".into()
746            }
747        );
748        assert_eq!(
749            keep(
750                &fixture("/srv/widget@release-1.2", Some("release/1.2")),
751                Some(&observation("release/1.2", true)),
752                false
753            ),
754            WtClass::Kept {
755                reason: "a protected branch".into()
756            }
757        );
758        assert_eq!(
759            keep(
760                &fixture("/srv/widget@feat-x", Some("feat/x")),
761                Some(&gone),
762                true
763            ),
764            WtClass::Kept {
765                reason: "uncommitted changes".into()
766            }
767        );
768        assert_eq!(
769            keep(&fixture("/srv/widget@feat-x", Some("feat/x")), None, true),
770            WtClass::Kept {
771                reason: "no branch observation covers feat/x".into()
772            },
773            "a missing observation keeps by name, before the dirt reading"
774        );
775        assert_eq!(
776            keep(
777                &fixture("/srv/widget@feat-x", Some("feat/x")),
778                Some(&observation("feat/x", false)),
779                false
780            ),
781            WtClass::Kept {
782                reason: "the upstream is live or unset".into()
783            }
784        );
785        assert_eq!(
786            keep(
787                &fixture("/srv/widget@feat-x", Some("feat/x")),
788                Some(&gone),
789                false
790            ),
791            WtClass::Candidate
792        );
793    }
794}