use std::fs;
use std::path::Path;
use ito_config::types::{CoordinationStorage, ItoConfig};
use ito_config::{ConfigContext, load_cascading_project_config};
use sha2::{Digest, Sha256};
use crate::coordination::{update_gitignore_for_symlinks, wire_coordination_symlinks};
use crate::errors::{CoreError, CoreResult};
use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
use crate::repo_paths::coordination_worktree_path;
const ITO_SUBDIRS: &[&str] = &["changes", "specs", "modules", "workflows", "audit"];
pub fn auto_commit_coordination(worktree_path: &Path, message: &str) -> CoreResult<()> {
let runner = SystemProcessRunner;
auto_commit_coordination_with_runner(&runner, worktree_path, message)
}
pub fn create_coordination_worktree(
project_root: &Path,
branch_name: &str,
target_path: &Path,
) -> CoreResult<()> {
let runner = SystemProcessRunner;
create_coordination_worktree_with_runner(&runner, project_root, branch_name, target_path)
}
pub fn maybe_auto_commit_coordination(
project_root: &Path,
ito_path: &Path,
message: &str,
) -> CoreResult<()> {
let ctx = ConfigContext::from_process_env();
let cfg_value = load_cascading_project_config(project_root, ito_path, &ctx).merged;
let typed: ito_config::types::ItoConfig = serde_json::from_value(cfg_value).map_err(|e| {
CoreError::serde(
"parse Ito configuration for auto-commit check",
e.to_string(),
)
})?;
let coord = &typed.changes.coordination_branch;
let CoordinationStorage::Worktree = coord.storage else {
return Ok(());
};
let (org, repo) = match crate::git_remote::resolve_org_repo_from_config_or_remote(
project_root,
&typed.backend,
) {
Some(pair) => pair,
None => {
let hash = fnv1a_hash(project_root.to_string_lossy().as_bytes());
("_local".to_string(), format!("{hash:016x}"))
}
};
let worktree_path = coordination_worktree_path(coord, ito_path, &org, &repo);
if !worktree_path.is_dir() {
return Ok(());
}
auto_commit_coordination(&worktree_path, message)
}
pub fn remove_coordination_worktree(project_root: &Path, target_path: &Path) -> CoreResult<()> {
let runner = SystemProcessRunner;
remove_coordination_worktree_with_runner(&runner, project_root, target_path)
}
pub fn provision_coordination_worktree(
project_root: &Path,
ito_path: &Path,
skip: bool,
) -> CoreResult<Option<CoordinationStorage>> {
if skip {
return Ok(Some(CoordinationStorage::Embedded));
}
let ctx = ConfigContext::from_process_env();
let cfg_value = load_cascading_project_config(project_root, ito_path, &ctx).merged;
let typed: ItoConfig = serde_json::from_value(cfg_value).map_err(|e| {
CoreError::serde(
"parse Ito configuration for coordination worktree setup",
e.to_string(),
)
})?;
if typed.backend.enabled {
return Ok(None);
}
let coord_config = typed.changes.coordination_branch;
let worktree_path = if coord_config
.worktree_path
.as_deref()
.filter(|s| !s.is_empty())
.is_some()
{
coordination_worktree_path(&coord_config, ito_path, "", "")
} else {
let (org, repo) =
crate::git_remote::resolve_org_repo_from_config_or_remote(project_root, &typed.backend)
.ok_or_else(|| {
CoreError::process(
"Cannot resolve org/repo for coordination worktree.\n\
Neither `backend.project.org`/`backend.project.repo` are set in \
.ito/config.json, nor does the repository have a parseable `origin` \
remote URL.\n\
Fix: add an 'origin' remote (`git remote add origin <url>`) or set \
`backend.project.org` and `backend.project.repo` in .ito/config.json.",
)
})?;
coordination_worktree_path(&coord_config, ito_path, &org, &repo)
};
if !worktree_path.exists() {
let branch_name = &coord_config.name;
create_coordination_worktree(project_root, branch_name, &worktree_path)?;
}
let worktree_ito_path = worktree_path.join(".ito");
wire_coordination_symlinks(ito_path, &worktree_ito_path)?;
let _ = update_gitignore_for_symlinks(project_root);
Ok(Some(CoordinationStorage::Worktree))
}
pub(crate) fn auto_commit_coordination_with_runner(
runner: &dyn ProcessRunner,
worktree_path: &Path,
message: &str,
) -> CoreResult<()> {
stage_all(runner, worktree_path)?;
let has_changes = has_staged_changes(runner, worktree_path)?;
if !has_changes {
return Ok(());
}
commit_staged(runner, worktree_path, message)?;
Ok(())
}
pub(crate) fn create_coordination_worktree_with_runner(
runner: &dyn ProcessRunner,
project_root: &Path,
branch_name: &str,
target_path: &Path,
) -> CoreResult<()> {
let branch_exists_locally = local_branch_exists(runner, project_root, branch_name)?;
if !branch_exists_locally {
let fetched = fetch_branch_from_origin(runner, project_root, branch_name)?;
if !fetched {
create_orphan_branch(runner, project_root, branch_name)?;
}
}
add_worktree(runner, project_root, branch_name, target_path)?;
ensure_ito_dirs(target_path)?;
Ok(())
}
pub(crate) fn remove_coordination_worktree_with_runner(
runner: &dyn ProcessRunner,
project_root: &Path,
target_path: &Path,
) -> CoreResult<()> {
remove_worktree(runner, project_root, target_path)?;
prune_worktrees(runner, project_root)?;
Ok(())
}
fn local_branch_exists(
runner: &dyn ProcessRunner,
project_root: &Path,
branch_name: &str,
) -> CoreResult<bool> {
let request = ProcessRequest::new("git")
.args(["rev-parse", "--verify", branch_name])
.current_dir(project_root);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot check whether branch '{branch_name}' exists locally.\n\
Git command failed to run: {err}\n\
Fix: ensure git is installed and '{project_root}' is a git repository.",
project_root = project_root.display(),
))
})?;
Ok(output.success)
}
fn fetch_branch_from_origin(
runner: &dyn ProcessRunner,
project_root: &Path,
branch_name: &str,
) -> CoreResult<bool> {
let request = ProcessRequest::new("git")
.args(["fetch", "origin", branch_name])
.current_dir(project_root);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot fetch branch '{branch_name}' from origin.\n\
Git command failed to run: {err}\n\
Fix: ensure git is installed and the remote 'origin' is reachable.",
))
})?;
if output.success {
return Ok(true);
}
let detail = render_output(&output);
let detail_lower = detail.to_ascii_lowercase();
if detail_lower.contains("couldn't find remote ref")
|| detail_lower.contains("remote ref does not exist")
{
return Ok(false);
}
if detail_lower.contains("no such remote")
|| detail_lower.contains("does not appear to be a git repository")
{
return Ok(false);
}
Err(CoreError::process(format!(
"Cannot fetch branch '{branch_name}' from origin.\n\
Git reported: {detail}\n\
Fix: check that the remote 'origin' is configured and reachable \
(`git remote -v`).",
)))
}
fn create_orphan_branch(
runner: &dyn ProcessRunner,
project_root: &Path,
branch_name: &str,
) -> CoreResult<()> {
let safe_name = branch_name.replace('/', "-").replace(' ', "_");
let tmp_wt = project_root.join(format!(".git-orphan-tmp-{safe_name}"));
let tmp_str = tmp_wt.to_string_lossy();
let worktree_orphan = runner
.run(
&ProcessRequest::new("git")
.args(["worktree", "add", "--orphan", branch_name, tmp_str.as_ref()])
.current_dir(project_root),
)
.map_err(|err| {
CoreError::process(format!(
"Cannot create orphan branch '{branch_name}' in '{project_root}'.\n\
Git command failed to run: {err}\n\
Fix: ensure git is installed and '{project_root}' is a git repository.",
project_root = project_root.display(),
))
})?;
if worktree_orphan.success {
let _ = runner.run(
&ProcessRequest::new("git")
.args(["worktree", "remove", "--force", tmp_str.as_ref()])
.current_dir(project_root),
);
let _ = runner.run(
&ProcessRequest::new("git")
.args(["worktree", "prune"])
.current_dir(project_root),
);
return Ok(());
}
let worktree_orphan_detail = render_output(&worktree_orphan);
let empty_tree = empty_tree_hash(runner, project_root)?;
let commit_tree = runner
.run(
&ProcessRequest::new("git")
.args([
"commit-tree",
empty_tree.as_str(),
"-m",
"Initialize coordination branch",
])
.current_dir(project_root),
)
.map_err(|err| {
CoreError::process(format!(
"Cannot create initial commit for orphan branch '{branch_name}'.\n\
Git command failed to run: {err}",
))
})?;
if !commit_tree.success {
return Err(CoreError::process(format!(
"Cannot create orphan branch '{branch_name}' in '{project_root}'.\n\
`git worktree add --orphan` reported: {worktree_orphan_detail}\n\
`git commit-tree` reported: {}\n\
Fix: upgrade git to ≥ 2.36, or ensure git user.name and user.email are \
configured (`git config --global user.email \"you@example.com\"`).",
render_output(&commit_tree),
project_root = project_root.display(),
)));
}
let commit_hash = commit_tree.stdout.trim().to_string();
let branch_create = runner
.run(
&ProcessRequest::new("git")
.args(["branch", branch_name, &commit_hash])
.current_dir(project_root),
)
.map_err(|err| {
CoreError::process(format!(
"Cannot create branch '{branch_name}' pointing at initial commit.\n\
Git command failed to run: {err}",
))
})?;
if !branch_create.success {
return Err(CoreError::process(format!(
"Cannot create branch '{branch_name}' pointing at initial commit '{commit_hash}'.\n\
Git reported: {}\n\
Fix: ensure the branch name is valid and does not already exist \
(`git branch -l '{branch_name}'`).",
render_output(&branch_create),
)));
}
Ok(())
}
fn add_worktree(
runner: &dyn ProcessRunner,
project_root: &Path,
branch_name: &str,
target_path: &Path,
) -> CoreResult<()> {
let target_str = target_path.to_string_lossy();
let request = ProcessRequest::new("git")
.args(["worktree", "add", target_str.as_ref(), branch_name])
.current_dir(project_root);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot add worktree at '{target}' for branch '{branch_name}'.\n\
Git command failed to run: {err}\n\
Fix: ensure git is installed and the target path is writable.",
target = target_path.display(),
))
})?;
if output.success {
return Ok(());
}
Err(CoreError::process(format!(
"Cannot add worktree at '{target}' for branch '{branch_name}'.\n\
Git reported: {detail}\n\
Fix: check that '{target}' does not already exist and that the branch \
is not already checked out in another worktree.",
target = target_path.display(),
detail = render_output(&output),
)))
}
fn ensure_ito_dirs(worktree_root: &Path) -> CoreResult<()> {
let ito_root = worktree_root.join(".ito");
for subdir in ITO_SUBDIRS {
let dir = ito_root.join(subdir);
fs::create_dir_all(&dir).map_err(|err| {
CoreError::io(
format!(
"Cannot create .ito/{subdir} inside coordination worktree '{worktree}'.\n\
Fix: ensure the worktree path is writable.",
worktree = worktree_root.display(),
),
err,
)
})?;
}
Ok(())
}
fn remove_worktree(
runner: &dyn ProcessRunner,
project_root: &Path,
target_path: &Path,
) -> CoreResult<()> {
let target_str = target_path.to_string_lossy();
let clean = runner
.run(
&ProcessRequest::new("git")
.args(["worktree", "remove", target_str.as_ref()])
.current_dir(project_root),
)
.map_err(|err| {
CoreError::process(format!(
"Cannot remove coordination worktree at '{target}'.\n\
Git command failed to run: {err}\n\
Fix: run `git worktree remove {target}` manually.",
target = target_path.display(),
))
})?;
if clean.success {
return Ok(());
}
let forced = runner
.run(
&ProcessRequest::new("git")
.args(["worktree", "remove", "--force", target_str.as_ref()])
.current_dir(project_root),
)
.map_err(|err| {
CoreError::process(format!(
"Cannot force-remove coordination worktree at '{target}'.\n\
Git command failed to run: {err}\n\
Fix: run `git worktree remove --force {target}` manually.",
target = target_path.display(),
))
})?;
if forced.success {
return Ok(());
}
Err(CoreError::process(format!(
"Cannot remove coordination worktree at '{target}'.\n\
Git reported: {detail}\n\
Fix: run `git worktree remove --force {target}` manually, \
or delete the directory and run `git worktree prune`.",
target = target_path.display(),
detail = render_output(&forced),
)))
}
fn prune_worktrees(runner: &dyn ProcessRunner, project_root: &Path) -> CoreResult<()> {
let output = runner
.run(
&ProcessRequest::new("git")
.args(["worktree", "prune"])
.current_dir(project_root),
)
.map_err(|err| {
CoreError::process(format!(
"Cannot prune stale worktree metadata in '{project_root}'.\n\
Git command failed to run: {err}\n\
Fix: run `git worktree prune` manually.",
project_root = project_root.display(),
))
})?;
if output.success {
return Ok(());
}
Err(CoreError::process(format!(
"Cannot prune stale worktree metadata in '{project_root}'.\n\
Git reported: {detail}\n\
Fix: run `git worktree prune` manually.",
project_root = project_root.display(),
detail = render_output(&output),
)))
}
fn stage_all(runner: &dyn ProcessRunner, worktree_path: &Path) -> CoreResult<()> {
let worktree_str = worktree_path.to_string_lossy();
let request = ProcessRequest::new("git").args(["-C", worktree_str.as_ref(), "add", "-A"]);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot stage changes in coordination worktree '{worktree}'.\n\
Git command failed to run: {err}\n\
Fix: ensure git is installed and '{worktree}' is a git worktree.",
worktree = worktree_path.display(),
))
})?;
if output.success {
return Ok(());
}
Err(CoreError::process(format!(
"Cannot stage changes in coordination worktree '{worktree}'.\n\
Git reported: {detail}\n\
Fix: ensure '{worktree}' is a valid git worktree and the files are readable.",
worktree = worktree_path.display(),
detail = render_output(&output),
)))
}
fn has_staged_changes(runner: &dyn ProcessRunner, worktree_path: &Path) -> CoreResult<bool> {
let worktree_str = worktree_path.to_string_lossy();
let request = ProcessRequest::new("git").args([
"-C",
worktree_str.as_ref(),
"diff",
"--cached",
"--quiet",
]);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot check for staged changes in coordination worktree '{worktree}'.\n\
Git command failed to run: {err}\n\
Fix: ensure git is installed and '{worktree}' is a git worktree.",
worktree = worktree_path.display(),
))
})?;
match output.exit_code {
0 => Ok(false),
1 => Ok(true),
code => Err(CoreError::process(format!(
"Cannot check for staged changes in coordination worktree '{worktree}'.\n\
Git exited with unexpected code {code}: {detail}\n\
Fix: ensure '{worktree}' is a valid git worktree.",
worktree = worktree_path.display(),
detail = render_output(&output),
))),
}
}
fn commit_staged(
runner: &dyn ProcessRunner,
worktree_path: &Path,
message: &str,
) -> CoreResult<()> {
let worktree_str = worktree_path.to_string_lossy();
let request =
ProcessRequest::new("git").args(["-C", worktree_str.as_ref(), "commit", "-m", message]);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot commit staged changes in coordination worktree '{worktree}'.\n\
Git command failed to run: {err}\n\
Fix: ensure git is installed and '{worktree}' is a git worktree.",
worktree = worktree_path.display(),
))
})?;
if output.success {
return Ok(());
}
Err(CoreError::process(format!(
"Cannot commit staged changes in coordination worktree '{worktree}'.\n\
Git reported: {detail}\n\
Fix: ensure git user.name and user.email are configured \
(`git config --global user.email \"you@example.com\"`).",
worktree = worktree_path.display(),
detail = render_output(&output),
)))
}
fn fnv1a_hash(data: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &byte in data {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn render_output(output: &crate::process::ProcessOutput) -> String {
let stderr = output.stderr.trim();
let stdout = output.stdout.trim();
if !stderr.is_empty() {
return stderr.to_string();
}
if !stdout.is_empty() {
return stdout.to_string();
}
"no command output".to_string()
}
fn empty_tree_hash(runner: &dyn ProcessRunner, project_root: &Path) -> CoreResult<String> {
let object_format = repository_object_format(runner, project_root)?;
let hash = match object_format {
GitObjectFormat::Sha1 => "4b825dc642cb6eb9a060e54bf8d69288fbee4904".to_string(),
GitObjectFormat::Sha256 => hex::encode(Sha256::digest(b"tree 0\0")),
};
Ok(hash)
}
fn repository_object_format(
runner: &dyn ProcessRunner,
project_root: &Path,
) -> CoreResult<GitObjectFormat> {
let output = runner.run(
&ProcessRequest::new("git")
.args(["rev-parse", "--show-object-format"])
.current_dir(project_root),
);
let Ok(output) = output else {
return Ok(GitObjectFormat::Sha1);
};
if !output.success {
return Ok(GitObjectFormat::Sha1);
}
let format = output.stdout.trim();
let object_format = match format {
"sha256" => GitObjectFormat::Sha256,
_ => GitObjectFormat::Sha1,
};
Ok(object_format)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GitObjectFormat {
Sha1,
Sha256,
}
#[cfg(test)]
#[path = "coordination_worktree_tests.rs"]
mod coordination_worktree_tests;