use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::git::{self, WorktreeRecord};
use crate::root;
use super::gc::landed_against;
use super::marker::{Cause, describe_mode, marker_present};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WorktreeRole {
Primary,
Coordination,
WorkerFork,
Benign,
}
impl WorktreeRole {
pub(crate) fn token(self) -> &'static str {
match self {
WorktreeRole::Primary => "primary",
WorktreeRole::Coordination => "coordination",
WorktreeRole::WorkerFork => "worker-fork",
WorktreeRole::Benign => "benign",
}
}
}
pub(crate) fn classify_worktree(
is_primary: bool,
branch: Option<&str>,
marker_cause: Cause,
) -> WorktreeRole {
if is_primary {
return WorktreeRole::Primary;
}
if matches!(marker_cause, Cause::Marker | Cause::Both) {
return WorktreeRole::WorkerFork;
}
if let Some(suffix) = dispatch_suffix(branch) {
if suffix.starts_with("agent-") {
return WorktreeRole::WorkerFork;
}
if !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()) {
return WorktreeRole::Coordination;
}
}
WorktreeRole::Benign
}
fn dispatch_suffix(branch: Option<&str>) -> Option<&str> {
branch
.map(|b| b.strip_prefix("refs/heads/").unwrap_or(b))
.and_then(|b| b.strip_prefix("dispatch/"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LandedCell {
Landed,
NotLanded,
Unknown,
NotApplicable,
}
impl LandedCell {
fn token(self) -> &'static str {
match self {
LandedCell::Landed => "landed",
LandedCell::NotLanded => "unlanded",
LandedCell::Unknown => "unknown",
LandedCell::NotApplicable => "n/a",
}
}
}
struct InventoryRow {
path: PathBuf,
role: WorktreeRole,
slice: Option<u32>,
branch: Option<String>,
head: Option<String>,
marker: bool,
live: bool,
landed: LandedCell,
}
pub(crate) fn run_list(
path: Option<PathBuf>,
slice_filter: Option<u32>,
json: bool,
no_landed: bool,
) -> anyhow::Result<()> {
let root = root::find(path, &root::default_markers())?;
let root = std::fs::canonicalize(&root)
.with_context(|| format!("canonicalize root {}", root.display()))?;
let records = git::list_worktrees(&root).context("enumerate worktrees")?;
let rows: Vec<InventoryRow> = records
.iter()
.enumerate()
.map(|(i, rec)| resolve_row(&root, rec, i == 0, no_landed))
.filter(|row| slice_filter.is_none_or(|n| row.slice == Some(n)))
.collect();
if json {
print_json(&rows, no_landed)
} else {
print_table(&rows, no_landed)
}
}
fn resolve_row(
root: &Path,
rec: &WorktreeRecord,
is_primary: bool,
no_landed: bool,
) -> InventoryRow {
let branch = rec.branch.as_deref();
let marker = !is_primary && marker_present(&rec.path);
let cause = describe_mode(!is_primary, marker, false).cause;
let role = classify_worktree(is_primary, branch, cause);
let slice = slice_of(&rec.path, branch);
let landed = if no_landed {
LandedCell::NotApplicable
} else {
landed_cell(root, role, rec, slice)
};
InventoryRow {
path: rec.path.clone(),
role,
slice,
branch: rec.branch.clone(),
head: rec.head.clone(),
marker,
live: rec.path.exists() && !rec.prunable,
landed,
}
}
fn slice_of(path: &Path, branch: Option<&str>) -> Option<u32> {
if let Some(n) = dispatch_suffix(branch).and_then(|s| s.parse::<u32>().ok()) {
return Some(n);
}
path.components().find_map(|c| {
c.as_os_str()
.to_str()
.and_then(|s| s.strip_prefix("SL-"))
.and_then(|d| d.parse::<u32>().ok())
})
}
fn landed_cell(
root: &Path,
role: WorktreeRole,
rec: &WorktreeRecord,
slice: Option<u32>,
) -> LandedCell {
let (target, fork) = match role {
WorktreeRole::Primary | WorktreeRole::Benign => return LandedCell::NotApplicable,
WorktreeRole::WorkerFork => {
let Some(n) = slice else {
return LandedCell::Unknown;
};
(format!("refs/heads/dispatch/{n:03}"), rec.branch.clone())
}
WorktreeRole::Coordination => match git::trunk_commit(root).ok().flatten() {
Some(trunk) => (trunk, rec.branch.clone()),
None => return LandedCell::Unknown,
},
};
let Some(fork) = fork else {
return LandedCell::Unknown;
};
let (Some(target), Some(fork)) = (resolve_commit(root, &target), resolve_commit(root, &fork))
else {
return LandedCell::Unknown;
};
match landed_against(root, &target, &fork) {
Ok(true) => LandedCell::Landed,
Ok(false) => LandedCell::NotLanded,
Err(_) => LandedCell::Unknown,
}
}
fn resolve_commit(root: &Path, refspec: &str) -> Option<String> {
git::git_opt(
root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{refspec}^{{commit}}"),
],
)
.ok()
.flatten()
}
fn branch_label(branch: Option<&str>) -> String {
match branch {
Some(b) => b.strip_prefix("refs/heads/").unwrap_or(b).to_string(),
None => "(detached)".to_string(),
}
}
fn head_label(head: Option<&str>) -> String {
match head {
Some(h) => h.chars().take(12).collect(),
None => "-".to_string(),
}
}
fn yes_no(v: bool) -> &'static str {
if v { "yes" } else { "no" }
}
fn slice_label(slice: Option<u32>) -> String {
slice.map_or_else(|| "-".to_string(), |n| n.to_string())
}
fn print_table(rows: &[InventoryRow], no_landed: bool) -> anyhow::Result<()> {
let mut header: Vec<&str> = vec!["path", "role", "slice", "branch", "head", "marker", "live?"];
if !no_landed {
header.push("landed");
}
let mut table: Vec<Vec<String>> = vec![header.iter().map(|s| (*s).to_string()).collect()];
for row in rows {
let mut cells = vec![
row.path.display().to_string(),
row.role.token().to_string(),
slice_label(row.slice),
branch_label(row.branch.as_deref()),
head_label(row.head.as_deref()),
yes_no(row.marker).to_string(),
yes_no(row.live).to_string(),
];
if !no_landed {
cells.push(row.landed.token().to_string());
}
table.push(cells);
}
let cols = header.len();
let mut widths = vec![0usize; cols];
for row in &table {
for (width, cell) in widths.iter_mut().zip(row.iter()) {
*width = (*width).max(cell.len());
}
}
let stdout = io::stdout();
let mut out = stdout.lock();
for row in &table {
let line = row
.iter()
.zip(widths.iter())
.map(|(cell, width)| format!("{cell:<width$}", width = *width))
.collect::<Vec<_>>()
.join(" ");
writeln!(out, "{}", line.trim_end())?;
}
Ok(())
}
fn print_json(rows: &[InventoryRow], no_landed: bool) -> anyhow::Result<()> {
let items: Vec<serde_json::Value> = rows
.iter()
.map(|row| {
let mut obj = serde_json::json!({
"path": row.path.display().to_string(),
"role": row.role.token(),
"slice": row.slice,
"branch": row.branch,
"head": row.head,
"marker": row.marker,
"live": row.live,
});
if !no_landed && let Some(map) = obj.as_object_mut() {
map.insert(
"landed".to_string(),
serde_json::Value::from(row.landed.token()),
);
}
obj
})
.collect();
let payload = serde_json::Value::Array(items);
let text = serde_json::to_string_pretty(&payload).context("serialize worktree list json")?;
writeln!(io::stdout(), "{text}")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_worktree_over_each_combination() {
assert_eq!(
classify_worktree(true, Some("refs/heads/edge"), Cause::None),
WorktreeRole::Primary
);
assert_eq!(
classify_worktree(true, Some("refs/heads/dispatch/190"), Cause::Marker),
WorktreeRole::Primary,
"the primary is primary even bearing a marker"
);
assert_eq!(
classify_worktree(false, None, Cause::Marker),
WorktreeRole::WorkerFork
);
assert_eq!(
classify_worktree(false, Some("refs/heads/anything"), Cause::Both),
WorktreeRole::WorkerFork
);
assert_eq!(
classify_worktree(false, Some("refs/heads/dispatch/190"), Cause::None),
WorktreeRole::Coordination
);
assert_eq!(
classify_worktree(false, Some("dispatch/007"), Cause::None),
WorktreeRole::Coordination
);
assert_eq!(
classify_worktree(
false,
Some("refs/heads/dispatch/agent-ab9f5d9e"),
Cause::None
),
WorktreeRole::WorkerFork
);
assert_eq!(
classify_worktree(false, Some("refs/heads/w/SL-186-p02"), Cause::None),
WorktreeRole::Benign
);
assert_eq!(
classify_worktree(false, None, Cause::None),
WorktreeRole::Benign
);
assert_eq!(
classify_worktree(false, Some("refs/heads/dispatch/nonnumeric"), Cause::None),
WorktreeRole::Benign,
"a non-numeric, non-agent dispatch suffix is not coordination"
);
assert_eq!(
classify_worktree(false, None, Cause::Env),
WorktreeRole::Benign,
"env alone is not a per-row worker-fork signal for inventory"
);
}
#[test]
fn role_tokens_are_stable() {
assert_eq!(WorktreeRole::Primary.token(), "primary");
assert_eq!(WorktreeRole::Coordination.token(), "coordination");
assert_eq!(WorktreeRole::WorkerFork.token(), "worker-fork");
assert_eq!(WorktreeRole::Benign.token(), "benign");
}
#[test]
fn dispatch_suffix_strips_both_prefixes() {
assert_eq!(
dispatch_suffix(Some("refs/heads/dispatch/190")),
Some("190")
);
assert_eq!(dispatch_suffix(Some("dispatch/agent-x")), Some("agent-x"));
assert_eq!(dispatch_suffix(Some("refs/heads/main")), None);
assert_eq!(dispatch_suffix(None), None);
}
#[test]
fn slice_of_reads_branch_then_path() {
assert_eq!(
slice_of(
Path::new("/x/.dispatch/SL-190"),
Some("refs/heads/dispatch/190")
),
Some(190)
);
assert_eq!(
slice_of(
Path::new("/x/.dispatch/SL-190/.worktrees/agent-abc"),
Some("refs/heads/dispatch/agent-abc")
),
Some(190)
);
assert_eq!(
slice_of(Path::new("/x/plain"), Some("refs/heads/main")),
None
);
}
#[test]
fn landed_cell_tokens_are_stable() {
assert_eq!(LandedCell::Landed.token(), "landed");
assert_eq!(LandedCell::NotLanded.token(), "unlanded");
assert_eq!(LandedCell::Unknown.token(), "unknown");
assert_eq!(LandedCell::NotApplicable.token(), "n/a");
}
}