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
140#[derive(Debug)]
146pub struct WorktreeCreation {
147 pub path: PathBuf,
149 pub branch_created: bool,
151}
152
153pub 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 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 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
218pub 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
240pub 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 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 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 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 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 #[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 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 #[test]
377 #[serial]
378 fn list_branches_returns_sorted_branches() {
379 let repo = setup_test_repo();
380
381 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 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 #[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 #[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 #[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 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 assert!(worktree_path.exists());
498 assert!(worktree_path.join("README.md").exists());
499
500 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, ¤t);
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 #[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 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}