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