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