1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CoordinationGitErrorKind {
14 NonFastForward,
16 ProtectedBranch,
18 RemoteRejected,
20 RemoteMissing,
22 RemoteNotConfigured,
24 CommandFailed,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CoordinationGitError {
31 pub kind: CoordinationGitErrorKind,
33 pub message: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum CoordinationBranchSetupStatus {
40 Ready,
42 Created,
44}
45
46impl CoordinationGitError {
47 fn new(kind: CoordinationGitErrorKind, message: impl Into<String>) -> Self {
60 Self {
61 kind,
62 message: message.into(),
63 }
64 }
65}
66
67pub 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
78pub 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
100pub 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
127pub 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
151pub 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
166pub 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
188pub 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
216pub 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
245pub(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
362pub(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
418pub(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;