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 push_coordination_branch_with_runner(runner, repo_root, "HEAD", branch)
280 .map(|()| CoordinationBranchSetupStatus::Created)
281 }
282 }
283}
284
285pub(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
341pub(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}