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 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
52fn 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 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 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#[must_use]
100pub fn flatten(branch: &str) -> String {
101 branch.replace('/', "-")
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Layout {
108 pub main: Utf8PathBuf,
110 pub parent: Utf8PathBuf,
112 pub project: String,
114}
115
116impl Layout {
117 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct Worktree {
158 pub path: Utf8PathBuf,
160 pub head: String,
162 pub branch: Option<String>,
164 pub bare: bool,
166 pub locked: Option<String>,
168 pub prunable: Option<String>,
170}
171
172#[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 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 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
222pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum WtClass {
301 Kept {
304 reason: String,
306 },
307 Candidate,
309 Judged(Class),
312 Stale,
316}
317
318#[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#[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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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}