use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use ito_config::types::{CoordinationStorage, ItoConfig};
use ito_config::{ConfigContext, load_cascading_project_config};
use serde::{Deserialize, Serialize};
use crate::coordination::{
COORDINATION_DIRS, check_coordination_health, format_health_message,
update_gitignore_for_symlinks, wire_coordination_symlinks,
};
use crate::coordination_worktree_helpers::{
commit_staged, empty_tree_hash, fnv1a_hash, has_staged_changes, render_output, stage_all,
};
use crate::errors::{CoreError, CoreResult};
use crate::git::{
CoordinationGitErrorKind, fetch_coordination_branch_with_runner,
push_coordination_branch_with_runner,
};
use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
use crate::repo_paths::coordination_worktree_path;
const ITO_SUBDIRS: &[&str] = COORDINATION_DIRS;
const SYNC_STATE_FILE_NAME: &str = "ito-sync-state.json";
pub fn auto_commit_coordination(worktree_path: &Path, message: &str) -> CoreResult<()> {
let runner = SystemProcessRunner;
auto_commit_coordination_with_runner(&runner, worktree_path, message)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoordinationSyncOutcome {
Embedded,
RateLimited,
Synchronized,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CoordinationSyncState {
head: String,
dirty: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct StoredCoordinationSyncState {
synced_at_epoch_seconds: u64,
head: String,
}
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 = deserialize_config(&cfg_value, "parse Ito configuration for auto-commit check")?;
let coord = &typed.changes.coordination_branch;
let CoordinationStorage::Worktree = coord.storage else {
return Ok(());
};
let worktree_path = resolved_coordination_worktree_path(project_root, ito_path, &typed, true)?;
if !worktree_path.is_dir() {
return Ok(());
}
auto_commit_coordination(&worktree_path, message)
}
pub fn sync_coordination_worktree(
project_root: &Path,
ito_path: &Path,
force: bool,
) -> CoreResult<CoordinationSyncOutcome> {
let runner = SystemProcessRunner;
sync_coordination_worktree_with_runner(&runner, project_root, ito_path, force)
}
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 repair_current_worktree_coordination_links(
project_root: &Path,
ito_path: &Path,
typed: &ItoConfig,
) -> CoreResult<()> {
let CoordinationStorage::Worktree = typed.changes.coordination_branch.storage else {
return Ok(());
};
let worktree_path = resolved_coordination_worktree_path(project_root, ito_path, typed, false)?;
if !worktree_path.is_dir() {
return Err(CoreError::process(format!(
"Coordination worktree not found at '{}'.\n\
Fix: run `ito init --update` from a project checkout with coordination worktrees enabled, or recreate the coordination worktree before retrying.",
worktree_path.display()
)));
}
std::fs::create_dir_all(ito_path).map_err(|err| {
CoreError::io(
format!(
"Cannot create Ito directory '{}' before wiring coordination links.\n\
Fix: ensure the worktree path is writable.",
ito_path.display()
),
err,
)
})?;
let worktree_ito_path = worktree_path.join(".ito");
wire_coordination_symlinks(ito_path, &worktree_ito_path)
}
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 sync_coordination_worktree_with_runner(
runner: &dyn ProcessRunner,
project_root: &Path,
ito_path: &Path,
force: bool,
) -> CoreResult<CoordinationSyncOutcome> {
let ctx = ConfigContext::from_process_env();
let cfg_value = load_cascading_project_config(project_root, ito_path, &ctx).merged;
let typed = deserialize_config(&cfg_value, "parse Ito configuration for sync")?;
let coord = &typed.changes.coordination_branch;
let CoordinationStorage::Worktree = coord.storage else {
return Ok(CoordinationSyncOutcome::Embedded);
};
let (should_fast_forward, local_only) =
match fetch_coordination_branch_with_runner(runner, project_root, &coord.name) {
Ok(()) => (true, false),
Err(err) if err.kind == CoordinationGitErrorKind::RemoteMissing => (false, false),
Err(err) if err.kind == CoordinationGitErrorKind::RemoteNotConfigured => (false, true),
Err(err) => {
return Err(CoreError::process(format!(
"coordination fetch failed: {}",
err.message
)));
}
};
let worktree_path = resolved_coordination_worktree_path(project_root, ito_path, &typed, false)?;
let worktree_ito_path = worktree_path.join(".ito");
if should_fast_forward {
fast_forward_coordination_with_runner(runner, &worktree_path, &coord.name)?;
}
wire_coordination_symlinks(ito_path, &worktree_ito_path)?;
let status = check_coordination_health(ito_path, &worktree_ito_path, &coord.storage);
if let Some(message) = format_health_message(&status) {
return Err(CoreError::process(message));
}
if local_only {
return Ok(CoordinationSyncOutcome::RateLimited);
}
let current_state = coordination_sync_state_with_runner(runner, &worktree_path)?;
let git_common_dir = git_common_dir_with_runner(runner, project_root)?;
let state_file = git_common_dir.join(SYNC_STATE_FILE_NAME);
let last_sync = read_sync_state(&state_file)?;
if should_rate_limit(
force,
¤t_state,
last_sync.as_ref(),
coord.sync_interval_seconds,
) {
return Ok(CoordinationSyncOutcome::RateLimited);
}
auto_commit_coordination_with_runner(
runner,
&worktree_path,
"chore: sync coordination worktree",
)?;
let synced_head = if current_state.dirty {
current_head_with_runner(runner, &worktree_path)?
} else {
current_state.head
};
push_coordination_branch_with_runner(runner, &worktree_path, "HEAD", &coord.name)
.map_err(|err| CoreError::process(format!("coordination push failed: {}", err.message)))?;
write_sync_state(
&state_file,
&StoredCoordinationSyncState {
synced_at_epoch_seconds: now_epoch_seconds(),
head: synced_head,
},
)?;
Ok(CoordinationSyncOutcome::Synchronized)
}
fn deserialize_config(cfg_value: &serde_json::Value, context: &str) -> CoreResult<ItoConfig> {
serde_json::from_value(cfg_value.clone())
.map_err(|e| CoreError::serde(context.to_string(), e.to_string()))
}
pub(crate) fn resolved_coordination_worktree_path(
project_root: &Path,
ito_path: &Path,
typed: &ItoConfig,
allow_local_fallback: bool,
) -> CoreResult<PathBuf> {
let coord = &typed.changes.coordination_branch;
if coord
.worktree_path
.as_deref()
.filter(|s| !s.is_empty())
.is_some()
{
return Ok(coordination_worktree_path(coord, ito_path, "", ""));
}
let Some((org, repo)) =
crate::git_remote::resolve_org_repo_from_config_or_remote(project_root, &typed.backend)
else {
if !allow_local_fallback {
return Err(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.",
));
}
let hash = fnv1a_hash(project_root.to_string_lossy().as_bytes());
return Ok(coordination_worktree_path(
coord,
ito_path,
"_local",
&format!("{hash:016x}"),
));
};
Ok(coordination_worktree_path(coord, ito_path, &org, &repo))
}
fn coordination_sync_state_with_runner(
runner: &dyn ProcessRunner,
worktree_path: &Path,
) -> CoreResult<CoordinationSyncState> {
let head = current_head_with_runner(runner, worktree_path)?;
let dirty = working_tree_dirty_with_runner(runner, worktree_path)?;
Ok(CoordinationSyncState { head, dirty })
}
fn current_head_with_runner(
runner: &dyn ProcessRunner,
worktree_path: &Path,
) -> CoreResult<String> {
let request =
ProcessRequest::new("git").args(["-C", path_arg(worktree_path)?, "rev-parse", "HEAD"]);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot resolve coordination worktree HEAD at '{}'.\nGit command failed to run: {err}\nFix: ensure '{}' is a git worktree and retry.",
worktree_path.display(),
worktree_path.display()
))
})?;
if !output.success {
return Err(CoreError::process(format!(
"Cannot resolve coordination worktree HEAD at '{}'.\nGit returned: {}\nFix: ensure '{}' is a valid git worktree and retry.",
worktree_path.display(),
render_output(&output),
worktree_path.display()
)));
}
Ok(output.stdout.trim().to_string())
}
fn working_tree_dirty_with_runner(
runner: &dyn ProcessRunner,
worktree_path: &Path,
) -> CoreResult<bool> {
let request =
ProcessRequest::new("git").args(["-C", path_arg(worktree_path)?, "status", "--porcelain"]);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot inspect coordination worktree status at '{}'.\nGit command failed to run: {err}\nFix: ensure '{}' is a git worktree and retry.",
worktree_path.display(),
worktree_path.display()
))
})?;
if !output.success {
return Err(CoreError::process(format!(
"Cannot inspect coordination worktree status at '{}'.\nGit returned: {}\nFix: ensure '{}' is a valid git worktree and retry.",
worktree_path.display(),
render_output(&output),
worktree_path.display()
)));
}
Ok(!output.stdout.trim().is_empty())
}
fn git_common_dir_with_runner(
runner: &dyn ProcessRunner,
project_root: &Path,
) -> CoreResult<std::path::PathBuf> {
let request = ProcessRequest::new("git")
.args(["rev-parse", "--git-common-dir"])
.current_dir(project_root);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot resolve shared git metadata for '{}'.\nGit command failed to run: {err}\nFix: ensure '{}' is inside a git worktree and retry.",
project_root.display(),
project_root.display()
))
})?;
if !output.success {
return Err(CoreError::process(format!(
"Cannot resolve shared git metadata for '{}'.\nGit returned: {}\nFix: ensure '{}' is inside a git worktree and retry.",
project_root.display(),
render_output(&output),
project_root.display()
)));
}
let path = std::path::PathBuf::from(output.stdout.trim());
if path.is_absolute() {
return Ok(path);
}
Ok(project_root.join(path))
}
fn read_sync_state(path: &Path) -> CoreResult<Option<StoredCoordinationSyncState>> {
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(CoreError::io(
format!(
"cannot read coordination sync state '{}': ensure the git metadata directory is readable",
path.display()
),
err,
));
}
};
let state = serde_json::from_str(&content).map_err(|err| {
CoreError::serde(
format!(
"parse coordination sync state '{}': invalid JSON",
path.display()
),
err.to_string(),
)
})?;
Ok(Some(state))
}
fn write_sync_state(path: &Path, state: &StoredCoordinationSyncState) -> CoreResult<()> {
let Some(parent) = path.parent() else {
return Err(CoreError::process(format!(
"Cannot write coordination sync state '{}'.\nThe target path has no parent directory.\nFix: choose a valid git metadata path and retry.",
path.display()
)));
};
fs::create_dir_all(parent).map_err(|err| {
CoreError::io(
format!(
"cannot create coordination sync state directory '{}': ensure it is writable",
parent.display()
),
err,
)
})?;
let json = serde_json::to_string(state).map_err(|err| {
CoreError::serde(
format!(
"serialize coordination sync state '{}': invalid state",
path.display()
),
err.to_string(),
)
})?;
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let mut tmp_name = path
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new(SYNC_STATE_FILE_NAME))
.to_os_string();
tmp_name.push(format!(".{pid}.{nanos}.tmp"));
let tmp_path = parent.join(tmp_name);
fs::write(&tmp_path, &json).map_err(|err| {
CoreError::io(
format!(
"cannot write coordination sync state temp file '{}': ensure the git metadata directory is writable",
tmp_path.display()
),
err,
)
})?;
fs::rename(&tmp_path, path).map_err(|err| {
CoreError::io(
format!(
"cannot rename coordination sync state '{}' → '{}': ensure the directory is writable",
tmp_path.display(),
path.display()
),
err,
)
})
}
fn sync_state_is_recent(state: &StoredCoordinationSyncState, interval_seconds: u64) -> bool {
now_epoch_seconds().saturating_sub(state.synced_at_epoch_seconds) < interval_seconds
}
fn should_rate_limit(
force: bool,
current_state: &CoordinationSyncState,
last_sync: Option<&StoredCoordinationSyncState>,
interval_seconds: u64,
) -> bool {
if force || current_state.dirty {
return false;
}
let Some(last_sync) = last_sync else {
return false;
};
last_sync.head == current_state.head && sync_state_is_recent(last_sync, interval_seconds)
}
fn now_epoch_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn fast_forward_coordination_with_runner(
runner: &dyn ProcessRunner,
worktree_path: &Path,
branch_name: &str,
) -> CoreResult<()> {
let wt_str = path_arg(worktree_path)?;
let remote_ref = format!("origin/{branch_name}");
let request =
ProcessRequest::new("git").args(["-C", wt_str, "merge", "--ff-only", &remote_ref]);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot fast-forward coordination branch in '{}'.\n\
Git command failed: {err}\n\
Fix: ensure the worktree is a valid git checkout.",
worktree_path.display(),
))
})?;
if !output.success {
let detail = output.stderr.trim();
return Err(CoreError::process(format!(
"Fast-forward merge of '{remote_ref}' failed in '{}'.\n\
Git reported: {detail}\n\
Resolve the coordination branch divergence before migrating or pushing state.",
worktree_path.display(),
)));
}
Ok(())
}
fn path_arg(path: &Path) -> CoreResult<&str> {
path.to_str().ok_or_else(|| {
CoreError::process(format!(
"Path '{}' contains non-UTF-8 characters and cannot be used in git commands.",
path.display()
))
})
}
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),
)))
}
#[cfg(test)]
#[path = "coordination_worktree_migration_tests.rs"]
mod coordination_worktree_migration_tests;
#[cfg(test)]
#[path = "coordination_worktree_tests.rs"]
mod coordination_worktree_tests;