Skip to main content

oxi/skills/
worktree.rs

1//! Git worktree management skill for oxi
2//!
3//! Provides typed Rust abstractions over `git worktree` operations:
4//!
5//! - **Create** worktrees for parallel feature/hotfix branches
6//! - **List** existing worktrees with branch, commit, and status info
7//! - **Merge** a worktree's branch back into a target branch
8//! - **Clean up** (remove) worktrees that are no longer needed
9//! - **Branch** — create a new branch + worktree in one step
10//!
11//! All operations are carried out by spawning `git` subprocesses — this
12//! module does not link against `libgit2`.
13//!
14//! # Example
15//!
16//! ```rust,no_run
17//! use oxi::skills::worktree::{WorktreeManager, WorktreeCreateOpts};
18//!
19//! #[tokio::main]
20//! async fn main() -> anyhow::Result<()> {
21//!     let mgr = WorktreeManager::for_current_repo()?;
22//!
23//!     // Create a worktree for a feature branch
24//!     let opts = WorktreeCreateOpts::feature_branch("feat/auth", &std::path::PathBuf::from("."));
25//!     let wt = mgr.create(&opts).await?;
26//!     println!("Created worktree at {}", wt.path.display());
27//!
28//!     // List all worktrees
29//!     let list = mgr.list().await?;
30//!     for wt in &list.worktrees {
31//!         println!("{} [{}] at {}", wt.branch(), wt.commit_short(), wt.path.display());
32//!     }
33//!
34//!     // Merge and remove
35//!     mgr.merge_and_remove("feat/auth", "main").await?;
36//!     Ok(())
37//! }
38//! ```
39
40use anyhow::{bail, Context, Result};
41use serde::{Deserialize, Serialize};
42use std::fmt;
43use std::path::{Path, PathBuf};
44use tokio::process::Command;
45
46// ── Worktree info ─────────────────────────────────────────────────────
47
48/// Information about a single git worktree.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct WorktreeInfo {
51    /// Absolute path to the worktree directory.
52    pub path: PathBuf,
53    /// Branch name (detached HEAD → `None`).
54    pub branch: Option<String>,
55    /// Full commit SHA.
56    pub commit: String,
57    /// Whether this is the main/primary worktree.
58    pub is_main: bool,
59    /// Whether this worktree is bare.
60    pub is_bare: bool,
61    /// Whether the worktree has a detached HEAD.
62    pub is_detached: bool,
63    /// Whether the worktree is prunable (directory missing/deleted).
64    pub is_prunable: bool,
65    /// Whether the working directory is locked.
66    pub is_locked: bool,
67}
68
69impl WorktreeInfo {
70    /// Short (7-char) commit hash.
71    pub fn commit_short(&self) -> String {
72        self.commit.chars().take(7).collect()
73    }
74
75    /// Branch name, falling back to the short commit for detached HEAD.
76    pub fn branch(&self) -> String {
77        self.branch
78            .clone()
79            .unwrap_or_else(|| format!("(detached {})", self.commit_short()))
80    }
81
82    /// Human-readable label: the directory name of the worktree.
83    pub fn dir_name(&self) -> String {
84        self.path
85            .file_name()
86            .map(|n| n.to_string_lossy().to_string())
87            .unwrap_or_else(|| self.path.display().to_string())
88    }
89}
90
91impl fmt::Display for WorktreeInfo {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        let main_flag = if self.is_main { " [main]" } else { "" };
94        let locked_flag = if self.is_locked { " [locked]" } else { "" };
95        let prunable_flag = if self.is_prunable { " [prunable]" } else { "" };
96        write!(
97            f,
98            "{} {} at {}{}{}{}",
99            self.branch(),
100            self.commit_short(),
101            self.path.display(),
102            main_flag,
103            locked_flag,
104            prunable_flag,
105        )
106    }
107}
108
109// ── Create options ────────────────────────────────────────────────────
110
111/// Options for creating a new worktree.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct WorktreeCreateOpts {
114    /// Branch name to create (or check out if it already exists).
115    pub branch: String,
116    /// Target path for the new worktree.
117    /// Can be absolute or relative to the repository root.
118    pub path: PathBuf,
119    /// Starting point for the new branch (default: HEAD).
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub start_point: Option<String>,
122    /// If `true`, create a new branch even if one with the same name exists
123    /// (forces `-B` instead of `-b`). Default: `false`.
124    #[serde(default)]
125    pub force_branch: bool,
126    /// If `true`, detach HEAD in the new worktree instead of creating a branch.
127    /// Default: `false`.
128    #[serde(default)]
129    pub detach: bool,
130}
131
132impl WorktreeCreateOpts {
133    /// Build options for a simple feature-branch worktree.
134    ///
135    /// The path is derived by appending the branch name (with `/` → `-`) as a
136    /// sibling directory next to the repo root.
137    pub fn feature_branch(branch: &str, repo_root: &Path) -> Self {
138        let safe_name = branch.replace('/', "-");
139        let parent = repo_root
140            .parent()
141            .unwrap_or(repo_root);
142        let path = parent.join(safe_name);
143        Self {
144            branch: branch.to_string(),
145            path,
146            start_point: None,
147            force_branch: false,
148            detach: false,
149        }
150    }
151
152    /// Build options for a hotfix worktree based on a specific commit/tag.
153    pub fn hotfix(branch: &str, start_point: &str, repo_root: &Path) -> Self {
154        let safe_name = branch.replace('/', "-");
155        let parent = repo_root
156            .parent()
157            .unwrap_or(repo_root);
158        let path = parent.join(safe_name);
159        Self {
160            branch: branch.to_string(),
161            path,
162            start_point: Some(start_point.to_string()),
163            force_branch: false,
164            detach: false,
165        }
166    }
167}
168
169// ── Merge result ──────────────────────────────────────────────────────
170
171/// Outcome of merging a worktree's branch into a target branch.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct MergeResult {
174    /// Branch that was merged (source).
175    pub source_branch: String,
176    /// Branch that received the merge (target).
177    pub target_branch: String,
178    /// Whether the merge produced a merge commit (vs fast-forward).
179    pub was_merge_commit: bool,
180    /// Merge commit SHA (if a merge commit was created).
181    pub merge_commit: Option<String>,
182    /// Whether there were conflicts during the merge.
183    pub had_conflicts: bool,
184    /// Conflict details (file paths) if `had_conflicts` is true.
185    pub conflicts: Vec<String>,
186}
187
188// ── List result ───────────────────────────────────────────────────────
189
190/// Summary of all worktrees in a repository.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct WorktreeList {
193    /// All worktrees found.
194    pub worktrees: Vec<WorktreeInfo>,
195    /// Path to the main repository.
196    pub repo_root: PathBuf,
197}
198
199impl WorktreeList {
200    /// Number of worktrees.
201    pub fn len(&self) -> usize {
202        self.worktrees.len()
203    }
204
205    /// Whether there are no worktrees (should always have at least the main one).
206    pub fn is_empty(&self) -> bool {
207        self.worktrees.is_empty()
208    }
209
210    /// The main (primary) worktree.
211    pub fn main(&self) -> Option<&WorktreeInfo> {
212        self.worktrees.iter().find(|w| w.is_main)
213    }
214
215    /// Non-main worktrees (feature/hotfix worktrees).
216    pub fn linked(&self) -> Vec<&WorktreeInfo> {
217        self.worktrees.iter().filter(|w| !w.is_main).collect()
218    }
219
220    /// Find a worktree by branch name.
221    pub fn by_branch(&self, branch: &str) -> Option<&WorktreeInfo> {
222        self.worktrees
223            .iter()
224            .find(|w| w.branch.as_deref() == Some(branch))
225    }
226
227    /// Find a worktree by its directory path.
228    pub fn by_path(&self, path: &Path) -> Option<&WorktreeInfo> {
229        self.worktrees.iter().find(|w| w.path == path)
230    }
231}
232
233// ── Worktree manager ─────────────────────────────────────────────────
234
235/// Manages git worktree operations for a repository.
236///
237/// All commands are run via the `git` CLI in async subprocesses.
238#[derive(Debug, Clone)]
239pub struct WorktreeManager {
240    /// Root directory of the git repository (the `.git` location).
241    repo_root: PathBuf,
242}
243
244impl WorktreeManager {
245    /// Create a manager for the repository at `repo_root`.
246    ///
247    /// Verifies that the directory is inside a git repository.
248    pub fn new(repo_root: &Path) -> Result<Self> {
249        // Canonicalize to resolve symlinks
250        let canonical = repo_root
251            .canonicalize()
252            .with_context(|| format!("Cannot resolve path: {}", repo_root.display()))?;
253
254        Ok(Self {
255            repo_root: canonical,
256        })
257    }
258
259    /// Create a manager by auto-detecting the git repo from the current
260    /// directory (or any directory).
261    pub fn for_current_repo() -> Result<Self> {
262        let output = std::process::Command::new("git")
263            .args(["rev-parse", "--show-toplevel"])
264            .output()
265            .context("Failed to run `git rev-parse`. Is git installed?")?;
266
267        if !output.status.success() {
268            bail!(
269                "Not inside a git repository: {}",
270                String::from_utf8_lossy(&output.stderr).trim()
271            );
272        }
273
274        let root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
275        Self::new(&root)
276    }
277
278    /// The repository root path.
279    pub fn repo_root(&self) -> &Path {
280        &self.repo_root
281    }
282
283    // ── Create ────────────────────────────────────────────────────────
284
285    /// Create a new worktree with the given options.
286    pub async fn create(&self, opts: &WorktreeCreateOpts) -> Result<WorktreeInfo> {
287        let mut args = vec!["worktree".to_string(), "add".to_string()];
288
289        if opts.detach {
290            args.push("--detach".to_string());
291        } else if opts.force_branch {
292            args.push("-B".to_string());
293            args.push(opts.branch.clone());
294        } else {
295            args.push("-b".to_string());
296            args.push(opts.branch.clone());
297        }
298
299        args.push(opts.path.to_string_lossy().to_string());
300
301        if let Some(ref sp) = opts.start_point {
302            args.push(sp.clone());
303        }
304
305        let str_args: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
306        self.run_git(&str_args).await?;
307
308        // Return the created worktree info
309        let path = if opts.path.is_absolute() {
310            opts.path.clone()
311        } else {
312            self.repo_root.join(&opts.path)
313        };
314
315        let canonical = path
316            .canonicalize()
317            .context("Worktree path was created but cannot be resolved")?;
318
319        // Find it in the list
320        let list = self.list().await?;
321        list.by_path(&canonical)
322            .cloned()
323            .context("Worktree was created but not found in listing")
324    }
325
326    /// Create a worktree at `target_path` that checks out `branch`.
327    ///
328    /// If `branch` doesn't exist yet, it is created from `start_point` (or HEAD).
329    pub async fn create_worktree(
330        &self,
331        branch: &str,
332        target_path: &Path,
333        start_point: Option<&str>,
334    ) -> Result<WorktreeInfo> {
335        let opts = WorktreeCreateOpts {
336            branch: branch.to_string(),
337            path: target_path.to_path_buf(),
338            start_point: start_point.map(|s| s.to_string()),
339            force_branch: false,
340            detach: false,
341        };
342        self.create(&opts).await
343    }
344
345    // ── List ──────────────────────────────────────────────────────────
346
347    /// List all worktrees in the repository.
348    ///
349    /// Uses `git worktree list --porcelain` for machine-readable output.
350    pub async fn list(&self) -> Result<WorktreeList> {
351        let output = self
352            .run_git_output(&["worktree", "list", "--porcelain"])
353            .await?;
354
355        let worktrees = Self::parse_worktree_list(&output)?;
356
357        Ok(WorktreeList {
358            worktrees,
359            repo_root: self.repo_root.clone(),
360        })
361    }
362
363    // ── Remove / Prune ────────────────────────────────────────────────
364
365    /// Remove a worktree by its path.
366    ///
367    /// Fails if the worktree has uncommitted changes unless `force` is `true`.
368    pub async fn remove(&self, path: &Path, force: bool) -> Result<()> {
369        let mut args = vec!["worktree".to_string(), "remove".to_string()];
370        if force {
371            args.push("--force".to_string());
372        }
373        args.push(path.to_string_lossy().to_string());
374
375        let str_args: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
376        self.run_git(&str_args).await
377    }
378
379    /// Remove a worktree by branch name.
380    ///
381    /// Finds the worktree with the given branch and removes it.
382    pub async fn remove_by_branch(&self, branch: &str, force: bool) -> Result<()> {
383        let list = self.list().await?;
384        let wt = list
385            .by_branch(branch)
386            .with_context(|| format!("No worktree found for branch '{}'", branch))?;
387        self.remove(&wt.path, force).await
388    }
389
390    /// Prune deleted worktrees (clean up stale administrative files).
391    pub async fn prune(&self) -> Result<()> {
392        self.run_git(&["worktree", "prune"]).await
393    }
394
395    /// Remove a worktree and delete its branch.
396    pub async fn remove_and_delete_branch(&self, branch: &str, force: bool) -> Result<()> {
397        self.remove_by_branch(branch, force).await?;
398        self.run_git(&["branch", "-d", branch]).await?;
399        Ok(())
400    }
401
402    /// Remove a worktree and force-delete its branch (even if unmerged).
403    pub async fn force_remove_and_delete_branch(&self, branch: &str) -> Result<()> {
404        self.remove_by_branch(branch, true).await?;
405        self.run_git(&["branch", "-D", branch]).await?;
406        Ok(())
407    }
408
409    // ── Merge ─────────────────────────────────────────────────────────
410
411    /// Merge a worktree's branch into a target branch.
412    ///
413    /// This checks out `target_branch` in the main worktree, merges
414    /// `source_branch`, and returns the result.
415    pub async fn merge(
416        &self,
417        source_branch: &str,
418        target_branch: &str,
419    ) -> Result<MergeResult> {
420        // Checkout target branch in the main worktree
421        self.run_git(&["checkout", target_branch]).await?;
422
423        // Perform the merge
424        let output = self
425            .run_git_output(&["merge", source_branch, "--no-edit"])
426            .await?;
427
428        // Check for conflicts
429        let had_conflicts = output.contains("CONFLICT") || output.contains("Merge conflict");
430
431        let conflicts = if had_conflicts {
432            Self::extract_conflicts(&output)
433        } else {
434            Vec::new()
435        };
436
437        // Determine if it was a merge commit vs fast-forward
438        let was_merge_commit = output.contains("Merge made by")
439            || output.contains("Merge:") // log line for merge commits
440            || !output.contains("Fast-forward");
441
442        // Get the merge commit SHA if applicable
443        let merge_commit = if was_merge_commit {
444            let rev_output = self.run_git_output(&["rev-parse", "HEAD"]).await?;
445            Some(rev_output.trim().to_string())
446        } else {
447            None
448        };
449
450        Ok(MergeResult {
451            source_branch: source_branch.to_string(),
452            target_branch: target_branch.to_string(),
453            was_merge_commit,
454            merge_commit,
455            had_conflicts,
456            conflicts,
457        })
458    }
459
460    /// Merge a worktree's branch into `target_branch`, then remove the
461    /// worktree and delete the source branch.
462    ///
463    /// On merge conflict, the worktree is NOT removed — the caller must
464    /// resolve conflicts and then call `remove_and_delete_branch`.
465    pub async fn merge_and_remove(
466        &self,
467        source_branch: &str,
468        target_branch: &str,
469    ) -> Result<MergeResult> {
470        let result = self.merge(source_branch, target_branch).await?;
471
472        if result.had_conflicts {
473            // Don't clean up — the user needs to resolve conflicts first
474            return Ok(result);
475        }
476
477        // Remove the worktree and delete the branch
478        self.remove_and_delete_branch(source_branch, false).await?;
479
480        Ok(result)
481    }
482
483    // ── Branching ─────────────────────────────────────────────────────
484
485    /// Create a new branch + worktree in one operation.
486    ///
487    /// Equivalent to `git worktree add -b <branch> <path> [<start-point>]`.
488    pub async fn branch(
489        &self,
490        branch: &str,
491        target_path: &Path,
492        start_point: Option<&str>,
493    ) -> Result<WorktreeInfo> {
494        self.create_worktree(branch, target_path, start_point).await
495    }
496
497    /// Create a feature worktree: sibling directory named after the branch.
498    pub async fn feature(&self, branch: &str) -> Result<WorktreeInfo> {
499        let opts = WorktreeCreateOpts::feature_branch(branch, &self.repo_root);
500        self.create(&opts).await
501    }
502
503    /// Create a hotfix worktree: branching off a specific start point.
504    pub async fn hotfix(&self, branch: &str, start_point: &str) -> Result<WorktreeInfo> {
505        let opts = WorktreeCreateOpts::hotfix(branch, start_point, &self.repo_root);
506        self.create(&opts).await
507    }
508
509    // ── Status helpers ────────────────────────────────────────────────
510
511    /// Check whether a worktree has uncommitted changes.
512    pub async fn is_dirty(&self, worktree_path: &Path) -> Result<bool> {
513        let output = self
514            .run_git_output_at(worktree_path, &["status", "--porcelain"])
515            .await?;
516        Ok(!output.trim().is_empty())
517    }
518
519    /// Get the current branch name of a worktree.
520    pub async fn current_branch(&self, worktree_path: &Path) -> Result<String> {
521        let output = self
522            .run_git_output_at(worktree_path, &["rev-parse", "--abbrev-ref", "HEAD"])
523            .await?;
524        let branch = output.trim().to_string();
525        if branch.is_empty() || branch == "HEAD" {
526            bail!("Worktree at {} has a detached HEAD", worktree_path.display());
527        }
528        Ok(branch)
529    }
530
531    /// Get the commit SHA of HEAD in a worktree.
532    pub async fn head_commit(&self, worktree_path: &Path) -> Result<String> {
533        let output = self
534            .run_git_output_at(worktree_path, &["rev-parse", "HEAD"])
535            .await?;
536        Ok(output.trim().to_string())
537    }
538
539    /// Check if a branch name already exists in the repo.
540    pub async fn branch_exists(&self, branch: &str) -> Result<bool> {
541        let output = self
542            .run_git_output(&["branch", "--list", branch])
543            .await?;
544        Ok(!output.trim().is_empty())
545    }
546
547    // ── Parsing ───────────────────────────────────────────────────────
548
549    /// Parse the porcelain output of `git worktree list --porcelain`.
550    ///
551    /// Format:
552    /// ```text
553    /// worktree /path/to/main
554    /// HEAD abc123...
555    /// branch refs/heads/main
556    ///
557    /// worktree /path/to/linked
558    /// HEAD def456...
559    /// branch refs/heads/feat/x
560    /// ```
561    fn parse_worktree_list(porcelain: &str) -> Result<Vec<WorktreeInfo>> {
562        let mut worktrees = Vec::new();
563        let mut current_path: Option<PathBuf> = None;
564        let mut current_head: Option<String> = None;
565        let mut current_branch: Option<String> = None;
566        let mut current_is_bare = false;
567        let mut current_is_detached = false;
568        let mut current_is_prunable = false;
569        let mut current_is_locked = false;
570
571        for line in porcelain.lines() {
572            let line = line.trim();
573
574            if line.starts_with("worktree ") {
575                // Flush previous entry
576                if let Some(path) = current_path.take() {
577                    let branch = current_branch.take().map(|b| {
578                        // Strip "refs/heads/" prefix
579                        if let Some(stripped) = b.strip_prefix("refs/heads/") {
580                            stripped.to_string()
581                        } else {
582                            b
583                        }
584                    });
585                    let commit = current_head.take().unwrap_or_default();
586                    let is_main = worktrees.is_empty(); // First entry is the main worktree
587                    worktrees.push(WorktreeInfo {
588                        path,
589                        branch,
590                        commit,
591                        is_main,
592                        is_bare: current_is_bare,
593                        is_detached: current_is_detached,
594                        is_prunable: current_is_prunable,
595                        is_locked: current_is_locked,
596                    });
597                }
598
599                // Start new entry
600                current_path = Some(PathBuf::from(line.strip_prefix("worktree ").unwrap()));
601                current_head = None;
602                current_branch = None;
603                current_is_bare = false;
604                current_is_detached = false;
605                current_is_prunable = false;
606                current_is_locked = false;
607            } else if line.starts_with("HEAD ") {
608                current_head = Some(line.strip_prefix("HEAD ").unwrap().to_string());
609            } else if line.starts_with("branch ") {
610                current_branch = Some(line.strip_prefix("branch ").unwrap().to_string());
611            } else if line == "bare" {
612                current_is_bare = true;
613            } else if line == "detached" {
614                current_is_detached = true;
615            } else if line.starts_with("prunable") {
616                current_is_prunable = true;
617            } else if line.starts_with("locked") {
618                current_is_locked = true;
619            }
620        }
621
622        // Flush last entry
623        if let Some(path) = current_path {
624            let branch = current_branch.map(|b| {
625                if let Some(stripped) = b.strip_prefix("refs/heads/") {
626                    stripped.to_string()
627                } else {
628                    b
629                }
630            });
631            let commit = current_head.unwrap_or_default();
632            let is_main = worktrees.is_empty();
633            worktrees.push(WorktreeInfo {
634                path,
635                branch,
636                commit,
637                is_main,
638                is_bare: current_is_bare,
639                is_detached: current_is_detached,
640                is_prunable: current_is_prunable,
641                is_locked: current_is_locked,
642            });
643        }
644
645        Ok(worktrees)
646    }
647
648    /// Extract conflict file paths from merge output.
649    fn extract_conflicts(merge_output: &str) -> Vec<String> {
650        let mut conflicts = Vec::new();
651        for line in merge_output.lines() {
652            let trimmed = line.trim();
653            if trimmed.starts_with("CONFLICT") || trimmed.contains("Merge conflict") {
654                // Try to extract the file path from the line
655                // Format: "CONFLICT (content): Merge conflict in path/to/file"
656                if let Some(pos) = trimmed.rfind(" in ") {
657                    conflicts.push(trimmed[pos + 4..].trim().to_string());
658                } else if let Some(pos) = trimmed.rfind(": ") {
659                    conflicts.push(trimmed[pos + 2..].trim().to_string());
660                }
661            }
662        }
663        conflicts
664    }
665
666    // ── Command execution ─────────────────────────────────────────────
667
668    /// Run a git command in the repo root, checking for a successful exit.
669    async fn run_git(&self, args: &[&str]) -> Result<()> {
670        let output = Command::new("git")
671            .args(args)
672            .current_dir(&self.repo_root)
673            .output()
674            .await
675            .with_context(|| format!("Failed to execute `git {}`", args.join(" ")))?;
676
677        if !output.status.success() {
678            let stderr = String::from_utf8_lossy(&output.stderr);
679            bail!(
680                "`git {}` failed (exit {}): {}",
681                args.join(" "),
682                output.status.code().unwrap_or(-1),
683                stderr.trim()
684            );
685        }
686
687        Ok(())
688    }
689
690    /// Run a git command and return its stdout as a String.
691    async fn run_git_output(&self, args: &[&str]) -> Result<String> {
692        let output = Command::new("git")
693            .args(args)
694            .current_dir(&self.repo_root)
695            .output()
696            .await
697            .with_context(|| format!("Failed to execute `git {}`", args.join(" ")))?;
698
699        if !output.status.success() {
700            let stderr = String::from_utf8_lossy(&output.stderr);
701            bail!(
702                "`git {}` failed (exit {}): {}",
703                args.join(" "),
704                output.status.code().unwrap_or(-1),
705                stderr.trim()
706            );
707        }
708
709        Ok(String::from_utf8_lossy(&output.stdout).to_string())
710    }
711
712    /// Run a git command at a specific working directory (e.g., a linked worktree).
713    async fn run_git_output_at(&self, cwd: &Path, args: &[&str]) -> Result<String> {
714        let output = Command::new("git")
715            .args(args)
716            .current_dir(cwd)
717            .output()
718            .await
719            .with_context(|| {
720                format!(
721                    "Failed to execute `git {}` in {}",
722                    args.join(" "),
723                    cwd.display()
724                )
725            })?;
726
727        if !output.status.success() {
728            let stderr = String::from_utf8_lossy(&output.stderr);
729            bail!(
730                "`git {}` failed (exit {}): {}",
731                args.join(" "),
732                output.status.code().unwrap_or(-1),
733                stderr.trim()
734            );
735        }
736
737        Ok(String::from_utf8_lossy(&output.stdout).to_string())
738    }
739}
740
741// ── Skill instructions ───────────────────────────────────────────────
742
743/// Generate the skill instructions to be injected into the system prompt
744/// when the worktree skill is active.
745///
746/// This tells the LLM how to manage git worktrees for parallel development.
747pub fn skill_instructions() -> String {
748    r#"# Git Worktree Skill
749
750You are now operating in **worktree mode**. Your goal is to manage git worktrees
751for parallel feature development, hotfixes, and branch isolation.
752
753## Capabilities
754
755### 1. Create Worktrees
756- Create linked worktrees for parallel feature branches
757- Create hotfix worktrees based on specific commits or tags
758- Create detached worktrees for temporary exploration
759- Automatic sibling-directory naming from branch names
760
761### 2. List Worktrees
762- List all worktrees with branch, commit, and status info
763- Identify main vs linked worktrees
764- Detect prunable (stale) and locked worktrees
765- Find worktrees by branch name or path
766
767### 3. Merge Worktrees
768- Merge a worktree's branch into a target branch (e.g., main)
769- Detect and report merge conflicts
770- Fast-forward and merge-commit strategies
771- Merge-and-remove workflow for completed features
772
773### 4. Clean Up Worktrees
774- Remove individual worktrees (with dirty-check protection)
775- Prune stale worktree metadata
776- Remove worktree and delete its branch in one step
777- Force removal for unmerged branches
778
779### 5. Worktree Branching
780- Create branch + worktree in a single operation
781- Feature and hotfix presets with sensible defaults
782- Branch-existence checking before creation
783
784## Workflow
785
786### Parallel Feature Development
787```bash
788# Create worktrees for two independent features
789git worktree add -b feat/auth ../auth-worktree
790git worktree add -b feat/dashboard ../dashboard-worktree
791
792# Work in each directory independently
793cd ../auth-worktree && git commit -am "Add auth module"
794cd ../dashboard-worktree && git commit -am "Add dashboard layout"
795
796# Merge when ready
797git checkout main && git merge feat/auth
798git checkout main && git merge feat/dashboard
799
800# Clean up
801git worktree remove ../auth-worktree && git branch -d feat/auth
802git worktree remove ../dashboard-worktree && git branch -d feat/dashboard
803```
804
805### Hotfix Workflow
806```bash
807# Create a hotfix worktree based on a tag
808git worktree add -b hotfix/fix-crash ../hotfix-crash v1.2.0
809
810# Fix the issue
811cd ../hotfix-crash && git commit -am "Fix null pointer crash"
812
813# Merge back to main and tag
814git checkout main && git merge hotfix/fix-crash
815git worktree remove ../hotfix-crash && git branch -d hotfix/fix-crash
816```
817
818## Guidelines
819
820- **Always clean up** — remove worktrees and delete branches after merging
821- **Check for dirty state** — don't remove worktrees with uncommitted changes
822- **Use descriptive branch names** — `feat/`, `fix/`, `hotfix/`, `refactor/` prefixes
823- **One feature per worktree** — keep concerns isolated
824- **Prune regularly** — run `git worktree prune` to clean up stale metadata
825- **Avoid nested worktrees** — place worktrees as sibling directories, not children
826- **Resolve conflicts before removing** — merge conflicts block cleanup
827
828## Common Commands
829
830```bash
831# List all worktrees
832git worktree list
833
834# Create a feature worktree
835git worktree add -b feat/new-feature ../new-feature
836
837# Create from specific commit
838git worktree add -b hotfix/urgent ../urgent abc123
839
840# Check status of a worktree
841cd ../worktree-dir && git status
842
843# Remove a clean worktree
844git worktree remove ../worktree-dir
845
846# Force remove (even if dirty)
847git worktree remove --force ../worktree-dir
848
849# Prune deleted worktrees
850git worktree prune
851```
852"#
853    .to_string()
854}
855
856// ── Tests ─────────────────────────────────────────────────────────────
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    // ── WorktreeInfo tests ───────────────────────────────────────────
863
864    #[test]
865    fn test_commit_short() {
866        let info = WorktreeInfo {
867            path: PathBuf::from("/repo"),
868            branch: Some("main".to_string()),
869            commit: "abc123def456789".to_string(),
870            is_main: true,
871            is_bare: false,
872            is_detached: false,
873            is_prunable: false,
874            is_locked: false,
875        };
876        assert_eq!(info.commit_short(), "abc123d");
877    }
878
879    #[test]
880    fn test_branch_display() {
881        let mut info = WorktreeInfo {
882            path: PathBuf::from("/repo"),
883            branch: Some("feat/auth".to_string()),
884            commit: "abc123def456789".to_string(),
885            is_main: false,
886            is_bare: false,
887            is_detached: false,
888            is_prunable: false,
889            is_locked: false,
890        };
891
892        assert_eq!(info.branch(), "feat/auth");
893
894        // Detached HEAD
895        info.branch = None;
896        info.is_detached = true;
897        assert!(info.branch().contains("detached"));
898        assert!(info.branch().contains("abc123d"));
899    }
900
901    #[test]
902    fn test_dir_name() {
903        let info = WorktreeInfo {
904            path: PathBuf::from("/projects/auth-worktree"),
905            branch: Some("feat/auth".to_string()),
906            commit: "abc123".to_string(),
907            is_main: false,
908            is_bare: false,
909            is_detached: false,
910            is_prunable: false,
911            is_locked: false,
912        };
913        assert_eq!(info.dir_name(), "auth-worktree");
914    }
915
916    #[test]
917    fn test_display() {
918        let info = WorktreeInfo {
919            path: PathBuf::from("/repo"),
920            branch: Some("main".to_string()),
921            commit: "abc123def456789".to_string(),
922            is_main: true,
923            is_bare: false,
924            is_detached: false,
925            is_prunable: false,
926            is_locked: false,
927        };
928        let display = format!("{}", info);
929        assert!(display.contains("main"));
930        assert!(display.contains("abc123d"));
931        assert!(display.contains("[main]"));
932        assert!(!display.contains("[locked]"));
933    }
934
935    #[test]
936    fn test_display_with_flags() {
937        let info = WorktreeInfo {
938            path: PathBuf::from("/repo"),
939            branch: Some("feat".to_string()),
940            commit: "def456".to_string(),
941            is_main: false,
942            is_bare: false,
943            is_detached: false,
944            is_prunable: true,
945            is_locked: true,
946        };
947        let display = format!("{}", info);
948        assert!(display.contains("[prunable]"));
949        assert!(display.contains("[locked]"));
950    }
951
952    // ── WorktreeCreateOpts tests ─────────────────────────────────────
953
954    #[test]
955    fn test_feature_branch_opts() {
956        let repo_root = PathBuf::from("/projects/myapp");
957        let opts = WorktreeCreateOpts::feature_branch("feat/auth", &repo_root);
958        assert_eq!(opts.branch, "feat/auth");
959        assert_eq!(opts.path, PathBuf::from("/projects/feat-auth"));
960        assert!(opts.start_point.is_none());
961        assert!(!opts.force_branch);
962        assert!(!opts.detach);
963    }
964
965    #[test]
966    fn test_hotfix_opts() {
967        let repo_root = PathBuf::from("/projects/myapp");
968        let opts = WorktreeCreateOpts::hotfix("hotfix/crash", "v1.2.0", &repo_root);
969        assert_eq!(opts.branch, "hotfix/crash");
970        assert_eq!(opts.path, PathBuf::from("/projects/hotfix-crash"));
971        assert_eq!(opts.start_point, Some("v1.2.0".to_string()));
972    }
973
974    #[test]
975    fn test_create_opts_serde_roundtrip() {
976        let opts = WorktreeCreateOpts {
977            branch: "feat/x".to_string(),
978            path: PathBuf::from("/tmp/wt"),
979            start_point: Some("main".to_string()),
980            force_branch: true,
981            detach: false,
982        };
983
984        let json = serde_json::to_string(&opts).unwrap();
985        let parsed: WorktreeCreateOpts = serde_json::from_str(&json).unwrap();
986        assert_eq!(parsed.branch, "feat/x");
987        assert_eq!(parsed.path, PathBuf::from("/tmp/wt"));
988        assert_eq!(parsed.start_point, Some("main".to_string()));
989        assert!(parsed.force_branch);
990        assert!(!parsed.detach);
991    }
992
993    // ── Parsing tests ────────────────────────────────────────────────
994
995    #[test]
996    fn test_parse_worktree_list_single() {
997        let porcelain = "worktree /home/user/project\nHEAD abc123def456789012345678901234567890abcd\nbranch refs/heads/main\n";
998        let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
999        assert_eq!(worktrees.len(), 1);
1000        assert_eq!(worktrees[0].path, PathBuf::from("/home/user/project"));
1001        assert_eq!(worktrees[0].branch, Some("main".to_string()));
1002        assert_eq!(worktrees[0].commit, "abc123def456789012345678901234567890abcd");
1003        assert!(worktrees[0].is_main);
1004        assert!(!worktrees[0].is_detached);
1005    }
1006
1007    #[test]
1008    fn test_parse_worktree_list_multiple() {
1009        let porcelain = "\
1010worktree /home/user/project
1011HEAD aaa111
1012branch refs/heads/main
1013
1014worktree /home/user/feat-auth
1015HEAD bbb222
1016branch refs/heads/feat/auth
1017
1018";
1019        let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1020        assert_eq!(worktrees.len(), 2);
1021
1022        assert!(worktrees[0].is_main);
1023        assert_eq!(worktrees[0].branch, Some("main".to_string()));
1024
1025        assert!(!worktrees[1].is_main);
1026        assert_eq!(worktrees[1].branch, Some("feat/auth".to_string()));
1027        assert_eq!(worktrees[1].path, PathBuf::from("/home/user/feat-auth"));
1028    }
1029
1030    #[test]
1031    fn test_parse_worktree_detached() {
1032        let porcelain = "\
1033worktree /home/user/project
1034HEAD abc123
1035branch refs/heads/main
1036
1037worktree /home/user/explore
1038HEAD def456
1039detached
1040
1041";
1042        let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1043        assert_eq!(worktrees.len(), 2);
1044        assert!(worktrees[1].is_detached);
1045        assert!(worktrees[1].branch.is_none());
1046    }
1047
1048    #[test]
1049    fn test_parse_worktree_bare() {
1050        let porcelain = "worktree /home/user/repo.git\nHEAD abc123\nbare\n";
1051        let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1052        assert_eq!(worktrees.len(), 1);
1053        assert!(worktrees[0].is_bare);
1054    }
1055
1056    #[test]
1057    fn test_parse_worktree_prunable() {
1058        let porcelain = "\
1059worktree /home/user/project
1060HEAD abc123
1061branch refs/heads/main
1062
1063worktree /home/user/deleted-dir
1064HEAD def456
1065branch refs/heads/orphan
1066prunable gitdir pointing to nowhere
1067
1068";
1069        let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1070        assert_eq!(worktrees.len(), 2);
1071        assert!(worktrees[1].is_prunable);
1072    }
1073
1074    #[test]
1075    fn test_parse_worktree_locked() {
1076        let porcelain = "\
1077worktree /home/user/project
1078HEAD abc123
1079branch refs/heads/main
1080
1081worktree /home/user/locked-wt
1082HEAD def456
1083branch refs/heads/locked-branch
1084locked
1085
1086";
1087        let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1088        assert!(worktrees[1].is_locked);
1089    }
1090
1091    #[test]
1092    fn test_parse_empty_list() {
1093        let porcelain = "";
1094        let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1095        assert!(worktrees.is_empty());
1096    }
1097
1098    // ── WorktreeList tests ───────────────────────────────────────────
1099
1100    #[test]
1101    fn test_worktree_list_queries() {
1102        let list = WorktreeList {
1103            worktrees: vec![
1104                WorktreeInfo {
1105                    path: PathBuf::from("/main"),
1106                    branch: Some("main".to_string()),
1107                    commit: "aaa".to_string(),
1108                    is_main: true,
1109                    is_bare: false,
1110                    is_detached: false,
1111                    is_prunable: false,
1112                    is_locked: false,
1113                },
1114                WorktreeInfo {
1115                    path: PathBuf::from("/feat"),
1116                    branch: Some("feat/x".to_string()),
1117                    commit: "bbb".to_string(),
1118                    is_main: false,
1119                    is_bare: false,
1120                    is_detached: false,
1121                    is_prunable: false,
1122                    is_locked: false,
1123                },
1124            ],
1125            repo_root: PathBuf::from("/main"),
1126        };
1127
1128        assert_eq!(list.len(), 2);
1129        assert!(list.main().is_some());
1130        assert_eq!(list.linked().len(), 1);
1131        assert!(list.by_branch("feat/x").is_some());
1132        assert!(list.by_branch("nonexistent").is_none());
1133        assert!(list.by_path(&PathBuf::from("/feat")).is_some());
1134    }
1135
1136    // ── Conflict extraction ──────────────────────────────────────────
1137
1138    #[test]
1139    fn test_extract_conflicts() {
1140        let output = "\
1141Auto-merging src/main.rs
1142CONFLICT (content): Merge conflict in src/main.rs
1143Auto-merging src/lib.rs
1144CONFLICT (content): Merge conflict in src/lib.rs
1145Automatic merge failed; fix conflicts and then commit the result.
1146";
1147        let conflicts = WorktreeManager::extract_conflicts(output);
1148        assert_eq!(conflicts.len(), 2);
1149        assert!(conflicts.contains(&"src/main.rs".to_string()));
1150        assert!(conflicts.contains(&"src/lib.rs".to_string()));
1151    }
1152
1153    #[test]
1154    fn test_extract_conflicts_empty() {
1155        let output = "Merge made by the 'ort' strategy.\n src/main.rs | 2 +-";
1156        let conflicts = WorktreeManager::extract_conflicts(output);
1157        assert!(conflicts.is_empty());
1158    }
1159
1160    // ── MergeResult tests ────────────────────────────────────────────
1161
1162    #[test]
1163    fn test_merge_result_serde() {
1164        let result = MergeResult {
1165            source_branch: "feat/auth".to_string(),
1166            target_branch: "main".to_string(),
1167            was_merge_commit: true,
1168            merge_commit: Some("abc123".to_string()),
1169            had_conflicts: false,
1170            conflicts: vec![],
1171        };
1172
1173        let json = serde_json::to_string(&result).unwrap();
1174        let parsed: MergeResult = serde_json::from_str(&json).unwrap();
1175        assert_eq!(parsed.source_branch, "feat/auth");
1176        assert!(parsed.was_merge_commit);
1177        assert!(!parsed.had_conflicts);
1178    }
1179
1180    // ── Skill instructions ───────────────────────────────────────────
1181
1182    #[test]
1183    fn test_skill_instructions_not_empty() {
1184        let instructions = skill_instructions();
1185        assert!(!instructions.is_empty());
1186        assert!(instructions.contains("worktree"));
1187        assert!(instructions.contains("Create"));
1188        assert!(instructions.contains("Merge"));
1189        assert!(instructions.contains("Clean Up"));
1190    }
1191
1192    // ── Integration tests (require a git repo) ───────────────────────
1193
1194    #[tokio::test]
1195    async fn test_manager_for_current_repo() {
1196        // This test only works inside a git repo (which pi2oxi is)
1197        let result = WorktreeManager::for_current_repo();
1198        // We're running inside the pi2oxi repo, so this should succeed
1199        assert!(result.is_ok());
1200        let mgr = result.unwrap();
1201        assert!(mgr.repo_root().exists());
1202    }
1203
1204    #[tokio::test]
1205    async fn test_list_worktrees() {
1206        let mgr = WorktreeManager::for_current_repo().unwrap();
1207        let list = mgr.list().await.unwrap();
1208        // Should have at least the main worktree
1209        assert!(!list.is_empty());
1210        assert!(list.main().is_some());
1211    }
1212}