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 branch_exists(branch: &str, repo: Option<&Path>) -> bool {
112 git_command(&["rev-parse", "--verify", branch], repo, false, true)
113 .map(|r| r.returncode == 0)
114 .unwrap_or(false)
115}
116
117pub fn remote_branch_exists(branch: &str, repo: Option<&Path>, remote: &str) -> bool {
119 let ref_name = format!("{}/{}", remote, branch);
120 git_command(&["rev-parse", "--verify", &ref_name], repo, false, true)
121 .map(|r| r.returncode == 0)
122 .unwrap_or(false)
123}
124
125pub fn get_config(key: &str, repo: Option<&Path>) -> Option<String> {
127 git_command(&["config", "--local", "--get", key], repo, false, true)
128 .ok()
129 .and_then(|r| {
130 if r.returncode == 0 {
131 Some(r.stdout.trim().to_string())
132 } else {
133 None
134 }
135 })
136}
137
138pub fn set_config(key: &str, value: &str, repo: Option<&Path>) -> Result<()> {
140 git_command(&["config", "--local", key, value], repo, true, false)?;
141 Ok(())
142}
143
144pub fn unset_config(key: &str, repo: Option<&Path>) {
146 let _ = git_command(
147 &["config", "--local", "--unset-all", key],
148 repo,
149 false,
150 false,
151 );
152}
153
154pub fn normalize_branch_name(branch: &str) -> &str {
156 branch.strip_prefix("refs/heads/").unwrap_or(branch)
157}
158
159pub type WorktreeEntry = (String, PathBuf);
161
162pub fn parse_worktrees(repo: &Path) -> Result<Vec<WorktreeEntry>> {
164 let result = git_command(&["worktree", "list", "--porcelain"], Some(repo), true, true)?;
165
166 let mut items: Vec<WorktreeEntry> = Vec::new();
167 let mut cur_path: Option<String> = None;
168 let mut cur_branch: Option<String> = None;
169
170 for line in result.stdout.lines() {
171 if let Some(path) = line.strip_prefix("worktree ") {
172 cur_path = Some(path.to_string());
173 } else if let Some(branch) = line.strip_prefix("branch ") {
174 cur_branch = Some(branch.to_string());
175 } else if line.trim().is_empty() {
176 if let Some(path) = cur_path.take() {
177 let branch = cur_branch
178 .take()
179 .unwrap_or_else(|| "(detached)".to_string());
180 items.push((branch, PathBuf::from(path)));
181 }
182 }
183 }
184 if let Some(path) = cur_path {
186 let branch = cur_branch.unwrap_or_else(|| "(detached)".to_string());
187 items.push((branch, PathBuf::from(path)));
188 }
189
190 Ok(items)
191}
192
193pub fn get_feature_worktrees(repo: Option<&Path>) -> Result<Vec<(String, PathBuf)>> {
195 let effective_repo = get_repo_root(repo)?;
196 let worktrees = parse_worktrees(&effective_repo)?;
197 if worktrees.is_empty() {
198 return Ok(Vec::new());
199 }
200
201 let main_path = canonicalize_or(&worktrees[0].1);
202
203 let mut result = Vec::new();
204 for (branch, path) in &worktrees {
205 let resolved = canonicalize_or(path);
206 if resolved == main_path {
207 continue;
208 }
209 if branch == "(detached)" {
210 continue;
211 }
212 let branch_name = normalize_branch_name(branch).to_string();
213 result.push((branch_name, path.clone()));
214 }
215 Ok(result)
216}
217
218pub fn get_main_repo_root(repo: Option<&Path>) -> Result<PathBuf> {
220 let current_root = get_repo_root(repo)?;
221 let worktrees = parse_worktrees(¤t_root)?;
222 if let Some(first) = worktrees.first() {
223 Ok(first.1.clone())
224 } else {
225 Ok(current_root)
226 }
227}
228
229pub fn find_worktree_by_branch(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
231 let worktrees = parse_worktrees(repo)?;
232 Ok(worktrees
233 .into_iter()
234 .find(|(br, _)| br == branch)
235 .map(|(_, path)| path))
236}
237
238pub fn find_worktree_by_name(repo: &Path, worktree_name: &str) -> Result<Option<PathBuf>> {
240 let worktrees = parse_worktrees(repo)?;
241 Ok(worktrees
242 .into_iter()
243 .find(|(_, path)| {
244 path.file_name()
245 .map(|n| n.to_string_lossy() == worktree_name)
246 .unwrap_or(false)
247 })
248 .map(|(_, path)| path))
249}
250
251pub fn find_worktree_by_intended_branch(
253 repo: &Path,
254 intended_branch: &str,
255) -> Result<Option<PathBuf>> {
256 let intended_branch = normalize_branch_name(intended_branch);
257
258 if let Some(path) = find_worktree_by_branch(repo, intended_branch)? {
260 return Ok(Some(path));
261 }
262 let with_prefix = format!("refs/heads/{}", intended_branch);
264 if let Some(path) = find_worktree_by_branch(repo, &with_prefix)? {
265 return Ok(Some(path));
266 }
267
268 let result = git_command(
270 &[
271 "config",
272 "--local",
273 "--get-regexp",
274 r"^worktree\..*\.intendedBranch",
275 ],
276 Some(repo),
277 false,
278 true,
279 )?;
280
281 if result.returncode == 0 {
282 for line in result.stdout.trim().lines() {
283 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
284 if parts.len() == 2 {
285 let key = parts[0];
286 let value = parts[1];
287 let key_parts: Vec<&str> = key.split('.').collect();
289 if key_parts.len() >= 2 {
290 let branch_from_key = key_parts[1];
291 if branch_from_key == intended_branch || value == intended_branch {
292 let worktrees = parse_worktrees(repo)?;
293 let repo_name = repo
294 .file_name()
295 .map(|n| n.to_string_lossy().to_string())
296 .unwrap_or_default();
297 let expected_suffix =
298 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
299 for (_, path) in &worktrees {
300 if let Some(name) = path.file_name() {
301 if name.to_string_lossy() == expected_suffix {
302 return Ok(Some(path.clone()));
303 }
304 }
305 }
306 }
307 }
308 }
309 }
310 }
311
312 let repo_name = repo
314 .file_name()
315 .map(|n| n.to_string_lossy().to_string())
316 .unwrap_or_default();
317 let expected_suffix = format!("{}-{}", repo_name, sanitize_branch_name(intended_branch));
318 let worktrees = parse_worktrees(repo)?;
319 let repo_resolved = canonicalize_or(repo);
320
321 for (_, path) in &worktrees {
322 if let Some(name) = path.file_name() {
323 if name.to_string_lossy() == expected_suffix {
324 let path_resolved = canonicalize_or(path);
325 if path_resolved != repo_resolved {
326 return Ok(Some(path.clone()));
327 }
328 }
329 }
330 }
331
332 Ok(None)
333}
334
335pub fn fetch_and_rebase_target(base_branch: &str, repo: &Path, cwd: &Path) -> (bool, String) {
340 let fetch_ok = git_command(&["fetch", "--all", "--prune"], Some(repo), false, true)
341 .map(|r| r.returncode == 0)
342 .unwrap_or(false);
343
344 let rebase_target = if fetch_ok {
345 let origin_ref = format!("origin/{}", base_branch);
346 if branch_exists(&origin_ref, Some(cwd)) {
347 origin_ref
348 } else {
349 base_branch.to_string()
350 }
351 } else {
352 base_branch.to_string()
353 };
354
355 (fetch_ok, rebase_target)
356}
357
358pub fn has_command(name: &str) -> bool {
360 if let Ok(path_var) = std::env::var("PATH") {
361 for dir in std::env::split_paths(&path_var) {
362 let candidate = dir.join(name);
363 if candidate.is_file() {
364 return true;
365 }
366 #[cfg(target_os = "windows")]
368 {
369 let with_ext = dir.join(format!("{}.exe", name));
370 if with_ext.is_file() {
371 return true;
372 }
373 }
374 }
375 }
376 false
377}
378
379pub fn is_non_interactive() -> bool {
381 if let Ok(val) = std::env::var("CW_NON_INTERACTIVE") {
383 let val = val.to_lowercase();
384 if val == "1" || val == "true" || val == "yes" {
385 return true;
386 }
387 }
388
389 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
391 return true;
392 }
393
394 let ci_vars = [
396 "CI",
397 "GITHUB_ACTIONS",
398 "GITLAB_CI",
399 "JENKINS_HOME",
400 "CIRCLECI",
401 "TRAVIS",
402 "BUILDKITE",
403 "DRONE",
404 "BITBUCKET_PIPELINE",
405 "CODEBUILD_BUILD_ID",
406 ];
407
408 ci_vars.iter().any(|var| std::env::var(var).is_ok())
409}
410
411pub fn is_valid_branch_name(branch_name: &str, repo: Option<&Path>) -> bool {
413 if branch_name.is_empty() {
414 return false;
415 }
416 git_command(
417 &["check-ref-format", "--branch", branch_name],
418 repo,
419 false,
420 true,
421 )
422 .map(|r| r.returncode == 0)
423 .unwrap_or(false)
424}
425
426pub fn get_branch_name_error(branch_name: &str) -> String {
428 if branch_name.is_empty() {
429 return "Branch name cannot be empty".to_string();
430 }
431 if branch_name == "@" {
432 return "Branch name cannot be '@' alone".to_string();
433 }
434 if branch_name.ends_with(".lock") {
435 return "Branch name cannot end with '.lock'".to_string();
436 }
437 if branch_name.starts_with('/') || branch_name.ends_with('/') {
438 return "Branch name cannot start or end with '/'".to_string();
439 }
440 if branch_name.contains("//") {
441 return "Branch name cannot contain consecutive slashes '//'".to_string();
442 }
443 if branch_name.contains("..") {
444 return "Branch name cannot contain consecutive dots '..'".to_string();
445 }
446 if branch_name.contains("@{") {
447 return "Branch name cannot contain '@{'".to_string();
448 }
449
450 let invalid_chars: &[char] = &['~', '^', ':', '?', '*', '[', '\\'];
451 let found: Vec<char> = invalid_chars
452 .iter()
453 .filter(|&&c| branch_name.contains(c))
454 .copied()
455 .collect();
456 if !found.is_empty() {
457 let chars_display: Vec<String> = found.iter().map(|c| format!("{:?}", c)).collect();
458 return format!(
459 "Branch name contains invalid characters: {}",
460 chars_display.join(", ")
461 );
462 }
463
464 if branch_name.chars().any(|c| (c as u32) < 32 || c == ' ') {
465 return "Branch name cannot contain spaces or control characters".to_string();
466 }
467
468 format!(
469 "'{}' is not a valid branch name. See 'git check-ref-format --help' for rules",
470 branch_name
471 )
472}
473
474pub fn remove_worktree_safe(worktree_path: &Path, repo: &Path, force: bool) -> Result<()> {
476 let worktree_str = canonicalize_or(worktree_path).to_string_lossy().to_string();
477
478 let mut args = vec!["worktree", "remove", &worktree_str];
479 if force {
480 args.push("--force");
481 }
482
483 let result = git_command(&args, Some(repo), false, true)?;
484
485 if result.returncode == 0 {
486 return Ok(());
487 }
488
489 #[cfg(target_os = "windows")]
491 {
492 if result.stdout.contains("Directory not empty") {
493 let path = PathBuf::from(&worktree_str);
494 if path.exists() {
495 std::fs::remove_dir_all(&path).map_err(|e| {
496 CwError::Git(format!(
497 "Failed to remove worktree directory on Windows: {}\nError: {}",
498 worktree_str, e
499 ))
500 })?;
501 }
502 git_command(&["worktree", "prune"], Some(repo), true, false)?;
503 return Ok(());
504 }
505 }
506
507 Err(CwError::Git(format!(
508 "Command failed: {}\n{}",
509 args.join(" "),
510 result.stdout
511 )))
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 #[cfg(not(windows))]
520 fn test_canonicalize_or_existing_path() {
521 let path = Path::new("/tmp");
523 let result = canonicalize_or(path);
524 assert!(result.is_absolute());
526 }
527
528 #[test]
529 fn test_canonicalize_or_nonexistent_path() {
530 let path = Path::new("/nonexistent/path/that/does/not/exist");
531 let result = canonicalize_or(path);
532 assert_eq!(result, path);
534 }
535
536 #[test]
537 fn test_canonicalize_or_relative_path() {
538 let path = Path::new("relative/path");
539 let result = canonicalize_or(path);
540 assert_eq!(result, path);
542 }
543
544 #[test]
545 fn test_normalize_branch_name() {
546 assert_eq!(normalize_branch_name("refs/heads/main"), "main");
547 assert_eq!(normalize_branch_name("feature-branch"), "feature-branch");
548 assert_eq!(normalize_branch_name("refs/heads/feat/auth"), "feat/auth");
549 }
550
551 #[test]
552 fn test_get_branch_name_error() {
553 assert_eq!(get_branch_name_error(""), "Branch name cannot be empty");
554 assert_eq!(
555 get_branch_name_error("@"),
556 "Branch name cannot be '@' alone"
557 );
558 assert_eq!(
559 get_branch_name_error("foo.lock"),
560 "Branch name cannot end with '.lock'"
561 );
562 assert_eq!(
563 get_branch_name_error("/foo"),
564 "Branch name cannot start or end with '/'"
565 );
566 assert_eq!(
567 get_branch_name_error("foo//bar"),
568 "Branch name cannot contain consecutive slashes '//'"
569 );
570 }
571}