use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use glob::{MatchOptions, Pattern};
use crate::fsutil::{self, CopyOutcome};
use crate::git;
use crate::root;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tier {
State,
PhaseLink,
Handover,
Inquisition,
MemoryCache,
}
impl std::fmt::Display for Tier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Tier::State => "state",
Tier::PhaseLink => "phase-link",
Tier::Handover => "handover",
Tier::Inquisition => "inquisition",
Tier::MemoryCache => "memory-cache",
};
f.write_str(name)
}
}
#[derive(Debug)]
pub(crate) struct Withhold {
pub(crate) tier: Tier,
pub(crate) glob: &'static str,
}
const fn w(tier: Tier, glob: &'static str) -> Withhold {
Withhold { tier, glob }
}
pub(crate) const WITHHELD: &[Withhold] = &[
w(Tier::State, ".doctrine/state/**"),
w(Tier::PhaseLink, ".doctrine/slice/*/phases"),
w(Tier::Handover, "**/handover.md"),
w(Tier::Inquisition, ".doctrine/slice/*/inquisition.md"),
w(Tier::MemoryCache, ".doctrine/memory/index/**"),
w(Tier::MemoryCache, ".doctrine/memory/embeddings/**"),
w(Tier::MemoryCache, ".doctrine/memory/state/**"),
w(Tier::MemoryCache, ".doctrine/memory/shipped/**"),
];
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "classification authority; only the .gitignore parity test reads it so far (SL-029)"
)
)]
pub(crate) const DERIVED_RUNTIME: &[&str] = &[".doctrine/skills/*"];
const MATCH_OPTS: MatchOptions = MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: false,
};
fn glob_matches(pat: &Pattern, path: &str) -> bool {
pat.matches_with(path, MATCH_OPTS)
}
#[derive(Debug)]
pub(crate) struct Allowlist {
pub(crate) patterns: Vec<Pattern>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ParseError {
#[error("line {line}: negation (`!`) is unsupported in .worktreeinclude v1: `{raw}`")]
Negation { line: usize, raw: String },
#[error("line {line}: anchoring (leading `/`) is unsupported in .worktreeinclude v1: `{raw}`")]
Anchoring { line: usize, raw: String },
#[error("line {line}: invalid glob `{raw}`: {source}")]
BadGlob {
line: usize,
raw: String,
#[source]
source: glob::PatternError,
},
}
pub(crate) fn parse_allowlist(text: &str) -> Result<Allowlist, ParseError> {
let mut patterns = Vec::new();
for (i, raw_line) in text.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let n = i + 1;
if line.starts_with('!') {
return Err(ParseError::Negation {
line: n,
raw: line.to_string(),
});
}
if line.starts_with('/') {
return Err(ParseError::Anchoring {
line: n,
raw: line.to_string(),
});
}
let pat = Pattern::new(line).map_err(|source| ParseError::BadGlob {
line: n,
raw: line.to_string(),
source,
})?;
patterns.push(pat);
}
Ok(Allowlist { patterns })
}
pub(crate) fn is_withheld(rel: &str) -> Option<Tier> {
WITHHELD.iter().find_map(|item| {
Pattern::new(item.glob)
.ok()
.filter(|p| glob_matches(p, rel))
.map(|_p| item.tier)
})
}
#[derive(Debug)]
pub(crate) struct Withheld {
pub(crate) path: String,
pub(crate) tier: Tier,
}
#[derive(Debug)]
pub(crate) struct Selection {
pub(crate) copy: Vec<String>,
pub(crate) withheld: Vec<Withheld>,
}
pub(crate) fn select_copies(allow: &Allowlist, candidates: &[String]) -> Selection {
let mut copy = Vec::new();
let mut withheld = Vec::new();
for cand in candidates {
if !allow.patterns.iter().any(|p| glob_matches(p, cand)) {
continue;
}
match is_withheld(cand) {
Some(tier) => withheld.push(Withheld {
path: cand.clone(),
tier,
}),
None => copy.push(cand.clone()),
}
}
Selection { copy, withheld }
}
#[derive(Debug)]
pub(crate) struct Violation {
pub(crate) pattern: String,
pub(crate) tier: Tier,
}
fn representative(glob: &str) -> String {
glob.replace("**", "x").replace(['*', '?'], "x")
}
pub(crate) fn allowlist_violations(allow: &Allowlist) -> Vec<Violation> {
let mut out = Vec::new();
for item in WITHHELD {
let rep = representative(item.glob);
for pat in &allow.patterns {
if glob_matches(pat, &rep) {
out.push(Violation {
pattern: pat.as_str().to_string(),
tier: item.tier,
});
}
}
}
out
}
pub(crate) fn matches(base: &str, head: &str) -> bool {
base == head
}
const ALLOWLIST_FILE: &str = ".worktreeinclude";
fn read_allowlist(root: &Path) -> anyhow::Result<Allowlist> {
let path = root.join(ALLOWLIST_FILE);
match fs::read_to_string(&path) {
Ok(text) => parse_allowlist(&text).map_err(|e| anyhow::anyhow!("{}: {e}", path.display())),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Allowlist {
patterns: Vec::new(),
}),
Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
}
}
fn resolve_common_dir(root: &Path, common: &str) -> anyhow::Result<PathBuf> {
let raw = Path::new(common);
let joined = if raw.is_absolute() {
raw.to_path_buf()
} else {
root.join(raw)
};
fs::canonicalize(&joined)
.with_context(|| format!("canonicalize git-common-dir {}", joined.display()))
}
pub(crate) fn is_linked_worktree(root: &Path) -> anyhow::Result<bool> {
let git_dir = resolve_common_dir(root, &git::git_text(root, &["rev-parse", "--git-dir"])?)?;
let common = resolve_common_dir(
root,
&git::git_text(root, &["rev-parse", "--git-common-dir"])?,
)?;
Ok(git_dir != common)
}
fn verify_sibling_worktree(source: &Path, fork: &Path) -> anyhow::Result<()> {
if source == fork {
bail!("fork path is the source tree itself; refusing to provision");
}
let source_common = resolve_common_dir(
source,
&git::git_text(source, &["rev-parse", "--git-common-dir"])?,
)?;
let fork_common = resolve_common_dir(
fork,
&git::git_text(fork, &["rev-parse", "--git-common-dir"])?,
)?;
if source_common != fork_common {
bail!(
"fork {} is not a worktree of the source repo (git-common-dir differs)",
fork.display()
);
}
Ok(())
}
fn enumerate_candidates(root: &Path) -> anyhow::Result<Vec<String>> {
let raw = git::git_bytes(
root,
&[
"ls-files",
"-z",
"--others",
"--ignored",
"--exclude-standard",
],
)?;
let mut out = Vec::new();
for chunk in raw.split(|b| *b == 0) {
if chunk.is_empty() {
continue;
}
let path = std::str::from_utf8(chunk)
.map_err(|e| anyhow::anyhow!("non-utf8 path from git ls-files: {e}"))?;
out.push(path.to_string());
}
Ok(out)
}
pub(crate) fn run_provision(path: Option<PathBuf>, fork: &Path) -> anyhow::Result<()> {
let source = root::find(path, &root::default_markers())?;
let source = fs::canonicalize(&source)
.with_context(|| format!("canonicalize source root {}", source.display()))?;
let allow = read_allowlist(&source)?;
let violations = allowlist_violations(&allow);
if !violations.is_empty() {
for v in &violations {
writeln!(
io::stderr(),
"refusing: pattern `{}` names the withheld {} tier",
v.pattern,
v.tier
)?;
}
bail!(
"{} .worktreeinclude pattern(s) name a withheld tier; refusing to provision",
violations.len()
);
}
let fork =
fs::canonicalize(fork).with_context(|| format!("canonicalize fork {}", fork.display()))?;
verify_sibling_worktree(&source, &fork)?;
let candidates = enumerate_candidates(&source)?;
let selection = select_copies(&allow, &candidates);
let withheld_target = |rel: &Path| rel.to_str().is_some_and(|s| is_withheld(s).is_some());
let mut copied = 0usize;
let mut skipped = 0usize;
for rel in &selection.copy {
match fsutil::copy_selected(&source, &fork, Path::new(rel), &withheld_target)? {
CopyOutcome::Copied => copied += 1,
CopyOutcome::Skipped(reason) => {
skipped += 1;
writeln!(io::stderr(), "skipped {rel}: {reason}")?;
}
}
}
for held in &selection.withheld {
writeln!(io::stderr(), "withheld {} ({} tier)", held.path, held.tier)?;
}
writeln!(
io::stdout(),
"provisioned {}: {copied} copied, {} withheld, {skipped} skipped",
fork.display(),
selection.withheld.len()
)?;
Ok(())
}
pub(crate) fn run_check_allowlist(path: Option<PathBuf>) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let file = root.join(ALLOWLIST_FILE);
let text = match fs::read_to_string(&file) {
Ok(t) => t,
Err(e) if e.kind() == ErrorKind::NotFound => {
writeln!(io::stdout(), "no {ALLOWLIST_FILE} — nothing to check")?;
return Ok(());
}
Err(e) => return Err(e).with_context(|| format!("read {}", file.display())),
};
let allow = parse_allowlist(&text).map_err(|e| anyhow::anyhow!("{}: {e}", file.display()))?;
let violations = allowlist_violations(&allow);
if violations.is_empty() {
writeln!(
io::stdout(),
"ok — no allowlist pattern names a withheld tier"
)?;
return Ok(());
}
for v in &violations {
writeln!(
io::stderr(),
"violation: pattern `{}` names the withheld {} tier",
v.pattern,
v.tier
)?;
}
bail!(
"{} allowlist pattern(s) name a withheld tier",
violations.len()
)
}
fn resolve_commit(root: &Path, reference: &str) -> anyhow::Result<String> {
Ok(git::git_text(
root,
&["rev-parse", "--verify", &format!("{reference}^{{commit}}")],
)?)
}
pub(crate) fn run_branch_point_check(
path: Option<PathBuf>,
base: &str,
head: Option<String>,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let head = head.unwrap_or_else(|| "HEAD".to_owned());
let base_sha = resolve_commit(&root, base)?;
let head_sha = resolve_commit(&root, &head)?;
if matches(&base_sha, &head_sha) {
writeln!(io::stdout(), "stationary: HEAD == base {base_sha}")?;
Ok(())
} else {
bail!("HEAD moved: base {base_sha} != HEAD {head_sha}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_is_ref_equality() {
assert!(matches("abc123", "abc123"), "equal shas ⇒ stationary");
assert!(!matches("abc123", "def456"), "differing shas ⇒ moved");
assert!(!matches("abc123", ""), "empty head ⇒ moved");
assert!(
matches("", ""),
"degenerate equal ⇒ stationary (caller guards emptiness)"
);
}
#[test]
fn withheld_globs_all_compile() {
for item in WITHHELD {
Pattern::new(item.glob).unwrap();
}
for g in DERIVED_RUNTIME {
Pattern::new(g).unwrap();
}
}
fn gitignore_representative(line: &str) -> String {
let base = line
.strip_suffix('/')
.map_or_else(|| line.to_string(), |dir| format!("{dir}/f"));
base.replace('*', "x")
}
fn classified(rep: &str) -> bool {
WITHHELD
.iter()
.any(|item| glob_matches(&Pattern::new(item.glob).unwrap(), rep))
|| DERIVED_RUNTIME
.iter()
.any(|g| glob_matches(&Pattern::new(g).unwrap(), rep))
}
#[test]
fn every_runtime_gitignore_glob_is_classified() {
let gitignore = fs::read_to_string(".gitignore").unwrap();
for raw in gitignore.lines() {
let line = raw.trim();
if !line.starts_with(".doctrine/") || line == ".doctrine/*" {
continue;
}
let rep = gitignore_representative(line);
assert!(
classified(&rep),
"unclassified runtime gitignore glob `{line}` (rep `{rep}`) — \
add it to WITHHELD or DERIVED_RUNTIME"
);
}
}
#[test]
fn parse_allowlist_accepts_each_supported_class() {
let text = "# a comment\n\nsrc/main.rs\nconfig/*.toml\n**/*.md\nfile?.txt\n";
let allow = parse_allowlist(text).unwrap();
assert_eq!(allow.patterns.len(), 4);
}
#[test]
fn parse_allowlist_rejects_negation() {
let err = parse_allowlist("src/*\n!secret").unwrap_err();
assert!(matches!(err, ParseError::Negation { .. }));
}
#[test]
fn parse_allowlist_rejects_anchoring() {
let err = parse_allowlist("/anchored").unwrap_err();
assert!(matches!(err, ParseError::Anchoring { .. }));
}
#[test]
fn parse_allowlist_rejects_bad_glob() {
let err = parse_allowlist("a[b").unwrap_err();
assert!(matches!(err, ParseError::BadGlob { .. }));
}
#[test]
fn is_withheld_classifies_each_tier() {
assert_eq!(is_withheld(".doctrine/state/boot.md"), Some(Tier::State));
assert_eq!(
is_withheld(".doctrine/state/slice/029/phases/phase-01.md"),
Some(Tier::State)
);
assert_eq!(
is_withheld(".doctrine/slice/029/phases"),
Some(Tier::PhaseLink)
);
assert_eq!(
is_withheld(".doctrine/slice/029/handover.md"),
Some(Tier::Handover)
);
assert_eq!(
is_withheld(".doctrine/memory/index/foo"),
Some(Tier::MemoryCache)
);
assert_eq!(is_withheld("src/main.rs"), None);
assert_eq!(is_withheld(".doctrine/skills/code-review/SKILL.md"), None);
}
#[test]
fn select_copies_withholds_tier_files_under_a_broad_glob() {
let allow = parse_allowlist("**").unwrap();
let candidates = vec![
"src/main.rs".to_string(),
".doctrine/state/boot.md".to_string(),
".doctrine/slice/029/handover.md".to_string(),
];
let sel = select_copies(&allow, &candidates);
assert_eq!(sel.copy, ["src/main.rs"]);
let held: Vec<&str> = sel.withheld.iter().map(|h| h.path.as_str()).collect();
assert!(held.contains(&".doctrine/state/boot.md"));
assert!(held.contains(&".doctrine/slice/029/handover.md"));
}
#[test]
fn select_copies_skips_unallowlisted_candidates() {
let allow = parse_allowlist("docs/**").unwrap();
let candidates = vec!["src/main.rs".to_string(), "docs/guide.md".to_string()];
let sel = select_copies(&allow, &candidates);
assert_eq!(sel.copy, ["docs/guide.md"]);
assert!(sel.withheld.is_empty());
}
#[test]
fn allowlist_violations_flags_a_tier_naming_pattern() {
let allow = parse_allowlist(".doctrine/state/*").unwrap();
let v = allowlist_violations(&allow);
assert!(!v.is_empty());
assert_eq!(v[0].tier, Tier::State);
}
#[test]
fn allowlist_violations_passes_benign_patterns() {
let allow = parse_allowlist("src/**\nconfig/app.toml").unwrap();
assert!(allowlist_violations(&allow).is_empty());
}
#[test]
fn allowlist_violations_flags_a_broad_wildcard() {
let allow = parse_allowlist("**").unwrap();
assert!(!allowlist_violations(&allow).is_empty());
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn init_repo(dir: &Path) -> PathBuf {
fs::create_dir_all(dir).unwrap();
git(dir, &["init", "-q", "-b", "main"]);
git(dir, &["config", "user.email", "t@example.com"]);
git(dir, &["config", "user.name", "Test"]);
fs::write(dir.join("seed"), "x").unwrap();
git(dir, &["add", "."]);
git(dir, &["commit", "-q", "-m", "base"]);
fs::canonicalize(dir).unwrap()
}
#[test]
fn is_linked_worktree_true_for_a_fork_false_for_the_primary_tree() {
let tmp = tempfile::tempdir().unwrap();
let primary = init_repo(&tmp.path().join("src"));
let fork = tmp.path().join("fork");
git(
&primary,
&[
"worktree",
"add",
"-q",
"-b",
"feat",
fork.to_str().unwrap(),
],
);
let fork = fs::canonicalize(&fork).unwrap();
assert!(is_linked_worktree(&fork).unwrap(), "a linked worktree");
assert!(!is_linked_worktree(&primary).unwrap(), "the primary tree");
}
}