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
37impl CoordinationGitError {
38    fn new(kind: CoordinationGitErrorKind, message: impl Into<String>) -> Self {
39        Self {
40            kind,
41            message: message.into(),
42        }
43    }
44}
45
46/// Fetches a coordination branch from `origin` into remote-tracking refs.
47///
48/// Returns `Ok(())` on success. Returns a classified error when fetch fails.
49pub fn fetch_coordination_branch(
50    repo_root: &Path,
51    branch: &str,
52) -> Result<(), CoordinationGitError> {
53    let runner = SystemProcessRunner;
54    fetch_coordination_branch_with_runner(&runner, repo_root, branch)
55}
56
57/// Pushes a local ref to a coordination branch on `origin`.
58///
59/// Returns `Ok(())` on success. Returns a classified error when push fails.
60pub fn push_coordination_branch(
61    repo_root: &Path,
62    local_ref: &str,
63    branch: &str,
64) -> Result<(), CoordinationGitError> {
65    let runner = SystemProcessRunner;
66    push_coordination_branch_with_runner(&runner, repo_root, local_ref, branch)
67}
68
69/// Reserves a newly created change directory on the coordination branch.
70///
71/// The reservation is performed in an ephemeral worktree so the caller's active
72/// branch/worktree is not modified.
73pub fn reserve_change_on_coordination_branch(
74    repo_root: &Path,
75    ito_path: &Path,
76    change_id: &str,
77    branch: &str,
78) -> Result<(), CoordinationGitError> {
79    let runner = SystemProcessRunner;
80    reserve_change_on_coordination_branch_with_runner(
81        &runner, repo_root, ito_path, change_id, branch,
82    )
83}
84
85/// CoreResult wrapper for fetching a coordination branch.
86pub fn fetch_coordination_branch_core(repo_root: &Path, branch: &str) -> CoreResult<()> {
87    fetch_coordination_branch(repo_root, branch)
88        .map_err(|err| CoreError::process(format!("coordination fetch failed: {}", err.message)))
89}
90
91/// CoreResult wrapper for pushing a local ref to a coordination branch.
92pub fn push_coordination_branch_core(
93    repo_root: &Path,
94    local_ref: &str,
95    branch: &str,
96) -> CoreResult<()> {
97    push_coordination_branch(repo_root, local_ref, branch)
98        .map_err(|err| CoreError::process(format!("coordination push failed: {}", err.message)))
99}
100
101/// CoreResult wrapper for reserving change metadata on the coordination branch.
102pub fn reserve_change_on_coordination_branch_core(
103    repo_root: &Path,
104    ito_path: &Path,
105    change_id: &str,
106    branch: &str,
107) -> CoreResult<()> {
108    reserve_change_on_coordination_branch(repo_root, ito_path, change_id, branch).map_err(|err| {
109        CoreError::process(format!("coordination reservation failed: {}", err.message))
110    })
111}
112
113pub(crate) fn fetch_coordination_branch_with_runner(
114    runner: &dyn ProcessRunner,
115    repo_root: &Path,
116    branch: &str,
117) -> Result<(), CoordinationGitError> {
118    validate_coordination_branch_name(branch)?;
119
120    let request = ProcessRequest::new("git")
121        .args(["fetch", "origin", branch])
122        .current_dir(repo_root);
123    let output = run_git(runner, request, "fetch")?;
124    if output.success {
125        return Ok(());
126    }
127
128    let detail = render_output(&output);
129    let detail_lower = detail.to_ascii_lowercase();
130    if detail_lower.contains("couldn't find remote ref")
131        || detail_lower.contains("remote ref does not exist")
132    {
133        return Err(CoordinationGitError::new(
134            CoordinationGitErrorKind::RemoteMissing,
135            format!("remote branch '{branch}' does not exist ({detail})"),
136        ));
137    }
138    if detail_lower.contains("no such remote") {
139        return Err(CoordinationGitError::new(
140            CoordinationGitErrorKind::RemoteNotConfigured,
141            format!("git remote 'origin' is not configured ({detail})"),
142        ));
143    }
144
145    Err(CoordinationGitError::new(
146        CoordinationGitErrorKind::CommandFailed,
147        format!("git fetch origin {branch} failed ({detail})"),
148    ))
149}
150
151pub(crate) fn push_coordination_branch_with_runner(
152    runner: &dyn ProcessRunner,
153    repo_root: &Path,
154    local_ref: &str,
155    branch: &str,
156) -> Result<(), CoordinationGitError> {
157    validate_coordination_branch_name(branch)?;
158
159    let refspec = format!("{local_ref}:refs/heads/{branch}");
160    let request = ProcessRequest::new("git")
161        .args(["push", "origin", &refspec])
162        .current_dir(repo_root);
163    let output = run_git(runner, request, "push")?;
164    if output.success {
165        return Ok(());
166    }
167
168    let detail = render_output(&output);
169    let detail_lower = detail.to_ascii_lowercase();
170    if detail_lower.contains("non-fast-forward") {
171        return Err(CoordinationGitError::new(
172            CoordinationGitErrorKind::NonFastForward,
173            format!(
174                "push to '{branch}' was rejected because remote is ahead; sync and retry ({detail})"
175            ),
176        ));
177    }
178    if detail_lower.contains("protected branch")
179        || detail_lower.contains("protected branch hook declined")
180    {
181        return Err(CoordinationGitError::new(
182            CoordinationGitErrorKind::ProtectedBranch,
183            format!("push to '{branch}' blocked by branch protection ({detail})"),
184        ));
185    }
186    if detail_lower.contains("[rejected]") || detail_lower.contains("remote rejected") {
187        return Err(CoordinationGitError::new(
188            CoordinationGitErrorKind::RemoteRejected,
189            format!("push to '{branch}' was rejected by remote ({detail})"),
190        ));
191    }
192
193    Err(CoordinationGitError::new(
194        CoordinationGitErrorKind::CommandFailed,
195        format!("git push for '{branch}' failed ({detail})"),
196    ))
197}
198
199pub(crate) fn reserve_change_on_coordination_branch_with_runner(
200    runner: &dyn ProcessRunner,
201    repo_root: &Path,
202    ito_path: &Path,
203    change_id: &str,
204    branch: &str,
205) -> Result<(), CoordinationGitError> {
206    if !is_git_worktree(runner, repo_root) {
207        return Ok(());
208    }
209
210    validate_coordination_branch_name(branch)?;
211
212    let Some(tasks_path) = tasks_path_checked(ito_path, change_id) else {
213        return Err(CoordinationGitError::new(
214            CoordinationGitErrorKind::CommandFailed,
215            format!("invalid change id path segment: '{change_id}'"),
216        ));
217    };
218    let Some(source_change_dir) = tasks_path.parent() else {
219        return Err(CoordinationGitError::new(
220            CoordinationGitErrorKind::CommandFailed,
221            format!(
222                "failed to derive change directory from '{}'",
223                tasks_path.display()
224            ),
225        ));
226    };
227
228    if !source_change_dir.exists() {
229        return Err(CoordinationGitError::new(
230            CoordinationGitErrorKind::CommandFailed,
231            format!(
232                "change directory '{}' does not exist",
233                source_change_dir.display()
234            ),
235        ));
236    }
237
238    let worktree_path = unique_temp_worktree_path();
239
240    run_git(
241        runner,
242        ProcessRequest::new("git")
243            .args([
244                "worktree",
245                "add",
246                "--detach",
247                worktree_path.to_string_lossy().as_ref(),
248            ])
249            .current_dir(repo_root),
250        "worktree add",
251    )?;
252
253    let cleanup = WorktreeCleanup {
254        repo_root: repo_root.to_path_buf(),
255        worktree_path: worktree_path.clone(),
256    };
257
258    let fetch_result = fetch_coordination_branch_with_runner(runner, repo_root, branch);
259    match fetch_result {
260        Ok(()) => {
261            let checkout_target = format!("origin/{branch}");
262            let checkout = run_git(
263                runner,
264                ProcessRequest::new("git")
265                    .args(["checkout", "--detach", &checkout_target])
266                    .current_dir(&worktree_path),
267                "checkout coordination branch",
268            )?;
269            if !checkout.success {
270                return Err(CoordinationGitError::new(
271                    CoordinationGitErrorKind::CommandFailed,
272                    format!(
273                        "failed to checkout coordination branch '{branch}' ({})",
274                        render_output(&checkout),
275                    ),
276                ));
277            }
278        }
279        Err(err) => {
280            if err.kind != CoordinationGitErrorKind::RemoteMissing {
281                return Err(err);
282            }
283        }
284    }
285
286    let target_change_dir = worktree_path.join(".ito").join("changes").join(change_id);
287    if target_change_dir.exists() {
288        fs::remove_dir_all(&target_change_dir).map_err(|err| {
289            CoordinationGitError::new(
290                CoordinationGitErrorKind::CommandFailed,
291                format!(
292                    "failed to replace existing reserved change '{}' ({err})",
293                    target_change_dir.display()
294                ),
295            )
296        })?;
297    }
298    copy_dir_recursive(source_change_dir, &target_change_dir).map_err(|err| {
299        CoordinationGitError::new(
300            CoordinationGitErrorKind::CommandFailed,
301            format!("failed to copy change into reservation worktree: {err}"),
302        )
303    })?;
304
305    let relative_change_path = format!(".ito/changes/{change_id}");
306    let add = run_git(
307        runner,
308        ProcessRequest::new("git")
309            .args(["add", &relative_change_path])
310            .current_dir(&worktree_path),
311        "add reserved change",
312    )?;
313    if !add.success {
314        return Err(CoordinationGitError::new(
315            CoordinationGitErrorKind::CommandFailed,
316            format!("failed to stage reserved change ({})", render_output(&add)),
317        ));
318    }
319
320    let staged = run_git(
321        runner,
322        ProcessRequest::new("git")
323            .args(["diff", "--cached", "--quiet", "--", &relative_change_path])
324            .current_dir(&worktree_path),
325        "check staged changes",
326    )?;
327    if staged.success {
328        if let Err(err) = cleanup.cleanup_with_runner(runner) {
329            eprintln!(
330                "Warning: failed to remove temporary coordination worktree '{}': {}",
331                cleanup.worktree_path.display(),
332                err.message
333            );
334        }
335        drop(cleanup);
336        return Ok(());
337    }
338    if staged.exit_code != 1 {
339        return Err(CoordinationGitError::new(
340            CoordinationGitErrorKind::CommandFailed,
341            format!(
342                "failed to inspect staged reservation changes ({})",
343                render_output(&staged)
344            ),
345        ));
346    }
347
348    let commit_message = format!("chore(coordination): reserve {change_id}");
349    let commit = run_git(
350        runner,
351        ProcessRequest::new("git")
352            .args(["commit", "-m", &commit_message])
353            .current_dir(&worktree_path),
354        "commit reserved change",
355    )?;
356    if !commit.success {
357        return Err(CoordinationGitError::new(
358            CoordinationGitErrorKind::CommandFailed,
359            format!(
360                "failed to commit reserved change ({})",
361                render_output(&commit)
362            ),
363        ));
364    }
365
366    let push = push_coordination_branch_with_runner(runner, &worktree_path, "HEAD", branch);
367    if let Err(err) = cleanup.cleanup_with_runner(runner) {
368        eprintln!(
369            "Warning: failed to remove temporary coordination worktree '{}': {}",
370            cleanup.worktree_path.display(),
371            err.message
372        );
373    }
374    drop(cleanup);
375    push
376}
377
378fn run_git(
379    runner: &dyn ProcessRunner,
380    request: ProcessRequest,
381    operation: &str,
382) -> Result<ProcessOutput, CoordinationGitError> {
383    runner.run(&request).map_err(|err| {
384        CoordinationGitError::new(
385            CoordinationGitErrorKind::CommandFailed,
386            format!("git {operation} command failed to run: {err}"),
387        )
388    })
389}
390
391fn render_output(output: &ProcessOutput) -> String {
392    let stdout = output.stdout.trim();
393    let stderr = output.stderr.trim();
394
395    if !stderr.is_empty() {
396        return stderr.to_string();
397    }
398    if !stdout.is_empty() {
399        return stdout.to_string();
400    }
401    "no command output".to_string()
402}
403
404fn copy_dir_recursive(source: &Path, target: &Path) -> std::io::Result<()> {
405    fs::create_dir_all(target)?;
406    for entry in fs::read_dir(source)? {
407        let entry = entry?;
408        let source_path = entry.path();
409        let target_path = target.join(entry.file_name());
410        let metadata = fs::symlink_metadata(&source_path)?;
411        let file_type = metadata.file_type();
412        if file_type.is_symlink() {
413            eprintln!(
414                "Warning: skipped symlink while reserving coordination change: {}",
415                source_path.display()
416            );
417            continue;
418        }
419        if file_type.is_dir() {
420            copy_dir_recursive(&source_path, &target_path)?;
421            continue;
422        }
423        if file_type.is_file() {
424            fs::copy(&source_path, &target_path)?;
425        }
426    }
427    Ok(())
428}
429
430fn is_git_worktree(runner: &dyn ProcessRunner, repo_root: &Path) -> bool {
431    let request = ProcessRequest::new("git")
432        .args(["rev-parse", "--is-inside-work-tree"])
433        .current_dir(repo_root);
434    let Ok(output) = runner.run(&request) else {
435        return false;
436    };
437    output.success && output.stdout.trim() == "true"
438}
439
440fn unique_temp_worktree_path() -> std::path::PathBuf {
441    let pid = std::process::id();
442    let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
443        Ok(duration) => duration.as_nanos(),
444        Err(_) => 0,
445    };
446    std::env::temp_dir().join(format!("ito-coordination-{pid}-{nanos}"))
447}
448
449fn validate_coordination_branch_name(branch: &str) -> Result<(), CoordinationGitError> {
450    if branch.is_empty()
451        || branch.starts_with('-')
452        || branch.starts_with('/')
453        || branch.ends_with('/')
454    {
455        return Err(CoordinationGitError::new(
456            CoordinationGitErrorKind::CommandFailed,
457            format!("invalid coordination branch name '{branch}'"),
458        ));
459    }
460    if branch.contains("..")
461        || branch.contains("@{")
462        || branch.contains("//")
463        || branch.ends_with('.')
464        || branch.ends_with(".lock")
465    {
466        return Err(CoordinationGitError::new(
467            CoordinationGitErrorKind::CommandFailed,
468            format!("invalid coordination branch name '{branch}'"),
469        ));
470    }
471
472    for ch in branch.chars() {
473        if ch.is_ascii_control() || ch == ' ' {
474            return Err(CoordinationGitError::new(
475                CoordinationGitErrorKind::CommandFailed,
476                format!("invalid coordination branch name '{branch}'"),
477            ));
478        }
479        if ch == '~' || ch == '^' || ch == ':' || ch == '?' || ch == '*' || ch == '[' || ch == '\\'
480        {
481            return Err(CoordinationGitError::new(
482                CoordinationGitErrorKind::CommandFailed,
483                format!("invalid coordination branch name '{branch}'"),
484            ));
485        }
486    }
487
488    for segment in branch.split('/') {
489        if segment.is_empty()
490            || segment.starts_with('.')
491            || segment.ends_with('.')
492            || segment.ends_with(".lock")
493        {
494            return Err(CoordinationGitError::new(
495                CoordinationGitErrorKind::CommandFailed,
496                format!("invalid coordination branch name '{branch}'"),
497            ));
498        }
499    }
500
501    Ok(())
502}
503
504struct WorktreeCleanup {
505    repo_root: std::path::PathBuf,
506    worktree_path: std::path::PathBuf,
507}
508
509impl WorktreeCleanup {
510    fn cleanup_with_runner(&self, runner: &dyn ProcessRunner) -> Result<(), CoordinationGitError> {
511        let output = run_git(
512            runner,
513            ProcessRequest::new("git")
514                .args([
515                    "worktree",
516                    "remove",
517                    "--force",
518                    self.worktree_path.to_string_lossy().as_ref(),
519                ])
520                .current_dir(&self.repo_root),
521            "worktree remove",
522        )?;
523        if output.success {
524            return Ok(());
525        }
526
527        Err(CoordinationGitError::new(
528            CoordinationGitErrorKind::CommandFailed,
529            format!(
530                "failed to remove temporary worktree '{}' ({})",
531                self.worktree_path.display(),
532                render_output(&output)
533            ),
534        ))
535    }
536}
537
538impl Drop for WorktreeCleanup {
539    fn drop(&mut self) {
540        let _ = std::process::Command::new("git")
541            .args([
542                "worktree",
543                "remove",
544                "--force",
545                self.worktree_path.to_string_lossy().as_ref(),
546            ])
547            .current_dir(&self.repo_root)
548            .output();
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::process::ProcessExecutionError;
556    use std::cell::RefCell;
557    use std::collections::VecDeque;
558
559    struct StubRunner {
560        outputs: RefCell<VecDeque<Result<ProcessOutput, ProcessExecutionError>>>,
561    }
562
563    impl StubRunner {
564        fn with_outputs(outputs: Vec<Result<ProcessOutput, ProcessExecutionError>>) -> Self {
565            Self {
566                outputs: RefCell::new(outputs.into()),
567            }
568        }
569    }
570
571    impl ProcessRunner for StubRunner {
572        fn run(&self, _request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError> {
573            self.outputs
574                .borrow_mut()
575                .pop_front()
576                .expect("expected process output")
577        }
578
579        fn run_with_timeout(
580            &self,
581            _request: &ProcessRequest,
582            _timeout: std::time::Duration,
583        ) -> Result<ProcessOutput, ProcessExecutionError> {
584            unreachable!("not used")
585        }
586    }
587
588    fn ok_output(stdout: &str, stderr: &str) -> ProcessOutput {
589        ProcessOutput {
590            exit_code: 0,
591            success: true,
592            stdout: stdout.to_string(),
593            stderr: stderr.to_string(),
594            timed_out: false,
595        }
596    }
597
598    fn err_output(stderr: &str) -> ProcessOutput {
599        ProcessOutput {
600            exit_code: 1,
601            success: false,
602            stdout: String::new(),
603            stderr: stderr.to_string(),
604            timed_out: false,
605        }
606    }
607
608    #[test]
609    fn fetch_coordination_branch_succeeds_on_clean_fetch() {
610        let runner = StubRunner::with_outputs(vec![Ok(ok_output("", ""))]);
611        let repo = std::env::temp_dir();
612        let result = fetch_coordination_branch_with_runner(&runner, &repo, "ito/internal/changes");
613        assert!(result.is_ok());
614    }
615
616    #[test]
617    fn fetch_coordination_branch_classifies_missing_remote_branch() {
618        let runner = StubRunner::with_outputs(vec![Ok(err_output(
619            "fatal: couldn't find remote ref ito/internal/changes",
620        ))]);
621        let repo = std::env::temp_dir();
622        let err = fetch_coordination_branch_with_runner(&runner, &repo, "ito/internal/changes")
623            .unwrap_err();
624        assert_eq!(err.kind, CoordinationGitErrorKind::RemoteMissing);
625        assert!(err.message.contains("does not exist"));
626    }
627
628    #[test]
629    fn push_coordination_branch_classifies_non_fast_forward_rejection() {
630        let runner = StubRunner::with_outputs(vec![Ok(err_output(
631            "! [rejected] HEAD -> ito/internal/changes (non-fast-forward)",
632        ))]);
633        let repo = std::env::temp_dir();
634        let err =
635            push_coordination_branch_with_runner(&runner, &repo, "HEAD", "ito/internal/changes")
636                .unwrap_err();
637        assert_eq!(err.kind, CoordinationGitErrorKind::NonFastForward);
638        assert!(err.message.contains("sync and retry"));
639    }
640
641    #[test]
642    fn push_coordination_branch_classifies_protection_rejection() {
643        let runner = StubRunner::with_outputs(vec![Ok(err_output(
644            "remote: error: GH006: Protected branch update failed",
645        ))]);
646        let repo = std::env::temp_dir();
647        let err =
648            push_coordination_branch_with_runner(&runner, &repo, "HEAD", "ito/internal/changes")
649                .unwrap_err();
650        assert_eq!(err.kind, CoordinationGitErrorKind::ProtectedBranch);
651    }
652
653    #[test]
654    fn fetch_coordination_branch_classifies_missing_remote_configuration() {
655        let runner = StubRunner::with_outputs(vec![Ok(err_output(
656            "fatal: 'origin' does not appear to be a git repository\nfatal: No such remote: 'origin'",
657        ))]);
658        let repo = std::env::temp_dir();
659        let err = fetch_coordination_branch_with_runner(&runner, &repo, "ito/internal/changes")
660            .unwrap_err();
661        assert_eq!(err.kind, CoordinationGitErrorKind::RemoteNotConfigured);
662        assert!(err.message.contains("not configured"));
663    }
664}