use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::errors::{CoreError, CoreResult};
use crate::process::{ProcessOutput, ProcessRequest, ProcessRunner, SystemProcessRunner};
use ito_domain::tasks::tasks_path_checked;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoordinationGitErrorKind {
NonFastForward,
ProtectedBranch,
RemoteRejected,
RemoteMissing,
RemoteNotConfigured,
CommandFailed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoordinationGitError {
pub kind: CoordinationGitErrorKind,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoordinationBranchSetupStatus {
Ready,
Created,
}
impl CoordinationGitError {
fn new(kind: CoordinationGitErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
pub fn fetch_coordination_branch(
repo_root: &Path,
branch: &str,
) -> Result<(), CoordinationGitError> {
let runner = SystemProcessRunner;
fetch_coordination_branch_with_runner(&runner, repo_root, branch)
}
pub fn push_coordination_branch(
repo_root: &Path,
local_ref: &str,
branch: &str,
) -> Result<(), CoordinationGitError> {
let runner = SystemProcessRunner;
push_coordination_branch_with_runner(&runner, repo_root, local_ref, branch)
}
pub fn reserve_change_on_coordination_branch(
repo_root: &Path,
ito_path: &Path,
change_id: &str,
branch: &str,
) -> Result<(), CoordinationGitError> {
let runner = SystemProcessRunner;
reserve_change_on_coordination_branch_with_runner(
&runner, repo_root, ito_path, change_id, branch,
)
}
pub fn ensure_coordination_branch_on_origin(
repo_root: &Path,
branch: &str,
) -> Result<CoordinationBranchSetupStatus, CoordinationGitError> {
let runner = SystemProcessRunner;
ensure_coordination_branch_on_origin_with_runner(&runner, repo_root, branch)
}
pub fn fetch_coordination_branch_core(repo_root: &Path, branch: &str) -> CoreResult<()> {
fetch_coordination_branch(repo_root, branch)
.map_err(|err| CoreError::process(format!("coordination fetch failed: {}", err.message)))
}
pub fn push_coordination_branch_core(
repo_root: &Path,
local_ref: &str,
branch: &str,
) -> CoreResult<()> {
push_coordination_branch(repo_root, local_ref, branch)
.map_err(|err| CoreError::process(format!("coordination push failed: {}", err.message)))
}
pub fn reserve_change_on_coordination_branch_core(
repo_root: &Path,
ito_path: &Path,
change_id: &str,
branch: &str,
) -> CoreResult<()> {
reserve_change_on_coordination_branch(repo_root, ito_path, change_id, branch).map_err(|err| {
CoreError::process(format!("coordination reservation failed: {}", err.message))
})
}
pub fn ensure_coordination_branch_on_origin_core(
repo_root: &Path,
branch: &str,
) -> CoreResult<CoordinationBranchSetupStatus> {
ensure_coordination_branch_on_origin(repo_root, branch)
.map_err(|err| CoreError::process(format!("coordination setup failed: {}", err.message)))
}
pub(crate) fn ensure_coordination_branch_on_origin_with_runner(
runner: &dyn ProcessRunner,
repo_root: &Path,
branch: &str,
) -> Result<CoordinationBranchSetupStatus, CoordinationGitError> {
if !is_git_worktree(runner, repo_root) {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
"cannot set up coordination branch outside a git worktree",
));
}
match fetch_coordination_branch_with_runner(runner, repo_root, branch) {
Ok(()) => Ok(CoordinationBranchSetupStatus::Ready),
Err(err) => {
if err.kind != CoordinationGitErrorKind::RemoteMissing {
return Err(err);
}
create_empty_coordination_branch_on_origin(runner, repo_root, branch)
.map(|()| CoordinationBranchSetupStatus::Created)
}
}
}
fn create_empty_coordination_branch_on_origin(
runner: &dyn ProcessRunner,
repo_root: &Path,
branch: &str,
) -> Result<(), CoordinationGitError> {
let empty_tree = create_empty_tree(runner, repo_root, branch)?;
let commit = run_git(
runner,
ProcessRequest::new("git")
.args([
"commit-tree",
empty_tree.as_str(),
"-m",
"Initialize coordination branch",
])
.current_dir(repo_root),
"commit-tree",
)?;
if !commit.success {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!(
"failed to create empty coordination branch commit for '{branch}' ({})",
render_output(&commit)
),
));
}
let commit = last_non_empty_line(&commit.stdout).ok_or_else(|| {
CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("commit-tree for '{branch}' produced no commit hash"),
)
})?;
push_coordination_branch_with_runner(runner, repo_root, commit, branch)
}
fn create_empty_tree(
runner: &dyn ProcessRunner,
repo_root: &Path,
branch: &str,
) -> Result<String, CoordinationGitError> {
let empty_tree = run_git(
runner,
ProcessRequest::new("git")
.args(["mktree"])
.current_dir(repo_root),
"mktree",
)?;
if !empty_tree.success {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!(
"failed to create empty tree for coordination branch '{branch}' ({})",
render_output(&empty_tree)
),
));
}
last_non_empty_line(&empty_tree.stdout)
.map(ToOwned::to_owned)
.ok_or_else(|| {
CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("mktree for '{branch}' produced no tree hash"),
)
})
}
fn last_non_empty_line(output: &str) -> Option<&str> {
output
.lines()
.rev()
.map(str::trim)
.find(|line| !line.is_empty())
}
pub(crate) fn fetch_coordination_branch_with_runner(
runner: &dyn ProcessRunner,
repo_root: &Path,
branch: &str,
) -> Result<(), CoordinationGitError> {
validate_coordination_branch_name(branch)?;
let request = ProcessRequest::new("git")
.args(["fetch", "origin", branch])
.current_dir(repo_root);
let output = run_git(runner, request, "fetch")?;
if output.success {
return Ok(());
}
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 Err(CoordinationGitError::new(
CoordinationGitErrorKind::RemoteMissing,
format!("remote branch '{branch}' does not exist ({detail})"),
));
}
if detail_lower.contains("no such remote")
|| detail_lower.contains("does not appear to be a git repository")
{
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::RemoteNotConfigured,
format!("git remote 'origin' is not configured ({detail})"),
));
}
Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("git fetch origin {branch} failed ({detail})"),
))
}
pub(crate) fn push_coordination_branch_with_runner(
runner: &dyn ProcessRunner,
repo_root: &Path,
local_ref: &str,
branch: &str,
) -> Result<(), CoordinationGitError> {
validate_coordination_branch_name(branch)?;
let refspec = format!("{local_ref}:refs/heads/{branch}");
let request = ProcessRequest::new("git")
.args(["push", "origin", &refspec])
.current_dir(repo_root);
let output = run_git(runner, request, "push")?;
if output.success {
return Ok(());
}
let detail = render_output(&output);
let detail_lower = detail.to_ascii_lowercase();
if detail_lower.contains("non-fast-forward") {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::NonFastForward,
format!(
"push to '{branch}' was rejected because remote is ahead; sync and retry ({detail})"
),
));
}
if detail_lower.contains("protected branch")
|| detail_lower.contains("protected branch hook declined")
{
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::ProtectedBranch,
format!("push to '{branch}' blocked by branch protection ({detail})"),
));
}
if detail_lower.contains("[rejected]") || detail_lower.contains("remote rejected") {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::RemoteRejected,
format!("push to '{branch}' was rejected by remote ({detail})"),
));
}
if detail_lower.contains("no such remote")
|| detail_lower.contains("does not appear to be a git repository")
{
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::RemoteNotConfigured,
format!("git remote 'origin' is not configured ({detail})"),
));
}
Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("git push for '{branch}' failed ({detail})"),
))
}
pub(crate) fn reserve_change_on_coordination_branch_with_runner(
runner: &dyn ProcessRunner,
repo_root: &Path,
ito_path: &Path,
change_id: &str,
branch: &str,
) -> Result<(), CoordinationGitError> {
if !is_git_worktree(runner, repo_root) {
return Ok(());
}
validate_coordination_branch_name(branch)?;
let Some(tasks_path) = tasks_path_checked(ito_path, change_id) else {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("invalid change id path segment: '{change_id}'"),
));
};
let Some(source_change_dir) = tasks_path.parent() else {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!(
"failed to derive change directory from '{}'",
tasks_path.display()
),
));
};
if !source_change_dir.exists() {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!(
"change directory '{}' does not exist",
source_change_dir.display()
),
));
}
let worktree_path = unique_temp_worktree_path();
ensure_coordination_branch_on_origin_with_runner(runner, repo_root, branch)?;
run_git(
runner,
ProcessRequest::new("git")
.args([
"worktree",
"add",
"--detach",
worktree_path.to_string_lossy().as_ref(),
])
.current_dir(repo_root),
"worktree add",
)?;
let cleanup = WorktreeCleanup {
repo_root: repo_root.to_path_buf(),
worktree_path: worktree_path.clone(),
};
fetch_coordination_branch_with_runner(runner, repo_root, branch)?;
let checkout_target = format!("origin/{branch}");
let checkout = run_git(
runner,
ProcessRequest::new("git")
.args(["checkout", "--detach", &checkout_target])
.current_dir(&worktree_path),
"checkout coordination branch",
)?;
if !checkout.success {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!(
"failed to checkout coordination branch '{branch}' ({})",
render_output(&checkout),
),
));
}
let target_change_dir = worktree_path.join(".ito").join("changes").join(change_id);
if target_change_dir.exists() {
fs::remove_dir_all(&target_change_dir).map_err(|err| {
CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!(
"failed to replace existing reserved change '{}' ({err})",
target_change_dir.display()
),
)
})?;
}
copy_dir_recursive(source_change_dir, &target_change_dir).map_err(|err| {
CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("failed to copy change into reservation worktree: {err}"),
)
})?;
let relative_change_path = format!(".ito/changes/{change_id}");
let add = run_git(
runner,
ProcessRequest::new("git")
.args(["add", &relative_change_path])
.current_dir(&worktree_path),
"add reserved change",
)?;
if !add.success {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("failed to stage reserved change ({})", render_output(&add)),
));
}
let staged = run_git(
runner,
ProcessRequest::new("git")
.args(["diff", "--cached", "--quiet", "--", &relative_change_path])
.current_dir(&worktree_path),
"check staged changes",
)?;
if staged.success {
if let Err(err) = cleanup.cleanup_with_runner(runner) {
eprintln!(
"Warning: failed to remove temporary coordination worktree '{}': {}",
cleanup.worktree_path.display(),
err.message
);
}
drop(cleanup);
return Ok(());
}
if staged.exit_code != 1 {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!(
"failed to inspect staged reservation changes ({})",
render_output(&staged)
),
));
}
let commit_message = format!("chore(coordination): reserve {change_id}");
let commit = run_git(
runner,
ProcessRequest::new("git")
.args(["commit", "-m", &commit_message])
.current_dir(&worktree_path),
"commit reserved change",
)?;
if !commit.success {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!(
"failed to commit reserved change ({})",
render_output(&commit)
),
));
}
let push = push_coordination_branch_with_runner(runner, &worktree_path, "HEAD", branch);
if let Err(err) = cleanup.cleanup_with_runner(runner) {
eprintln!(
"Warning: failed to remove temporary coordination worktree '{}': {}",
cleanup.worktree_path.display(),
err.message
);
}
drop(cleanup);
push
}
fn run_git(
runner: &dyn ProcessRunner,
request: ProcessRequest,
operation: &str,
) -> Result<ProcessOutput, CoordinationGitError> {
runner.run(&request).map_err(|err| {
CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("git {operation} command failed to run: {err}"),
)
})
}
fn render_output(output: &ProcessOutput) -> String {
let stdout = output.stdout.trim();
let stderr = output.stderr.trim();
if !stderr.is_empty() {
return stderr.to_string();
}
if !stdout.is_empty() {
return stdout.to_string();
}
"no command output".to_string()
}
fn copy_dir_recursive(source: &Path, target: &Path) -> std::io::Result<()> {
fs::create_dir_all(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let metadata = fs::symlink_metadata(&source_path)?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
eprintln!(
"Warning: skipped symlink while reserving coordination change: {}",
source_path.display()
);
continue;
}
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
continue;
}
if file_type.is_file() {
fs::copy(&source_path, &target_path)?;
}
}
Ok(())
}
fn is_git_worktree(runner: &dyn ProcessRunner, repo_root: &Path) -> bool {
let request = ProcessRequest::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(repo_root);
let Ok(output) = runner.run(&request) else {
return false;
};
output.success && output.stdout.trim() == "true"
}
fn unique_temp_worktree_path() -> std::path::PathBuf {
let pid = std::process::id();
let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_nanos(),
Err(_) => 0,
};
std::env::temp_dir().join(format!("ito-coordination-{pid}-{nanos}"))
}
fn validate_coordination_branch_name(branch: &str) -> Result<(), CoordinationGitError> {
if branch.is_empty()
|| branch.starts_with('-')
|| branch.starts_with('/')
|| branch.ends_with('/')
{
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("invalid coordination branch name '{branch}'"),
));
}
if branch.contains("..")
|| branch.contains("@{")
|| branch.contains("//")
|| branch.ends_with('.')
|| branch.ends_with(".lock")
{
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("invalid coordination branch name '{branch}'"),
));
}
for ch in branch.chars() {
if ch.is_ascii_control() || ch == ' ' {
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("invalid coordination branch name '{branch}'"),
));
}
if ch == '~' || ch == '^' || ch == ':' || ch == '?' || ch == '*' || ch == '[' || ch == '\\'
{
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("invalid coordination branch name '{branch}'"),
));
}
}
for segment in branch.split('/') {
if segment.is_empty()
|| segment.starts_with('.')
|| segment.ends_with('.')
|| segment.ends_with(".lock")
{
return Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!("invalid coordination branch name '{branch}'"),
));
}
}
Ok(())
}
struct WorktreeCleanup {
repo_root: std::path::PathBuf,
worktree_path: std::path::PathBuf,
}
impl WorktreeCleanup {
fn cleanup_with_runner(&self, runner: &dyn ProcessRunner) -> Result<(), CoordinationGitError> {
let output = run_git(
runner,
ProcessRequest::new("git")
.args([
"worktree",
"remove",
"--force",
self.worktree_path.to_string_lossy().as_ref(),
])
.current_dir(&self.repo_root),
"worktree remove",
)?;
if output.success {
return Ok(());
}
Err(CoordinationGitError::new(
CoordinationGitErrorKind::CommandFailed,
format!(
"failed to remove temporary worktree '{}' ({})",
self.worktree_path.display(),
render_output(&output)
),
))
}
}
impl Drop for WorktreeCleanup {
fn drop(&mut self) {
let _ = std::process::Command::new("git")
.args([
"worktree",
"remove",
"--force",
self.worktree_path.to_string_lossy().as_ref(),
])
.current_dir(&self.repo_root)
.output();
}
}
#[cfg(test)]
#[path = "git_tests.rs"]
mod git_tests;