1use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use crate::error::PawError;
11
12pub 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
30pub 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
54fn 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 if name.contains("HEAD") {
66 continue;
67 }
68 let stripped = strip_remote_prefix(name);
70 branches.insert(stripped.to_string());
71 }
72
73 branches.into_iter().collect()
74}
75
76fn strip_remote_prefix(branch: &str) -> &str {
81 if let Some(rest) = branch.strip_prefix("origin/") {
84 rest
85 } else {
86 branch
87 }
88}
89
90pub 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
100pub 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
119pub 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
140pub 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
169pub 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 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 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 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 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 #[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 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 #[test]
306 #[serial]
307 fn list_branches_returns_sorted_branches() {
308 let repo = setup_test_repo();
309
310 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 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 #[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 #[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 #[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 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 assert!(worktree_path.exists());
426 assert!(worktree_path.join("README.md").exists());
427
428 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, ¤t);
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 #[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 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}