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 if let Some(line) = rest.strip_prefix(['-', '/']) {
35 if !line.is_empty() {
36 return true;
37 }
38 }
39 }
40 if let Some((kind, slug)) = branch.split_once('/') {
42 if BRANCH_TYPES.contains(&kind)
43 && !slug.is_empty()
44 && slug
45 .chars()
46 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-'))
47 {
48 return true;
49 }
50 }
51 issue_form(branch)
52}
53
54fn issue_form(branch: &str) -> bool {
57 let slug_ok = |slug: &str| {
58 !slug.is_empty()
59 && slug
60 .chars()
61 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
62 };
63 let digits = branch
67 .find(|c: char| !c.is_ascii_digit())
68 .unwrap_or(branch.len());
69 if digits >= 1 {
70 if let Some(slug) = branch[digits..].strip_prefix('-') {
71 if slug_ok(slug) {
72 return true;
73 }
74 }
75 }
76 if !branch.starts_with(|c: char| c.is_ascii_uppercase()) {
78 return false;
79 }
80 let key = branch[1..]
81 .find(|c: char| !(c.is_ascii_uppercase() || c.is_ascii_digit()))
82 .map_or(branch.len(), |offset| offset + 1);
83 if key < 2 {
84 return false;
85 }
86 let Some(rest) = branch[key..].strip_prefix('-') else {
87 return false;
88 };
89 let number = rest
90 .find(|c: char| !c.is_ascii_digit())
91 .unwrap_or(rest.len());
92 if number < 1 {
93 return false;
94 }
95 rest[number..].strip_prefix('-').is_some_and(slug_ok)
96}
97
98#[must_use]
103pub fn flatten(branch: &str) -> String {
104 branch.replace('/', "-")
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Layout {
111 pub main: Utf8PathBuf,
113 pub parent: Utf8PathBuf,
115 pub project: String,
117}
118
119impl Layout {
120 pub fn of(worktrees: &[Worktree]) -> Result<Self, String> {
129 let main = worktrees
130 .first()
131 .ok_or_else(|| "the worktree inventory is empty".to_owned())?;
132 let parent = main
133 .path
134 .parent()
135 .ok_or_else(|| format!("the main worktree {} has no parent directory", main.path))?
136 .to_owned();
137 let project = main
138 .path
139 .file_name()
140 .ok_or_else(|| format!("the main worktree {} has no basename", main.path))?
141 .to_owned();
142 Ok(Self {
143 main: main.path.clone(),
144 parent,
145 project,
146 })
147 }
148}
149
150#[must_use]
152pub fn derived_path(layout: &Layout, branch: &str) -> Utf8PathBuf {
153 layout
154 .parent
155 .join(format!("{}@{}", layout.project, flatten(branch)))
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct Worktree {
161 pub path: Utf8PathBuf,
163 pub head: String,
165 pub branch: Option<String>,
167 pub bare: bool,
169 pub locked: Option<String>,
171 pub prunable: Option<String>,
173}
174
175#[derive(Debug, Default)]
177struct Partial {
178 path: Option<Utf8PathBuf>,
179 head: Option<String>,
180 branch: Option<String>,
181 bare: bool,
182 detached: bool,
183 locked: Option<String>,
184 prunable: Option<String>,
185}
186
187impl Partial {
188 const fn is_empty(&self) -> bool {
189 self.path.is_none()
190 && self.head.is_none()
191 && self.branch.is_none()
192 && !self.bare
193 && !self.detached
194 && self.locked.is_none()
195 && self.prunable.is_none()
196 }
197
198 fn close(self) -> Result<Worktree, String> {
200 let path = self
201 .path
202 .ok_or_else(|| "a worktree record carries no path".to_owned())?;
203 let head = match (self.head, self.bare) {
205 (Some(head), _) => head,
206 (None, true) => String::new(),
207 (None, false) => return Err(format!("the record for {path} carries no HEAD")),
208 };
209 if !self.bare && self.branch.is_none() && !self.detached {
210 return Err(format!(
211 "the record for {path} names neither a branch nor a detached HEAD"
212 ));
213 }
214 Ok(Worktree {
215 path,
216 head,
217 branch: self.branch,
218 bare: self.bare,
219 locked: self.locked,
220 prunable: self.prunable,
221 })
222 }
223}
224
225pub fn parse_worktrees(bytes: &[u8]) -> Result<Vec<Worktree>, String> {
243 let mut worktrees = Vec::new();
244 let mut partial = Partial::default();
245 for token in bytes.split(|byte| *byte == 0) {
246 if token.is_empty() {
247 if !partial.is_empty() {
248 worktrees.push(std::mem::take(&mut partial).close()?);
249 }
250 continue;
251 }
252 let line = std::str::from_utf8(token)
253 .map_err(|_| "a worktree record carries a path that is not UTF-8".to_owned())?;
254 let (attribute, value) = line
255 .split_once(' ')
256 .map_or((line, None), |(attribute, value)| (attribute, Some(value)));
257 match (attribute, value) {
258 ("worktree", Some(path)) => partial.path = Some(Utf8PathBuf::from(path)),
259 ("HEAD", Some(head)) => partial.head = Some(head.to_owned()),
260 ("branch", Some(reference)) => {
261 partial.branch = Some(
262 reference
263 .strip_prefix("refs/heads/")
264 .unwrap_or(reference)
265 .to_owned(),
266 );
267 }
268 ("bare", None) => partial.bare = true,
269 ("detached", None) => partial.detached = true,
270 ("locked", reason) => partial.locked = Some(reason.unwrap_or("").to_owned()),
271 ("prunable", reason) => partial.prunable = Some(reason.unwrap_or("").to_owned()),
272 _ => {
273 return Err(format!(
274 "the worktree inventory carries an attribute this binary does not know: {line}"
275 ));
276 }
277 }
278 }
279 if !partial.is_empty() {
280 return Err("the worktree inventory ends mid-record".to_owned());
282 }
283 let Some(main) = worktrees.first() else {
284 return Err("the worktree inventory is empty".to_owned());
285 };
286 if main.bare {
287 return Err(
288 "the repository is bare; the sibling convention has no main checkout to compose with"
289 .to_owned(),
290 );
291 }
292 if main.prunable.is_some() {
293 return Err(format!(
294 "the first record, {}, is not a complete main worktree",
295 main.path
296 ));
297 }
298 Ok(worktrees)
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
303pub enum WtClass {
304 Kept {
307 reason: String,
309 },
310 Candidate,
312 Judged(Class),
315 Stale,
319}
320
321#[must_use]
333pub fn reobservation(seat: Option<&Worktree>, branch: &str) -> Option<String> {
334 let Some(seat) = seat else {
335 return Some("the worktree record vanished".to_owned());
336 };
337 if seat.locked.is_some() {
338 return Some("a lock arrived".to_owned());
339 }
340 if seat.prunable.is_some() {
341 return Some("the directory vanished".to_owned());
342 }
343 if seat.branch.as_deref() != Some(branch) {
344 return Some(format!("the seat switched off {branch}"));
345 }
346 None
347}
348
349#[must_use]
365pub fn classify(
366 worktree: &Worktree,
367 branch: Option<&Branch>,
368 layout: &Layout,
369 seats: &[&Utf8Path],
370 trunk: &str,
371 dirty: bool,
372) -> WtClass {
373 if worktree.path == layout.main {
374 return WtClass::Kept {
375 reason: "the main checkout".to_owned(),
376 };
377 }
378 if seats.iter().any(|seat| **seat == worktree.path) {
379 return WtClass::Kept {
380 reason: "a seat in use".to_owned(),
381 };
382 }
383 if let Some(reason) = &worktree.locked {
384 return WtClass::Kept {
385 reason: if reason.is_empty() {
386 "locked".to_owned()
387 } else {
388 format!("locked: {reason}")
389 },
390 };
391 }
392 if worktree.prunable.is_some() {
393 return WtClass::Stale;
394 }
395 let Some(name) = &worktree.branch else {
396 return WtClass::Kept {
397 reason: "detached HEAD".to_owned(),
398 };
399 };
400 if name == trunk || name.starts_with(PROTECTED_PREFIX) {
401 return WtClass::Kept {
402 reason: "a protected branch".to_owned(),
403 };
404 }
405 let Some(branch) = branch else {
410 return WtClass::Kept {
411 reason: format!("no branch observation covers {name}"),
412 };
413 };
414 if dirty {
415 return WtClass::Kept {
416 reason: "uncommitted changes".to_owned(),
417 };
418 }
419 if !branch.gone {
420 return WtClass::Kept {
421 reason: "the upstream is live or unset".to_owned(),
422 };
423 }
424 WtClass::Candidate
425}
426
427#[cfg(test)]
428mod tests {
429 use camino::{Utf8Path, Utf8PathBuf};
430
431 use super::{Layout, Worktree, WtClass, classify, derived_path, flatten, parse_worktrees};
432 use crate::branches::Branch;
433
434 #[test]
439 fn the_matcher_agrees_with_the_one_branch_grammar() {
440 let cases = [
441 ("feat/oauth-login", true),
442 ("fix/PROJ-412-empty-csv", true),
443 ("guides/release", false),
444 ("chore/deps/bump", true),
445 ("feat/", false),
446 ("412-empty-csv", true),
447 ("PROJ-412-empty-csv", true),
448 ("A-1-x", false),
449 ("AB-1-x", true),
450 ("412-", false),
451 ("release/1.2", true),
452 ("release-1.2", true),
453 ("release-", false),
454 ("release", false),
455 ("master", false),
456 ("worktree-session", false),
457 ("feature/x", false),
458 ("123", false),
459 ];
460 for (name, expected) in cases {
461 assert_eq!(
462 super::matches_grammar(name),
463 expected,
464 "matcher disagrees on {name}"
465 );
466 let grepped = std::process::Command::new(crate::probes::sh_bin())
467 .args([
468 "-c",
469 &format!(
470 "printf %s \"$1\" | grep -Eq \"{}\"",
471 crate::landing::BRANCH_GRAMMAR
472 ),
473 "sh",
474 name,
475 ])
476 .status()
477 .expect("grep runs");
478 assert_eq!(
479 grepped.success(),
480 expected,
481 "the regex itself disagrees on {name}"
482 );
483 }
484 }
485
486 #[test]
489 fn a_branch_flattens_into_a_sibling_directory_name() {
490 assert_eq!(flatten("feat/oauth-login"), "feat-oauth-login");
491 assert_eq!(flatten("guides/release/x"), "guides-release-x");
492 assert_eq!(flatten("plain"), "plain");
493 assert_eq!(
494 flatten("feat/a-b"),
495 flatten("feat-a/b"),
496 "flattening is not injective; add refuses the collision by name"
497 );
498 let layout = Layout {
499 main: Utf8PathBuf::from("/srv/checkouts/widget"),
500 parent: Utf8PathBuf::from("/srv/checkouts"),
501 project: "widget".into(),
502 };
503 assert_eq!(
504 derived_path(&layout, "feat/oauth-login"),
505 Utf8PathBuf::from("/srv/checkouts/widget@feat-oauth-login")
506 );
507 }
508
509 fn stream(records: &[&[&str]]) -> Vec<u8> {
512 let mut bytes = Vec::new();
513 for record in records {
514 for line in *record {
515 bytes.extend_from_slice(line.as_bytes());
516 bytes.push(0);
517 }
518 bytes.push(0);
519 }
520 bytes
521 }
522
523 #[test]
527 fn porcelain_parsing_refuses_what_it_cannot_trust() {
528 let parsed = parse_worktrees(&stream(&[
529 &[
530 "worktree /srv/checkouts/widget",
531 "HEAD aaaa",
532 "branch refs/heads/master",
533 ],
534 &[
535 "worktree /srv/checkouts/widget@feat-x",
536 "HEAD bbbb",
537 "branch refs/heads/feat/x",
538 ],
539 &[
540 "worktree /srv/checkouts/widget-probe",
541 "HEAD cccc",
542 "detached",
543 ],
544 &[
545 "worktree /srv/checkouts/widget-held",
546 "HEAD dddd",
547 "branch refs/heads/feat/held",
548 "locked a running agent",
549 ],
550 &[
551 "worktree /srv/checkouts/widget-gone",
552 "HEAD eeee",
553 "branch refs/heads/feat/gone",
554 "prunable gitdir file points to non-existent location",
555 ],
556 ]))
557 .expect("a complete inventory parses");
558 assert_eq!(parsed.len(), 5);
559 assert_eq!(parsed[0].branch.as_deref(), Some("master"));
560 assert_eq!(parsed[1].branch.as_deref(), Some("feat/x"));
561 assert_eq!(parsed[2].branch, None);
562 assert_eq!(parsed[3].locked.as_deref(), Some("a running agent"));
563 assert!(parsed[4].prunable.is_some());
564 let layout = Layout::of(&parsed).expect("the layout resolves");
565 assert_eq!(layout.parent, Utf8PathBuf::from("/srv/checkouts"));
566 assert_eq!(layout.project, "widget");
567
568 let truncated = stream(&[&["worktree /srv/checkouts/widget", "HEAD aaaa"]]);
569 let truncated = &truncated[..truncated.len() - 2];
570 assert!(
571 parse_worktrees(truncated)
572 .expect_err("a truncated stream refuses")
573 .contains("mid-record")
574 );
575 assert!(
576 parse_worktrees(&stream(&[&["worktree /srv/x", "branch refs/heads/master"]]))
577 .expect_err("a record without a HEAD refuses")
578 .contains("no HEAD")
579 );
580 assert!(
581 parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa"]]))
582 .expect_err("neither branch nor detached refuses")
583 .contains("neither a branch nor a detached HEAD")
584 );
585 assert!(
586 parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa", "gitdir /y"]]))
587 .expect_err("an unknown attribute refuses")
588 .contains("does not know")
589 );
590 assert!(
591 parse_worktrees(&stream(&[&["worktree /srv/bare.git", "bare"]]))
592 .expect_err("a bare main record refuses by name")
593 .contains("bare")
594 );
595 assert!(
596 parse_worktrees(&stream(&[&[
597 "worktree /srv/x",
598 "HEAD aaaa",
599 "branch refs/heads/x",
600 "prunable gone",
601 ]]))
602 .expect_err("a prunable first record is no main worktree")
603 .contains("main worktree")
604 );
605 let mut invalid = b"worktree /srv/\xff\0HEAD aaaa\0branch refs/heads/x\0\0".to_vec();
606 assert!(
607 parse_worktrees(&invalid)
608 .expect_err("a non-UTF-8 path refuses")
609 .contains("not UTF-8")
610 );
611 invalid.clear();
612 assert!(
613 parse_worktrees(&invalid).is_err(),
614 "an empty inventory refuses"
615 );
616 }
617
618 fn fixture(path: &str, branch: Option<&str>) -> Worktree {
619 Worktree {
620 path: Utf8PathBuf::from(path),
621 head: "aaaa".into(),
622 branch: branch.map(str::to_owned),
623 bare: false,
624 locked: None,
625 prunable: None,
626 }
627 }
628
629 fn observation(name: &str, gone: bool) -> Branch {
630 Branch {
631 name: name.into(),
632 tip: "aaaa".into(),
633 upstream: Some(format!("origin/{name}")),
634 gone,
635 worktree: None,
636 }
637 }
638
639 #[test]
644 fn a_reobservation_clears_only_the_verified_resource() {
645 let seat = fixture("/srv/widget@feat-x", Some("feat/x"));
646 assert_eq!(super::reobservation(Some(&seat), "feat/x"), None);
647 assert!(
648 super::reobservation(None, "feat/x").is_some_and(|reason| reason.contains("vanished"))
649 );
650 let locked = Worktree {
651 locked: Some(String::new()),
652 ..seat.clone()
653 };
654 assert!(
655 super::reobservation(Some(&locked), "feat/x")
656 .is_some_and(|reason| reason.contains("lock"))
657 );
658 let gone = Worktree {
659 prunable: Some("gone".into()),
660 ..seat.clone()
661 };
662 assert!(
663 super::reobservation(Some(&gone), "feat/x")
664 .is_some_and(|reason| reason.contains("directory"))
665 );
666 let switched = Worktree {
667 branch: Some("feat/other".into()),
668 ..seat.clone()
669 };
670 assert!(
671 super::reobservation(Some(&switched), "feat/x")
672 .is_some_and(|reason| reason.contains("switched")),
673 "a merge proof authorizes no other resource"
674 );
675 let detached = Worktree {
676 branch: None,
677 ..seat
678 };
679 assert!(super::reobservation(Some(&detached), "feat/x").is_some());
680 }
681
682 #[test]
686 fn classification_guards_hold_in_order() {
687 let layout = Layout {
688 main: Utf8PathBuf::from("/srv/widget"),
689 parent: Utf8PathBuf::from("/srv"),
690 project: "widget".into(),
691 };
692 let seat = Utf8Path::new("/srv/widget@feat-seat");
693 let seats: &[&Utf8Path] = &[seat];
694 let gone = observation("feat/x", true);
695 let keep = |worktree: &Worktree, branch: Option<&Branch>, dirty: bool| {
696 classify(worktree, branch, &layout, seats, "master", dirty)
697 };
698
699 assert_eq!(
700 keep(&fixture("/srv/widget", Some("master")), None, false),
701 WtClass::Kept {
702 reason: "the main checkout".into()
703 }
704 );
705 assert_eq!(
706 keep(
707 &fixture("/srv/widget@feat-seat", Some("feat/x")),
708 Some(&gone),
709 false
710 ),
711 WtClass::Kept {
712 reason: "a seat in use".into()
713 }
714 );
715 let locked_missing = Worktree {
716 locked: Some(String::new()),
717 prunable: Some("gone".into()),
718 ..fixture("/srv/widget@feat-x", Some("feat/x"))
719 };
720 assert_eq!(
721 keep(&locked_missing, Some(&gone), false),
722 WtClass::Kept {
723 reason: "locked".into()
724 },
725 "a lock is kept unconditionally, missing directory included"
726 );
727 let stale_detached = Worktree {
728 prunable: Some("gone".into()),
729 ..fixture("/srv/widget@feat-x", None)
730 };
731 assert_eq!(
732 keep(&stale_detached, None, false),
733 WtClass::Stale,
734 "a missing directory precedes the detached arm by construction"
735 );
736 assert_eq!(
737 keep(&fixture("/srv/widget-probe", None), None, false),
738 WtClass::Kept {
739 reason: "detached HEAD".into()
740 }
741 );
742 assert_eq!(
743 keep(
744 &fixture("/srv/widget@release-1.2", Some("release/1.2")),
745 Some(&observation("release/1.2", true)),
746 false
747 ),
748 WtClass::Kept {
749 reason: "a protected branch".into()
750 }
751 );
752 assert_eq!(
753 keep(
754 &fixture("/srv/widget@feat-x", Some("feat/x")),
755 Some(&gone),
756 true
757 ),
758 WtClass::Kept {
759 reason: "uncommitted changes".into()
760 }
761 );
762 assert_eq!(
763 keep(&fixture("/srv/widget@feat-x", Some("feat/x")), None, true),
764 WtClass::Kept {
765 reason: "no branch observation covers feat/x".into()
766 },
767 "a missing observation keeps by name, before the dirt reading"
768 );
769 assert_eq!(
770 keep(
771 &fixture("/srv/widget@feat-x", Some("feat/x")),
772 Some(&observation("feat/x", false)),
773 false
774 ),
775 WtClass::Kept {
776 reason: "the upstream is live or unset".into()
777 }
778 );
779 assert_eq!(
780 keep(
781 &fixture("/srv/widget@feat-x", Some("feat/x")),
782 Some(&gone),
783 false
784 ),
785 WtClass::Candidate
786 );
787 }
788}