1use std::path::{Path, PathBuf};
4use std::process::{Command, Output};
5
6use crate::constants::sanitize_branch_name;
7use crate::error::{CwError, Result};
8
9pub fn canonicalize_or(path: &Path) -> PathBuf {
11 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
12}
13
14#[derive(Debug)]
16pub struct CommandResult {
17 pub stdout: String,
18 pub returncode: i32,
19}
20
21pub fn run_command(
23 cmd: &[&str],
24 cwd: Option<&Path>,
25 check: bool,
26 capture: bool,
27) -> Result<CommandResult> {
28 if cmd.is_empty() {
29 return Err(CwError::Git("Empty command".to_string()));
30 }
31
32 let mut command = Command::new(cmd[0]);
33 command.args(&cmd[1..]);
34
35 if let Some(dir) = cwd {
36 command.current_dir(dir);
37 }
38
39 if capture {
40 command.stdout(std::process::Stdio::piped());
41 command.stderr(std::process::Stdio::piped());
42 }
43
44 let output: Output = command.output().map_err(|e| {
45 if e.kind() == std::io::ErrorKind::NotFound {
46 CwError::Git(format!("Command not found: {}", cmd[0]))
47 } else {
48 CwError::Io(e)
49 }
50 })?;
51
52 let returncode = output.status.code().unwrap_or(-1);
53 let stdout = if capture {
54 let mut out = String::from_utf8_lossy(&output.stdout).to_string();
56 let err = String::from_utf8_lossy(&output.stderr);
57 if !err.is_empty() {
58 if !out.is_empty() {
59 out.push('\n');
60 }
61 out.push_str(&err);
62 }
63 out
64 } else {
65 String::new()
66 };
67
68 if check && returncode != 0 {
69 return Err(CwError::Git(format!(
70 "Command failed: {}\n{}",
71 cmd.join(" "),
72 stdout
73 )));
74 }
75
76 Ok(CommandResult { stdout, returncode })
77}
78
79pub fn git_command(
81 args: &[&str],
82 repo: Option<&Path>,
83 check: bool,
84 capture: bool,
85) -> Result<CommandResult> {
86 let mut cmd = vec!["git"];
87 cmd.extend_from_slice(args);
88 run_command(&cmd, repo, check, capture)
89}
90
91pub fn get_repo_root(path: Option<&Path>) -> Result<PathBuf> {
93 let result = git_command(&["rev-parse", "--show-toplevel"], path, true, true);
94 match result {
95 Ok(r) => Ok(PathBuf::from(r.stdout.trim())),
96 Err(_) => Err(CwError::Git("Not in a git repository".to_string())),
97 }
98}
99
100pub fn get_current_branch(repo: Option<&Path>) -> Result<String> {
102 let result = git_command(&["rev-parse", "--abbrev-ref", "HEAD"], repo, true, true)?;
103 let branch = result.stdout.trim().to_string();
104 if branch == "HEAD" {
105 return Err(CwError::InvalidBranch("In detached HEAD state".to_string()));
106 }
107 Ok(branch)
108}
109
110pub fn detect_default_branch(repo: Option<&Path>) -> String {
118 if let Ok(r) = git_command(
120 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
121 repo,
122 false,
123 true,
124 ) {
125 if r.returncode == 0 {
126 let branch = r
127 .stdout
128 .trim()
129 .strip_prefix("origin/")
130 .unwrap_or(r.stdout.trim());
131 if !branch.is_empty() {
132 return branch.to_string();
133 }
134 }
135 }
136
137 if branch_exists("main", repo) {
139 return "main".to_string();
140 }
141
142 if branch_exists("master", repo) {
144 return "master".to_string();
145 }
146
147 "main".to_string()
149}
150
151pub fn branch_exists(branch: &str, repo: Option<&Path>) -> bool {
153 git_command(&["rev-parse", "--verify", branch], repo, false, true)
154 .map(|r| r.returncode == 0)
155 .unwrap_or(false)
156}
157
158pub fn remote_branch_exists(branch: &str, repo: Option<&Path>, remote: &str) -> bool {
160 let ref_name = format!("{}/{}", remote, branch);
161 git_command(&["rev-parse", "--verify", &ref_name], repo, false, true)
162 .map(|r| r.returncode == 0)
163 .unwrap_or(false)
164}
165
166pub fn get_config(key: &str, repo: Option<&Path>) -> Option<String> {
168 git_command(&["config", "--local", "--get", key], repo, false, true)
169 .ok()
170 .and_then(|r| {
171 if r.returncode == 0 {
172 Some(r.stdout.trim().to_string())
173 } else {
174 None
175 }
176 })
177}
178
179pub fn set_config(key: &str, value: &str, repo: Option<&Path>) -> Result<()> {
181 git_command(&["config", "--local", key, value], repo, true, false)?;
182 Ok(())
183}
184
185pub fn unset_config(key: &str, repo: Option<&Path>) {
187 let _ = git_command(
188 &["config", "--local", "--unset-all", key],
189 repo,
190 false,
191 false,
192 );
193}
194
195pub fn normalize_branch_name(branch: &str) -> &str {
197 branch.strip_prefix("refs/heads/").unwrap_or(branch)
198}
199
200pub type WorktreeEntry = (String, PathBuf);
202
203pub fn parse_worktrees(repo: &Path) -> Result<Vec<WorktreeEntry>> {
205 let result = git_command(&["worktree", "list", "--porcelain"], Some(repo), true, true)?;
206
207 let mut items: Vec<WorktreeEntry> = Vec::new();
208 let mut cur_path: Option<String> = None;
209 let mut cur_branch: Option<String> = None;
210
211 for line in result.stdout.lines() {
212 if let Some(path) = line.strip_prefix("worktree ") {
213 cur_path = Some(path.to_string());
214 } else if let Some(branch) = line.strip_prefix("branch ") {
215 cur_branch = Some(branch.to_string());
216 } else if line.trim().is_empty() {
217 if let Some(path) = cur_path.take() {
218 let branch = cur_branch
219 .take()
220 .unwrap_or_else(|| "(detached)".to_string());
221 items.push((branch, PathBuf::from(path)));
222 }
223 }
224 }
225 if let Some(path) = cur_path {
227 let branch = cur_branch.unwrap_or_else(|| "(detached)".to_string());
228 items.push((branch, PathBuf::from(path)));
229 }
230
231 Ok(items)
232}
233
234pub fn get_feature_worktrees(repo: Option<&Path>) -> Result<Vec<(String, PathBuf)>> {
236 let effective_repo = get_repo_root(repo)?;
237 let worktrees = parse_worktrees(&effective_repo)?;
238 if worktrees.is_empty() {
239 return Ok(Vec::new());
240 }
241
242 let main_path = canonicalize_or(&worktrees[0].1);
243
244 let mut result = Vec::new();
245 for (branch, path) in &worktrees {
246 let resolved = canonicalize_or(path);
247 if resolved == main_path {
248 continue;
249 }
250 if branch == "(detached)" {
251 continue;
252 }
253 let branch_name = normalize_branch_name(branch).to_string();
254 result.push((branch_name, path.clone()));
255 }
256 Ok(result)
257}
258
259pub fn get_main_repo_root(repo: Option<&Path>) -> Result<PathBuf> {
261 let current_root = get_repo_root(repo)?;
262 let worktrees = parse_worktrees(¤t_root)?;
263 if let Some(first) = worktrees.first() {
264 Ok(first.1.clone())
265 } else {
266 Ok(current_root)
267 }
268}
269
270pub fn find_worktree_by_branch(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
272 let worktrees = parse_worktrees(repo)?;
273 Ok(worktrees
274 .into_iter()
275 .find(|(br, _)| br == branch)
276 .map(|(_, path)| path))
277}
278
279pub fn find_worktree_by_name(repo: &Path, worktree_name: &str) -> Result<Option<PathBuf>> {
281 let worktrees = parse_worktrees(repo)?;
282 Ok(worktrees
283 .into_iter()
284 .find(|(_, path)| {
285 path.file_name()
286 .map(|n| n.to_string_lossy() == worktree_name)
287 .unwrap_or(false)
288 })
289 .map(|(_, path)| path))
290}
291
292pub fn find_worktree_by_intended_branch(
294 repo: &Path,
295 intended_branch: &str,
296) -> Result<Option<PathBuf>> {
297 let intended_branch = normalize_branch_name(intended_branch);
298
299 if let Some(path) = find_worktree_by_branch(repo, intended_branch)? {
301 return Ok(Some(path));
302 }
303 let with_prefix = format!("refs/heads/{}", intended_branch);
305 if let Some(path) = find_worktree_by_branch(repo, &with_prefix)? {
306 return Ok(Some(path));
307 }
308
309 let result = git_command(
311 &[
312 "config",
313 "--local",
314 "--get-regexp",
315 r"^worktree\..*\.intendedBranch",
316 ],
317 Some(repo),
318 false,
319 true,
320 )?;
321
322 if result.returncode == 0 {
323 for line in result.stdout.trim().lines() {
324 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
325 if parts.len() == 2 {
326 let key = parts[0];
327 let value = parts[1];
328 let key_parts: Vec<&str> = key.split('.').collect();
330 if key_parts.len() >= 2 {
331 let branch_from_key = key_parts[1];
332 if branch_from_key == intended_branch || value == intended_branch {
333 let worktrees = parse_worktrees(repo)?;
334 let repo_name = repo
335 .file_name()
336 .map(|n| n.to_string_lossy().to_string())
337 .unwrap_or_default();
338 let expected_suffix =
339 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
340 for (_, path) in &worktrees {
341 if let Some(name) = path.file_name() {
342 if name.to_string_lossy() == expected_suffix {
343 return Ok(Some(path.clone()));
344 }
345 }
346 }
347 }
348 }
349 }
350 }
351 }
352
353 let repo_name = repo
355 .file_name()
356 .map(|n| n.to_string_lossy().to_string())
357 .unwrap_or_default();
358 let expected_suffix = format!("{}-{}", repo_name, sanitize_branch_name(intended_branch));
359 let worktrees = parse_worktrees(repo)?;
360 let repo_resolved = canonicalize_or(repo);
361
362 for (_, path) in &worktrees {
363 if let Some(name) = path.file_name() {
364 if name.to_string_lossy() == expected_suffix {
365 let path_resolved = canonicalize_or(path);
366 if path_resolved != repo_resolved {
367 return Ok(Some(path.clone()));
368 }
369 }
370 }
371 }
372
373 Ok(None)
374}
375
376pub fn fetch_and_rebase_target(base_branch: &str, repo: &Path, cwd: &Path) -> (bool, String) {
381 let fetch_ok = git_command(&["fetch", "--all", "--prune"], Some(repo), false, true)
382 .map(|r| r.returncode == 0)
383 .unwrap_or(false);
384
385 let rebase_target = if fetch_ok {
386 let origin_ref = format!("origin/{}", base_branch);
387 if branch_exists(&origin_ref, Some(cwd)) {
388 origin_ref
389 } else {
390 base_branch.to_string()
391 }
392 } else {
393 base_branch.to_string()
394 };
395
396 (fetch_ok, rebase_target)
397}
398
399pub fn has_command(name: &str) -> bool {
401 if let Ok(path_var) = std::env::var("PATH") {
402 for dir in std::env::split_paths(&path_var) {
403 let candidate = dir.join(name);
404 if candidate.is_file() {
405 return true;
406 }
407 #[cfg(target_os = "windows")]
409 {
410 let with_ext = dir.join(format!("{}.exe", name));
411 if with_ext.is_file() {
412 return true;
413 }
414 }
415 }
416 }
417 false
418}
419
420pub fn is_non_interactive() -> bool {
422 if let Ok(val) = std::env::var("CW_NON_INTERACTIVE") {
424 let val = val.to_lowercase();
425 if val == "1" || val == "true" || val == "yes" {
426 return true;
427 }
428 }
429
430 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
432 return true;
433 }
434
435 let ci_vars = [
437 "CI",
438 "GITHUB_ACTIONS",
439 "GITLAB_CI",
440 "JENKINS_HOME",
441 "CIRCLECI",
442 "TRAVIS",
443 "BUILDKITE",
444 "DRONE",
445 "BITBUCKET_PIPELINE",
446 "CODEBUILD_BUILD_ID",
447 ];
448
449 ci_vars.iter().any(|var| std::env::var(var).is_ok())
450}
451
452pub fn is_valid_branch_name(branch_name: &str, repo: Option<&Path>) -> bool {
454 if branch_name.is_empty() {
455 return false;
456 }
457 git_command(
458 &["check-ref-format", "--branch", branch_name],
459 repo,
460 false,
461 true,
462 )
463 .map(|r| r.returncode == 0)
464 .unwrap_or(false)
465}
466
467pub fn get_branch_name_error(branch_name: &str) -> String {
469 if branch_name.is_empty() {
470 return "Branch name cannot be empty".to_string();
471 }
472 if branch_name == "@" {
473 return "Branch name cannot be '@' alone".to_string();
474 }
475 if branch_name.ends_with(".lock") {
476 return "Branch name cannot end with '.lock'".to_string();
477 }
478 if branch_name.starts_with('/') || branch_name.ends_with('/') {
479 return "Branch name cannot start or end with '/'".to_string();
480 }
481 if branch_name.contains("//") {
482 return "Branch name cannot contain consecutive slashes '//'".to_string();
483 }
484 if branch_name.contains("..") {
485 return "Branch name cannot contain consecutive dots '..'".to_string();
486 }
487 if branch_name.contains("@{") {
488 return "Branch name cannot contain '@{'".to_string();
489 }
490
491 let invalid_chars: &[char] = &['~', '^', ':', '?', '*', '[', '\\'];
492 let found: Vec<char> = invalid_chars
493 .iter()
494 .filter(|&&c| branch_name.contains(c))
495 .copied()
496 .collect();
497 if !found.is_empty() {
498 let chars_display: Vec<String> = found.iter().map(|c| format!("{:?}", c)).collect();
499 return format!(
500 "Branch name contains invalid characters: {}",
501 chars_display.join(", ")
502 );
503 }
504
505 if branch_name.chars().any(|c| (c as u32) < 32 || c == ' ') {
506 return "Branch name cannot contain spaces or control characters".to_string();
507 }
508
509 format!(
510 "'{}' is not a valid branch name. See 'git check-ref-format --help' for rules",
511 branch_name
512 )
513}
514
515pub fn remove_worktree_safe(worktree_path: &Path, repo: &Path, force: bool) -> Result<()> {
517 let worktree_str = canonicalize_or(worktree_path).to_string_lossy().to_string();
518
519 let mut args = vec!["worktree", "remove", &worktree_str];
520 if force {
521 args.push("--force");
522 }
523
524 let result = git_command(&args, Some(repo), false, true)?;
525
526 if result.returncode == 0 {
527 return Ok(());
528 }
529
530 #[cfg(target_os = "windows")]
532 {
533 if result.stdout.contains("Directory not empty") {
534 let path = PathBuf::from(&worktree_str);
535 if path.exists() {
536 std::fs::remove_dir_all(&path).map_err(|e| {
537 CwError::Git(format!(
538 "Failed to remove worktree directory on Windows: {}\nError: {}",
539 worktree_str, e
540 ))
541 })?;
542 }
543 git_command(&["worktree", "prune"], Some(repo), true, false)?;
544 return Ok(());
545 }
546 }
547
548 Err(CwError::Git(format!(
549 "Command failed: {}\n{}",
550 args.join(" "),
551 result.stdout
552 )))
553}
554
555pub fn is_branch_merged(feature_branch: &str, base_branch: &str, repo: Option<&Path>) -> bool {
559 let remote_base = format!("origin/{}", base_branch);
561 if let Ok(r) = git_command(&["branch", "--merged", &remote_base], repo, false, true) {
562 if r.returncode == 0 {
563 for line in r.stdout.lines() {
564 let name = line.trim().trim_start_matches("* ");
565 if name == feature_branch {
566 return true;
567 }
568 }
569 }
570 }
571
572 if let Ok(r) = git_command(&["branch", "--merged", base_branch], repo, false, true) {
574 if r.returncode == 0 {
575 for line in r.stdout.lines() {
576 let name = line.trim().trim_start_matches("* ");
577 if name == feature_branch {
578 return true;
579 }
580 }
581 }
582 }
583
584 false
585}
586
587pub fn get_pr_state(feature_branch: &str, repo: Option<&Path>) -> Option<String> {
592 if !has_command("gh") {
593 return None;
594 }
595
596 let result = run_command(
597 &[
598 "gh",
599 "pr",
600 "view",
601 feature_branch,
602 "--json",
603 "state",
604 "--jq",
605 ".state",
606 ],
607 repo,
608 false,
609 true,
610 )
611 .ok()?;
612
613 if result.returncode == 0 {
614 let state = result.stdout.trim().to_string();
615 if !state.is_empty() {
616 return Some(state);
617 }
618 }
619
620 None
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 #[cfg(not(windows))]
629 fn test_canonicalize_or_existing_path() {
630 let path = Path::new("/tmp");
632 let result = canonicalize_or(path);
633 assert!(result.is_absolute());
635 }
636
637 #[test]
638 fn test_canonicalize_or_nonexistent_path() {
639 let path = Path::new("/nonexistent/path/that/does/not/exist");
640 let result = canonicalize_or(path);
641 assert_eq!(result, path);
643 }
644
645 #[test]
646 fn test_canonicalize_or_relative_path() {
647 let path = Path::new("relative/path");
648 let result = canonicalize_or(path);
649 assert_eq!(result, path);
651 }
652
653 #[test]
654 fn test_normalize_branch_name() {
655 assert_eq!(normalize_branch_name("refs/heads/main"), "main");
656 assert_eq!(normalize_branch_name("feature-branch"), "feature-branch");
657 assert_eq!(normalize_branch_name("refs/heads/feat/auth"), "feat/auth");
658 }
659
660 #[test]
661 fn test_get_branch_name_error() {
662 assert_eq!(get_branch_name_error(""), "Branch name cannot be empty");
663 assert_eq!(
664 get_branch_name_error("@"),
665 "Branch name cannot be '@' alone"
666 );
667 assert_eq!(
668 get_branch_name_error("foo.lock"),
669 "Branch name cannot end with '.lock'"
670 );
671 assert_eq!(
672 get_branch_name_error("/foo"),
673 "Branch name cannot start or end with '/'"
674 );
675 assert_eq!(
676 get_branch_name_error("foo//bar"),
677 "Branch name cannot contain consecutive slashes '//'"
678 );
679 }
680}