Skip to main content

git_paw/
git.rs

1//! Git operations.
2//!
3//! Validates git repositories, lists branches, creates and removes worktrees,
4//! and derives worktree directory names from project and branch names.
5
6use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use crate::error::PawError;
11use crate::specs::SpecEntry;
12
13/// Validates that the given path is inside a git repository.
14///
15/// Returns the absolute path to the repository root.
16pub fn validate_repo(path: &Path) -> Result<PathBuf, PawError> {
17    let output = Command::new("git")
18        .current_dir(path)
19        .args(["rev-parse", "--show-toplevel"])
20        .output()
21        .map_err(|e| PawError::BranchError(format!("failed to run git: {e}")))?;
22
23    if !output.status.success() {
24        return Err(PawError::NotAGitRepo);
25    }
26
27    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
28    Ok(PathBuf::from(root))
29}
30
31/// Lists all branches (local and remote), deduplicated, sorted, with remote
32/// prefixes stripped.
33///
34/// Remote branches like `origin/main` are included as `main`. If a branch
35/// exists both locally and remotely, only one entry appears. `HEAD` pointers
36/// are excluded.
37pub fn list_branches(repo_root: &Path) -> Result<Vec<String>, PawError> {
38    let output = Command::new("git")
39        .current_dir(repo_root)
40        .args(["branch", "-a", "--format=%(refname:short)"])
41        .output()
42        .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
43
44    if !output.status.success() {
45        let stderr = String::from_utf8_lossy(&output.stderr);
46        return Err(PawError::BranchError(format!(
47            "git branch failed: {stderr}"
48        )));
49    }
50
51    let stdout = String::from_utf8_lossy(&output.stdout);
52    let branches: BTreeSet<String> = stdout
53        .lines()
54        .filter(|line| !line.trim().is_empty() && !line.contains("HEAD"))
55        .map(|line| {
56            // Strip remote prefix (e.g., "origin/main" -> "main")
57            let mut branch_name = line.trim().to_string();
58
59            // Handle full ref format: refs/remotes/origin/branch -> branch
60            if let Some(stripped) = branch_name.strip_prefix("refs/remotes/") {
61                branch_name = stripped.to_string();
62            }
63            // Handle short format: origin/branch -> branch
64            if let Some(stripped) = branch_name.strip_prefix("origin/") {
65                branch_name = stripped.to_string();
66            }
67
68            branch_name
69        })
70        .collect();
71
72    // Remove duplicates that can arise from local+remote branches with same name
73    let mut unique: Vec<String> = branches.into_iter().collect();
74    unique.sort();
75    Ok(unique)
76}
77
78/// Derives a worktree directory name from project and branch names.
79///
80/// The format is: `<project>-<branch>` with non-alphanumeric characters replaced by `-`.
81pub fn worktree_dir_name(project: &str, branch: &str) -> String {
82    let project_safe: String = project
83        .chars()
84        .map(|c| if c.is_alphanumeric() { c } else { '-' })
85        .collect();
86    let branch_safe: String = branch
87        .chars()
88        .map(|c| if c.is_alphanumeric() { c } else { '-' })
89        .collect();
90    format!("{project_safe}-{branch_safe}")
91}
92
93/// Returns the name of the default branch (usually "main" or "master").
94pub fn default_branch(repo_root: &Path) -> Result<String, PawError> {
95    let output = Command::new("git")
96        .current_dir(repo_root)
97        .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
98        .output()
99        .map_err(|e| PawError::BranchError(format!("failed to run git symbolic-ref: {e}")))?;
100
101    if !output.status.success() {
102        let stderr = String::from_utf8_lossy(&output.stderr);
103        return Err(PawError::BranchError(format!(
104            "git symbolic-ref failed: {stderr}"
105        )));
106    }
107
108    let ref_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
109    if let Some(branch) = ref_name.strip_prefix("refs/remotes/origin/") {
110        Ok(branch.to_string())
111    } else {
112        Err(PawError::BranchError(format!(
113            "unexpected ref format: {ref_name}"
114        )))
115    }
116}
117
118/// Returns the short name of the current branch (e.g., "main", "feat/add-auth").
119pub fn current_branch(repo_root: &Path) -> Result<String, PawError> {
120    let output = Command::new("git")
121        .current_dir(repo_root)
122        .args(["branch", "--show-current"])
123        .output()
124        .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
125
126    if !output.status.success() {
127        let stderr = String::from_utf8_lossy(&output.stderr);
128        return Err(PawError::BranchError(format!(
129            "git branch failed: {stderr}"
130        )));
131    }
132
133    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
134    if branch.is_empty() {
135        return Err(PawError::BranchError(
136            "not on any branch (detached HEAD)".to_string(),
137        ));
138    }
139    Ok(branch)
140}
141
142/// Returns the name of the project (directory name of the git repository).
143pub fn project_name(repo_root: &Path) -> String {
144    repo_root
145        .file_name()
146        .and_then(std::ffi::OsStr::to_str)
147        .unwrap_or("unknown")
148        .to_string()
149}
150
151/// Result of creating a worktree, including whether the branch was newly created.
152#[derive(Debug)]
153pub struct WorktreeCreation {
154    /// Path to the created worktree directory.
155    pub path: PathBuf,
156    /// Whether git-paw created the branch (true) or it already existed (false).
157    pub branch_created: bool,
158}
159
160/// Creates a git worktree for `branch`.
161///
162/// If the branch already exists, checks it out in a new worktree. If the
163/// branch does not exist, creates it from HEAD with `git worktree add -b`.
164/// Returns both the worktree path and whether the branch was newly created,
165/// so the session can track which branches to delete on purge.
166pub fn create_worktree(repo_root: &Path, branch: &str) -> Result<WorktreeCreation, PawError> {
167    let project = project_name(repo_root);
168    let dir_name = worktree_dir_name(&project, branch);
169
170    let parent = repo_root.parent().ok_or_else(|| {
171        PawError::WorktreeError("cannot determine parent directory of repo".to_string())
172    })?;
173    let worktree_path = parent.join(&dir_name);
174
175    // Try with existing branch first.
176    let output = Command::new("git")
177        .current_dir(repo_root)
178        .args(["worktree", "add", &worktree_path.to_string_lossy(), branch])
179        .output()
180        .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree add: {e}")))?;
181
182    if output.status.success() {
183        return Ok(WorktreeCreation {
184            path: worktree_path,
185            branch_created: false,
186        });
187    }
188
189    let stderr = String::from_utf8_lossy(&output.stderr);
190
191    // If the branch doesn't exist, create it with -b.
192    if stderr.contains("invalid reference") {
193        let output = Command::new("git")
194            .current_dir(repo_root)
195            .args([
196                "worktree",
197                "add",
198                "-b",
199                branch,
200                &worktree_path.to_string_lossy(),
201            ])
202            .output()
203            .map_err(|e| {
204                PawError::WorktreeError(format!("failed to run git worktree add -b: {e}"))
205            })?;
206
207        if output.status.success() {
208            return Ok(WorktreeCreation {
209                path: worktree_path,
210                branch_created: true,
211            });
212        }
213
214        let stderr = String::from_utf8_lossy(&output.stderr);
215        return Err(PawError::WorktreeError(format!(
216            "git worktree add -b failed for branch '{branch}': {stderr}"
217        )));
218    }
219
220    Err(PawError::WorktreeError(format!(
221        "git worktree add failed for branch '{branch}': {stderr}"
222    )))
223}
224
225/// Removes the worktree at the given path.
226///
227/// The path should be the worktree directory path, not a branch name.
228///
229/// # Panics
230///
231/// Panics if the worktree path contains non-Unicode characters.
232pub fn remove_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), PawError> {
233    // Always pass --force per `git-operations/spec.md`'s "SHALL force-remove a
234    // worktree" requirement. `remove_worktree` is only called from purge,
235    // which is destructive by nature: an agent that produced uncommitted or
236    // untracked files in its worktree would otherwise trip "contains modified
237    // or untracked files, use --force to delete it" and leak the worktree on
238    // disk even though the user already typed `--force` at the CLI.
239    let output = Command::new("git")
240        .current_dir(repo_root)
241        .args([
242            "worktree",
243            "remove",
244            "--force",
245            worktree_path.to_str().unwrap(),
246        ])
247        .output()
248        .map_err(|e| {
249            PawError::WorktreeError(format!(
250                "failed to remove worktree at {}: {e}",
251                worktree_path.display()
252            ))
253        })?;
254
255    if !output.status.success() {
256        let stderr = String::from_utf8_lossy(&output.stderr);
257        return Err(PawError::WorktreeError(format!(
258            "git worktree remove failed for worktree at {}: {stderr}",
259            worktree_path.display()
260        )));
261    }
262
263    Ok(())
264}
265
266/// Prunes stale worktree registrations from the git worktree list.
267///
268/// This should be called before creating new worktrees to avoid conflicts.
269pub fn prune_worktrees(repo_root: &Path) -> Result<(), PawError> {
270    let output = Command::new("git")
271        .current_dir(repo_root)
272        .args(["worktree", "prune"])
273        .output()
274        .map_err(|e| PawError::WorktreeError(format!("failed to prune worktrees: {e}")))?;
275
276    if !output.status.success() {
277        let stderr = String::from_utf8_lossy(&output.stderr);
278        return Err(PawError::WorktreeError(format!(
279            "git worktree prune failed: {stderr}"
280        )));
281    }
282
283    Ok(())
284}
285
286/// Checks for uncommitted changes in spec directories or files.
287///
288/// Returns a list of spec IDs that have uncommitted changes (modified, added,
289/// or untracked files). Uses `git status --porcelain` against the spec's path.
290///
291/// Supports both spec layouts:
292/// - `OpenSpec`: `specs/<id>/` directory; the whole directory is probed.
293/// - `Markdown`: `specs/<id>.md` file; the single file is probed.
294///
295/// If neither layout exists for a spec id, it is silently skipped.
296pub fn check_uncommitted_specs(
297    repo_root: &Path,
298    specs: &[SpecEntry],
299) -> Result<Vec<String>, PawError> {
300    let mut uncommitted_specs = Vec::new();
301
302    let specs_dir = repo_root.join("specs");
303
304    for spec in specs {
305        let dir_path = specs_dir.join(&spec.id);
306        let file_path = specs_dir.join(format!("{}.md", spec.id));
307
308        let porcelain_target = if dir_path.is_dir() {
309            format!("specs/{}", spec.id)
310        } else if file_path.is_file() {
311            format!("specs/{}.md", spec.id)
312        } else {
313            continue;
314        };
315
316        let output = Command::new("git")
317            .current_dir(repo_root)
318            .args(["status", "--porcelain", "--", &porcelain_target])
319            .output()
320            .map_err(|e| {
321                PawError::BranchError(format!(
322                    "failed to run git status for spec {}: {e}",
323                    spec.id
324                ))
325            })?;
326
327        if !output.status.success() {
328            let stderr = String::from_utf8_lossy(&output.stderr);
329            return Err(PawError::BranchError(format!(
330                "git status failed for spec {}: {stderr}",
331                spec.id
332            )));
333        }
334
335        let status_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
336        if !status_output.is_empty() {
337            uncommitted_specs.push(spec.id.clone());
338        }
339    }
340
341    Ok(uncommitted_specs)
342}
343
344/// Merges the specified branch into the current branch.
345///
346/// Returns `true` if the merge was successful, `false` if there were conflicts.
347pub fn merge_branch(repo_root: &Path, branch: &str) -> Result<bool, PawError> {
348    let output = Command::new("git")
349        .current_dir(repo_root)
350        .args(["merge", "--no-ff", "--no-commit", branch])
351        .output()
352        .map_err(|e| {
353            PawError::WorktreeError(format!("failed to run git merge for branch {branch}: {e}"))
354        })?;
355
356    if !output.status.success() {
357        let stderr = String::from_utf8_lossy(&output.stderr);
358        // Check if this is a conflict (exit code 1) vs other error
359        if output.status.code() == Some(1) {
360            return Ok(false);
361        }
362        return Err(PawError::WorktreeError(format!(
363            "git merge failed for branch {branch}: {stderr}"
364        )));
365    }
366
367    Ok(true)
368}
369
370/// Deletes a branch.
371pub fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), PawError> {
372    let output = Command::new("git")
373        .current_dir(repo_root)
374        .args(["branch", "-D", branch])
375        .output()
376        .map_err(|e| PawError::BranchError(format!("failed to delete branch {branch}: {e}")))?;
377
378    if !output.status.success() {
379        let stderr = String::from_utf8_lossy(&output.stderr);
380        return Err(PawError::BranchError(format!(
381            "git branch -D failed for branch {branch}: {stderr}"
382        )));
383    }
384
385    Ok(())
386}
387
388/// Excludes a file from git tracking by adding it to `.git/info/exclude`.
389///
390/// This prevents the file from being tracked by git without modifying the
391/// repository's `.gitignore` file, which is useful for worktree-specific
392/// files that should not be committed.
393pub fn exclude_from_git(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
394    let exclude_file = worktree_root.join(".git/info/exclude");
395
396    // Read existing exclude patterns
397    let existing = if exclude_file.exists() {
398        std::fs::read_to_string(&exclude_file).unwrap_or_default()
399    } else {
400        String::new()
401    };
402
403    // Add the filename if not already present
404    if !existing.lines().any(|line| line.trim() == filename) {
405        let mut updated = existing;
406        if !updated.ends_with('\n') && !updated.is_empty() {
407            updated.push('\n');
408        }
409        updated.push_str(filename);
410        updated.push('\n');
411
412        // Create .git/info directory if it doesn't exist
413        if let Some(parent) = exclude_file.parent() {
414            // Check if .git (the grandparent) is a file (worktree case)
415            if let Some(git_dir) = parent.parent()
416                && git_dir.is_file()
417            {
418                // This is a worktree - .git is a file pointing to main repo
419                // The actual git directory is inside the main repo
420                let main_git_dir = std::fs::read_to_string(git_dir)
421                    .ok()
422                    .and_then(|s| s.strip_prefix("gitdir: ").map(|s| s.trim().to_owned()))
423                    .unwrap_or_default();
424                let main_git_info = PathBuf::from(main_git_dir).join("info");
425                if !main_git_info.try_exists().unwrap_or(false) {
426                    std::fs::create_dir_all(&main_git_info).map_err(|e| {
427                        PawError::SessionError(format!("failed to create main .git/info: {e}"))
428                    })?;
429                }
430                let main_exclude = main_git_info.join("exclude");
431                std::fs::write(&main_exclude, updated).map_err(|e| {
432                    PawError::SessionError(format!(
433                        "failed to write to main .git/info/exclude: {e}"
434                    ))
435                })?;
436                return Ok(());
437            }
438            if parent.exists() && parent.is_file() {
439                std::fs::remove_file(parent).map_err(|e| {
440                    PawError::SessionError(format!("failed to remove .git/info file: {e}"))
441                })?;
442            }
443            std::fs::create_dir_all(parent).map_err(|e| {
444                PawError::SessionError(format!("failed to create .git/info directory: {e}"))
445            })?;
446        }
447
448        std::fs::write(&exclude_file, updated).map_err(|e| {
449            PawError::SessionError(format!("failed to write to .git/info/exclude: {e}"))
450        })?;
451    }
452
453    Ok(())
454}
455
456/// Marks a file as assume-unchanged in git's index.
457///
458/// This prevents `git add -A`, `git add .`, and `git commit -a` from
459/// staging the file. Returns `Ok` even if the command fails, as this
460/// is a belt-and-suspenders measure.
461pub fn assume_unchanged(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
462    let _ = std::process::Command::new("git")
463        .current_dir(worktree_root)
464        .args(["update-index", "--assume-unchanged", filename])
465        .status();
466    Ok(())
467}