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