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 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
148pub 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 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 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 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 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 #[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 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 #[test]
285 #[serial]
286 fn list_branches_returns_sorted_branches() {
287 let repo = setup_test_repo();
288
289 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 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 #[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 #[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 #[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 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 assert!(worktree_path.exists());
405 assert!(worktree_path.join("README.md").exists());
406
407 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, ¤t);
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 #[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 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}