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) -> WtClass {
370 if worktree.path == layout.main {
371 return WtClass::Kept {
372 reason: "the main checkout".to_owned(),
373 };
374 }
375 if seats.iter().any(|seat| **seat == worktree.path) {
376 return WtClass::Kept {
377 reason: "a seat in use".to_owned(),
378 };
379 }
380 if let Some(reason) = &worktree.locked {
381 return WtClass::Kept {
382 reason: if reason.is_empty() {
383 "locked".to_owned()
384 } else {
385 format!("locked: {reason}")
386 },
387 };
388 }
389 if worktree.prunable.is_some() {
390 return WtClass::Stale;
391 }
392 let Some(name) = &worktree.branch else {
393 return WtClass::Kept {
394 reason: "detached HEAD".to_owned(),
395 };
396 };
397 if name == trunk || name.starts_with(PROTECTED_PREFIX) {
398 return WtClass::Kept {
399 reason: "a protected branch".to_owned(),
400 };
401 }
402 let Some(branch) = branch else {
407 return WtClass::Kept {
408 reason: format!("no branch observation covers {name}"),
409 };
410 };
411 if dirty {
412 return WtClass::Kept {
413 reason: "uncommitted changes".to_owned(),
414 };
415 }
416 if !branch.gone {
417 return WtClass::Kept {
418 reason: "the upstream is live or unset".to_owned(),
419 };
420 }
421 WtClass::Candidate
422}
423
424#[cfg(test)]
425mod tests {
426 use camino::{Utf8Path, Utf8PathBuf};
427
428 use super::{Layout, Worktree, WtClass, classify, derived_path, flatten, parse_worktrees};
429 use crate::branches::Branch;
430
431 #[test]
436 fn the_matcher_agrees_with_the_one_branch_grammar() {
437 let cases = [
438 ("feat/oauth-login", true),
439 ("fix/PROJ-412-empty-csv", true),
440 ("guides/release", false),
441 ("chore/deps/bump", true),
442 ("feat/", false),
443 ("412-empty-csv", true),
444 ("PROJ-412-empty-csv", true),
445 ("A-1-x", false),
446 ("AB-1-x", true),
447 ("412-", false),
448 ("release/1.2", true),
449 ("release-1.2", true),
450 ("release-", false),
451 ("release", false),
452 ("master", false),
453 ("worktree-session", false),
454 ("feature/x", false),
455 ("123", false),
456 ];
457 for (name, expected) in cases {
458 assert_eq!(
459 super::matches_grammar(name),
460 expected,
461 "matcher disagrees on {name}"
462 );
463 let grepped = std::process::Command::new(crate::probes::sh_bin())
464 .args([
465 "-c",
466 &format!(
467 "printf %s \"$1\" | grep -Eq \"{}\"",
468 crate::landing::BRANCH_GRAMMAR
469 ),
470 "sh",
471 name,
472 ])
473 .status()
474 .expect("grep runs");
475 assert_eq!(
476 grepped.success(),
477 expected,
478 "the regex itself disagrees on {name}"
479 );
480 }
481 }
482
483 #[test]
486 fn a_branch_flattens_into_a_sibling_directory_name() {
487 assert_eq!(flatten("feat/oauth-login"), "feat-oauth-login");
488 assert_eq!(flatten("guides/release/x"), "guides-release-x");
489 assert_eq!(flatten("plain"), "plain");
490 assert_eq!(
491 flatten("feat/a-b"),
492 flatten("feat-a/b"),
493 "flattening is not injective; add refuses the collision by name"
494 );
495 let layout = Layout {
496 main: Utf8PathBuf::from("/srv/checkouts/widget"),
497 parent: Utf8PathBuf::from("/srv/checkouts"),
498 project: "widget".into(),
499 };
500 assert_eq!(
501 derived_path(&layout, "feat/oauth-login"),
502 Utf8PathBuf::from("/srv/checkouts/widget@feat-oauth-login")
503 );
504 }
505
506 fn stream(records: &[&[&str]]) -> Vec<u8> {
509 let mut bytes = Vec::new();
510 for record in records {
511 for line in *record {
512 bytes.extend_from_slice(line.as_bytes());
513 bytes.push(0);
514 }
515 bytes.push(0);
516 }
517 bytes
518 }
519
520 #[test]
524 fn porcelain_parsing_refuses_what_it_cannot_trust() {
525 let parsed = parse_worktrees(&stream(&[
526 &[
527 "worktree /srv/checkouts/widget",
528 "HEAD aaaa",
529 "branch refs/heads/master",
530 ],
531 &[
532 "worktree /srv/checkouts/widget@feat-x",
533 "HEAD bbbb",
534 "branch refs/heads/feat/x",
535 ],
536 &[
537 "worktree /srv/checkouts/widget-probe",
538 "HEAD cccc",
539 "detached",
540 ],
541 &[
542 "worktree /srv/checkouts/widget-held",
543 "HEAD dddd",
544 "branch refs/heads/feat/held",
545 "locked a running agent",
546 ],
547 &[
548 "worktree /srv/checkouts/widget-gone",
549 "HEAD eeee",
550 "branch refs/heads/feat/gone",
551 "prunable gitdir file points to non-existent location",
552 ],
553 ]))
554 .expect("a complete inventory parses");
555 assert_eq!(parsed.len(), 5);
556 assert_eq!(parsed[0].branch.as_deref(), Some("master"));
557 assert_eq!(parsed[1].branch.as_deref(), Some("feat/x"));
558 assert_eq!(parsed[2].branch, None);
559 assert_eq!(parsed[3].locked.as_deref(), Some("a running agent"));
560 assert!(parsed[4].prunable.is_some());
561 let layout = Layout::of(&parsed).expect("the layout resolves");
562 assert_eq!(layout.parent, Utf8PathBuf::from("/srv/checkouts"));
563 assert_eq!(layout.project, "widget");
564
565 let truncated = stream(&[&["worktree /srv/checkouts/widget", "HEAD aaaa"]]);
566 let truncated = &truncated[..truncated.len() - 2];
567 assert!(
568 parse_worktrees(truncated)
569 .expect_err("a truncated stream refuses")
570 .contains("mid-record")
571 );
572 assert!(
573 parse_worktrees(&stream(&[&["worktree /srv/x", "branch refs/heads/master"]]))
574 .expect_err("a record without a HEAD refuses")
575 .contains("no HEAD")
576 );
577 assert!(
578 parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa"]]))
579 .expect_err("neither branch nor detached refuses")
580 .contains("neither a branch nor a detached HEAD")
581 );
582 assert!(
583 parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa", "gitdir /y"]]))
584 .expect_err("an unknown attribute refuses")
585 .contains("does not know")
586 );
587 assert!(
588 parse_worktrees(&stream(&[&["worktree /srv/bare.git", "bare"]]))
589 .expect_err("a bare main record refuses by name")
590 .contains("bare")
591 );
592 assert!(
593 parse_worktrees(&stream(&[&[
594 "worktree /srv/x",
595 "HEAD aaaa",
596 "branch refs/heads/x",
597 "prunable gone",
598 ]]))
599 .expect_err("a prunable first record is no main worktree")
600 .contains("main worktree")
601 );
602 let mut invalid = b"worktree /srv/\xff\0HEAD aaaa\0branch refs/heads/x\0\0".to_vec();
603 assert!(
604 parse_worktrees(&invalid)
605 .expect_err("a non-UTF-8 path refuses")
606 .contains("not UTF-8")
607 );
608 invalid.clear();
609 assert!(
610 parse_worktrees(&invalid).is_err(),
611 "an empty inventory refuses"
612 );
613 }
614
615 fn fixture(path: &str, branch: Option<&str>) -> Worktree {
616 Worktree {
617 path: Utf8PathBuf::from(path),
618 head: "aaaa".into(),
619 branch: branch.map(str::to_owned),
620 bare: false,
621 locked: None,
622 prunable: None,
623 }
624 }
625
626 fn observation(name: &str, gone: bool) -> Branch {
627 Branch {
628 name: name.into(),
629 tip: "aaaa".into(),
630 upstream: Some(format!("origin/{name}")),
631 gone,
632 worktree: None,
633 }
634 }
635
636 #[test]
641 fn a_reobservation_clears_only_the_verified_resource() {
642 let seat = fixture("/srv/widget@feat-x", Some("feat/x"));
643 assert_eq!(super::reobservation(Some(&seat), "feat/x"), None);
644 assert!(
645 super::reobservation(None, "feat/x").is_some_and(|reason| reason.contains("vanished"))
646 );
647 let locked = Worktree {
648 locked: Some(String::new()),
649 ..seat.clone()
650 };
651 assert!(
652 super::reobservation(Some(&locked), "feat/x")
653 .is_some_and(|reason| reason.contains("lock"))
654 );
655 let gone = Worktree {
656 prunable: Some("gone".into()),
657 ..seat.clone()
658 };
659 assert!(
660 super::reobservation(Some(&gone), "feat/x")
661 .is_some_and(|reason| reason.contains("directory"))
662 );
663 let switched = Worktree {
664 branch: Some("feat/other".into()),
665 ..seat.clone()
666 };
667 assert!(
668 super::reobservation(Some(&switched), "feat/x")
669 .is_some_and(|reason| reason.contains("switched")),
670 "a merge proof authorizes no other resource"
671 );
672 let detached = Worktree {
673 branch: None,
674 ..seat
675 };
676 assert!(super::reobservation(Some(&detached), "feat/x").is_some());
677 }
678
679 #[test]
683 fn classification_guards_hold_in_order() {
684 let layout = Layout {
685 main: Utf8PathBuf::from("/srv/widget"),
686 parent: Utf8PathBuf::from("/srv"),
687 project: "widget".into(),
688 };
689 let seat = Utf8Path::new("/srv/widget@feat-seat");
690 let seats: &[&Utf8Path] = &[seat];
691 let gone = observation("feat/x", true);
692 let keep = |worktree: &Worktree, branch: Option<&Branch>, dirty: bool| {
693 classify(worktree, branch, &layout, seats, "master", dirty)
694 };
695
696 assert_eq!(
697 keep(&fixture("/srv/widget", Some("master")), None, false),
698 WtClass::Kept {
699 reason: "the main checkout".into()
700 }
701 );
702 assert_eq!(
703 keep(
704 &fixture("/srv/widget@feat-seat", Some("feat/x")),
705 Some(&gone),
706 false
707 ),
708 WtClass::Kept {
709 reason: "a seat in use".into()
710 }
711 );
712 let locked_missing = Worktree {
713 locked: Some(String::new()),
714 prunable: Some("gone".into()),
715 ..fixture("/srv/widget@feat-x", Some("feat/x"))
716 };
717 assert_eq!(
718 keep(&locked_missing, Some(&gone), false),
719 WtClass::Kept {
720 reason: "locked".into()
721 },
722 "a lock is kept unconditionally, missing directory included"
723 );
724 let stale_detached = Worktree {
725 prunable: Some("gone".into()),
726 ..fixture("/srv/widget@feat-x", None)
727 };
728 assert_eq!(
729 keep(&stale_detached, None, false),
730 WtClass::Stale,
731 "a missing directory precedes the detached arm by construction"
732 );
733 assert_eq!(
734 keep(&fixture("/srv/widget-probe", None), None, false),
735 WtClass::Kept {
736 reason: "detached HEAD".into()
737 }
738 );
739 assert_eq!(
740 keep(
741 &fixture("/srv/widget@release-1.2", Some("release/1.2")),
742 Some(&observation("release/1.2", true)),
743 false
744 ),
745 WtClass::Kept {
746 reason: "a protected branch".into()
747 }
748 );
749 assert_eq!(
750 keep(
751 &fixture("/srv/widget@feat-x", Some("feat/x")),
752 Some(&gone),
753 true
754 ),
755 WtClass::Kept {
756 reason: "uncommitted changes".into()
757 }
758 );
759 assert_eq!(
760 keep(&fixture("/srv/widget@feat-x", Some("feat/x")), None, true),
761 WtClass::Kept {
762 reason: "no branch observation covers feat/x".into()
763 },
764 "a missing observation keeps by name, before the dirt reading"
765 );
766 assert_eq!(
767 keep(
768 &fixture("/srv/widget@feat-x", Some("feat/x")),
769 Some(&observation("feat/x", false)),
770 false
771 ),
772 WtClass::Kept {
773 reason: "the upstream is live or unset".into()
774 }
775 );
776 assert_eq!(
777 keep(
778 &fixture("/srv/widget@feat-x", Some("feat/x")),
779 Some(&gone),
780 false
781 ),
782 WtClass::Candidate
783 );
784 }
785}