Skip to main content

ito_core/
git.rs

1//! Git synchronization helpers for coordination workflows.
2
3use std::fs;
4use std::path::Path;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::errors::{CoreError, CoreResult};
8use crate::process::{ProcessOutput, ProcessRequest, ProcessRunner, SystemProcessRunner};
9use ito_domain::tasks::tasks_path_checked;
10
11/// Error category for coordination branch git operations.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CoordinationGitErrorKind {
14    /// Push was rejected because remote history moved ahead.
15    NonFastForward,
16    /// Push was rejected by branch protection.
17    ProtectedBranch,
18    /// Remote rejected the update for another reason.
19    RemoteRejected,
20    /// Requested branch does not exist on remote.
21    RemoteMissing,
22    /// Git remote is not configured/available.
23    RemoteNotConfigured,
24    /// Generic command failure.
25    CommandFailed,
26}
27
28/// Structured failure details for coordination branch operations.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CoordinationGitError {
31    /// Classified error kind.
32    pub kind: CoordinationGitErrorKind,
33    /// Human-readable error message.
34    pub message: String,
35}
36
37/// Outcome of a coordination branch setup attempt.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum CoordinationBranchSetupStatus {
40    /// Remote branch already existed and is reachable.
41    Ready,
42    /// Remote branch was created during setup.
43    Created,
44}
45
46impl CoordinationGitError {
47    /// Constructs a `CoordinationGitError` with the specified kind and human-readable message.
48    ///
49    /// The provided message is converted into a `String`.
50    ///
51    /// # Examples
52    ///
53    /// ```ignore
54    /// let _err = CoordinationGitError::new(
55    ///     CoordinationGitErrorKind::RemoteMissing,
56    ///     "origin/ref not found",
57    /// );
58    /// ```
59    fn new(kind: CoordinationGitErrorKind, message: impl Into<String>) -> Self {
60        Self {
61            kind,
62            message: message.into(),
63        }
64    }
65}
66
67/// Fetches a coordination branch from `origin` into remote-tracking refs.
68///
69/// Returns `Ok(())` on success. Returns a classified error when fetch fails.
70pub fn fetch_coordination_branch(
71    repo_root: &Path,
72    branch: &str,
73) -> Result<(), CoordinationGitError> {
74    let runner = SystemProcessRunner;
75    fetch_coordination_branch_with_runner(&runner, repo_root, branch)
76}
77
78/// Pushes the given local ref to the coordination branch on `origin`.
79///
80/// Returns `Ok(())` on success, `Err(CoordinationGitError)` classified by failure reason otherwise.
81///
82/// # Examples
83///
84/// ```
85/// use std::path::Path;
86/// use ito_core::git::push_coordination_branch;
87///
88/// let repo = Path::new(".");
89/// let _ = push_coordination_branch(repo, "HEAD", "coordination");
90/// ```
91pub fn push_coordination_branch(
92    repo_root: &Path,
93    local_ref: &str,
94    branch: &str,
95) -> Result<(), CoordinationGitError> {
96    let runner = SystemProcessRunner;
97    push_coordination_branch_with_runner(&runner, repo_root, local_ref, branch)
98}
99
100/// Reserves a newly created change on the coordination branch using an ephemeral worktree.
101///
102/// The reservation is written in a temporary worktree so the caller's current worktree and branch are not modified.
103/// Returns `Ok(())` on success or a `CoordinationGitError` describing the failure.
104///
105/// # Examples
106///
107/// ```no_run
108/// use std::path::Path;
109/// let repo = Path::new("/path/to/repo");
110/// let ito = Path::new(".ito");
111/// let change_id = "change-123";
112/// let branch = "coordination";
113/// ito_core::git::reserve_change_on_coordination_branch(repo, ito, change_id, branch).unwrap();
114/// ```
115pub fn reserve_change_on_coordination_branch(
116    repo_root: &Path,
117    ito_path: &Path,
118    change_id: &str,
119    branch: &str,
120) -> Result<(), CoordinationGitError> {
121    let runner = SystemProcessRunner;
122    reserve_change_on_coordination_branch_with_runner(
123        &runner, repo_root, ito_path, change_id, branch,
124    )
125}
126
127/// Ensures the coordination branch exists on `origin`.
128///
129/// If the branch is already present on the remote, this returns `CoordinationBranchSetupStatus::Ready`.
130/// If the branch is missing and is created from an empty tree, this returns `CoordinationBranchSetupStatus::Created`.
131///
132/// # Examples
133///
134/// ```no_run
135/// use std::path::Path;
136/// let status = ito_core::git::ensure_coordination_branch_on_origin(Path::new("."), "coordination-branch");
137/// match status {
138///     Ok(ito_core::git::CoordinationBranchSetupStatus::Ready) => println!("Branch already exists"),
139///     Ok(ito_core::git::CoordinationBranchSetupStatus::Created) => println!("Branch created on origin"),
140///     Err(e) => eprintln!("Failed to ensure branch: {:?}", e),
141/// }
142/// ```
143pub fn ensure_coordination_branch_on_origin(
144    repo_root: &Path,
145    branch: &str,
146) -> Result<CoordinationBranchSetupStatus, CoordinationGitError> {
147    let runner = SystemProcessRunner;
148    ensure_coordination_branch_on_origin_with_runner(&runner, repo_root, branch)
149}
150
151/// Fetches the coordination branch from the `origin` remote.
152///
153/// # Examples
154///
155/// ```no_run
156/// use ito_core::git::fetch_coordination_branch_core;
157///
158/// let res = fetch_coordination_branch_core(std::path::Path::new("."), "coordination");
159/// assert!(res.is_ok());
160/// ```
161pub fn fetch_coordination_branch_core(repo_root: &Path, branch: &str) -> CoreResult<()> {
162    fetch_coordination_branch(repo_root, branch)
163        .map_err(|err| CoreError::process(format!("coordination fetch failed: {}", err.message)))
164}
165
166/// Push a local ref to the coordination branch on origin, converting git-related failures into a CoreError prefixed with "coordination push failed:".
167///
168/// On success this returns `Ok(())`. On failure this returns a `CoreError` whose message begins with `coordination push failed:` followed by the underlying coordination git error message.
169///
170/// # Examples
171///
172/// ```
173/// use std::path::Path;
174///
175/// // Attempt to push the local ref "HEAD" to the coordination branch "refs/heads/coord".
176/// // In real usage, handle the CoreResult appropriately; here we simply call the function.
177/// let _ = ito_core::git::push_coordination_branch_core(Path::new("."), "HEAD", "coord");
178/// ```
179pub fn push_coordination_branch_core(
180    repo_root: &Path,
181    local_ref: &str,
182    branch: &str,
183) -> CoreResult<()> {
184    push_coordination_branch(repo_root, local_ref, branch)
185        .map_err(|err| CoreError::process(format!("coordination push failed: {}", err.message)))
186}
187
188/// Reserve change metadata on the coordination branch, translating coordination git failures into `CoreError`.
189///
190/// On success the reservation is pushed to the remote coordination branch; on failure the returned error is a
191/// `CoreError` describing the coordination failure.
192///
193/// # Examples
194///
195/// ```
196/// use std::path::Path;
197///
198/// let _ = ito_core::git::reserve_change_on_coordination_branch_core(
199///     Path::new("/path/to/repo"),
200///     Path::new(".ito"),
201///     "CHANGE-123",
202///     "coordination",
203/// );
204/// ```
205pub fn reserve_change_on_coordination_branch_core(
206    repo_root: &Path,
207    ito_path: &Path,
208    change_id: &str,
209    branch: &str,
210) -> CoreResult<()> {
211    reserve_change_on_coordination_branch(repo_root, ito_path, change_id, branch).map_err(|err| {
212        CoreError::process(format!("coordination reservation failed: {}", err.message))
213    })
214}
215
216/// Ensure the coordination branch exists on the remote origin and report whether it was already present or was created.
217///
218/// On failure, converts coordination git errors into a `CoreError` whose message is prefixed with `coordination setup failed: `.
219///
220/// # Returns
221///
222/// `Ok(CoordinationBranchSetupStatus)` when the branch is present or was created; `Err(CoreError)` when the operation fails.
223///
224/// # Examples
225///
226/// ```no_run
227/// use std::path::Path;
228/// use ito_core::errors::CoreError;
229///
230/// let status = ito_core::git::ensure_coordination_branch_on_origin_core(Path::new("."), "coordination/main")?;
231/// match status {
232///     ito_core::git::CoordinationBranchSetupStatus::Ready => println!("Branch exists on origin"),
233///     ito_core::git::CoordinationBranchSetupStatus::Created => println!("Branch was created on origin"),
234/// }
235/// # Ok::<(), CoreError>(())
236/// ```
237pub fn ensure_coordination_branch_on_origin_core(
238    repo_root: &Path,
239    branch: &str,
240) -> CoreResult<CoordinationBranchSetupStatus> {
241    ensure_coordination_branch_on_origin(repo_root, branch)
242        .map_err(|err| CoreError::process(format!("coordination setup failed: {}", err.message)))
243}
244
245/// Ensures the coordination branch exists on the remote `origin`, creating it from an empty tree if necessary.
246///
247/// Attempts to fetch `origin/<branch>`; if the fetch succeeds the function returns
248/// `CoordinationBranchSetupStatus::Ready`. If the fetch fails because the remote ref is
249/// missing, the function creates an empty root commit and pushes that commit to `origin` to create the branch and returns
250/// `CoordinationBranchSetupStatus::Created`. The function returns an `Err` if it is not
251/// invoked inside a git worktree or if the underlying fetch/push fail for other reasons.
252///
253/// # Examples
254///
255/// ```ignore
256/// // `runner` must implement `ProcessRunner`.
257/// use std::path::Path;
258/// let _ = ensure_coordination_branch_on_origin_with_runner(&runner, Path::new("/path/to/repo"), "coordination").unwrap();
259/// ```
260pub(crate) fn ensure_coordination_branch_on_origin_with_runner(
261    runner: &dyn ProcessRunner,
262    repo_root: &Path,
263    branch: &str,
264) -> Result<CoordinationBranchSetupStatus, CoordinationGitError> {
265    if !is_git_worktree(runner, repo_root) {
266        return Err(CoordinationGitError::new(
267            CoordinationGitErrorKind::CommandFailed,
268            "cannot set up coordination branch outside a git worktree",
269        ));
270    }
271
272    match fetch_coordination_branch_with_runner(runner, repo_root, branch) {
273        Ok(()) => Ok(CoordinationBranchSetupStatus::Ready),
274        Err(err) => {
275            if err.kind != CoordinationGitErrorKind::RemoteMissing {
276                return Err(err);
277            }
278
279            create_empty_coordination_branch_on_origin(runner, repo_root, branch)
280                .map(|()| CoordinationBranchSetupStatus::Created)
281        }
282    }
283}
284
285fn create_empty_coordination_branch_on_origin(
286    runner: &dyn ProcessRunner,
287    repo_root: &Path,
288    branch: &str,
289) -> Result<(), CoordinationGitError> {
290    let empty_tree = create_empty_tree(runner, repo_root, branch)?;
291    let commit = run_git(
292        runner,
293        ProcessRequest::new("git")
294            .args([
295                "commit-tree",
296                empty_tree.as_str(),
297                "-m",
298                "Initialize coordination branch",
299            ])
300            .current_dir(repo_root),
301        "commit-tree",
302    )?;
303    if !commit.success {
304        return Err(CoordinationGitError::new(
305            CoordinationGitErrorKind::CommandFailed,
306            format!(
307                "failed to create empty coordination branch commit for '{branch}' ({})",
308                render_output(&commit)
309            ),
310        ));
311    }
312
313    let commit = last_non_empty_line(&commit.stdout).ok_or_else(|| {
314        CoordinationGitError::new(
315            CoordinationGitErrorKind::CommandFailed,
316            format!("commit-tree for '{branch}' produced no commit hash"),
317        )
318    })?;
319    push_coordination_branch_with_runner(runner, repo_root, commit, branch)
320}
321
322fn create_empty_tree(
323    runner: &dyn ProcessRunner,
324    repo_root: &Path,
325    branch: &str,
326) -> Result<String, CoordinationGitError> {
327    let empty_tree = run_git(
328        runner,
329        ProcessRequest::new("git")
330            .args(["mktree"])
331            .current_dir(repo_root),
332        "mktree",
333    )?;
334    if !empty_tree.success {
335        return Err(CoordinationGitError::new(
336            CoordinationGitErrorKind::CommandFailed,
337            format!(
338                "failed to create empty tree for coordination branch '{branch}' ({})",
339                render_output(&empty_tree)
340            ),
341        ));
342    }
343
344    last_non_empty_line(&empty_tree.stdout)
345        .map(ToOwned::to_owned)
346        .ok_or_else(|| {
347            CoordinationGitError::new(
348                CoordinationGitErrorKind::CommandFailed,
349                format!("mktree for '{branch}' produced no tree hash"),
350            )
351        })
352}
353
354fn last_non_empty_line(output: &str) -> Option<&str> {
355    output
356        .lines()
357        .rev()
358        .map(str::trim)
359        .find(|line| !line.is_empty())
360}
361
362/// Fetches the coordination branch from `origin` into the repository's remote-tracking refs.
363///
364/// Returns a `CoordinationGitError` when the operation fails:
365/// - `RemoteMissing` if the remote branch does not exist on `origin`.
366/// - `RemoteNotConfigured` if the `origin` remote is not configured or unreachable.
367/// - `CommandFailed` for other failures; the error message includes git command output.
368///
369/// # Examples
370///
371/// ```ignore
372/// use std::path::Path;
373/// // `runner` should implement `ProcessRunner` (e.g., `SystemProcessRunner` in production).
374/// let runner = crate::tests::StubRunner::default(); // replace with a real runner in real usage
375/// let repo = Path::new("/path/to/repo");
376/// let _ = fetch_coordination_branch_with_runner(&runner, repo, "coordination");
377/// ```
378pub(crate) fn fetch_coordination_branch_with_runner(
379    runner: &dyn ProcessRunner,
380    repo_root: &Path,
381    branch: &str,
382) -> Result<(), CoordinationGitError> {
383    validate_coordination_branch_name(branch)?;
384
385    let request = ProcessRequest::new("git")
386        .args(["fetch", "origin", branch])
387        .current_dir(repo_root);
388    let output = run_git(runner, request, "fetch")?;
389    if output.success {
390        return Ok(());
391    }
392
393    let detail = render_output(&output);
394    let detail_lower = detail.to_ascii_lowercase();
395    if detail_lower.contains("couldn't find remote ref")
396        || detail_lower.contains("remote ref does not exist")
397    {
398        return Err(CoordinationGitError::new(
399            CoordinationGitErrorKind::RemoteMissing,
400            format!("remote branch '{branch}' does not exist ({detail})"),
401        ));
402    }
403    if detail_lower.contains("no such remote")
404        || detail_lower.contains("does not appear to be a git repository")
405    {
406        return Err(CoordinationGitError::new(
407            CoordinationGitErrorKind::RemoteNotConfigured,
408            format!("git remote 'origin' is not configured ({detail})"),
409        ));
410    }
411
412    Err(CoordinationGitError::new(
413        CoordinationGitErrorKind::CommandFailed,
414        format!("git fetch origin {branch} failed ({detail})"),
415    ))
416}
417
418/// Pushes a local ref to the coordination branch on the `origin` remote.
419///
420/// The `local_ref` is pushed to `refs/heads/<branch>` on the `origin` remote. The function
421/// validates the branch name before attempting the push and classifies failures into
422/// meaningful `CoordinationGitErrorKind` variants.
423///
424/// # Parameters
425///
426/// - `repo_root`: repository working directory where the git command is executed.
427/// - `local_ref`: source ref to push (for example `"HEAD"` or `"refs/heads/my-branch"`).
428/// - `branch`: target coordination branch name on `origin`.
429///
430/// # Returns
431///
432/// `Ok(())` if the push succeeded; `Err(CoordinationGitError)` on failure with a kind that
433/// indicates the failure reason (for example: non-fast-forward, protected branch, remote rejected,
434/// remote not configured, or a general command failure).
435///
436/// # Examples
437///
438/// ```no_run
439/// use std::path::Path;
440/// // `runner` should implement ProcessRunner; `repo_root` should point to a git repository.
441/// // push_coordination_branch_with_runner(&runner, Path::new("/path/to/repo"), "HEAD", "coordination")
442/// //     .expect("push should succeed");
443/// ```
444pub(crate) fn push_coordination_branch_with_runner(
445    runner: &dyn ProcessRunner,
446    repo_root: &Path,
447    local_ref: &str,
448    branch: &str,
449) -> Result<(), CoordinationGitError> {
450    validate_coordination_branch_name(branch)?;
451
452    let refspec = format!("{local_ref}:refs/heads/{branch}");
453    let request = ProcessRequest::new("git")
454        .args(["push", "origin", &refspec])
455        .current_dir(repo_root);
456    let output = run_git(runner, request, "push")?;
457    if output.success {
458        return Ok(());
459    }
460
461    let detail = render_output(&output);
462    let detail_lower = detail.to_ascii_lowercase();
463    if detail_lower.contains("non-fast-forward") {
464        return Err(CoordinationGitError::new(
465            CoordinationGitErrorKind::NonFastForward,
466            format!(
467                "push to '{branch}' was rejected because remote is ahead; sync and retry ({detail})"
468            ),
469        ));
470    }
471    if detail_lower.contains("protected branch")
472        || detail_lower.contains("protected branch hook declined")
473    {
474        return Err(CoordinationGitError::new(
475            CoordinationGitErrorKind::ProtectedBranch,
476            format!("push to '{branch}' blocked by branch protection ({detail})"),
477        ));
478    }
479    if detail_lower.contains("[rejected]") || detail_lower.contains("remote rejected") {
480        return Err(CoordinationGitError::new(
481            CoordinationGitErrorKind::RemoteRejected,
482            format!("push to '{branch}' was rejected by remote ({detail})"),
483        ));
484    }
485    if detail_lower.contains("no such remote")
486        || detail_lower.contains("does not appear to be a git repository")
487    {
488        return Err(CoordinationGitError::new(
489            CoordinationGitErrorKind::RemoteNotConfigured,
490            format!("git remote 'origin' is not configured ({detail})"),
491        ));
492    }
493
494    Err(CoordinationGitError::new(
495        CoordinationGitErrorKind::CommandFailed,
496        format!("git push for '{branch}' failed ({detail})"),
497    ))
498}
499
500pub(crate) fn reserve_change_on_coordination_branch_with_runner(
501    runner: &dyn ProcessRunner,
502    repo_root: &Path,
503    ito_path: &Path,
504    change_id: &str,
505    branch: &str,
506) -> Result<(), CoordinationGitError> {
507    if !is_git_worktree(runner, repo_root) {
508        return Ok(());
509    }
510
511    validate_coordination_branch_name(branch)?;
512
513    let Some(tasks_path) = tasks_path_checked(ito_path, change_id) else {
514        return Err(CoordinationGitError::new(
515            CoordinationGitErrorKind::CommandFailed,
516            format!("invalid change id path segment: '{change_id}'"),
517        ));
518    };
519    let Some(source_change_dir) = tasks_path.parent() else {
520        return Err(CoordinationGitError::new(
521            CoordinationGitErrorKind::CommandFailed,
522            format!(
523                "failed to derive change directory from '{}'",
524                tasks_path.display()
525            ),
526        ));
527    };
528
529    if !source_change_dir.exists() {
530        return Err(CoordinationGitError::new(
531            CoordinationGitErrorKind::CommandFailed,
532            format!(
533                "change directory '{}' does not exist",
534                source_change_dir.display()
535            ),
536        ));
537    }
538
539    let worktree_path = unique_temp_worktree_path();
540    ensure_coordination_branch_on_origin_with_runner(runner, repo_root, branch)?;
541
542    run_git(
543        runner,
544        ProcessRequest::new("git")
545            .args([
546                "worktree",
547                "add",
548                "--detach",
549                worktree_path.to_string_lossy().as_ref(),
550            ])
551            .current_dir(repo_root),
552        "worktree add",
553    )?;
554
555    let cleanup = WorktreeCleanup {
556        repo_root: repo_root.to_path_buf(),
557        worktree_path: worktree_path.clone(),
558    };
559
560    fetch_coordination_branch_with_runner(runner, repo_root, branch)?;
561    let checkout_target = format!("origin/{branch}");
562    let checkout = run_git(
563        runner,
564        ProcessRequest::new("git")
565            .args(["checkout", "--detach", &checkout_target])
566            .current_dir(&worktree_path),
567        "checkout coordination branch",
568    )?;
569    if !checkout.success {
570        return Err(CoordinationGitError::new(
571            CoordinationGitErrorKind::CommandFailed,
572            format!(
573                "failed to checkout coordination branch '{branch}' ({})",
574                render_output(&checkout),
575            ),
576        ));
577    }
578
579    let target_change_dir = worktree_path.join(".ito").join("changes").join(change_id);
580    if target_change_dir.exists() {
581        fs::remove_dir_all(&target_change_dir).map_err(|err| {
582            CoordinationGitError::new(
583                CoordinationGitErrorKind::CommandFailed,
584                format!(
585                    "failed to replace existing reserved change '{}' ({err})",
586                    target_change_dir.display()
587                ),
588            )
589        })?;
590    }
591    copy_dir_recursive(source_change_dir, &target_change_dir).map_err(|err| {
592        CoordinationGitError::new(
593            CoordinationGitErrorKind::CommandFailed,
594            format!("failed to copy change into reservation worktree: {err}"),
595        )
596    })?;
597
598    let relative_change_path = format!(".ito/changes/{change_id}");
599    let add = run_git(
600        runner,
601        ProcessRequest::new("git")
602            .args(["add", &relative_change_path])
603            .current_dir(&worktree_path),
604        "add reserved change",
605    )?;
606    if !add.success {
607        return Err(CoordinationGitError::new(
608            CoordinationGitErrorKind::CommandFailed,
609            format!("failed to stage reserved change ({})", render_output(&add)),
610        ));
611    }
612
613    let staged = run_git(
614        runner,
615        ProcessRequest::new("git")
616            .args(["diff", "--cached", "--quiet", "--", &relative_change_path])
617            .current_dir(&worktree_path),
618        "check staged changes",
619    )?;
620    if staged.success {
621        if let Err(err) = cleanup.cleanup_with_runner(runner) {
622            eprintln!(
623                "Warning: failed to remove temporary coordination worktree '{}': {}",
624                cleanup.worktree_path.display(),
625                err.message
626            );
627        }
628        drop(cleanup);
629        return Ok(());
630    }
631    if staged.exit_code != 1 {
632        return Err(CoordinationGitError::new(
633            CoordinationGitErrorKind::CommandFailed,
634            format!(
635                "failed to inspect staged reservation changes ({})",
636                render_output(&staged)
637            ),
638        ));
639    }
640
641    let commit_message = format!("chore(coordination): reserve {change_id}");
642    let commit = run_git(
643        runner,
644        ProcessRequest::new("git")
645            .args(["commit", "-m", &commit_message])
646            .current_dir(&worktree_path),
647        "commit reserved change",
648    )?;
649    if !commit.success {
650        return Err(CoordinationGitError::new(
651            CoordinationGitErrorKind::CommandFailed,
652            format!(
653                "failed to commit reserved change ({})",
654                render_output(&commit)
655            ),
656        ));
657    }
658
659    let push = push_coordination_branch_with_runner(runner, &worktree_path, "HEAD", branch);
660    if let Err(err) = cleanup.cleanup_with_runner(runner) {
661        eprintln!(
662            "Warning: failed to remove temporary coordination worktree '{}': {}",
663            cleanup.worktree_path.display(),
664            err.message
665        );
666    }
667    drop(cleanup);
668    push
669}
670
671fn run_git(
672    runner: &dyn ProcessRunner,
673    request: ProcessRequest,
674    operation: &str,
675) -> Result<ProcessOutput, CoordinationGitError> {
676    runner.run(&request).map_err(|err| {
677        CoordinationGitError::new(
678            CoordinationGitErrorKind::CommandFailed,
679            format!("git {operation} command failed to run: {err}"),
680        )
681    })
682}
683
684fn render_output(output: &ProcessOutput) -> String {
685    let stdout = output.stdout.trim();
686    let stderr = output.stderr.trim();
687
688    if !stderr.is_empty() {
689        return stderr.to_string();
690    }
691    if !stdout.is_empty() {
692        return stdout.to_string();
693    }
694    "no command output".to_string()
695}
696
697fn copy_dir_recursive(source: &Path, target: &Path) -> std::io::Result<()> {
698    fs::create_dir_all(target)?;
699    for entry in fs::read_dir(source)? {
700        let entry = entry?;
701        let source_path = entry.path();
702        let target_path = target.join(entry.file_name());
703        let metadata = fs::symlink_metadata(&source_path)?;
704        let file_type = metadata.file_type();
705        if file_type.is_symlink() {
706            eprintln!(
707                "Warning: skipped symlink while reserving coordination change: {}",
708                source_path.display()
709            );
710            continue;
711        }
712        if file_type.is_dir() {
713            copy_dir_recursive(&source_path, &target_path)?;
714            continue;
715        }
716        if file_type.is_file() {
717            fs::copy(&source_path, &target_path)?;
718        }
719    }
720    Ok(())
721}
722
723fn is_git_worktree(runner: &dyn ProcessRunner, repo_root: &Path) -> bool {
724    let request = ProcessRequest::new("git")
725        .args(["rev-parse", "--is-inside-work-tree"])
726        .current_dir(repo_root);
727    let Ok(output) = runner.run(&request) else {
728        return false;
729    };
730    output.success && output.stdout.trim() == "true"
731}
732
733fn unique_temp_worktree_path() -> std::path::PathBuf {
734    let pid = std::process::id();
735    let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
736        Ok(duration) => duration.as_nanos(),
737        Err(_) => 0,
738    };
739    std::env::temp_dir().join(format!("ito-coordination-{pid}-{nanos}"))
740}
741
742fn validate_coordination_branch_name(branch: &str) -> Result<(), CoordinationGitError> {
743    if branch.is_empty()
744        || branch.starts_with('-')
745        || branch.starts_with('/')
746        || branch.ends_with('/')
747    {
748        return Err(CoordinationGitError::new(
749            CoordinationGitErrorKind::CommandFailed,
750            format!("invalid coordination branch name '{branch}'"),
751        ));
752    }
753    if branch.contains("..")
754        || branch.contains("@{")
755        || branch.contains("//")
756        || branch.ends_with('.')
757        || branch.ends_with(".lock")
758    {
759        return Err(CoordinationGitError::new(
760            CoordinationGitErrorKind::CommandFailed,
761            format!("invalid coordination branch name '{branch}'"),
762        ));
763    }
764
765    for ch in branch.chars() {
766        if ch.is_ascii_control() || ch == ' ' {
767            return Err(CoordinationGitError::new(
768                CoordinationGitErrorKind::CommandFailed,
769                format!("invalid coordination branch name '{branch}'"),
770            ));
771        }
772        if ch == '~' || ch == '^' || ch == ':' || ch == '?' || ch == '*' || ch == '[' || ch == '\\'
773        {
774            return Err(CoordinationGitError::new(
775                CoordinationGitErrorKind::CommandFailed,
776                format!("invalid coordination branch name '{branch}'"),
777            ));
778        }
779    }
780
781    for segment in branch.split('/') {
782        if segment.is_empty()
783            || segment.starts_with('.')
784            || segment.ends_with('.')
785            || segment.ends_with(".lock")
786        {
787            return Err(CoordinationGitError::new(
788                CoordinationGitErrorKind::CommandFailed,
789                format!("invalid coordination branch name '{branch}'"),
790            ));
791        }
792    }
793
794    Ok(())
795}
796
797struct WorktreeCleanup {
798    repo_root: std::path::PathBuf,
799    worktree_path: std::path::PathBuf,
800}
801
802impl WorktreeCleanup {
803    fn cleanup_with_runner(&self, runner: &dyn ProcessRunner) -> Result<(), CoordinationGitError> {
804        let output = run_git(
805            runner,
806            ProcessRequest::new("git")
807                .args([
808                    "worktree",
809                    "remove",
810                    "--force",
811                    self.worktree_path.to_string_lossy().as_ref(),
812                ])
813                .current_dir(&self.repo_root),
814            "worktree remove",
815        )?;
816        if output.success {
817            return Ok(());
818        }
819
820        Err(CoordinationGitError::new(
821            CoordinationGitErrorKind::CommandFailed,
822            format!(
823                "failed to remove temporary worktree '{}' ({})",
824                self.worktree_path.display(),
825                render_output(&output)
826            ),
827        ))
828    }
829}
830
831impl Drop for WorktreeCleanup {
832    fn drop(&mut self) {
833        let _ = std::process::Command::new("git")
834            .args([
835                "worktree",
836                "remove",
837                "--force",
838                self.worktree_path.to_string_lossy().as_ref(),
839            ])
840            .current_dir(&self.repo_root)
841            .output();
842    }
843}
844
845#[cfg(test)]
846#[path = "git_tests.rs"]
847mod git_tests;