1use camino::{Utf8Path, Utf8PathBuf};
13
14use crate::branches::{Branch, Class, PROTECTED_PREFIX};
15
16const BRANCH_TYPES: [&str; 11] = [
19 "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
20];
21
22#[must_use]
31pub fn matches_grammar(branch: &str) -> bool {
32 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 if branch
41 .strip_prefix(crate::release_engine::REQUEST_PREFIX)
42 .is_some_and(|base| !base.is_empty())
43 {
44 return true;
45 }
46 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
59fn 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 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 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#[must_use]
107pub fn flatten(branch: &str) -> String {
108 branch.replace('/', "-")
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Layout {
115 pub main: Utf8PathBuf,
117 pub parent: Utf8PathBuf,
119 pub project: String,
121}
122
123impl Layout {
124 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct Worktree {
165 pub path: Utf8PathBuf,
167 pub head: String,
169 pub branch: Option<String>,
171 pub bare: bool,
173 pub locked: Option<String>,
175 pub prunable: Option<String>,
177}
178
179#[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 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 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
229pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
307pub enum WtClass {
308 Kept {
311 reason: String,
313 },
314 Candidate,
316 Judged(Class),
319 Stale,
323}
324
325#[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#[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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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}