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.
144pub fn create_worktree(repo_root: &Path, branch: &str) -> Result<PathBuf, PawError> {
145    let project = project_name(repo_root);
146    let dir_name = worktree_dir_name(&project, branch);
147
148    let parent = repo_root.parent().ok_or_else(|| {
149        PawError::WorktreeError("cannot determine parent directory of repo".to_string())
150    })?;
151    let worktree_path = parent.join(&dir_name);
152
153    let output = Command::new("git")
154        .current_dir(repo_root)
155        .args(["worktree", "add", &worktree_path.to_string_lossy(), branch])
156        .output()
157        .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree add: {e}")))?;
158
159    if !output.status.success() {
160        let stderr = String::from_utf8_lossy(&output.stderr);
161        return Err(PawError::WorktreeError(format!(
162            "git worktree add failed for branch '{branch}': {stderr}"
163        )));
164    }
165
166    Ok(worktree_path)
167}
168
169/// Removes a git worktree at the given path.
170///
171/// Runs `git worktree remove --force` and then prunes stale worktree entries.
172pub fn remove_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), PawError> {
173    let output = Command::new("git")
174        .current_dir(repo_root)
175        .args([
176            "worktree",
177            "remove",
178            "--force",
179            &worktree_path.to_string_lossy(),
180        ])
181        .output()
182        .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree remove: {e}")))?;
183
184    if !output.status.success() {
185        let stderr = String::from_utf8_lossy(&output.stderr);
186        return Err(PawError::WorktreeError(format!(
187            "git worktree remove failed: {stderr}"
188        )));
189    }
190
191    // Prune stale worktree entries
192    let _ = Command::new("git")
193        .current_dir(repo_root)
194        .args(["worktree", "prune"])
195        .output();
196
197    Ok(())
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use serial_test::serial;
204    use std::process::Command;
205    use tempfile::TempDir;
206
207    /// A test sandbox that owns an outer temp directory containing the git
208    /// repo. Worktrees created via `create_worktree` land as siblings of the
209    /// repo inside this outer dir, so everything is cleaned up when the
210    /// sandbox is dropped — even if a test panics.
211    struct TestRepo {
212        _sandbox: TempDir,
213        repo: PathBuf,
214    }
215
216    impl TestRepo {
217        fn path(&self) -> &Path {
218            &self.repo
219        }
220    }
221
222    /// Creates a temporary git repository inside a sandbox directory.
223    ///
224    /// The repo lives at `<sandbox>/repo/` so that worktrees created at
225    /// `../<project>-<branch>/` land inside `<sandbox>/` and are automatically
226    /// cleaned up when the returned `TestRepo` is dropped.
227    fn setup_test_repo() -> TestRepo {
228        let sandbox = TempDir::new().expect("create sandbox dir");
229        let repo = sandbox.path().join("repo");
230        std::fs::create_dir(&repo).expect("create repo dir");
231
232        Command::new("git")
233            .current_dir(&repo)
234            .args(["init"])
235            .output()
236            .expect("git init");
237
238        Command::new("git")
239            .current_dir(&repo)
240            .args(["config", "user.email", "test@test.com"])
241            .output()
242            .expect("git config email");
243
244        Command::new("git")
245            .current_dir(&repo)
246            .args(["config", "user.name", "Test"])
247            .output()
248            .expect("git config name");
249
250        // Create initial commit so branches work
251        std::fs::write(repo.join("README.md"), "# test").expect("write file");
252        Command::new("git")
253            .current_dir(&repo)
254            .args(["add", "."])
255            .output()
256            .expect("git add");
257        Command::new("git")
258            .current_dir(&repo)
259            .args(["commit", "-m", "initial"])
260            .output()
261            .expect("git commit");
262
263        TestRepo {
264            _sandbox: sandbox,
265            repo,
266        }
267    }
268
269    // --- validate_repo ---
270    // Behavioral: tests the public contract — given a path, does the system
271    // correctly identify whether it's inside a git repo and return the root?
272
273    #[test]
274    #[serial]
275    fn validate_repo_returns_root_inside_repo() {
276        let repo = setup_test_repo();
277        let result = validate_repo(repo.path());
278        assert!(result.is_ok());
279        let root = result.unwrap();
280        // The returned root should match the repo dir (canonicalize for symlinks)
281        assert_eq!(
282            root.canonicalize().unwrap(),
283            repo.path().canonicalize().unwrap()
284        );
285    }
286
287    #[test]
288    #[serial]
289    fn validate_repo_returns_not_a_git_repo_outside() {
290        let dir = TempDir::new().expect("create temp dir");
291        let result = validate_repo(dir.path());
292        assert!(result.is_err());
293        let err = result.unwrap_err();
294        assert!(
295            matches!(err, PawError::NotAGitRepo),
296            "expected NotAGitRepo, got: {err}"
297        );
298    }
299
300    // --- list_branches ---
301    // Behavioral: tests the public function against a real git repo.
302    // Deduplication and remote-prefix stripping are covered in integration tests
303    // (list_branches_strips_remote_prefix_and_deduplicates) using a real remote.
304
305    #[test]
306    #[serial]
307    fn list_branches_returns_sorted_branches() {
308        let repo = setup_test_repo();
309
310        // Create branches in non-alphabetical order
311        for branch in ["zebra", "alpha", "feature/auth"] {
312            Command::new("git")
313                .current_dir(repo.path())
314                .args(["branch", branch])
315                .output()
316                .expect("create branch");
317        }
318
319        let branches = list_branches(repo.path()).expect("list branches");
320
321        // The default branch name depends on git config (main or master)
322        let default_branch = branches
323            .iter()
324            .find(|b| *b == "main" || *b == "master")
325            .expect("should have a default branch")
326            .clone();
327
328        let mut expected = vec![
329            "alpha".to_string(),
330            "feature/auth".to_string(),
331            default_branch,
332            "zebra".to_string(),
333        ];
334        expected.sort();
335
336        assert_eq!(
337            branches, expected,
338            "branches should be sorted alphabetically"
339        );
340    }
341
342    // --- project_name ---
343    // Behavioral: public function contract — the directory name IS the project name.
344    // The exact output matters because it's used in session names and worktree paths.
345
346    #[test]
347    fn project_name_from_path() {
348        assert_eq!(
349            project_name(Path::new("/Users/jie/code/git-paw")),
350            "git-paw"
351        );
352    }
353
354    #[test]
355    fn project_name_fallback_for_root() {
356        assert_eq!(project_name(Path::new("/")), "project");
357    }
358
359    // --- worktree_dir_name ---
360    // Behavioral: public function whose exact output determines actual directory names
361    // on disk. The format is the contract — other modules depend on this for path
362    // construction, so the exact string matters.
363
364    #[test]
365    fn worktree_dir_name_replaces_slash_with_dash() {
366        assert_eq!(
367            worktree_dir_name("git-paw", "feature/auth-flow"),
368            "git-paw-feature-auth-flow"
369        );
370    }
371
372    #[test]
373    fn worktree_dir_name_handles_multiple_slashes() {
374        assert_eq!(
375            worktree_dir_name("git-paw", "feat/auth/v2"),
376            "git-paw-feat-auth-v2"
377        );
378    }
379
380    #[test]
381    fn worktree_dir_name_strips_special_chars() {
382        assert_eq!(
383            worktree_dir_name("my-proj", "fix/issue#42"),
384            "my-proj-fix-issue42"
385        );
386    }
387
388    #[test]
389    fn worktree_dir_name_simple_branch() {
390        assert_eq!(worktree_dir_name("git-paw", "main"), "git-paw-main");
391    }
392
393    // --- create_worktree / remove_worktree ---
394    // Behavioral: tests real git worktree operations against temp repos.
395    // Verifies observable outcomes (directory exists, files present, cleanup works).
396
397    #[test]
398    #[serial]
399    fn create_worktree_at_correct_path() {
400        let test_repo = setup_test_repo();
401        let repo_root = test_repo.path();
402
403        Command::new("git")
404            .current_dir(repo_root)
405            .args(["branch", "feature/test"])
406            .output()
407            .expect("create branch");
408
409        let worktree_path = create_worktree(repo_root, "feature/test").expect("create worktree");
410
411        // Verify path follows ../<project>-<sanitized-branch> convention
412        let expected_dir_name = worktree_dir_name(&project_name(repo_root), "feature/test");
413        assert_eq!(
414            worktree_path.file_name().unwrap().to_string_lossy(),
415            expected_dir_name,
416            "worktree should be at ../<project>-feature-test"
417        );
418        assert_eq!(
419            worktree_path.parent().unwrap().canonicalize().unwrap(),
420            repo_root.parent().unwrap().canonicalize().unwrap(),
421            "worktree should be in the parent of repo root"
422        );
423
424        // Verify files exist
425        assert!(worktree_path.exists());
426        assert!(worktree_path.join("README.md").exists());
427
428        // Cleanup
429        remove_worktree(repo_root, &worktree_path).expect("remove worktree");
430    }
431
432    #[test]
433    #[serial]
434    fn create_worktree_errors_on_checked_out_branch() {
435        let test_repo = setup_test_repo();
436        let repo_root = test_repo.path();
437
438        let output = Command::new("git")
439            .current_dir(repo_root)
440            .args(["branch", "--show-current"])
441            .output()
442            .expect("get branch");
443        let current = String::from_utf8_lossy(&output.stdout).trim().to_string();
444
445        let result = create_worktree(repo_root, &current);
446        assert!(result.is_err());
447        let err = result.unwrap_err();
448        assert!(
449            matches!(err, PawError::WorktreeError(_)),
450            "expected WorktreeError, got: {err}"
451        );
452    }
453
454    // --- remove_worktree ---
455
456    #[test]
457    #[serial]
458    fn remove_worktree_cleans_up_fully() {
459        let test_repo = setup_test_repo();
460        let repo_root = test_repo.path();
461
462        Command::new("git")
463            .current_dir(repo_root)
464            .args(["branch", "feature/cleanup"])
465            .output()
466            .expect("create branch");
467
468        let worktree_path = create_worktree(repo_root, "feature/cleanup").expect("create worktree");
469        assert!(worktree_path.exists());
470
471        remove_worktree(repo_root, &worktree_path).expect("remove worktree");
472
473        assert!(
474            !worktree_path.exists(),
475            "worktree directory should be removed"
476        );
477
478        // Verify git no longer tracks this worktree
479        let output = Command::new("git")
480            .current_dir(repo_root)
481            .args(["worktree", "list", "--porcelain"])
482            .output()
483            .expect("list worktrees");
484        let stdout = String::from_utf8_lossy(&output.stdout);
485        assert!(
486            !stdout.contains("feature/cleanup"),
487            "worktree should not appear in git worktree list"
488        );
489    }
490}