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
37impl CoordinationGitError {
38 fn new(kind: CoordinationGitErrorKind, message: impl Into<String>) -> Self {
39 Self {
40 kind,
41 message: message.into(),
42 }
43 }
44}
45
46pub 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
57pub 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
69pub 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
85pub 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
91pub 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
101pub 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}