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