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 by pushing the local `HEAD`, 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 by pushing `HEAD` 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 pushes `HEAD` 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            push_coordination_branch_with_runner(runner, repo_root, "HEAD", branch)
280                .map(|()| CoordinationBranchSetupStatus::Created)
281        }
282    }
283}
284
285/// Fetches the coordination branch from `origin` into the repository's remote-tracking refs.
286///
287/// Returns a `CoordinationGitError` when the operation fails:
288/// - `RemoteMissing` if the remote branch does not exist on `origin`.
289/// - `RemoteNotConfigured` if the `origin` remote is not configured or unreachable.
290/// - `CommandFailed` for other failures; the error message includes git command output.
291///
292/// # Examples
293///
294/// ```ignore
295/// use std::path::Path;
296/// // `runner` should implement `ProcessRunner` (e.g., `SystemProcessRunner` in production).
297/// let runner = crate::tests::StubRunner::default(); // replace with a real runner in real usage
298/// let repo = Path::new("/path/to/repo");
299/// let _ = fetch_coordination_branch_with_runner(&runner, repo, "coordination");
300/// ```
301pub(crate) fn fetch_coordination_branch_with_runner(
302    runner: &dyn ProcessRunner,
303    repo_root: &Path,
304    branch: &str,
305) -> Result<(), CoordinationGitError> {
306    validate_coordination_branch_name(branch)?;
307
308    let request = ProcessRequest::new("git")
309        .args(["fetch", "origin", branch])
310        .current_dir(repo_root);
311    let output = run_git(runner, request, "fetch")?;
312    if output.success {
313        return Ok(());
314    }
315
316    let detail = render_output(&output);
317    let detail_lower = detail.to_ascii_lowercase();
318    if detail_lower.contains("couldn't find remote ref")
319        || detail_lower.contains("remote ref does not exist")
320    {
321        return Err(CoordinationGitError::new(
322            CoordinationGitErrorKind::RemoteMissing,
323            format!("remote branch '{branch}' does not exist ({detail})"),
324        ));
325    }
326    if detail_lower.contains("no such remote")
327        || detail_lower.contains("does not appear to be a git repository")
328    {
329        return Err(CoordinationGitError::new(
330            CoordinationGitErrorKind::RemoteNotConfigured,
331            format!("git remote 'origin' is not configured ({detail})"),
332        ));
333    }
334
335    Err(CoordinationGitError::new(
336        CoordinationGitErrorKind::CommandFailed,
337        format!("git fetch origin {branch} failed ({detail})"),
338    ))
339}
340
341/// Pushes a local ref to the coordination branch on the `origin` remote.
342///
343/// The `local_ref` is pushed to `refs/heads/<branch>` on the `origin` remote. The function
344/// validates the branch name before attempting the push and classifies failures into
345/// meaningful `CoordinationGitErrorKind` variants.
346///
347/// # Parameters
348///
349/// - `repo_root`: repository working directory where the git command is executed.
350/// - `local_ref`: source ref to push (for example `"HEAD"` or `"refs/heads/my-branch"`).
351/// - `branch`: target coordination branch name on `origin`.
352///
353/// # Returns
354///
355/// `Ok(())` if the push succeeded; `Err(CoordinationGitError)` on failure with a kind that
356/// indicates the failure reason (for example: non-fast-forward, protected branch, remote rejected,
357/// remote not configured, or a general command failure).
358///
359/// # Examples
360///
361/// ```no_run
362/// use std::path::Path;
363/// // `runner` should implement ProcessRunner; `repo_root` should point to a git repository.
364/// // push_coordination_branch_with_runner(&runner, Path::new("/path/to/repo"), "HEAD", "coordination")
365/// //     .expect("push should succeed");
366/// ```
367pub(crate) fn push_coordination_branch_with_runner(
368    runner: &dyn ProcessRunner,
369    repo_root: &Path,
370    local_ref: &str,
371    branch: &str,
372) -> Result<(), CoordinationGitError> {
373    validate_coordination_branch_name(branch)?;
374
375    let refspec = format!("{local_ref}:refs/heads/{branch}");
376    let request = ProcessRequest::new("git")
377        .args(["push", "origin", &refspec])
378        .current_dir(repo_root);
379    let output = run_git(runner, request, "push")?;
380    if output.success {
381        return Ok(());
382    }
383
384    let detail = render_output(&output);
385    let detail_lower = detail.to_ascii_lowercase();
386    if detail_lower.contains("non-fast-forward") {
387        return Err(CoordinationGitError::new(
388            CoordinationGitErrorKind::NonFastForward,
389            format!(
390                "push to '{branch}' was rejected because remote is ahead; sync and retry ({detail})"
391            ),
392        ));
393    }
394    if detail_lower.contains("protected branch")
395        || detail_lower.contains("protected branch hook declined")
396    {
397        return Err(CoordinationGitError::new(
398            CoordinationGitErrorKind::ProtectedBranch,
399            format!("push to '{branch}' blocked by branch protection ({detail})"),
400        ));
401    }
402    if detail_lower.contains("[rejected]") || detail_lower.contains("remote rejected") {
403        return Err(CoordinationGitError::new(
404            CoordinationGitErrorKind::RemoteRejected,
405            format!("push to '{branch}' was rejected by remote ({detail})"),
406        ));
407    }
408    if detail_lower.contains("no such remote")
409        || detail_lower.contains("does not appear to be a git repository")
410    {
411        return Err(CoordinationGitError::new(
412            CoordinationGitErrorKind::RemoteNotConfigured,
413            format!("git remote 'origin' is not configured ({detail})"),
414        ));
415    }
416
417    Err(CoordinationGitError::new(
418        CoordinationGitErrorKind::CommandFailed,
419        format!("git push for '{branch}' failed ({detail})"),
420    ))
421}
422
423pub(crate) fn reserve_change_on_coordination_branch_with_runner(
424    runner: &dyn ProcessRunner,
425    repo_root: &Path,
426    ito_path: &Path,
427    change_id: &str,
428    branch: &str,
429) -> Result<(), CoordinationGitError> {
430    if !is_git_worktree(runner, repo_root) {
431        return Ok(());
432    }
433
434    validate_coordination_branch_name(branch)?;
435
436    let Some(tasks_path) = tasks_path_checked(ito_path, change_id) else {
437        return Err(CoordinationGitError::new(
438            CoordinationGitErrorKind::CommandFailed,
439            format!("invalid change id path segment: '{change_id}'"),
440        ));
441    };
442    let Some(source_change_dir) = tasks_path.parent() else {
443        return Err(CoordinationGitError::new(
444            CoordinationGitErrorKind::CommandFailed,
445            format!(
446                "failed to derive change directory from '{}'",
447                tasks_path.display()
448            ),
449        ));
450    };
451
452    if !source_change_dir.exists() {
453        return Err(CoordinationGitError::new(
454            CoordinationGitErrorKind::CommandFailed,
455            format!(
456                "change directory '{}' does not exist",
457                source_change_dir.display()
458            ),
459        ));
460    }
461
462    let worktree_path = unique_temp_worktree_path();
463
464    run_git(
465        runner,
466        ProcessRequest::new("git")
467            .args([
468                "worktree",
469                "add",
470                "--detach",
471                worktree_path.to_string_lossy().as_ref(),
472            ])
473            .current_dir(repo_root),
474        "worktree add",
475    )?;
476
477    let cleanup = WorktreeCleanup {
478        repo_root: repo_root.to_path_buf(),
479        worktree_path: worktree_path.clone(),
480    };
481
482    let fetch_result = fetch_coordination_branch_with_runner(runner, repo_root, branch);
483    match fetch_result {
484        Ok(()) => {
485            let checkout_target = format!("origin/{branch}");
486            let checkout = run_git(
487                runner,
488                ProcessRequest::new("git")
489                    .args(["checkout", "--detach", &checkout_target])
490                    .current_dir(&worktree_path),
491                "checkout coordination branch",
492            )?;
493            if !checkout.success {
494                return Err(CoordinationGitError::new(
495                    CoordinationGitErrorKind::CommandFailed,
496                    format!(
497                        "failed to checkout coordination branch '{branch}' ({})",
498                        render_output(&checkout),
499                    ),
500                ));
501            }
502        }
503        Err(err) => {
504            if err.kind != CoordinationGitErrorKind::RemoteMissing {
505                return Err(err);
506            }
507        }
508    }
509
510    let target_change_dir = worktree_path.join(".ito").join("changes").join(change_id);
511    if target_change_dir.exists() {
512        fs::remove_dir_all(&target_change_dir).map_err(|err| {
513            CoordinationGitError::new(
514                CoordinationGitErrorKind::CommandFailed,
515                format!(
516                    "failed to replace existing reserved change '{}' ({err})",
517                    target_change_dir.display()
518                ),
519            )
520        })?;
521    }
522    copy_dir_recursive(source_change_dir, &target_change_dir).map_err(|err| {
523        CoordinationGitError::new(
524            CoordinationGitErrorKind::CommandFailed,
525            format!("failed to copy change into reservation worktree: {err}"),
526        )
527    })?;
528
529    let relative_change_path = format!(".ito/changes/{change_id}");
530    let add = run_git(
531        runner,
532        ProcessRequest::new("git")
533            .args(["add", &relative_change_path])
534            .current_dir(&worktree_path),
535        "add reserved change",
536    )?;
537    if !add.success {
538        return Err(CoordinationGitError::new(
539            CoordinationGitErrorKind::CommandFailed,
540            format!("failed to stage reserved change ({})", render_output(&add)),
541        ));
542    }
543
544    let staged = run_git(
545        runner,
546        ProcessRequest::new("git")
547            .args(["diff", "--cached", "--quiet", "--", &relative_change_path])
548            .current_dir(&worktree_path),
549        "check staged changes",
550    )?;
551    if staged.success {
552        if let Err(err) = cleanup.cleanup_with_runner(runner) {
553            eprintln!(
554                "Warning: failed to remove temporary coordination worktree '{}': {}",
555                cleanup.worktree_path.display(),
556                err.message
557            );
558        }
559        drop(cleanup);
560        return Ok(());
561    }
562    if staged.exit_code != 1 {
563        return Err(CoordinationGitError::new(
564            CoordinationGitErrorKind::CommandFailed,
565            format!(
566                "failed to inspect staged reservation changes ({})",
567                render_output(&staged)
568            ),
569        ));
570    }
571
572    let commit_message = format!("chore(coordination): reserve {change_id}");
573    let commit = run_git(
574        runner,
575        ProcessRequest::new("git")
576            .args(["commit", "-m", &commit_message])
577            .current_dir(&worktree_path),
578        "commit reserved change",
579    )?;
580    if !commit.success {
581        return Err(CoordinationGitError::new(
582            CoordinationGitErrorKind::CommandFailed,
583            format!(
584                "failed to commit reserved change ({})",
585                render_output(&commit)
586            ),
587        ));
588    }
589
590    let push = push_coordination_branch_with_runner(runner, &worktree_path, "HEAD", branch);
591    if let Err(err) = cleanup.cleanup_with_runner(runner) {
592        eprintln!(
593            "Warning: failed to remove temporary coordination worktree '{}': {}",
594            cleanup.worktree_path.display(),
595            err.message
596        );
597    }
598    drop(cleanup);
599    push
600}
601
602fn run_git(
603    runner: &dyn ProcessRunner,
604    request: ProcessRequest,
605    operation: &str,
606) -> Result<ProcessOutput, CoordinationGitError> {
607    runner.run(&request).map_err(|err| {
608        CoordinationGitError::new(
609            CoordinationGitErrorKind::CommandFailed,
610            format!("git {operation} command failed to run: {err}"),
611        )
612    })
613}
614
615fn render_output(output: &ProcessOutput) -> String {
616    let stdout = output.stdout.trim();
617    let stderr = output.stderr.trim();
618
619    if !stderr.is_empty() {
620        return stderr.to_string();
621    }
622    if !stdout.is_empty() {
623        return stdout.to_string();
624    }
625    "no command output".to_string()
626}
627
628fn copy_dir_recursive(source: &Path, target: &Path) -> std::io::Result<()> {
629    fs::create_dir_all(target)?;
630    for entry in fs::read_dir(source)? {
631        let entry = entry?;
632        let source_path = entry.path();
633        let target_path = target.join(entry.file_name());
634        let metadata = fs::symlink_metadata(&source_path)?;
635        let file_type = metadata.file_type();
636        if file_type.is_symlink() {
637            eprintln!(
638                "Warning: skipped symlink while reserving coordination change: {}",
639                source_path.display()
640            );
641            continue;
642        }
643        if file_type.is_dir() {
644            copy_dir_recursive(&source_path, &target_path)?;
645            continue;
646        }
647        if file_type.is_file() {
648            fs::copy(&source_path, &target_path)?;
649        }
650    }
651    Ok(())
652}
653
654fn is_git_worktree(runner: &dyn ProcessRunner, repo_root: &Path) -> bool {
655    let request = ProcessRequest::new("git")
656        .args(["rev-parse", "--is-inside-work-tree"])
657        .current_dir(repo_root);
658    let Ok(output) = runner.run(&request) else {
659        return false;
660    };
661    output.success && output.stdout.trim() == "true"
662}
663
664fn unique_temp_worktree_path() -> std::path::PathBuf {
665    let pid = std::process::id();
666    let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
667        Ok(duration) => duration.as_nanos(),
668        Err(_) => 0,
669    };
670    std::env::temp_dir().join(format!("ito-coordination-{pid}-{nanos}"))
671}
672
673fn validate_coordination_branch_name(branch: &str) -> Result<(), CoordinationGitError> {
674    if branch.is_empty()
675        || branch.starts_with('-')
676        || branch.starts_with('/')
677        || branch.ends_with('/')
678    {
679        return Err(CoordinationGitError::new(
680            CoordinationGitErrorKind::CommandFailed,
681            format!("invalid coordination branch name '{branch}'"),
682        ));
683    }
684    if branch.contains("..")
685        || branch.contains("@{")
686        || branch.contains("//")
687        || branch.ends_with('.')
688        || branch.ends_with(".lock")
689    {
690        return Err(CoordinationGitError::new(
691            CoordinationGitErrorKind::CommandFailed,
692            format!("invalid coordination branch name '{branch}'"),
693        ));
694    }
695
696    for ch in branch.chars() {
697        if ch.is_ascii_control() || ch == ' ' {
698            return Err(CoordinationGitError::new(
699                CoordinationGitErrorKind::CommandFailed,
700                format!("invalid coordination branch name '{branch}'"),
701            ));
702        }
703        if ch == '~' || ch == '^' || ch == ':' || ch == '?' || ch == '*' || ch == '[' || ch == '\\'
704        {
705            return Err(CoordinationGitError::new(
706                CoordinationGitErrorKind::CommandFailed,
707                format!("invalid coordination branch name '{branch}'"),
708            ));
709        }
710    }
711
712    for segment in branch.split('/') {
713        if segment.is_empty()
714            || segment.starts_with('.')
715            || segment.ends_with('.')
716            || segment.ends_with(".lock")
717        {
718            return Err(CoordinationGitError::new(
719                CoordinationGitErrorKind::CommandFailed,
720                format!("invalid coordination branch name '{branch}'"),
721            ));
722        }
723    }
724
725    Ok(())
726}
727
728struct WorktreeCleanup {
729    repo_root: std::path::PathBuf,
730    worktree_path: std::path::PathBuf,
731}
732
733impl WorktreeCleanup {
734    fn cleanup_with_runner(&self, runner: &dyn ProcessRunner) -> Result<(), CoordinationGitError> {
735        let output = run_git(
736            runner,
737            ProcessRequest::new("git")
738                .args([
739                    "worktree",
740                    "remove",
741                    "--force",
742                    self.worktree_path.to_string_lossy().as_ref(),
743                ])
744                .current_dir(&self.repo_root),
745            "worktree remove",
746        )?;
747        if output.success {
748            return Ok(());
749        }
750
751        Err(CoordinationGitError::new(
752            CoordinationGitErrorKind::CommandFailed,
753            format!(
754                "failed to remove temporary worktree '{}' ({})",
755                self.worktree_path.display(),
756                render_output(&output)
757            ),
758        ))
759    }
760}
761
762impl Drop for WorktreeCleanup {
763    fn drop(&mut self) {
764        let _ = std::process::Command::new("git")
765            .args([
766                "worktree",
767                "remove",
768                "--force",
769                self.worktree_path.to_string_lossy().as_ref(),
770            ])
771            .current_dir(&self.repo_root)
772            .output();
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::errors::CoreError;
780    use crate::process::ProcessExecutionError;
781    use std::cell::RefCell;
782    use std::collections::VecDeque;
783
784    struct StubRunner {
785        outputs: RefCell<VecDeque<Result<ProcessOutput, ProcessExecutionError>>>,
786    }
787
788    impl StubRunner {
789        fn with_outputs(outputs: Vec<Result<ProcessOutput, ProcessExecutionError>>) -> Self {
790            Self {
791                outputs: RefCell::new(outputs.into()),
792            }
793        }
794    }
795
796    impl ProcessRunner for StubRunner {
797        fn run(&self, _request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError> {
798            self.outputs
799                .borrow_mut()
800                .pop_front()
801                .expect("expected process output")
802        }
803
804        fn run_with_timeout(
805            &self,
806            _request: &ProcessRequest,
807            _timeout: std::time::Duration,
808        ) -> Result<ProcessOutput, ProcessExecutionError> {
809            unreachable!("not used")
810        }
811    }
812
813    fn ok_output(stdout: &str, stderr: &str) -> ProcessOutput {
814        ProcessOutput {
815            exit_code: 0,
816            success: true,
817            stdout: stdout.to_string(),
818            stderr: stderr.to_string(),
819            timed_out: false,
820        }
821    }
822
823    fn err_output(stderr: &str) -> ProcessOutput {
824        ProcessOutput {
825            exit_code: 1,
826            success: false,
827            stdout: String::new(),
828            stderr: stderr.to_string(),
829            timed_out: false,
830        }
831    }
832
833    #[test]
834    fn fetch_coordination_branch_succeeds_on_clean_fetch() {
835        let runner = StubRunner::with_outputs(vec![Ok(ok_output("", ""))]);
836        let repo = std::env::temp_dir();
837        let result = fetch_coordination_branch_with_runner(&runner, &repo, "ito/internal/changes");
838        assert!(result.is_ok());
839    }
840
841    #[test]
842    fn fetch_coordination_branch_classifies_missing_remote_branch() {
843        let runner = StubRunner::with_outputs(vec![Ok(err_output(
844            "fatal: couldn't find remote ref ito/internal/changes",
845        ))]);
846        let repo = std::env::temp_dir();
847        let err = fetch_coordination_branch_with_runner(&runner, &repo, "ito/internal/changes")
848            .unwrap_err();
849        assert_eq!(err.kind, CoordinationGitErrorKind::RemoteMissing);
850        assert!(err.message.contains("does not exist"));
851    }
852
853    #[test]
854    fn push_coordination_branch_classifies_non_fast_forward_rejection() {
855        let runner = StubRunner::with_outputs(vec![Ok(err_output(
856            "! [rejected] HEAD -> ito/internal/changes (non-fast-forward)",
857        ))]);
858        let repo = std::env::temp_dir();
859        let err =
860            push_coordination_branch_with_runner(&runner, &repo, "HEAD", "ito/internal/changes")
861                .unwrap_err();
862        assert_eq!(err.kind, CoordinationGitErrorKind::NonFastForward);
863        assert!(err.message.contains("sync and retry"));
864    }
865
866    #[test]
867    fn push_coordination_branch_classifies_protection_rejection() {
868        let runner = StubRunner::with_outputs(vec![Ok(err_output(
869            "remote: error: GH006: Protected branch update failed",
870        ))]);
871        let repo = std::env::temp_dir();
872        let err =
873            push_coordination_branch_with_runner(&runner, &repo, "HEAD", "ito/internal/changes")
874                .unwrap_err();
875        assert_eq!(err.kind, CoordinationGitErrorKind::ProtectedBranch);
876    }
877
878    #[test]
879    fn fetch_coordination_branch_classifies_missing_remote_configuration() {
880        let runner = StubRunner::with_outputs(vec![Ok(err_output(
881            "fatal: 'origin' does not appear to be a git repository\nfatal: No such remote: 'origin'",
882        ))]);
883        let repo = std::env::temp_dir();
884        let err = fetch_coordination_branch_with_runner(&runner, &repo, "ito/internal/changes")
885            .unwrap_err();
886        assert_eq!(err.kind, CoordinationGitErrorKind::RemoteNotConfigured);
887        assert!(err.message.contains("not configured"));
888    }
889
890    #[test]
891    fn setup_coordination_branch_returns_ready_when_remote_branch_exists() {
892        let runner =
893            StubRunner::with_outputs(vec![Ok(ok_output("true\n", "")), Ok(ok_output("", ""))]);
894        let repo = std::env::temp_dir();
895        let result = ensure_coordination_branch_on_origin_with_runner(
896            &runner,
897            &repo,
898            "ito/internal/changes",
899        )
900        .expect("setup should succeed");
901        assert_eq!(result, CoordinationBranchSetupStatus::Ready);
902    }
903
904    #[test]
905    fn setup_coordination_branch_creates_branch_when_remote_missing() {
906        let runner = StubRunner::with_outputs(vec![
907            Ok(ok_output("true\n", "")),
908            Ok(err_output(
909                "fatal: couldn't find remote ref ito/internal/changes",
910            )),
911            Ok(ok_output("", "")),
912        ]);
913        let repo = std::env::temp_dir();
914        let result = ensure_coordination_branch_on_origin_with_runner(
915            &runner,
916            &repo,
917            "ito/internal/changes",
918        )
919        .expect("setup should create branch");
920        assert_eq!(result, CoordinationBranchSetupStatus::Created);
921    }
922
923    #[test]
924    fn setup_coordination_branch_fails_when_not_git_worktree() {
925        let runner = StubRunner::with_outputs(vec![Ok(err_output(
926            "fatal: not a git repository (or any of the parent directories): .git",
927        ))]);
928        let repo = std::env::temp_dir();
929        let err = ensure_coordination_branch_on_origin_with_runner(
930            &runner,
931            &repo,
932            "ito/internal/changes",
933        )
934        .unwrap_err();
935        assert_eq!(err.kind, CoordinationGitErrorKind::CommandFailed);
936        assert!(err.message.contains("outside a git worktree"));
937    }
938
939    #[test]
940    fn push_coordination_branch_classifies_missing_remote_configuration() {
941        let runner = StubRunner::with_outputs(vec![Ok(err_output(
942            "fatal: 'origin' does not appear to be a git repository\nfatal: No such remote: 'origin'",
943        ))]);
944        let repo = std::env::temp_dir();
945        let err =
946            push_coordination_branch_with_runner(&runner, &repo, "HEAD", "ito/internal/changes")
947                .unwrap_err();
948        assert_eq!(err.kind, CoordinationGitErrorKind::RemoteNotConfigured);
949        assert!(err.message.contains("not configured"));
950    }
951
952    #[test]
953    fn setup_coordination_branch_reports_missing_origin_when_create_push_fails() {
954        let runner = StubRunner::with_outputs(vec![
955            Ok(ok_output("true\n", "")),
956            Ok(err_output(
957                "fatal: couldn't find remote ref ito/internal/changes",
958            )),
959            Ok(err_output(
960                "fatal: 'origin' does not appear to be a git repository",
961            )),
962        ]);
963        let repo = std::env::temp_dir();
964        let err = ensure_coordination_branch_on_origin_with_runner(
965            &runner,
966            &repo,
967            "ito/internal/changes",
968        )
969        .unwrap_err();
970        assert_eq!(err.kind, CoordinationGitErrorKind::RemoteNotConfigured);
971        assert!(err.message.contains("not configured"));
972    }
973
974    #[test]
975    fn setup_coordination_branch_core_wraps_process_error() {
976        let repo = std::env::temp_dir().join("ito-not-a-repo");
977        let _ = std::fs::remove_dir_all(&repo);
978        std::fs::create_dir_all(&repo).expect("temp dir created");
979
980        let err =
981            ensure_coordination_branch_on_origin_core(&repo, "ito/internal/changes").unwrap_err();
982        let CoreError::Process(msg) = err else {
983            panic!("expected process error");
984        };
985        assert!(msg.contains("coordination setup failed"));
986    }
987}