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 crate::worktree_stub::inject(worktree_path)
662 }
663
664 async fn delete_branch(repo_path: &Path, branch: &str, remote: Option<&str>) {
669 let out = tokio::process::Command::new("git")
671 .args(["branch", "-D", branch])
672 .current_dir(repo_path)
673 .output()
674 .await;
675 match out {
676 Ok(o) if o.status.success() => {
677 tracing::info!(branch, "Deleted worktree branch");
678 }
679 Ok(o) => {
680 let err = String::from_utf8_lossy(&o.stderr);
681 tracing::debug!(branch, error = %err, "Branch delete skipped");
682 }
683 Err(e) => {
684 tracing::debug!(branch, error = %e, "Branch delete failed");
685 }
686 }
687 if let Some(remote_name) = remote {
689 let out = tokio::process::Command::new("git")
690 .args(["push", remote_name, "--delete", branch])
691 .current_dir(repo_path)
692 .output()
693 .await;
694 match out {
695 Ok(o) if o.status.success() => {
696 tracing::info!(
697 branch,
698 remote = remote_name,
699 "Deleted remote worktree branch"
700 );
701 }
702 Ok(o) => {
703 let err = String::from_utf8_lossy(&o.stderr);
704 tracing::debug!(
705 branch,
706 remote = remote_name,
707 error = %err,
708 "Remote branch delete skipped"
709 );
710 }
711 Err(e) => {
712 tracing::debug!(
713 branch,
714 remote = remote_name,
715 error = %e,
716 "Remote branch delete failed"
717 );
718 }
719 }
720 }
721 }
722
723 async fn get_conflict_list(&self) -> Result<Vec<String>> {
725 let output = tokio::process::Command::new("git")
726 .args(["diff", "--name-only", "--diff-filter=U"])
727 .current_dir(&self.repo_path)
728 .output()
729 .await
730 .context("Failed to get conflict list")?;
731
732 let conflicts = String::from_utf8_lossy(&output.stdout)
733 .lines()
734 .map(String::from)
735 .filter(|s| !s.is_empty())
736 .collect();
737
738 Ok(conflicts)
739 }
740
741 async fn get_conflict_diffs(&self) -> Result<Vec<(String, String)>> {
743 let conflicts = self.get_conflict_list().await?;
744 let mut diffs = Vec::new();
745
746 for file in conflicts {
747 let output = tokio::process::Command::new("git")
748 .args(["diff", &file])
749 .current_dir(&self.repo_path)
750 .output()
751 .await;
752
753 if let Ok(o) = output {
754 let diff = String::from_utf8_lossy(&o.stdout).to_string();
755 diffs.push((file, diff));
756 }
757 }
758
759 Ok(diffs)
760 }
761
762 async fn count_merge_files_changed(&self) -> Result<usize> {
764 let output = tokio::process::Command::new("git")
765 .args(["diff", "--name-only", "HEAD~1", "HEAD"])
766 .current_dir(&self.repo_path)
767 .output()
768 .await
769 .context("Failed to count changed files")?;
770
771 let count = String::from_utf8_lossy(&output.stdout)
772 .lines()
773 .filter(|s| !s.is_empty())
774 .count();
775
776 Ok(count)
777 }
778
779 fn stash_pop(repo_path: &Path) -> Result<()> {
781 let output = std::process::Command::new("git")
782 .args(["stash", "pop"])
783 .current_dir(repo_path)
784 .output()
785 .context("Failed to execute git stash pop")?;
786 if !output.status.success() {
787 tracing::warn!(
788 "stash pop failed (may be empty stash): {}",
789 String::from_utf8_lossy(&output.stderr)
790 );
791 }
792 Ok(())
793 }
794}
795
796#[cfg(test)]
797mod tests {
798 use super::WorktreeManager;
799
800 #[test]
801 fn corruption_detection_matches_missing_blob() {
802 let output = "error: missing blob 1234abcd";
803 assert!(WorktreeManager::looks_like_object_corruption(output));
804 }
805
806 #[test]
807 fn corruption_detection_ignores_non_corruption_errors() {
808 let output = "fatal: not a git repository";
809 assert!(!WorktreeManager::looks_like_object_corruption(output));
810 }
811
812 #[test]
813 fn summarize_output_uses_first_non_empty_line() {
814 let output = "\n\nfatal: bad object HEAD\nmore";
815 assert_eq!(
816 WorktreeManager::summarize_git_output(output),
817 "fatal: bad object HEAD"
818 );
819 }
820}
821
822impl Default for WorktreeManager {
823 fn default() -> Self {
824 Self::new("/tmp/codetether-worktrees")
825 }
826}