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