use camino::{Utf8Path, Utf8PathBuf};
use crate::branches::{Branch, Class, PROTECTED_PREFIX};
const BRANCH_TYPES: [&str; 11] = [
"build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
];
#[must_use]
pub fn matches_grammar(branch: &str) -> bool {
if let Some(rest) = branch.strip_prefix("release") {
if let Some(line) = rest.strip_prefix(['-', '/']) {
if !line.is_empty() {
return true;
}
}
}
if let Some((kind, slug)) = branch.split_once('/') {
if BRANCH_TYPES.contains(&kind)
&& !slug.is_empty()
&& slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-'))
{
return true;
}
}
issue_form(branch)
}
fn issue_form(branch: &str) -> bool {
let slug_ok = |slug: &str| {
!slug.is_empty()
&& slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
};
let digits = branch
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(branch.len());
if digits >= 1 {
if let Some(slug) = branch[digits..].strip_prefix('-') {
if slug_ok(slug) {
return true;
}
}
}
if !branch.starts_with(|c: char| c.is_ascii_uppercase()) {
return false;
}
let key = branch[1..]
.find(|c: char| !(c.is_ascii_uppercase() || c.is_ascii_digit()))
.map_or(branch.len(), |offset| offset + 1);
if key < 2 {
return false;
}
let Some(rest) = branch[key..].strip_prefix('-') else {
return false;
};
let number = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
if number < 1 {
return false;
}
rest[number..].strip_prefix('-').is_some_and(slug_ok)
}
#[must_use]
pub fn flatten(branch: &str) -> String {
branch.replace('/', "-")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Layout {
pub main: Utf8PathBuf,
pub parent: Utf8PathBuf,
pub project: String,
}
impl Layout {
pub fn of(worktrees: &[Worktree]) -> Result<Self, String> {
let main = worktrees
.first()
.ok_or_else(|| "the worktree inventory is empty".to_owned())?;
let parent = main
.path
.parent()
.ok_or_else(|| format!("the main worktree {} has no parent directory", main.path))?
.to_owned();
let project = main
.path
.file_name()
.ok_or_else(|| format!("the main worktree {} has no basename", main.path))?
.to_owned();
Ok(Self {
main: main.path.clone(),
parent,
project,
})
}
}
#[must_use]
pub fn derived_path(layout: &Layout, branch: &str) -> Utf8PathBuf {
layout
.parent
.join(format!("{}@{}", layout.project, flatten(branch)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Worktree {
pub path: Utf8PathBuf,
pub head: String,
pub branch: Option<String>,
pub bare: bool,
pub locked: Option<String>,
pub prunable: Option<String>,
}
#[derive(Debug, Default)]
struct Partial {
path: Option<Utf8PathBuf>,
head: Option<String>,
branch: Option<String>,
bare: bool,
detached: bool,
locked: Option<String>,
prunable: Option<String>,
}
impl Partial {
const fn is_empty(&self) -> bool {
self.path.is_none()
&& self.head.is_none()
&& self.branch.is_none()
&& !self.bare
&& !self.detached
&& self.locked.is_none()
&& self.prunable.is_none()
}
fn close(self) -> Result<Worktree, String> {
let path = self
.path
.ok_or_else(|| "a worktree record carries no path".to_owned())?;
let head = match (self.head, self.bare) {
(Some(head), _) => head,
(None, true) => String::new(),
(None, false) => return Err(format!("the record for {path} carries no HEAD")),
};
if !self.bare && self.branch.is_none() && !self.detached {
return Err(format!(
"the record for {path} names neither a branch nor a detached HEAD"
));
}
Ok(Worktree {
path,
head,
branch: self.branch,
bare: self.bare,
locked: self.locked,
prunable: self.prunable,
})
}
}
pub fn parse_worktrees(bytes: &[u8]) -> Result<Vec<Worktree>, String> {
let mut worktrees = Vec::new();
let mut partial = Partial::default();
for token in bytes.split(|byte| *byte == 0) {
if token.is_empty() {
if !partial.is_empty() {
worktrees.push(std::mem::take(&mut partial).close()?);
}
continue;
}
let line = std::str::from_utf8(token)
.map_err(|_| "a worktree record carries a path that is not UTF-8".to_owned())?;
let (attribute, value) = line
.split_once(' ')
.map_or((line, None), |(attribute, value)| (attribute, Some(value)));
match (attribute, value) {
("worktree", Some(path)) => partial.path = Some(Utf8PathBuf::from(path)),
("HEAD", Some(head)) => partial.head = Some(head.to_owned()),
("branch", Some(reference)) => {
partial.branch = Some(
reference
.strip_prefix("refs/heads/")
.unwrap_or(reference)
.to_owned(),
);
}
("bare", None) => partial.bare = true,
("detached", None) => partial.detached = true,
("locked", reason) => partial.locked = Some(reason.unwrap_or("").to_owned()),
("prunable", reason) => partial.prunable = Some(reason.unwrap_or("").to_owned()),
_ => {
return Err(format!(
"the worktree inventory carries an attribute this binary does not know: {line}"
));
}
}
}
if !partial.is_empty() {
return Err("the worktree inventory ends mid-record".to_owned());
}
let Some(main) = worktrees.first() else {
return Err("the worktree inventory is empty".to_owned());
};
if main.bare {
return Err(
"the repository is bare; the sibling convention has no main checkout to compose with"
.to_owned(),
);
}
if main.prunable.is_some() {
return Err(format!(
"the first record, {}, is not a complete main worktree",
main.path
));
}
Ok(worktrees)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WtClass {
Kept {
reason: String,
},
Candidate,
Judged(Class),
Stale,
}
#[must_use]
pub fn reobservation(seat: Option<&Worktree>, branch: &str) -> Option<String> {
let Some(seat) = seat else {
return Some("the worktree record vanished".to_owned());
};
if seat.locked.is_some() {
return Some("a lock arrived".to_owned());
}
if seat.prunable.is_some() {
return Some("the directory vanished".to_owned());
}
if seat.branch.as_deref() != Some(branch) {
return Some(format!("the seat switched off {branch}"));
}
None
}
#[must_use]
pub fn classify(
worktree: &Worktree,
branch: Option<&Branch>,
layout: &Layout,
seats: &[&Utf8Path],
trunk: &str,
dirty: bool,
) -> WtClass {
if worktree.path == layout.main {
return WtClass::Kept {
reason: "the main checkout".to_owned(),
};
}
if seats.iter().any(|seat| **seat == worktree.path) {
return WtClass::Kept {
reason: "a seat in use".to_owned(),
};
}
if let Some(reason) = &worktree.locked {
return WtClass::Kept {
reason: if reason.is_empty() {
"locked".to_owned()
} else {
format!("locked: {reason}")
},
};
}
if worktree.prunable.is_some() {
return WtClass::Stale;
}
let Some(name) = &worktree.branch else {
return WtClass::Kept {
reason: "detached HEAD".to_owned(),
};
};
if name == trunk || name.starts_with(PROTECTED_PREFIX) {
return WtClass::Kept {
reason: "a protected branch".to_owned(),
};
}
let Some(branch) = branch else {
return WtClass::Kept {
reason: format!("no branch observation covers {name}"),
};
};
if dirty {
return WtClass::Kept {
reason: "uncommitted changes".to_owned(),
};
}
if !branch.gone {
return WtClass::Kept {
reason: "the upstream is live or unset".to_owned(),
};
}
WtClass::Candidate
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use camino::{Utf8Path, Utf8PathBuf};
use super::{Layout, Worktree, WtClass, classify, derived_path, flatten, parse_worktrees};
use crate::branches::Branch;
#[test]
fn the_matcher_agrees_with_the_one_branch_grammar() {
let cases = [
("feat/oauth-login", true),
("fix/PROJ-412-empty-csv", true),
("guides/release", false),
("chore/deps/bump", true),
("feat/", false),
("412-empty-csv", true),
("PROJ-412-empty-csv", true),
("A-1-x", false),
("AB-1-x", true),
("412-", false),
("release/1.2", true),
("release-1.2", true),
("release-", false),
("release", false),
("master", false),
("worktree-session", false),
("feature/x", false),
("123", false),
];
for (name, expected) in cases {
assert_eq!(
super::matches_grammar(name),
expected,
"matcher disagrees on {name}"
);
let grepped = std::process::Command::new("sh")
.args([
"-c",
&format!(
"printf %s \"$1\" | grep -Eq \"{}\"",
crate::landing::BRANCH_GRAMMAR
),
"sh",
name,
])
.status()
.expect("grep runs");
assert_eq!(
grepped.success(),
expected,
"the regex itself disagrees on {name}"
);
}
}
#[test]
fn a_branch_flattens_into_a_sibling_directory_name() {
assert_eq!(flatten("feat/oauth-login"), "feat-oauth-login");
assert_eq!(flatten("guides/release/x"), "guides-release-x");
assert_eq!(flatten("plain"), "plain");
assert_eq!(
flatten("feat/a-b"),
flatten("feat-a/b"),
"flattening is not injective; add refuses the collision by name"
);
let layout = Layout {
main: Utf8PathBuf::from("/srv/checkouts/widget"),
parent: Utf8PathBuf::from("/srv/checkouts"),
project: "widget".into(),
};
assert_eq!(
derived_path(&layout, "feat/oauth-login"),
Utf8PathBuf::from("/srv/checkouts/widget@feat-oauth-login")
);
}
fn stream(records: &[&[&str]]) -> Vec<u8> {
let mut bytes = Vec::new();
for record in records {
for line in *record {
bytes.extend_from_slice(line.as_bytes());
bytes.push(0);
}
bytes.push(0);
}
bytes
}
#[test]
fn porcelain_parsing_refuses_what_it_cannot_trust() {
let parsed = parse_worktrees(&stream(&[
&[
"worktree /srv/checkouts/widget",
"HEAD aaaa",
"branch refs/heads/master",
],
&[
"worktree /srv/checkouts/widget@feat-x",
"HEAD bbbb",
"branch refs/heads/feat/x",
],
&[
"worktree /srv/checkouts/widget-probe",
"HEAD cccc",
"detached",
],
&[
"worktree /srv/checkouts/widget-held",
"HEAD dddd",
"branch refs/heads/feat/held",
"locked a running agent",
],
&[
"worktree /srv/checkouts/widget-gone",
"HEAD eeee",
"branch refs/heads/feat/gone",
"prunable gitdir file points to non-existent location",
],
]))
.expect("a complete inventory parses");
assert_eq!(parsed.len(), 5);
assert_eq!(parsed[0].branch.as_deref(), Some("master"));
assert_eq!(parsed[1].branch.as_deref(), Some("feat/x"));
assert_eq!(parsed[2].branch, None);
assert_eq!(parsed[3].locked.as_deref(), Some("a running agent"));
assert!(parsed[4].prunable.is_some());
let layout = Layout::of(&parsed).expect("the layout resolves");
assert_eq!(layout.parent, Utf8PathBuf::from("/srv/checkouts"));
assert_eq!(layout.project, "widget");
let truncated = stream(&[&["worktree /srv/checkouts/widget", "HEAD aaaa"]]);
let truncated = &truncated[..truncated.len() - 2];
assert!(
parse_worktrees(truncated)
.expect_err("a truncated stream refuses")
.contains("mid-record")
);
assert!(
parse_worktrees(&stream(&[&["worktree /srv/x", "branch refs/heads/master"]]))
.expect_err("a record without a HEAD refuses")
.contains("no HEAD")
);
assert!(
parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa"]]))
.expect_err("neither branch nor detached refuses")
.contains("neither a branch nor a detached HEAD")
);
assert!(
parse_worktrees(&stream(&[&["worktree /srv/x", "HEAD aaaa", "gitdir /y"]]))
.expect_err("an unknown attribute refuses")
.contains("does not know")
);
assert!(
parse_worktrees(&stream(&[&["worktree /srv/bare.git", "bare"]]))
.expect_err("a bare main record refuses by name")
.contains("bare")
);
assert!(
parse_worktrees(&stream(&[&[
"worktree /srv/x",
"HEAD aaaa",
"branch refs/heads/x",
"prunable gone",
]]))
.expect_err("a prunable first record is no main worktree")
.contains("main worktree")
);
let mut invalid = b"worktree /srv/\xff\0HEAD aaaa\0branch refs/heads/x\0\0".to_vec();
assert!(
parse_worktrees(&invalid)
.expect_err("a non-UTF-8 path refuses")
.contains("not UTF-8")
);
invalid.clear();
assert!(
parse_worktrees(&invalid).is_err(),
"an empty inventory refuses"
);
}
fn fixture(path: &str, branch: Option<&str>) -> Worktree {
Worktree {
path: Utf8PathBuf::from(path),
head: "aaaa".into(),
branch: branch.map(str::to_owned),
bare: false,
locked: None,
prunable: None,
}
}
fn observation(name: &str, gone: bool) -> Branch {
Branch {
name: name.into(),
tip: "aaaa".into(),
upstream: Some(format!("origin/{name}")),
gone,
worktree: None,
}
}
#[test]
fn a_reobservation_clears_only_the_verified_resource() {
let seat = fixture("/srv/widget@feat-x", Some("feat/x"));
assert_eq!(super::reobservation(Some(&seat), "feat/x"), None);
assert!(
super::reobservation(None, "feat/x").is_some_and(|reason| reason.contains("vanished"))
);
let locked = Worktree {
locked: Some(String::new()),
..seat.clone()
};
assert!(
super::reobservation(Some(&locked), "feat/x")
.is_some_and(|reason| reason.contains("lock"))
);
let gone = Worktree {
prunable: Some("gone".into()),
..seat.clone()
};
assert!(
super::reobservation(Some(&gone), "feat/x")
.is_some_and(|reason| reason.contains("directory"))
);
let switched = Worktree {
branch: Some("feat/other".into()),
..seat.clone()
};
assert!(
super::reobservation(Some(&switched), "feat/x")
.is_some_and(|reason| reason.contains("switched")),
"a merge proof authorizes no other resource"
);
let detached = Worktree {
branch: None,
..seat
};
assert!(super::reobservation(Some(&detached), "feat/x").is_some());
}
#[test]
fn classification_guards_hold_in_order() {
let layout = Layout {
main: Utf8PathBuf::from("/srv/widget"),
parent: Utf8PathBuf::from("/srv"),
project: "widget".into(),
};
let seat = Utf8Path::new("/srv/widget@feat-seat");
let seats: &[&Utf8Path] = &[seat];
let gone = observation("feat/x", true);
let keep = |worktree: &Worktree, branch: Option<&Branch>, dirty: bool| {
classify(worktree, branch, &layout, seats, "master", dirty)
};
assert_eq!(
keep(&fixture("/srv/widget", Some("master")), None, false),
WtClass::Kept {
reason: "the main checkout".into()
}
);
assert_eq!(
keep(
&fixture("/srv/widget@feat-seat", Some("feat/x")),
Some(&gone),
false
),
WtClass::Kept {
reason: "a seat in use".into()
}
);
let locked_missing = Worktree {
locked: Some(String::new()),
prunable: Some("gone".into()),
..fixture("/srv/widget@feat-x", Some("feat/x"))
};
assert_eq!(
keep(&locked_missing, Some(&gone), false),
WtClass::Kept {
reason: "locked".into()
},
"a lock is kept unconditionally, missing directory included"
);
let stale_detached = Worktree {
prunable: Some("gone".into()),
..fixture("/srv/widget@feat-x", None)
};
assert_eq!(
keep(&stale_detached, None, false),
WtClass::Stale,
"a missing directory precedes the detached arm by construction"
);
assert_eq!(
keep(&fixture("/srv/widget-probe", None), None, false),
WtClass::Kept {
reason: "detached HEAD".into()
}
);
assert_eq!(
keep(
&fixture("/srv/widget@release-1.2", Some("release/1.2")),
Some(&observation("release/1.2", true)),
false
),
WtClass::Kept {
reason: "a protected branch".into()
}
);
assert_eq!(
keep(
&fixture("/srv/widget@feat-x", Some("feat/x")),
Some(&gone),
true
),
WtClass::Kept {
reason: "uncommitted changes".into()
}
);
assert_eq!(
keep(&fixture("/srv/widget@feat-x", Some("feat/x")), None, true),
WtClass::Kept {
reason: "no branch observation covers feat/x".into()
},
"a missing observation keeps by name, before the dirt reading"
);
assert_eq!(
keep(
&fixture("/srv/widget@feat-x", Some("feat/x")),
Some(&observation("feat/x", false)),
false
),
WtClass::Kept {
reason: "the upstream is live or unset".into()
}
);
assert_eq!(
keep(
&fixture("/srv/widget@feat-x", Some("feat/x")),
Some(&gone),
false
),
WtClass::Candidate
);
}
}