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;
11
12/// Validates that the given path is inside a git repository.
13///
14/// Returns the absolute path to the repository root.
15pub fn validate_repo(path: &Path) -> Result<PathBuf, PawError> {
16    let output = Command::new("git")
17        .current_dir(path)
18        .args(["rev-parse", "--show-toplevel"])
19        .output()
20        .map_err(|e| PawError::BranchError(format!("failed to run git: {e}")))?;
21
22    if !output.status.success() {
23        return Err(PawError::NotAGitRepo);
24    }
25
26    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
27    Ok(PathBuf::from(root))
28}
29
30/// Lists all branches (local and remote), deduplicated, sorted, with remote
31/// prefixes stripped.
32///
33/// Remote branches like `origin/main` are included as `main`. If a branch
34/// exists both locally and remotely, only one entry appears. `HEAD` pointers
35/// are excluded.
36pub fn list_branches(repo_root: &Path) -> Result<Vec<String>, PawError> {
37    let output = Command::new("git")
38        .current_dir(repo_root)
39        .args(["branch", "-a", "--format=%(refname:short)"])
40        .output()
41        .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
42
43    if !output.status.success() {
44        let stderr = String::from_utf8_lossy(&output.stderr);
45        return Err(PawError::BranchError(format!(
46            "git branch failed: {stderr}"
47        )));
48    }
49
50    let stdout = String::from_utf8_lossy(&output.stdout);
51    Ok(parse_branch_output(&stdout))
52}
53
54/// Parses `git branch -a --format=%(refname:short)` output into a
55/// deduplicated, sorted list of branch names with remote prefixes stripped.
56fn parse_branch_output(output: &str) -> Vec<String> {
57    let mut branches = BTreeSet::new();
58
59    for line in output.lines() {
60        let name = line.trim();
61        if name.is_empty() {
62            continue;
63        }
64        // Skip HEAD pointers like "origin/HEAD"
65        if name.contains("HEAD") {
66            continue;
67        }
68        // Strip remote prefix (e.g., "origin/feature/auth" → "feature/auth")
69        let stripped = strip_remote_prefix(name);
70        branches.insert(stripped.to_string());
71    }
72
73    branches.into_iter().collect()
74}
75
76/// Strips the remote prefix from a branch name.
77///
78/// `origin/feature/auth` becomes `feature/auth`.
79/// `feature/auth` stays as `feature/auth`.
80fn strip_remote_prefix(branch: &str) -> &str {
81    // With --format=%(refname:short), remote branches appear as "origin/branch"
82    // We need to strip the first component if it looks like a remote name
83    if let Some(rest) = branch.strip_prefix("origin/") {
84        rest
85    } else {
86        branch
87    }
88}
89
90/// Derives the project name from the repository root path.
91///
92/// Uses the final component of the path (the directory name).
93pub fn project_name(repo_root: &Path) -> String {
94    repo_root.file_name().map_or_else(
95        || "project".to_string(),
96        |n| n.to_string_lossy().to_string(),
97    )
98}
99
100/// Builds the worktree directory name from a project name and branch.
101///
102/// Replaces `/` with `-` and strips characters that are unsafe for directory
103/// names.
104///
105/// # Examples
106///
107/// - `("git-paw", "feature/auth-flow")` → `"git-paw-feature-auth-flow"`
108/// - `("git-paw", "fix/db")` → `"git-paw-fix-db"`
109pub fn worktree_dir_name(project: &str, branch: &str) -> String {
110    let sanitized: String = branch
111        .chars()
112        .map(|c| if c == '/' { '-' } else { c })
113        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
114        .collect();
115
116    format!("{project}-{sanitized}")
117}
118
119/// Prunes stale worktree registrations.
120///
121/// Runs `git worktree prune` to clean up references to worktrees whose
122/// directories no longer exist. This prevents "already registered worktree"
123/// errors when recreating worktrees after a previous session was purged.
124pub fn prune_worktrees(repo_root: &Path) -> Result<(), PawError> {
125    let output = Command::new("git")
126        .current_dir(repo_root)
127        .args(["worktree", "prune"])
128        .output()
129        .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree prune: {e}")))?;
130
131    if !output.status.success() {
132        let stderr = String::from_utf8_lossy(&output.stderr);
133        return Err(PawError::WorktreeError(format!(
134            "git worktree prune failed: {stderr}"
135        )));
136    }
137    Ok(())
138}
139
140/// Creates a git worktree for the given branch.
141///
142/// The worktree is placed in the parent directory of `repo_root`, named using
143/// [`worktree_dir_name`]. Returns the path to the created worktree.
144/// Result of creating a worktree, including whether the branch was newly created.
145#[derive(Debug)]
146pub struct WorktreeCreation {
147    /// Path to the created worktree directory.
148    pub path: PathBuf,
149    /// Whether git-paw created the branch (true) or it already existed (false).
150    pub branch_created: bool,
151}
152
153/// Creates a git worktree for `branch`.
154///
155/// If the branch already exists, checks it out in a new worktree. If the
156/// branch does not exist, creates it from HEAD with `git worktree add -b`.
157/// Returns both the worktree path and whether the branch was newly created,
158/// so the session can track which branches to delete on purge.
159pub fn create_worktree(repo_root: &Path, branch: &str) -> Result<WorktreeCreation, PawError> {
160    let project = project_name(repo_root);
161    let dir_name = worktree_dir_name(&project, branch);
162
163    let parent = repo_root.parent().ok_or_else(|| {
164        PawError::WorktreeError("cannot determine parent directory of repo".to_string())
165    })?;
166    let worktree_path = parent.join(&dir_name);
167
168    // Try with existing branch first.
169    let output = Command::new("git")
170        .current_dir(repo_root)
171        .args(["worktree", "add", &worktree_path.to_string_lossy(), branch])
172        .output()
173        .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree add: {e}")))?;
174
175    if output.status.success() {
176        return Ok(WorktreeCreation {
177            path: worktree_path,
178            branch_created: false,
179        });
180    }
181
182    let stderr = String::from_utf8_lossy(&output.stderr);
183
184    // If the branch doesn't exist, create it with -b.
185    if stderr.contains("invalid reference") {
186        let output = Command::new("git")
187            .current_dir(repo_root)
188            .args([
189                "worktree",
190                "add",
191                "-b",
192                branch,
193                &worktree_path.to_string_lossy(),
194            ])
195            .output()
196            .map_err(|e| {
197                PawError::WorktreeError(format!("failed to run git worktree add -b: {e}"))
198            })?;
199
200        if output.status.success() {
201            return Ok(WorktreeCreation {
202                path: worktree_path,
203                branch_created: true,
204            });
205        }
206
207        let stderr = String::from_utf8_lossy(&output.stderr);
208        return Err(PawError::WorktreeError(format!(
209            "git worktree add -b failed for branch '{branch}': {stderr}"
210        )));
211    }
212
213    Err(PawError::WorktreeError(format!(
214        "git worktree add failed for branch '{branch}': {stderr}"
215    )))
216}
217
218/// Deletes a local git branch.
219///
220/// Uses `git branch -D` (force delete) because purge is a destructive
221/// operation and the user has already confirmed. Only call this for branches
222/// that git-paw created (tracked via `WorktreeEntry::branch_created`).
223pub fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), PawError> {
224    let output = Command::new("git")
225        .current_dir(repo_root)
226        .args(["branch", "-D", branch])
227        .output()
228        .map_err(|e| PawError::BranchError(format!("failed to run git branch -D: {e}")))?;
229
230    if !output.status.success() {
231        let stderr = String::from_utf8_lossy(&output.stderr);
232        return Err(PawError::BranchError(format!(
233            "git branch -D failed for '{branch}': {stderr}"
234        )));
235    }
236
237    Ok(())
238}
239
240/// Removes a git worktree at the given path.
241///
242/// Runs `git worktree remove --force` and then prunes stale worktree entries.
243pub fn remove_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), PawError> {
244    let output = Command::new("git")
245        .current_dir(repo_root)
246        .args([
247            "worktree",
248            "remove",
249            "--force",
250            &worktree_path.to_string_lossy(),
251        ])
252        .output()
253        .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree remove: {e}")))?;
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: {stderr}"
259        )));
260    }
261
262    // Prune stale worktree entries
263    let _ = Command::new("git")
264        .current_dir(repo_root)
265        .args(["worktree", "prune"])
266        .output();
267
268    Ok(())
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use serial_test::serial;
275    use std::process::Command;
276    use tempfile::TempDir;
277
278    /// A test sandbox that owns an outer temp directory containing the git
279    /// repo. Worktrees created via `create_worktree` land as siblings of the
280    /// repo inside this outer dir, so everything is cleaned up when the
281    /// sandbox is dropped — even if a test panics.
282    struct TestRepo {
283        _sandbox: TempDir,
284        repo: PathBuf,
285    }
286
287    impl TestRepo {
288        fn path(&self) -> &Path {
289            &self.repo
290        }
291    }
292
293    /// Creates a temporary git repository inside a sandbox directory.
294    ///
295    /// The repo lives at `<sandbox>/repo/` so that worktrees created at
296    /// `../<project>-<branch>/` land inside `<sandbox>/` and are automatically
297    /// cleaned up when the returned `TestRepo` is dropped.
298    fn setup_test_repo() -> TestRepo {
299        let sandbox = TempDir::new().expect("create sandbox dir");
300        let repo = sandbox.path().join("repo");
301        std::fs::create_dir(&repo).expect("create repo dir");
302
303        Command::new("git")
304            .current_dir(&repo)
305            .args(["init"])
306            .output()
307            .expect("git init");
308
309        Command::new("git")
310            .current_dir(&repo)
311            .args(["config", "user.email", "test@test.com"])
312            .output()
313            .expect("git config email");
314
315        Command::new("git")
316            .current_dir(&repo)
317            .args(["config", "user.name", "Test"])
318            .output()
319            .expect("git config name");
320
321        // Create initial commit so branches work
322        std::fs::write(repo.join("README.md"), "# test").expect("write file");
323        Command::new("git")
324            .current_dir(&repo)
325            .args(["add", "."])
326            .output()
327            .expect("git add");
328        Command::new("git")
329            .current_dir(&repo)
330            .args(["commit", "-m", "initial"])
331            .output()
332            .expect("git commit");
333
334        TestRepo {
335            _sandbox: sandbox,
336            repo,
337        }
338    }
339
340    // --- validate_repo ---
341    // Behavioral: tests the public contract — given a path, does the system
342    // correctly identify whether it's inside a git repo and return the root?
343
344    #[test]
345    #[serial]
346    fn validate_repo_returns_root_inside_repo() {
347        let repo = setup_test_repo();
348        let result = validate_repo(repo.path());
349        assert!(result.is_ok());
350        let root = result.unwrap();
351        // The returned root should match the repo dir (canonicalize for symlinks)
352        assert_eq!(
353            root.canonicalize().unwrap(),
354            repo.path().canonicalize().unwrap()
355        );
356    }
357
358    #[test]
359    #[serial]
360    fn validate_repo_returns_not_a_git_repo_outside() {
361        let dir = TempDir::new().expect("create temp dir");
362        let result = validate_repo(dir.path());
363        assert!(result.is_err());
364        let err = result.unwrap_err();
365        assert!(
366            matches!(err, PawError::NotAGitRepo),
367            "expected NotAGitRepo, got: {err}"
368        );
369    }
370
371    // --- list_branches ---
372    // Behavioral: tests the public function against a real git repo.
373    // Deduplication and remote-prefix stripping are covered in integration tests
374    // (list_branches_strips_remote_prefix_and_deduplicates) using a real remote.
375
376    #[test]
377    #[serial]
378    fn list_branches_returns_sorted_branches() {
379        let repo = setup_test_repo();
380
381        // Create branches in non-alphabetical order
382        for branch in ["zebra", "alpha", "feature/auth"] {
383            Command::new("git")
384                .current_dir(repo.path())
385                .args(["branch", branch])
386                .output()
387                .expect("create branch");
388        }
389
390        let branches = list_branches(repo.path()).expect("list branches");
391
392        // The default branch name depends on git config (main or master)
393        let default_branch = branches
394            .iter()
395            .find(|b| *b == "main" || *b == "master")
396            .expect("should have a default branch")
397            .clone();
398
399        let mut expected = vec![
400            "alpha".to_string(),
401            "feature/auth".to_string(),
402            default_branch,
403            "zebra".to_string(),
404        ];
405        expected.sort();
406
407        assert_eq!(
408            branches, expected,
409            "branches should be sorted alphabetically"
410        );
411    }
412
413    // --- project_name ---
414    // Behavioral: public function contract — the directory name IS the project name.
415    // The exact output matters because it's used in session names and worktree paths.
416
417    #[test]
418    fn project_name_from_path() {
419        assert_eq!(
420            project_name(Path::new("/Users/jie/code/git-paw")),
421            "git-paw"
422        );
423    }
424
425    #[test]
426    fn project_name_fallback_for_root() {
427        assert_eq!(project_name(Path::new("/")), "project");
428    }
429
430    // --- worktree_dir_name ---
431    // Behavioral: public function whose exact output determines actual directory names
432    // on disk. The format is the contract — other modules depend on this for path
433    // construction, so the exact string matters.
434
435    #[test]
436    fn worktree_dir_name_replaces_slash_with_dash() {
437        assert_eq!(
438            worktree_dir_name("git-paw", "feature/auth-flow"),
439            "git-paw-feature-auth-flow"
440        );
441    }
442
443    #[test]
444    fn worktree_dir_name_handles_multiple_slashes() {
445        assert_eq!(
446            worktree_dir_name("git-paw", "feat/auth/v2"),
447            "git-paw-feat-auth-v2"
448        );
449    }
450
451    #[test]
452    fn worktree_dir_name_strips_special_chars() {
453        assert_eq!(
454            worktree_dir_name("my-proj", "fix/issue#42"),
455            "my-proj-fix-issue42"
456        );
457    }
458
459    #[test]
460    fn worktree_dir_name_simple_branch() {
461        assert_eq!(worktree_dir_name("git-paw", "main"), "git-paw-main");
462    }
463
464    // --- create_worktree / remove_worktree ---
465    // Behavioral: tests real git worktree operations against temp repos.
466    // Verifies observable outcomes (directory exists, files present, cleanup works).
467
468    #[test]
469    #[serial]
470    fn create_worktree_at_correct_path() {
471        let test_repo = setup_test_repo();
472        let repo_root = test_repo.path();
473
474        Command::new("git")
475            .current_dir(repo_root)
476            .args(["branch", "feature/test"])
477            .output()
478            .expect("create branch");
479
480        let wt = create_worktree(repo_root, "feature/test").expect("create worktree");
481        let worktree_path = wt.path;
482
483        // Verify path follows ../<project>-<sanitized-branch> convention
484        let expected_dir_name = worktree_dir_name(&project_name(repo_root), "feature/test");
485        assert_eq!(
486            worktree_path.file_name().unwrap().to_string_lossy(),
487            expected_dir_name,
488            "worktree should be at ../<project>-feature-test"
489        );
490        assert_eq!(
491            worktree_path.parent().unwrap().canonicalize().unwrap(),
492            repo_root.parent().unwrap().canonicalize().unwrap(),
493            "worktree should be in the parent of repo root"
494        );
495
496        // Verify files exist
497        assert!(worktree_path.exists());
498        assert!(worktree_path.join("README.md").exists());
499
500        // Cleanup
501        remove_worktree(repo_root, &worktree_path).expect("remove worktree");
502    }
503
504    #[test]
505    #[serial]
506    fn create_worktree_errors_on_checked_out_branch() {
507        let test_repo = setup_test_repo();
508        let repo_root = test_repo.path();
509
510        let output = Command::new("git")
511            .current_dir(repo_root)
512            .args(["branch", "--show-current"])
513            .output()
514            .expect("get branch");
515        let current = String::from_utf8_lossy(&output.stdout).trim().to_string();
516
517        let result = create_worktree(repo_root, &current);
518        assert!(result.is_err());
519        let err = result.unwrap_err();
520        assert!(
521            matches!(err, PawError::WorktreeError(_)),
522            "expected WorktreeError, got: {err}"
523        );
524    }
525
526    // --- remove_worktree ---
527
528    #[test]
529    #[serial]
530    fn remove_worktree_cleans_up_fully() {
531        let test_repo = setup_test_repo();
532        let repo_root = test_repo.path();
533
534        Command::new("git")
535            .current_dir(repo_root)
536            .args(["branch", "feature/cleanup"])
537            .output()
538            .expect("create branch");
539
540        let worktree_path = create_worktree(repo_root, "feature/cleanup")
541            .expect("create worktree")
542            .path;
543        assert!(worktree_path.exists());
544
545        remove_worktree(repo_root, &worktree_path).expect("remove worktree");
546
547        assert!(
548            !worktree_path.exists(),
549            "worktree directory should be removed"
550        );
551
552        // Verify git no longer tracks this worktree
553        let output = Command::new("git")
554            .current_dir(repo_root)
555            .args(["worktree", "list", "--porcelain"])
556            .output()
557            .expect("list worktrees");
558        let stdout = String::from_utf8_lossy(&output.stdout);
559        assert!(
560            !stdout.contains("feature/cleanup"),
561            "worktree should not appear in git worktree list"
562        );
563    }
564}