1use crate::provenance::{ExecutionOrigin, ExecutionProvenance, git_commit_with_provenance};
6use anyhow::{Context, Result, anyhow};
7use std::path::{Path, PathBuf};
8use tokio::sync::Mutex;
9
10#[derive(Debug, Clone)]
12pub struct WorktreeInfo {
13 pub name: String,
15 pub path: PathBuf,
17 pub branch: String,
19 #[allow(dead_code)]
21 pub active: bool,
22}
23
24#[derive(Debug)]
26pub struct WorktreeManager {
27 base_dir: PathBuf,
29 repo_path: PathBuf,
31 worktrees: Mutex<Vec<WorktreeInfo>>,
33 integrity_checked: Mutex<bool>,
35}
36
37#[derive(Debug, Clone)]
39pub struct MergeResult {
40 pub success: bool,
41 pub aborted: bool,
42 pub conflicts: Vec<String>,
43 pub conflict_diffs: Vec<(String, String)>,
44 pub files_changed: usize,
45 pub summary: String,
46}
47
48impl WorktreeManager {
49 pub fn new(base_dir: impl Into<PathBuf>) -> Self {
51 Self {
52 base_dir: base_dir.into(),
53 repo_path: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
54 worktrees: Mutex::new(Vec::new()),
55 integrity_checked: Mutex::new(false),
56 }
57 }
58
59 #[allow(dead_code)]
61 pub fn with_repo(base_dir: impl Into<PathBuf>, repo_path: impl Into<PathBuf>) -> Self {
62 Self {
63 base_dir: base_dir.into(),
64 repo_path: repo_path.into(),
65 worktrees: Mutex::new(Vec::new()),
66 integrity_checked: Mutex::new(false),
67 }
68 }
69
70 pub async fn create(&self, name: &str) -> Result<WorktreeInfo> {
75 self.ensure_repo_integrity_once().await?;
76 Self::validate_worktree_name(name)?;
77 let worktree_path = self.base_dir.join(name);
78 let branch_name = format!("codetether/{}", name);
79
80 tokio::fs::create_dir_all(&self.base_dir)
82 .await
83 .with_context(|| {
84 format!(
85 "Failed to create base directory: {}",
86 self.base_dir.display()
87 )
88 })?;
89
90 let output = tokio::process::Command::new("git")
92 .args(["worktree", "add", "-b", &branch_name])
93 .arg(&worktree_path)
94 .current_dir(&self.repo_path)
95 .output()
96 .await
97 .context("Failed to execute git worktree add")?;
98
99 if !output.status.success() {
100 let output2 = tokio::process::Command::new("git")
102 .args(["worktree", "add"])
103 .arg(&worktree_path)
104 .arg(&branch_name)
105 .current_dir(&self.repo_path)
106 .output()
107 .await
108 .context("Failed to execute git worktree add (fallback)")?;
109
110 if !output2.status.success() {
111 return Err(anyhow!(
112 "Failed to create git worktree '{}': {}",
113 name,
114 String::from_utf8_lossy(&output2.stderr)
115 ));
116 }
117 }
118
119 let info = WorktreeInfo {
120 name: name.to_string(),
121 path: worktree_path.clone(),
122 branch: branch_name,
123 active: true,
124 };
125
126 let mut worktrees = self.worktrees.lock().await;
127 worktrees.push(info.clone());
128
129 tracing::info!(worktree = %name, path = %worktree_path.display(), "Created git worktree");
130 Ok(info)
131 }
132
133 pub async fn ensure_repo_integrity(&self) -> Result<()> {
137 let first_check = self.run_repo_fsck().await?;
138 if first_check.status.success() {
139 return Ok(());
140 }
141
142 let first_output = Self::combined_output(&first_check.stdout, &first_check.stderr);
143 if !Self::looks_like_object_corruption(&first_output) {
144 return Err(anyhow!(
145 "Git repository preflight failed: {}",
146 Self::summarize_git_output(&first_output)
147 ));
148 }
149
150 tracing::warn!(
151 repo_path = %self.repo_path.display(),
152 issue = %Self::summarize_git_output(&first_output),
153 "Detected git object corruption; attempting automatic repair"
154 );
155 self.try_auto_repair().await;
156
157 let second_check = self.run_repo_fsck().await?;
158 if second_check.status.success() {
159 tracing::info!(
160 repo_path = %self.repo_path.display(),
161 "Git repository integrity restored after automatic repair"
162 );
163 return Ok(());
164 }
165
166 let second_output = Self::combined_output(&second_check.stdout, &second_check.stderr);
167 Err(Self::integrity_error_message(
168 &self.repo_path,
169 &second_output,
170 ))
171 }
172
173 #[allow(dead_code)]
175 pub async fn get(&self, name: &str) -> Option<WorktreeInfo> {
176 let worktrees = self.worktrees.lock().await;
177 worktrees.iter().find(|w| w.name == name).cloned()
178 }
179
180 pub async fn list(&self) -> Vec<WorktreeInfo> {
182 self.worktrees.lock().await.clone()
183 }
184
185 pub async fn cleanup(&self, name: &str) -> Result<()> {
187 let info = {
189 let worktrees = self.worktrees.lock().await;
190 match worktrees.iter().find(|w| w.name == name) {
191 Some(w) => w.clone(),
192 None => return Ok(()),
193 }
194 };
195 let branch = info.branch.clone();
196
197 let output = tokio::process::Command::new("git")
199 .args(["worktree", "remove", "--force"])
200 .arg(&info.path)
201 .current_dir(&self.repo_path)
202 .output()
203 .await;
204
205 match output {
206 Ok(o) if o.status.success() => {
207 tracing::info!(worktree = %name, "Removed git worktree");
208 }
209 Ok(o) => {
210 tracing::warn!(
211 worktree = %name,
212 error = %String::from_utf8_lossy(&o.stderr),
213 "Git worktree remove failed, falling back to directory removal"
214 );
215 if let Err(e) = tokio::fs::remove_dir_all(&info.path).await {
216 tracing::warn!(worktree = %name, error = %e, "Failed to remove worktree directory");
217 }
218 }
219 Err(e) => {
220 tracing::warn!(worktree = %name, error = %e, "Failed to execute git worktree remove");
221 if let Err(e) = tokio::fs::remove_dir_all(&info.path).await {
222 tracing::warn!(worktree = %name, error = %e, "Failed to remove worktree directory");
223 }
224 }
225 }
226
227 Self::delete_branch(&self.repo_path, &branch, None).await;
229 Ok(())
230 }
231
232 pub async fn merge(&self, name: &str) -> Result<MergeResult> {
236 let worktrees = self.worktrees.lock().await;
237 let info = worktrees
238 .iter()
239 .find(|w| w.name == name)
240 .ok_or_else(|| anyhow!("Worktree not found: {}", name))?;
241
242 let branch = info.branch.clone();
243 drop(worktrees); let umg = std::process::Command::new("git")
249 .args(["diff", "--name-only", "--diff-filter=U"])
250 .current_dir(&self.repo_path)
251 .output()
252 .context("Failed to check for unmerged files")?;
253 if !String::from_utf8_lossy(&umg.stdout).trim().is_empty() {
254 tracing::warn!("Resetting unmerged index entries before merge");
255 let _ = std::process::Command::new("git")
257 .args(["merge", "--abort"])
258 .current_dir(&self.repo_path)
259 .output();
260 let _ = std::process::Command::new("git")
262 .args(["reset", "HEAD", "--"])
263 .current_dir(&self.repo_path)
264 .output();
265 let _ = std::process::Command::new("git")
267 .args(["checkout", "--", "."])
268 .current_dir(&self.repo_path)
269 .output();
270 }
271
272 let dirty = std::process::Command::new("git")
274 .args(["diff", "--quiet"])
275 .current_dir(&self.repo_path)
276 .output();
277 let has_dirty = dirty.is_err() || !dirty.unwrap().status.success();
278
279 if has_dirty {
280 tracing::info!("Stashing dirty working tree before merge");
281 let stash_out = std::process::Command::new("git")
282 .args(["stash", "--include-untracked"])
283 .current_dir(&self.repo_path)
284 .output()
285 .context("Failed to execute git stash")?;
286 if !stash_out.status.success() {
287 tracing::warn!(
288 "Stash failed (may be no changes): {}",
289 String::from_utf8_lossy(&stash_out.stderr)
290 );
291 }
292 }
293
294 tracing::info!(worktree = %name, branch = %branch, "Starting git merge");
295
296 let mut output = tokio::process::Command::new("git")
299 .args(["merge", "--no-ff", "--no-commit", &branch])
300 .current_dir(&self.repo_path)
301 .output()
302 .await
303 .context("Failed to execute git merge")?;
304
305 let stdout = String::from_utf8_lossy(&output.stdout);
306 let stderr = String::from_utf8_lossy(&output.stderr);
307
308 if !output.status.success() && (stderr.contains("CONFLICT") || stdout.contains("CONFLICT"))
312 {
313 tracing::warn!(
314 worktree = %name,
315 "Merge has conflicts — auto-resolving with -X theirs"
316 );
317 let _ = tokio::process::Command::new("git")
319 .args(["merge", "--abort"])
320 .current_dir(&self.repo_path)
321 .output()
322 .await;
323
324 output = tokio::process::Command::new("git")
326 .args(["merge", "--no-ff", "--no-commit", "-X", "theirs", &branch])
327 .current_dir(&self.repo_path)
328 .output()
329 .await
330 .context("Failed to execute git merge -X theirs")?;
331 }
332
333 let stdout = String::from_utf8_lossy(&output.stdout);
334 let stderr = String::from_utf8_lossy(&output.stderr);
335
336 if output.status.success() {
337 let commit_msg = format!("Merge branch '{}' into current branch", branch);
338 let provenance =
339 ExecutionProvenance::for_operation("worktree", ExecutionOrigin::LocalCli);
340 let commit_output =
341 git_commit_with_provenance(&self.repo_path, &commit_msg, Some(&provenance)).await?;
342 if !commit_output.status.success() {
343 let commit_stderr = String::from_utf8_lossy(&commit_output.stderr);
344 let _ = Self::stash_pop(&self.repo_path);
345 return Err(anyhow!("Git merge commit failed: {}", commit_stderr));
346 }
347 tracing::info!(worktree = %name, branch = %branch, "Git merge successful");
348
349 let _ = Self::stash_pop(&self.repo_path);
351
352 let files_changed = self.count_merge_files_changed().await.unwrap_or(0);
354
355 Ok(MergeResult {
356 success: true,
357 aborted: false,
358 conflicts: vec![],
359 conflict_diffs: vec![],
360 files_changed,
361 summary: commit_msg,
362 })
363 } else {
364 let _ = tokio::process::Command::new("git")
366 .args(["merge", "--abort"])
367 .current_dir(&self.repo_path)
368 .output()
369 .await;
370 let _ = Self::stash_pop(&self.repo_path);
371
372 if stderr.contains("CONFLICT") || stdout.contains("CONFLICT") {
374 tracing::warn!(worktree = %name, "Merge has conflicts");
375
376 let conflicts = self.get_conflict_list().await?;
377 let conflict_diffs = self.get_conflict_diffs().await?;
378
379 Ok(MergeResult {
380 success: false,
381 aborted: false,
382 conflicts,
383 conflict_diffs,
384 files_changed: 0,
385 summary: "Merge has conflicts that need resolution".to_string(),
386 })
387 } else {
388 Err(anyhow!("Git merge failed: {}", stderr))
389 }
390 }
391 }
392
393 pub async fn complete_merge(&self, name: &str, commit_msg: &str) -> Result<MergeResult> {
397 let worktrees = self.worktrees.lock().await;
398 let info = worktrees
399 .iter()
400 .find(|w| w.name == name)
401 .ok_or_else(|| anyhow!("Worktree not found: {}", name))?;
402
403 let branch = info.branch.clone();
404 drop(worktrees);
405
406 let merge_head = self.merge_head_path().await?;
408 let in_merge = tokio::fs::try_exists(&merge_head).await.unwrap_or(false);
409
410 if !in_merge {
411 return Err(anyhow!("Not in a merge state. Use merge() first."));
412 }
413
414 let provenance = ExecutionProvenance::for_operation("worktree", ExecutionOrigin::LocalCli);
416 let output =
417 git_commit_with_provenance(&self.repo_path, commit_msg, Some(&provenance)).await?;
418
419 if output.status.success() {
420 tracing::info!(worktree = %name, branch = %branch, "Merge completed");
421
422 let files_changed = self.count_merge_files_changed().await.unwrap_or(0);
423
424 Ok(MergeResult {
425 success: true,
426 aborted: false,
427 conflicts: vec![],
428 conflict_diffs: vec![],
429 files_changed,
430 summary: format!("Merge completed: {}", commit_msg),
431 })
432 } else {
433 let stderr = String::from_utf8_lossy(&output.stderr);
434 Err(anyhow!("Failed to complete merge: {}", stderr))
435 }
436 }
437
438 pub async fn abort_merge(&self, name: &str) -> Result<()> {
440 let worktrees = self.worktrees.lock().await;
441 if !worktrees.iter().any(|w| w.name == name) {
442 return Err(anyhow!("Worktree not found: {}", name));
443 }
444 drop(worktrees);
445
446 let merge_head = self.merge_head_path().await?;
448 let in_merge = tokio::fs::try_exists(&merge_head).await.unwrap_or(false);
449
450 if !in_merge {
451 tracing::warn!("Not in a merge state, nothing to abort");
452 return Ok(());
453 }
454
455 let output = tokio::process::Command::new("git")
456 .args(["merge", "--abort"])
457 .current_dir(&self.repo_path)
458 .output()
459 .await
460 .context("Failed to execute git merge --abort")?;
461
462 if output.status.success() {
463 tracing::info!("Merge aborted");
464 Ok(())
465 } else {
466 let stderr = String::from_utf8_lossy(&output.stderr);
467 Err(anyhow!("Failed to abort merge: {}", stderr))
468 }
469 }
470
471 fn validate_worktree_name(name: &str) -> Result<()> {
472 if name.is_empty() {
473 return Err(anyhow!("Worktree name cannot be empty"));
474 }
475 if name
476 .chars()
477 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
478 {
479 return Ok(());
480 }
481 Err(anyhow!(
482 "Invalid worktree name '{}'. Only alphanumeric characters, '-' and '_' are allowed.",
483 name
484 ))
485 }
486
487 async fn ensure_repo_integrity_once(&self) -> Result<()> {
488 let mut checked = self.integrity_checked.lock().await;
489 if *checked {
490 return Ok(());
491 }
492 self.ensure_repo_integrity().await?;
493 *checked = true;
494 Ok(())
495 }
496
497 async fn run_repo_fsck(&self) -> Result<std::process::Output> {
498 tokio::process::Command::new("git")
499 .args(["fsck", "--full", "--no-dangling"])
500 .current_dir(&self.repo_path)
501 .output()
502 .await
503 .context("Failed to execute git fsck --full --no-dangling")
504 }
505
506 async fn try_auto_repair(&self) {
507 self.run_repair_step(["fetch", "--all", "--prune", "--tags"])
508 .await;
509 self.run_repair_step(["worktree", "prune"]).await;
510 self.run_repair_step(["gc", "--prune=now"]).await;
511 }
512
513 async fn run_repair_step<const N: usize>(&self, args: [&str; N]) {
514 match tokio::process::Command::new("git")
515 .args(args)
516 .current_dir(&self.repo_path)
517 .output()
518 .await
519 {
520 Ok(output) if output.status.success() => {
521 tracing::info!(
522 repo_path = %self.repo_path.display(),
523 command = %format!("git {}", args.join(" ")),
524 "Git repair step succeeded"
525 );
526 }
527 Ok(output) => {
528 tracing::warn!(
529 repo_path = %self.repo_path.display(),
530 command = %format!("git {}", args.join(" ")),
531 error = %Self::summarize_git_output(&Self::combined_output(
532 &output.stdout,
533 &output.stderr
534 )),
535 "Git repair step failed"
536 );
537 }
538 Err(error) => {
539 tracing::warn!(
540 repo_path = %self.repo_path.display(),
541 command = %format!("git {}", args.join(" ")),
542 error = %error,
543 "Failed to execute git repair step"
544 );
545 }
546 }
547 }
548
549 fn integrity_error_message(repo_path: &Path, fsck_output: &str) -> anyhow::Error {
550 let summary = Self::summarize_git_output(fsck_output);
551 anyhow!(
552 "Git object database is corrupted in '{}': {}\n\
553Automatic repair was attempted but repository integrity is still broken.\n\
554Recovery steps:\n\
5551. Backup local changes: git diff > /tmp/codetether-recovery.patch\n\
5562. Attempt object recovery: git fetch --all --prune --tags && git fsck --full\n\
5573. If corruption remains, create a fresh clone and re-apply the patch.",
558 repo_path.display(),
559 summary
560 )
561 }
562
563 fn combined_output(stdout: &[u8], stderr: &[u8]) -> String {
564 let left = String::from_utf8_lossy(stdout);
565 let right = String::from_utf8_lossy(stderr);
566 format!("{left}\n{right}")
567 }
568
569 fn looks_like_object_corruption(output: &str) -> bool {
570 let lower = output.to_ascii_lowercase();
571 [
572 "missing blob",
573 "missing tree",
574 "missing commit",
575 "bad object",
576 "unable to read",
577 "object file",
578 "hash mismatch",
579 "broken link from",
580 "corrupt",
581 "invalid sha1 pointer",
582 "fatal: loose object",
583 "failed to parse commit",
584 ]
585 .iter()
586 .any(|needle| lower.contains(needle))
587 }
588
589 fn summarize_git_output(output: &str) -> String {
590 output
591 .lines()
592 .map(str::trim)
593 .find(|line| !line.is_empty())
594 .map(|line| line.chars().take(220).collect::<String>())
595 .unwrap_or_else(|| "git command reported no details".to_string())
596 }
597
598 async fn merge_head_path(&self) -> Result<PathBuf> {
599 let output = tokio::process::Command::new("git")
600 .args(["rev-parse", "--git-path", "MERGE_HEAD"])
601 .current_dir(&self.repo_path)
602 .output()
603 .await
604 .context("Failed to determine git merge metadata path")?;
605
606 if !output.status.success() {
607 return Err(anyhow!(
608 "Failed to resolve merge metadata path: {}",
609 String::from_utf8_lossy(&output.stderr).trim()
610 ));
611 }
612
613 let merge_head = String::from_utf8_lossy(&output.stdout).trim().to_string();
614 if merge_head.is_empty() {
615 return Err(anyhow!("Git returned an empty MERGE_HEAD path"));
616 }
617
618 let path = PathBuf::from(&merge_head);
619 if path.is_absolute() {
620 Ok(path)
621 } else {
622 Ok(self.repo_path.join(path))
623 }
624 }
625
626 pub async fn cleanup_all(&self) -> Result<usize> {
628 let infos: Vec<WorktreeInfo> = {
630 let worktrees = self.worktrees.lock().await;
631 worktrees.clone()
632 };
633 let count = infos.len();
634
635 for info in &infos {
636 let _ = tokio::process::Command::new("git")
638 .args(["worktree", "remove", "--force"])
639 .arg(&info.path)
640 .current_dir(&self.repo_path)
641 .output()
642 .await;
643
644 if let Err(e) = tokio::fs::remove_dir_all(&info.path).await {
646 tracing::warn!(worktree = %info.name, error = %e, "Failed to remove worktree directory");
647 }
648 }
649
650 for info in &infos {
652 Self::delete_branch(&self.repo_path, &info.branch, None).await;
653 }
654
655 tracing::info!(count, "Cleaned up all worktrees");
656 Ok(count)
657 }
658
659 pub fn inject_workspace_stub(&self, _worktree_path: &Path) -> Result<()> {
661 Ok(())
663 }
664
665 async fn delete_branch(repo_path: &Path, branch: &str, remote: Option<&str>) {
670 let out = tokio::process::Command::new("git")
672 .args(["branch", "-D", branch])
673 .current_dir(repo_path)
674 .output()
675 .await;
676 match out {
677 Ok(o) if o.status.success() => {
678 tracing::info!(branch, "Deleted worktree branch");
679 }
680 Ok(o) => {
681 let err = String::from_utf8_lossy(&o.stderr);
682 tracing::debug!(branch, error = %err, "Branch delete skipped");
683 }
684 Err(e) => {
685 tracing::debug!(branch, error = %e, "Branch delete failed");
686 }
687 }
688 if let Some(remote_name) = remote {
690 let out = tokio::process::Command::new("git")
691 .args(["push", remote_name, "--delete", branch])
692 .current_dir(repo_path)
693 .output()
694 .await;
695 match out {
696 Ok(o) if o.status.success() => {
697 tracing::info!(
698 branch,
699 remote = remote_name,
700 "Deleted remote worktree branch"
701 );
702 }
703 Ok(o) => {
704 let err = String::from_utf8_lossy(&o.stderr);
705 tracing::debug!(
706 branch,
707 remote = remote_name,
708 error = %err,
709 "Remote branch delete skipped"
710 );
711 }
712 Err(e) => {
713 tracing::debug!(
714 branch,
715 remote = remote_name,
716 error = %e,
717 "Remote branch delete failed"
718 );
719 }
720 }
721 }
722 }
723
724 async fn get_conflict_list(&self) -> Result<Vec<String>> {
726 let output = tokio::process::Command::new("git")
727 .args(["diff", "--name-only", "--diff-filter=U"])
728 .current_dir(&self.repo_path)
729 .output()
730 .await
731 .context("Failed to get conflict list")?;
732
733 let conflicts = String::from_utf8_lossy(&output.stdout)
734 .lines()
735 .map(String::from)
736 .filter(|s| !s.is_empty())
737 .collect();
738
739 Ok(conflicts)
740 }
741
742 async fn get_conflict_diffs(&self) -> Result<Vec<(String, String)>> {
744 let conflicts = self.get_conflict_list().await?;
745 let mut diffs = Vec::new();
746
747 for file in conflicts {
748 let output = tokio::process::Command::new("git")
749 .args(["diff", &file])
750 .current_dir(&self.repo_path)
751 .output()
752 .await;
753
754 if let Ok(o) = output {
755 let diff = String::from_utf8_lossy(&o.stdout).to_string();
756 diffs.push((file, diff));
757 }
758 }
759
760 Ok(diffs)
761 }
762
763 async fn count_merge_files_changed(&self) -> Result<usize> {
765 let output = tokio::process::Command::new("git")
766 .args(["diff", "--name-only", "HEAD~1", "HEAD"])
767 .current_dir(&self.repo_path)
768 .output()
769 .await
770 .context("Failed to count changed files")?;
771
772 let count = String::from_utf8_lossy(&output.stdout)
773 .lines()
774 .filter(|s| !s.is_empty())
775 .count();
776
777 Ok(count)
778 }
779
780 fn stash_pop(repo_path: &Path) -> Result<()> {
782 let output = std::process::Command::new("git")
783 .args(["stash", "pop"])
784 .current_dir(repo_path)
785 .output()
786 .context("Failed to execute git stash pop")?;
787 if !output.status.success() {
788 tracing::warn!(
789 "stash pop failed (may be empty stash): {}",
790 String::from_utf8_lossy(&output.stderr)
791 );
792 }
793 Ok(())
794 }
795}
796
797#[cfg(test)]
798mod tests {
799 use super::WorktreeManager;
800
801 #[test]
802 fn corruption_detection_matches_missing_blob() {
803 let output = "error: missing blob 1234abcd";
804 assert!(WorktreeManager::looks_like_object_corruption(output));
805 }
806
807 #[test]
808 fn corruption_detection_ignores_non_corruption_errors() {
809 let output = "fatal: not a git repository";
810 assert!(!WorktreeManager::looks_like_object_corruption(output));
811 }
812
813 #[test]
814 fn summarize_output_uses_first_non_empty_line() {
815 let output = "\n\nfatal: bad object HEAD\nmore";
816 assert_eq!(
817 WorktreeManager::summarize_git_output(output),
818 "fatal: bad object HEAD"
819 );
820 }
821}
822
823impl Default for WorktreeManager {
824 fn default() -> Self {
825 Self::new("/tmp/codetether-worktrees")
826 }
827}