pub mod topology;
pub mod watch;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::config::{FleetConfig, FleetConfigError, FleetOverrides, Source};
use topology::{WorktreeEntry, WtClass, classify, list_worktrees};
#[derive(Debug)]
pub enum RepoScopeError {
NotDaemonMode,
Enumerate(std::io::Error),
}
impl fmt::Display for RepoScopeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RepoScopeError::NotDaemonMode => write!(
f,
"repo-scoped daemon needs a repo root.\n \
pass `serve --repo <path>` (or set `TF_REPO` / \
`[fleet] repo` in tf.toml)."
),
RepoScopeError::Enumerate(e) => write!(
f,
"could not enumerate worktrees via \
`git worktree list --porcelain`: {e}.\n \
run cargoless from inside the repo, or check `git` is on PATH."
),
}
}
}
impl std::error::Error for RepoScopeError {}
#[derive(Debug, Clone)]
pub struct RepoScope {
pub fleet: FleetConfig,
pub repo_root: PathBuf,
pub worktrees: Vec<WorktreeEntry>,
}
impl RepoScope {
pub fn from_parts(
fleet: FleetConfig,
repo_root: impl Into<PathBuf>,
worktrees: Vec<WorktreeEntry>,
) -> Self {
Self {
fleet,
repo_root: repo_root.into(),
worktrees,
}
}
pub fn discover(fleet: FleetConfig) -> Result<Self, RepoScopeError> {
let repo_root = fleet.repo.clone().ok_or(RepoScopeError::NotDaemonMode)?;
let worktrees = list_worktrees(&repo_root).map_err(RepoScopeError::Enumerate)?;
Ok(Self::from_parts(fleet, repo_root, worktrees))
}
pub fn classified(&self) -> impl Iterator<Item = (WtClass, &WorktreeEntry)> {
self.worktrees
.iter()
.map(|wt| (classify(&self.repo_root, &wt.path), wt))
}
pub fn worktree_config(
&self,
wt_root: &Path,
env: &dyn Fn(&str) -> Option<String>,
) -> Result<FleetConfig, FleetConfigError> {
let per_wt = FleetConfig::resolve_layered(wt_root, FleetOverrides::default(), env)?;
let mut cfg = self.fleet.clone();
if per_wt.provenance.state_dir == Source::TfToml {
cfg.state_dir = per_wt.state_dir;
cfg.provenance.state_dir = Source::TfToml;
}
Ok(cfg)
}
pub fn worktree_state_dir(
&self,
wt_root: &Path,
env: &dyn Fn(&str) -> Option<String>,
) -> Result<PathBuf, FleetConfigError> {
Ok(self.worktree_config(wt_root, env)?.state_dir_abs(wt_root))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn no_env(_: &str) -> Option<String> {
None
}
fn wt(path: &str) -> WorktreeEntry {
let mut e = topology::parse_worktree_porcelain(&format!(
"worktree {path}\nHEAD a\nbranch refs/heads/x\n"
));
e.pop().expect("one entry")
}
const REPO: &str = "/Users/iggy/Documents/GitHub/tf-multiverse";
fn fleet_with(state_dir: &str, cas: Option<&str>) -> FleetConfig {
let mut f = FleetConfig::defaults();
f.repo = Some(PathBuf::from(REPO));
f.state_dir = PathBuf::from(state_dir);
f.provenance.state_dir = Source::Cli; f.cas_dir = cas.map(PathBuf::from);
f
}
#[test]
fn discover_requires_daemon_mode() {
let f = FleetConfig::defaults();
let err = RepoScope::discover(f).unwrap_err();
assert!(matches!(err, RepoScopeError::NotDaemonMode));
assert!(err.to_string().contains("--repo"));
}
#[test]
fn classified_ties_topology_to_repo_scope() {
let scope = RepoScope::from_parts(
fleet_with(".triform/cargoless", None),
REPO,
vec![
wt(REPO),
wt(&format!("{REPO}/.claude/worktrees/agent-x")),
wt("/Users/iggy/Documents/GitHub/tf-multiverse-flat"),
wt("/var/tmp/special"),
],
);
let classes: Vec<WtClass> = scope.classified().map(|(c, _)| c).collect();
assert_eq!(
classes,
vec![
WtClass::Main,
WtClass::Nested,
WtClass::Sibling,
WtClass::Other,
]
);
}
#[test]
fn worktree_without_own_tftoml_inherits_daemon_state_dir() {
let scope = RepoScope::from_parts(fleet_with(".triform/cargoless", None), REPO, vec![]);
let tmp = std::env::temp_dir().join(format!(
"cargoless-rs-noTOML-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&tmp).unwrap();
let cfg = scope.worktree_config(&tmp, &no_env).unwrap();
assert_eq!(
cfg.state_dir,
PathBuf::from(".triform/cargoless"),
"tf.toml-less WT inherits the daemon's resolved state_dir"
);
assert_eq!(
cfg.provenance.state_dir,
Source::Cli,
"provenance preserved"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn worktree_with_own_tftoml_overrides_only_state_dir() {
let scope = RepoScope::from_parts(
fleet_with(".triform/cargoless", Some("/shared/cas")),
REPO,
vec![],
);
let tmp = std::env::temp_dir().join(format!(
"cargoless-rs-ownTOML-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join("tf.toml"),
"[project]\nstate_dir = \".wt-local/state\"\n",
)
.unwrap();
let cfg = scope.worktree_config(&tmp, &no_env).unwrap();
assert_eq!(
cfg.state_dir,
PathBuf::from(".wt-local/state"),
"WT's own tf.toml overrides state_dir"
);
assert_eq!(cfg.provenance.state_dir, Source::TfToml);
assert_eq!(
cfg.cas_dir,
Some(PathBuf::from("/shared/cas")),
"shared CAS must NOT be re-derived per-worktree"
);
assert_eq!(cfg.repo, Some(PathBuf::from(REPO)));
let abs = scope.worktree_state_dir(&tmp, &no_env).unwrap();
assert_eq!(abs, tmp.join(".wt-local/state"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn malformed_worktree_tftoml_propagates_error() {
let scope = RepoScope::from_parts(fleet_with(".cargoless", None), REPO, vec![]);
let tmp = std::env::temp_dir().join(format!(
"cargoless-rs-badTOML-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("tf.toml"), "[fleet]\nbind = \"not-a-socket\"\n").unwrap();
let err = scope.worktree_config(&tmp, &no_env).unwrap_err();
assert!(
matches!(err, FleetConfigError::BadTfToml { .. }),
"malformed per-WT tf.toml fleet key propagates verbatim: {err:?}"
);
let _ = std::fs::remove_dir_all(&tmp);
}
}