use std::path::{Path, PathBuf};
use octl_core::{read_node_opt, NodeId, RunId, RunLock, RunPaths};
use crate::error::CliError;
use crate::run::{from_core, runs_root};
#[derive(Debug)]
struct CurrentWorktree {
root: PathBuf,
branch: String,
}
pub fn resolve_current(root: &Path, cwd: &Path) -> Result<RunId, CliError> {
let current = current_worktree(cwd)?;
let runs = runs_root(root);
let entries = std::fs::read_dir(&runs).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
CliError::user(
"run_owner_not_found",
format!(
"no orchestratectl run owns current worktree {} (runs directory is absent)",
current.root.display()
),
)
} else {
CliError::system("io_error", format!("read_dir {}: {e}", runs.display()))
}
})?;
let mut owners = Vec::<(RunId, NodeId)>::new();
let mut stale = Vec::<(RunId, NodeId)>::new();
for entry in entries {
let entry = entry.map_err(|e| {
CliError::system("io_error", format!("read_dir {}: {e}", runs.display()))
})?;
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let Ok(run_id) = RunId::parse_str(&name) else {
continue;
};
let paths = RunPaths::from_validated(entry.path(), run_id.clone()).map_err(|e| {
CliError::user(
"run_owner_malformed",
format!("cannot inspect ownership evidence for run {run_id}: {e}"),
)
})?;
let _guard = RunLock::acquire_shared(&paths.lock()).map_err(from_core)?;
let evidence = scan_nodes(&paths, ¤t)?;
for (node_id, branch_match) in evidence {
if branch_match {
owners.push((run_id.clone(), node_id));
} else {
stale.push((run_id.clone(), node_id));
}
}
}
match owners.len() {
1 if stale.is_empty() => Ok(owners.pop().expect("one owner").0),
1 => {
let mut claims = owner_labels(&owners);
claims.extend(stale_labels(&stale));
Err(ambiguous_owner(¤t.root, claims))
}
n if n > 1 => Err(ambiguous_owner(¤t.root, owner_labels(&owners))),
_ if !stale.is_empty() => Err(CliError::user(
"run_owner_stale",
format!(
"ownership evidence for current worktree {} is stale: checked-out branch {:?} does not match the recorded owner",
current.root.display(), current.branch
),
)
.with_expected(serde_json::Value::Array(
stale_labels(&stale)
.into_iter()
.map(serde_json::Value::String)
.collect(),
))),
_ => Err(CliError::user(
"run_owner_not_found",
format!(
"no orchestratectl node records current worktree {} and branch {:?}; use the exact run id from the generated worker context",
current.root.display(), current.branch
),
)),
}
}
fn scan_nodes(
paths: &RunPaths,
current: &CurrentWorktree,
) -> Result<Vec<(NodeId, bool)>, CliError> {
let entries = match std::fs::read_dir(paths.nodes_dir()) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(CliError::system(
"run_owner_malformed",
format!(
"cannot read ownership evidence for run {}: {e}",
paths.run_id
),
))
}
};
let mut matches = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| {
CliError::system(
"run_owner_malformed",
format!(
"cannot enumerate ownership evidence for run {}: {e}",
paths.run_id
),
)
})?;
let entry_path = entry.path();
if entry_path.extension().and_then(|s| s.to_str()) != Some("json") {
return Err(malformed_node(
paths,
&entry_path,
"unexpected non-JSON entry in the owned nodes directory",
));
}
let Some(stem) = entry_path.file_stem().and_then(|s| s.to_str()) else {
return Err(malformed_node(
paths,
&entry_path,
"non-UTF-8 node filename",
));
};
let node_id = NodeId::parse_str(stem)
.map_err(|e| malformed_node(paths, &entry_path, &e.to_string()))?;
let node = read_node_opt(paths, &node_id)
.map_err(|e| malformed_node(paths, &entry_path, &e.to_string()))?
.ok_or_else(|| {
malformed_node(
paths,
&entry_path,
"node projection disappeared during the ownership scan",
)
})?;
let Some(recorded_path) = node.worktree_path.as_deref() else {
continue;
};
let recorded_path = Path::new(recorded_path);
if !recorded_path.is_absolute() {
return Err(malformed_node(
paths,
&entry_path,
"recorded worktree_path must be absolute",
));
}
let recorded_root = match std::fs::canonicalize(recorded_path) {
Ok(path) => path,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(malformed_node(
paths,
&entry_path,
&format!("cannot canonicalize recorded worktree_path: {e}"),
))
}
};
if recorded_root != current.root {
continue;
}
let branch_match = node.branch.as_deref() == Some(current.branch.as_str());
matches.push((node_id, branch_match));
}
Ok(matches)
}
fn current_worktree(cwd: &Path) -> Result<CurrentWorktree, CliError> {
let cwd = std::fs::canonicalize(cwd).map_err(|e| {
CliError::system("io_error", format!("canonicalize current directory: {e}"))
})?;
let mut cursor = Some(cwd.as_path());
while let Some(dir) = cursor {
let dot_git = dir.join(".git");
if dot_git.exists() {
let git_dir = resolve_git_dir(dir, &dot_git)?;
let head = std::fs::read_to_string(git_dir.join("HEAD")).map_err(|e| {
CliError::system(
"run_owner_malformed",
format!("cannot read {}: {e}", git_dir.join("HEAD").display()),
)
})?;
let Some(branch) = head.strip_prefix("ref: refs/heads/").map(str::trim) else {
return Err(CliError::user(
"run_owner_stale",
format!(
"current worktree {} has a detached or malformed HEAD; exact owning-run discovery requires its recorded branch",
dir.display()
),
));
};
if branch.is_empty() {
return Err(CliError::system(
"run_owner_malformed",
format!(
"{} contains an empty branch ref",
git_dir.join("HEAD").display()
),
));
}
return Ok(CurrentWorktree {
root: dir.to_path_buf(),
branch: branch.to_string(),
});
}
cursor = dir.parent();
}
Err(CliError::user(
"run_owner_not_found",
format!(
"current directory {} is not inside a git worktree",
cwd.display()
),
))
}
fn resolve_git_dir(worktree: &Path, dot_git: &Path) -> Result<PathBuf, CliError> {
if dot_git.is_dir() {
return std::fs::canonicalize(dot_git).map_err(|e| {
CliError::system(
"io_error",
format!("canonicalize {}: {e}", dot_git.display()),
)
});
}
let marker = std::fs::read_to_string(dot_git).map_err(|e| {
CliError::system(
"run_owner_malformed",
format!("cannot read git worktree marker {}: {e}", dot_git.display()),
)
})?;
let Some(raw) = marker.strip_prefix("gitdir:").map(str::trim) else {
return Err(CliError::system(
"run_owner_malformed",
format!(
"git worktree marker {} does not contain a gitdir",
dot_git.display()
),
));
};
if raw.is_empty() {
return Err(CliError::system(
"run_owner_malformed",
format!(
"git worktree marker {} contains an empty gitdir",
dot_git.display()
),
));
}
let path = Path::new(raw);
let path = if path.is_absolute() {
path.to_path_buf()
} else {
worktree.join(path)
};
std::fs::canonicalize(&path).map_err(|e| {
CliError::system(
"run_owner_malformed",
format!("canonicalize gitdir {}: {e}", path.display()),
)
})
}
fn malformed_node(paths: &RunPaths, path: &Path, reason: &str) -> CliError {
CliError::user(
"run_owner_malformed",
format!(
"malformed ownership evidence for run {} at {}: {reason}",
paths.run_id,
path.display()
),
)
}
fn owner_labels(rows: &[(RunId, NodeId)]) -> Vec<String> {
rows.iter().map(|(r, n)| format!("{r}/{n}")).collect()
}
fn stale_labels(rows: &[(RunId, NodeId)]) -> Vec<String> {
owner_labels(rows)
}
fn ambiguous_owner(worktree: &Path, mut claims: Vec<String>) -> CliError {
claims.sort();
CliError::user(
"run_owner_ambiguous",
format!(
"multiple orchestratectl nodes claim current worktree {}; refusing to choose an owning run",
worktree.display()
),
)
.with_expected(serde_json::Value::Array(
claims.into_iter().map(serde_json::Value::String).collect(),
))
}