1use anyhow::{Context, Result};
7use directories::ProjectDirs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use tracing::{debug, info, warn};
11use uuid::Uuid;
12
13#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct WorktreeInfo {
17 pub id: String,
19 pub path: PathBuf,
21 pub branch: String,
23 pub repo_path: PathBuf,
25 pub parent_branch: String,
27}
28
29pub struct WorktreeManager {
31 base_dir: PathBuf,
33 repo_path: PathBuf,
35}
36
37impl WorktreeManager {
38 pub fn new(repo_path: impl AsRef<Path>) -> Result<Self> {
40 let repo_path = repo_path.as_ref().to_path_buf();
41
42 let repo_name = repo_path
44 .file_name()
45 .map(|n| n.to_string_lossy().to_string())
46 .unwrap_or_else(|| "unknown".to_string());
47
48 let base_dir = ProjectDirs::from("com", "codetether", "codetether-agent")
49 .map(|p| p.data_dir().to_path_buf())
50 .unwrap_or_else(|| PathBuf::from("/tmp/.codetether"))
51 .join("worktrees")
52 .join(&repo_name);
53
54 std::fs::create_dir_all(&base_dir)?;
55
56 Ok(Self {
57 base_dir,
58 repo_path,
59 })
60 }
61
62 pub fn create(&self, task_slug: &str) -> Result<WorktreeInfo> {
64 let id = format!("{}-{}", task_slug, &Uuid::new_v4().to_string()[..8]);
65 let branch = format!("codetether/subagent-{}", id);
66 let worktree_path = self.base_dir.join(&id);
67
68 let parent_branch = self.current_branch()?;
70
71 info!(
72 worktree_id = %id,
73 branch = %branch,
74 path = %worktree_path.display(),
75 parent_branch = %parent_branch,
76 "Creating worktree"
77 );
78
79 let output = Command::new("git")
81 .args([
82 "worktree",
83 "add",
84 "-b",
85 &branch,
86 worktree_path.to_str().unwrap(),
87 "HEAD",
88 ])
89 .current_dir(&self.repo_path)
90 .output()
91 .context("Failed to run git worktree add")?;
92
93 if !output.status.success() {
94 let stderr = String::from_utf8_lossy(&output.stderr);
95 return Err(anyhow::anyhow!("git worktree add failed: {}", stderr));
96 }
97
98 debug!(
99 worktree_id = %id,
100 "Worktree created successfully"
101 );
102
103 Ok(WorktreeInfo {
104 id,
105 path: worktree_path,
106 branch,
107 repo_path: self.repo_path.clone(),
108 parent_branch,
109 })
110 }
111
112 pub fn inject_workspace_stub(&self, worktree_path: &Path) -> Result<()> {
117 let cargo_toml = worktree_path.join("Cargo.toml");
118 if !cargo_toml.exists() {
119 return Ok(()); }
121
122 let content = std::fs::read_to_string(&cargo_toml).context("Failed to read Cargo.toml")?;
123
124 if content.contains("[workspace]") {
126 return Ok(());
127 }
128
129 let new_content = format!("[workspace]\n\n{}", content);
131 std::fs::write(&cargo_toml, new_content)
132 .context("Failed to write Cargo.toml with workspace stub")?;
133
134 info!(
135 cargo_toml = %cargo_toml.display(),
136 "Injected [workspace] stub for hermetic isolation"
137 );
138
139 Ok(())
140 }
141
142 fn current_branch(&self) -> Result<String> {
144 let output = Command::new("git")
145 .args(["rev-parse", "--abbrev-ref", "HEAD"])
146 .current_dir(&self.repo_path)
147 .output()
148 .context("Failed to get current branch")?;
149
150 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
151 }
152
153 pub fn merge(&self, worktree: &WorktreeInfo) -> Result<MergeResult> {
155 info!(
156 worktree_id = %worktree.id,
157 branch = %worktree.branch,
158 target = %worktree.parent_branch,
159 "Merging worktree changes"
160 );
161
162 if self.is_merging() {
164 warn!(
165 worktree_id = %worktree.id,
166 "Merge already in progress - cannot start new merge"
167 );
168 return Ok(MergeResult {
169 success: false,
170 conflicts: Vec::new(),
171 conflict_diffs: Vec::new(),
172 files_changed: 0,
173 summary: "Merge already in progress".to_string(),
174 aborted: false,
175 });
176 }
177
178 let diff_output = Command::new("git")
180 .args([
181 "diff",
182 "--stat",
183 &format!("{}..{}", worktree.parent_branch, worktree.branch),
184 ])
185 .current_dir(&self.repo_path)
186 .output()?;
187
188 let diff_stat = String::from_utf8_lossy(&diff_output.stdout).to_string();
189
190 let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
192
193 if files_changed == 0 {
195 warn!(
196 worktree_id = %worktree.id,
197 "Sub-agent made NO file changes - lazy or stuck?"
198 );
199 } else {
200 let full_diff = Command::new("git")
202 .args([
203 "diff",
204 &format!("{}..{}", worktree.parent_branch, worktree.branch),
205 ])
206 .current_dir(&self.repo_path)
207 .output()?;
208 let diff_content = String::from_utf8_lossy(&full_diff.stdout);
209
210 let lazy_indicators = [
212 ("TODO", diff_content.matches("TODO").count()),
213 ("FIXME", diff_content.matches("FIXME").count()),
214 (
215 "unimplemented!",
216 diff_content.matches("unimplemented!").count(),
217 ),
218 ("todo!", diff_content.matches("todo!").count()),
219 ("panic!", diff_content.matches("panic!").count()),
220 ];
221
222 let lazy_count: usize = lazy_indicators.iter().map(|(_, c)| c).sum();
223 if lazy_count > 0 {
224 let lazy_details: Vec<String> = lazy_indicators
225 .iter()
226 .filter(|(_, c)| *c > 0)
227 .map(|(name, c)| format!("{}:{}", name, c))
228 .collect();
229 warn!(
230 worktree_id = %worktree.id,
231 lazy_markers = %lazy_details.join(", "),
232 "Sub-agent left lazy markers - review carefully!"
233 );
234 }
235
236 info!(
237 worktree_id = %worktree.id,
238 files_changed = files_changed,
239 diff_summary = %diff_stat.trim(),
240 "Sub-agent changes to merge"
241 );
242 }
243
244 let merge_output = Command::new("git")
246 .args([
247 "merge",
248 "--no-ff",
249 "-m",
250 &format!("Merge subagent worktree: {}", worktree.id),
251 &worktree.branch,
252 ])
253 .current_dir(&self.repo_path)
254 .output()
255 .context("Failed to run git merge")?;
256
257 if merge_output.status.success() {
258 info!(
259 worktree_id = %worktree.id,
260 files_changed = files_changed,
261 "Merge successful"
262 );
263
264 Ok(MergeResult {
265 success: true,
266 conflicts: Vec::new(),
267 conflict_diffs: Vec::new(),
268 files_changed,
269 summary: format!(
270 "Merged {} files from subagent {}",
271 files_changed, worktree.id
272 ),
273 aborted: false,
274 })
275 } else {
276 let stderr = String::from_utf8_lossy(&merge_output.stderr).to_string();
277 let stdout = String::from_utf8_lossy(&merge_output.stdout).to_string();
278
279 let conflicts: Vec<String> =
281 if stderr.contains("CONFLICT") || stdout.contains("CONFLICT") {
282 let status = Command::new("git")
284 .args(["diff", "--name-only", "--diff-filter=U"])
285 .current_dir(&self.repo_path)
286 .output()?;
287
288 String::from_utf8_lossy(&status.stdout)
289 .lines()
290 .map(|s| s.to_string())
291 .collect()
292 } else {
293 Vec::new()
294 };
295
296 if stderr.contains("Already up to date") || stdout.contains("Already up to date") {
298 warn!(
299 worktree_id = %worktree.id,
300 "Merge says 'Already up to date' - sub-agent may have made no commits"
301 );
302 } else if conflicts.is_empty() {
303 warn!(
305 worktree_id = %worktree.id,
306 stderr = %stderr.trim(),
307 stdout = %stdout.trim(),
308 "Merge failed for unknown reason (not conflicts)"
309 );
310 } else {
311 warn!(
312 worktree_id = %worktree.id,
313 conflicts = ?conflicts,
314 "Merge had conflicts - sub-agent's changes conflict with main"
315 );
316 }
317
318 let conflict_diffs: Vec<(String, String)> = if !conflicts.is_empty() {
320 conflicts
321 .iter()
322 .filter_map(|file| {
323 let output = Command::new("git")
324 .args(["diff", file])
325 .current_dir(&self.repo_path)
326 .output()
327 .ok()?;
328 let diff = String::from_utf8_lossy(&output.stdout).to_string();
329 if diff.is_empty() {
330 None
331 } else {
332 Some((file.clone(), diff))
333 }
334 })
335 .collect()
336 } else {
337 Vec::new()
338 };
339
340 let aborted = if conflicts.is_empty() {
343 let _ = Command::new("git")
344 .args(["merge", "--abort"])
345 .current_dir(&self.repo_path)
346 .output();
347 true
348 } else {
349 info!(
351 worktree_id = %worktree.id,
352 num_conflicts = conflicts.len(),
353 "Leaving merge in conflicted state for resolution"
354 );
355 false
356 };
357
358 Ok(MergeResult {
359 success: false,
360 conflicts,
361 conflict_diffs,
362 files_changed: 0,
363 summary: format!("Merge failed: {}", stderr),
364 aborted,
365 })
366 }
367 }
368
369 pub fn complete_merge(
371 &self,
372 worktree: &WorktreeInfo,
373 commit_message: &str,
374 ) -> Result<MergeResult> {
375 info!(
376 worktree_id = %worktree.id,
377 "Completing merge after conflict resolution"
378 );
379
380 let add_output = Command::new("git")
382 .args(["add", "-A"])
383 .current_dir(&self.repo_path)
384 .output()
385 .context("Failed to stage resolved files")?;
386
387 if !add_output.status.success() {
388 let stderr = String::from_utf8_lossy(&add_output.stderr);
389 warn!(error = %stderr, "Failed to stage resolved files");
390 }
391
392 let commit_output = Command::new("git")
394 .args(["commit", "-m", commit_message])
395 .current_dir(&self.repo_path)
396 .output()
397 .context("Failed to complete merge commit")?;
398
399 if commit_output.status.success() {
400 let stat_output = Command::new("git")
402 .args(["diff", "--stat", "HEAD~1", "HEAD"])
403 .current_dir(&self.repo_path)
404 .output()?;
405 let diff_stat = String::from_utf8_lossy(&stat_output.stdout);
406 let files_changed = diff_stat.lines().filter(|l| l.contains('|')).count();
407
408 info!(
409 worktree_id = %worktree.id,
410 files_changed = files_changed,
411 "Merge completed after conflict resolution"
412 );
413
414 Ok(MergeResult {
415 success: true,
416 conflicts: Vec::new(),
417 conflict_diffs: Vec::new(),
418 files_changed,
419 summary: format!("Merge completed after resolving conflicts"),
420 aborted: false,
421 })
422 } else {
423 let stderr = String::from_utf8_lossy(&commit_output.stderr).to_string();
424 warn!(error = %stderr, "Failed to complete merge commit");
425
426 Ok(MergeResult {
427 success: false,
428 conflicts: Vec::new(),
429 conflict_diffs: Vec::new(),
430 files_changed: 0,
431 summary: format!("Failed to complete merge: {}", stderr),
432 aborted: false,
433 })
434 }
435 }
436
437 pub fn abort_merge(&self) -> Result<()> {
439 info!("Aborting merge in progress");
440 let _ = Command::new("git")
441 .args(["merge", "--abort"])
442 .current_dir(&self.repo_path)
443 .output();
444 Ok(())
445 }
446
447 pub fn is_merging(&self) -> bool {
449 self.repo_path.join(".git/MERGE_HEAD").exists()
450 }
451
452 pub fn cleanup(&self, worktree: &WorktreeInfo) -> Result<()> {
454 info!(
455 worktree_id = %worktree.id,
456 path = %worktree.path.display(),
457 "Cleaning up worktree"
458 );
459
460 if self.is_merging() {
462 warn!(
463 worktree_id = %worktree.id,
464 "Aborted merge detected during cleanup - aborting merge state"
465 );
466 let _ = self.abort_merge();
467 }
468
469 let output = Command::new("git")
471 .args([
472 "worktree",
473 "remove",
474 "--force",
475 worktree.path.to_str().unwrap(),
476 ])
477 .current_dir(&self.repo_path)
478 .output();
479
480 if let Err(e) = output {
481 warn!(error = %e, "Failed to remove worktree via git");
482 let _ = std::fs::remove_dir_all(&worktree.path);
484 }
485
486 let _ = Command::new("git")
488 .args(["branch", "-D", &worktree.branch])
489 .current_dir(&self.repo_path)
490 .output();
491
492 debug!(
493 worktree_id = %worktree.id,
494 "Worktree cleanup complete"
495 );
496
497 Ok(())
498 }
499
500 #[allow(dead_code)]
502 pub fn list(&self) -> Result<Vec<WorktreeInfo>> {
503 let output = Command::new("git")
504 .args(["worktree", "list", "--porcelain"])
505 .current_dir(&self.repo_path)
506 .output()
507 .context("Failed to list worktrees")?;
508
509 let stdout = String::from_utf8_lossy(&output.stdout);
510 let mut worktrees = Vec::new();
511
512 let mut current_path: Option<PathBuf> = None;
513 let mut current_branch: Option<String> = None;
514
515 for line in stdout.lines() {
516 if let Some(path) = line.strip_prefix("worktree ") {
517 current_path = Some(PathBuf::from(path));
518 } else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
519 current_branch = Some(branch.to_string());
520 } else if line.is_empty() {
521 if let (Some(path), Some(branch)) = (current_path.take(), current_branch.take()) {
522 if branch.starts_with("codetether/subagent-") {
524 let id = branch
525 .strip_prefix("codetether/subagent-")
526 .unwrap_or(&branch)
527 .to_string();
528
529 worktrees.push(WorktreeInfo {
530 id,
531 path,
532 branch,
533 repo_path: self.repo_path.clone(),
534 parent_branch: String::new(), });
536 }
537 }
538 }
539 }
540
541 Ok(worktrees)
542 }
543
544 pub fn cleanup_all(&self) -> Result<usize> {
546 let worktrees = self.list()?;
547 let count = worktrees.len();
548
549 for wt in worktrees {
550 if let Err(e) = self.cleanup(&wt) {
551 warn!(worktree_id = %wt.id, error = %e, "Failed to cleanup worktree");
552 }
553 }
554
555 let _ = Command::new("git")
557 .args(["worktree", "prune"])
558 .current_dir(&self.repo_path)
559 .output();
560
561 let orphaned = self.cleanup_orphaned_branches()?;
563
564 Ok(count + orphaned)
565 }
566
567 pub fn cleanup_orphaned_branches(&self) -> Result<usize> {
569 let output = Command::new("git")
571 .args(["branch", "--list", "codetether/subagent-*"])
572 .current_dir(&self.repo_path)
573 .output()
574 .context("Failed to list branches")?;
575
576 let stdout = String::from_utf8_lossy(&output.stdout);
577 let branches: Vec<&str> = stdout
578 .lines()
579 .map(|l| l.trim().trim_start_matches("* "))
580 .filter(|l| !l.is_empty())
581 .collect();
582
583 let active_worktrees = self.list()?;
585 let active_branches: std::collections::HashSet<&str> = active_worktrees
586 .iter()
587 .map(|wt| wt.branch.as_str())
588 .collect();
589
590 let mut deleted = 0;
591 for branch in branches {
592 if !active_branches.contains(branch) {
593 info!(branch = %branch, "Deleting orphaned subagent branch");
594 let result = Command::new("git")
595 .args(["branch", "-D", branch])
596 .current_dir(&self.repo_path)
597 .output();
598
599 match result {
600 Ok(output) if output.status.success() => {
601 deleted += 1;
602 }
603 Ok(output) => {
604 let stderr = String::from_utf8_lossy(&output.stderr);
605 warn!(branch = %branch, error = %stderr, "Failed to delete orphaned branch");
606 }
607 Err(e) => {
608 warn!(branch = %branch, error = %e, "Failed to run git branch -D");
609 }
610 }
611 }
612 }
613
614 if deleted > 0 {
615 info!(count = deleted, "Cleaned up orphaned subagent branches");
616 }
617
618 Ok(deleted)
619 }
620}
621
622#[derive(Debug, Clone)]
624pub struct MergeResult {
625 pub success: bool,
626 pub conflicts: Vec<String>,
627 pub conflict_diffs: Vec<(String, String)>,
629 pub files_changed: usize,
630 pub summary: String,
631 pub aborted: bool,
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use tempfile::TempDir;
639
640 fn setup_test_repo() -> Result<(TempDir, PathBuf)> {
641 let temp = TempDir::new()?;
642 let repo_path = temp.path().to_path_buf();
643
644 Command::new("git")
646 .args(["init"])
647 .current_dir(&repo_path)
648 .output()?;
649
650 Command::new("git")
651 .args(["config", "user.email", "test@test.com"])
652 .current_dir(&repo_path)
653 .output()?;
654
655 Command::new("git")
656 .args(["config", "user.name", "Test"])
657 .current_dir(&repo_path)
658 .output()?;
659
660 std::fs::write(repo_path.join("README.md"), "# Test")?;
662 Command::new("git")
663 .args(["add", "."])
664 .current_dir(&repo_path)
665 .output()?;
666 Command::new("git")
667 .args(["commit", "-m", "Initial commit"])
668 .current_dir(&repo_path)
669 .output()?;
670
671 Ok((temp, repo_path))
672 }
673
674 #[test]
675 fn test_create_worktree() -> Result<()> {
676 let (_temp, repo_path) = setup_test_repo()?;
677 let manager = WorktreeManager::new(&repo_path)?;
678
679 let wt = manager.create("test-task")?;
680
681 assert!(wt.path.exists());
682 assert!(wt.branch.starts_with("codetether/subagent-test-task-"));
683
684 manager.cleanup(&wt)?;
686 assert!(!wt.path.exists());
687
688 Ok(())
689 }
690}