1use std::path::{Path, PathBuf};
5use std::process::{Command, Output};
6
7use crate::constants::sanitize_branch_name;
8use crate::error::{CwError, Result};
9
10#[derive(Debug)]
12pub struct CommandResult {
13 pub stdout: String,
14 pub returncode: i32,
15}
16
17pub fn run_command(
19 cmd: &[&str],
20 cwd: Option<&Path>,
21 check: bool,
22 capture: bool,
23) -> Result<CommandResult> {
24 if cmd.is_empty() {
25 return Err(CwError::Git("Empty command".to_string()));
26 }
27
28 let mut command = Command::new(cmd[0]);
29 command.args(&cmd[1..]);
30
31 if let Some(dir) = cwd {
32 command.current_dir(dir);
33 }
34
35 if capture {
36 command.stdout(std::process::Stdio::piped());
37 command.stderr(std::process::Stdio::piped());
38 }
39
40 let output: Output = command.output().map_err(|e| {
41 if e.kind() == std::io::ErrorKind::NotFound {
42 CwError::Git(format!("Command not found: {}", cmd[0]))
43 } else {
44 CwError::Io(e)
45 }
46 })?;
47
48 let returncode = output.status.code().unwrap_or(-1);
49 let stdout = if capture {
50 let mut out = String::from_utf8_lossy(&output.stdout).to_string();
52 let err = String::from_utf8_lossy(&output.stderr);
53 if !err.is_empty() {
54 if !out.is_empty() {
55 out.push('\n');
56 }
57 out.push_str(&err);
58 }
59 out
60 } else {
61 String::new()
62 };
63
64 if check && returncode != 0 {
65 return Err(CwError::Git(format!(
66 "Command failed: {}\n{}",
67 cmd.join(" "),
68 stdout
69 )));
70 }
71
72 Ok(CommandResult { stdout, returncode })
73}
74
75pub fn git_command(
77 args: &[&str],
78 repo: Option<&Path>,
79 check: bool,
80 capture: bool,
81) -> Result<CommandResult> {
82 let mut cmd = vec!["git"];
83 cmd.extend_from_slice(args);
84 run_command(&cmd, repo, check, capture)
85}
86
87pub fn get_repo_root(path: Option<&Path>) -> Result<PathBuf> {
89 let result = git_command(&["rev-parse", "--show-toplevel"], path, true, true);
90 match result {
91 Ok(r) => Ok(PathBuf::from(r.stdout.trim())),
92 Err(_) => Err(CwError::Git("Not in a git repository".to_string())),
93 }
94}
95
96pub fn get_current_branch(repo: Option<&Path>) -> Result<String> {
98 let result = git_command(&["rev-parse", "--abbrev-ref", "HEAD"], repo, true, true)?;
99 let branch = result.stdout.trim().to_string();
100 if branch == "HEAD" {
101 return Err(CwError::InvalidBranch("In detached HEAD state".to_string()));
102 }
103 Ok(branch)
104}
105
106pub fn branch_exists(branch: &str, repo: Option<&Path>) -> bool {
108 git_command(&["rev-parse", "--verify", branch], repo, false, true)
109 .map(|r| r.returncode == 0)
110 .unwrap_or(false)
111}
112
113pub fn remote_branch_exists(branch: &str, repo: Option<&Path>, remote: &str) -> bool {
115 let ref_name = format!("{}/{}", remote, branch);
116 git_command(&["rev-parse", "--verify", &ref_name], repo, false, true)
117 .map(|r| r.returncode == 0)
118 .unwrap_or(false)
119}
120
121pub fn get_config(key: &str, repo: Option<&Path>) -> Option<String> {
123 git_command(&["config", "--local", "--get", key], repo, false, true)
124 .ok()
125 .and_then(|r| {
126 if r.returncode == 0 {
127 Some(r.stdout.trim().to_string())
128 } else {
129 None
130 }
131 })
132}
133
134pub fn set_config(key: &str, value: &str, repo: Option<&Path>) -> Result<()> {
136 git_command(&["config", "--local", key, value], repo, true, false)?;
137 Ok(())
138}
139
140pub fn unset_config(key: &str, repo: Option<&Path>) {
142 let _ = git_command(
143 &["config", "--local", "--unset-all", key],
144 repo,
145 false,
146 false,
147 );
148}
149
150pub fn normalize_branch_name(branch: &str) -> &str {
152 branch.strip_prefix("refs/heads/").unwrap_or(branch)
153}
154
155pub type WorktreeEntry = (String, PathBuf);
157
158pub fn parse_worktrees(repo: &Path) -> Result<Vec<WorktreeEntry>> {
160 let result = git_command(&["worktree", "list", "--porcelain"], Some(repo), true, true)?;
161
162 let mut items: Vec<WorktreeEntry> = Vec::new();
163 let mut cur_path: Option<String> = None;
164 let mut cur_branch: Option<String> = None;
165
166 for line in result.stdout.lines() {
167 if let Some(path) = line.strip_prefix("worktree ") {
168 cur_path = Some(path.to_string());
169 } else if let Some(branch) = line.strip_prefix("branch ") {
170 cur_branch = Some(branch.to_string());
171 } else if line.trim().is_empty() {
172 if let Some(path) = cur_path.take() {
173 let branch = cur_branch
174 .take()
175 .unwrap_or_else(|| "(detached)".to_string());
176 items.push((branch, PathBuf::from(path)));
177 }
178 }
179 }
180 if let Some(path) = cur_path {
182 let branch = cur_branch.unwrap_or_else(|| "(detached)".to_string());
183 items.push((branch, PathBuf::from(path)));
184 }
185
186 Ok(items)
187}
188
189pub fn get_feature_worktrees(repo: Option<&Path>) -> Result<Vec<(String, PathBuf)>> {
191 let effective_repo = get_repo_root(repo)?;
192 let worktrees = parse_worktrees(&effective_repo)?;
193 if worktrees.is_empty() {
194 return Ok(Vec::new());
195 }
196
197 let main_path = worktrees[0]
198 .1
199 .canonicalize()
200 .unwrap_or_else(|_| worktrees[0].1.clone());
201
202 let mut result = Vec::new();
203 for (branch, path) in &worktrees {
204 let resolved = path.canonicalize().unwrap_or_else(|_| path.clone());
205 if resolved == main_path {
206 continue;
207 }
208 if branch == "(detached)" {
209 continue;
210 }
211 let branch_name = normalize_branch_name(branch).to_string();
212 result.push((branch_name, path.clone()));
213 }
214 Ok(result)
215}
216
217pub fn get_main_repo_root(repo: Option<&Path>) -> Result<PathBuf> {
219 let current_root = get_repo_root(repo)?;
220 let worktrees = parse_worktrees(¤t_root)?;
221 if let Some(first) = worktrees.first() {
222 Ok(first.1.clone())
223 } else {
224 Ok(current_root)
225 }
226}
227
228pub fn find_worktree_by_branch(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
230 let worktrees = parse_worktrees(repo)?;
231 Ok(worktrees
232 .into_iter()
233 .find(|(br, _)| br == branch)
234 .map(|(_, path)| path))
235}
236
237pub fn find_worktree_by_name(repo: &Path, worktree_name: &str) -> Result<Option<PathBuf>> {
239 let worktrees = parse_worktrees(repo)?;
240 Ok(worktrees
241 .into_iter()
242 .find(|(_, path)| {
243 path.file_name()
244 .map(|n| n.to_string_lossy() == worktree_name)
245 .unwrap_or(false)
246 })
247 .map(|(_, path)| path))
248}
249
250pub fn find_worktree_by_intended_branch(
252 repo: &Path,
253 intended_branch: &str,
254) -> Result<Option<PathBuf>> {
255 let intended_branch = normalize_branch_name(intended_branch);
256
257 if let Some(path) = find_worktree_by_branch(repo, intended_branch)? {
259 return Ok(Some(path));
260 }
261 let with_prefix = format!("refs/heads/{}", intended_branch);
263 if let Some(path) = find_worktree_by_branch(repo, &with_prefix)? {
264 return Ok(Some(path));
265 }
266
267 let result = git_command(
269 &[
270 "config",
271 "--local",
272 "--get-regexp",
273 r"^worktree\..*\.intendedBranch",
274 ],
275 Some(repo),
276 false,
277 true,
278 )?;
279
280 if result.returncode == 0 {
281 for line in result.stdout.trim().lines() {
282 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
283 if parts.len() == 2 {
284 let key = parts[0];
285 let value = parts[1];
286 let key_parts: Vec<&str> = key.split('.').collect();
288 if key_parts.len() >= 2 {
289 let branch_from_key = key_parts[1];
290 if branch_from_key == intended_branch || value == intended_branch {
291 let worktrees = parse_worktrees(repo)?;
292 let repo_name = repo
293 .file_name()
294 .map(|n| n.to_string_lossy().to_string())
295 .unwrap_or_default();
296 let expected_suffix =
297 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
298 for (_, path) in &worktrees {
299 if let Some(name) = path.file_name() {
300 if name.to_string_lossy() == expected_suffix {
301 return Ok(Some(path.clone()));
302 }
303 }
304 }
305 }
306 }
307 }
308 }
309 }
310
311 let repo_name = repo
313 .file_name()
314 .map(|n| n.to_string_lossy().to_string())
315 .unwrap_or_default();
316 let expected_suffix = format!("{}-{}", repo_name, sanitize_branch_name(intended_branch));
317 let worktrees = parse_worktrees(repo)?;
318 let repo_resolved = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
319
320 for (_, path) in &worktrees {
321 if let Some(name) = path.file_name() {
322 if name.to_string_lossy() == expected_suffix {
323 let path_resolved = path.canonicalize().unwrap_or_else(|_| path.clone());
324 if path_resolved != repo_resolved {
325 return Ok(Some(path.clone()));
326 }
327 }
328 }
329 }
330
331 Ok(None)
332}
333
334pub fn has_command(name: &str) -> bool {
336 if let Ok(path_var) = std::env::var("PATH") {
337 for dir in std::env::split_paths(&path_var) {
338 let candidate = dir.join(name);
339 if candidate.is_file() {
340 return true;
341 }
342 #[cfg(target_os = "windows")]
344 {
345 let with_ext = dir.join(format!("{}.exe", name));
346 if with_ext.is_file() {
347 return true;
348 }
349 }
350 }
351 }
352 false
353}
354
355pub fn is_non_interactive() -> bool {
357 if let Ok(val) = std::env::var("CW_NON_INTERACTIVE") {
359 let val = val.to_lowercase();
360 if val == "1" || val == "true" || val == "yes" {
361 return true;
362 }
363 }
364
365 if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
367 return true;
368 }
369
370 let ci_vars = [
372 "CI",
373 "GITHUB_ACTIONS",
374 "GITLAB_CI",
375 "JENKINS_HOME",
376 "CIRCLECI",
377 "TRAVIS",
378 "BUILDKITE",
379 "DRONE",
380 "BITBUCKET_PIPELINE",
381 "CODEBUILD_BUILD_ID",
382 ];
383
384 ci_vars.iter().any(|var| std::env::var(var).is_ok())
385}
386
387pub fn is_valid_branch_name(branch_name: &str, repo: Option<&Path>) -> bool {
389 if branch_name.is_empty() {
390 return false;
391 }
392 git_command(
393 &["check-ref-format", "--branch", branch_name],
394 repo,
395 false,
396 true,
397 )
398 .map(|r| r.returncode == 0)
399 .unwrap_or(false)
400}
401
402pub fn get_branch_name_error(branch_name: &str) -> String {
404 if branch_name.is_empty() {
405 return "Branch name cannot be empty".to_string();
406 }
407 if branch_name == "@" {
408 return "Branch name cannot be '@' alone".to_string();
409 }
410 if branch_name.ends_with(".lock") {
411 return "Branch name cannot end with '.lock'".to_string();
412 }
413 if branch_name.starts_with('/') || branch_name.ends_with('/') {
414 return "Branch name cannot start or end with '/'".to_string();
415 }
416 if branch_name.contains("//") {
417 return "Branch name cannot contain consecutive slashes '//'".to_string();
418 }
419 if branch_name.contains("..") {
420 return "Branch name cannot contain consecutive dots '..'".to_string();
421 }
422 if branch_name.contains("@{") {
423 return "Branch name cannot contain '@{'".to_string();
424 }
425
426 let invalid_chars: &[char] = &['~', '^', ':', '?', '*', '[', '\\'];
427 let found: Vec<char> = invalid_chars
428 .iter()
429 .filter(|&&c| branch_name.contains(c))
430 .copied()
431 .collect();
432 if !found.is_empty() {
433 let chars_display: Vec<String> = found.iter().map(|c| format!("{:?}", c)).collect();
434 return format!(
435 "Branch name contains invalid characters: {}",
436 chars_display.join(", ")
437 );
438 }
439
440 if branch_name.chars().any(|c| (c as u32) < 32 || c == ' ') {
441 return "Branch name cannot contain spaces or control characters".to_string();
442 }
443
444 format!(
445 "'{}' is not a valid branch name. See 'git check-ref-format --help' for rules",
446 branch_name
447 )
448}
449
450pub fn remove_worktree_safe(worktree_path: &Path, repo: &Path, force: bool) -> Result<()> {
452 let worktree_str = worktree_path
453 .canonicalize()
454 .unwrap_or_else(|_| worktree_path.to_path_buf())
455 .to_string_lossy()
456 .to_string();
457
458 let mut args = vec!["worktree", "remove", &worktree_str];
459 if force {
460 args.push("--force");
461 }
462
463 let result = git_command(&args, Some(repo), false, true)?;
464
465 if result.returncode == 0 {
466 return Ok(());
467 }
468
469 #[cfg(target_os = "windows")]
471 {
472 if result.stdout.contains("Directory not empty") {
473 let path = PathBuf::from(&worktree_str);
474 if path.exists() {
475 std::fs::remove_dir_all(&path).map_err(|e| {
476 CwError::Git(format!(
477 "Failed to remove worktree directory on Windows: {}\nError: {}",
478 worktree_str, e
479 ))
480 })?;
481 }
482 git_command(&["worktree", "prune"], Some(repo), true, false)?;
483 return Ok(());
484 }
485 }
486
487 Err(CwError::Git(format!(
488 "Command failed: {}\n{}",
489 args.join(" "),
490 result.stdout
491 )))
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn test_normalize_branch_name() {
500 assert_eq!(normalize_branch_name("refs/heads/main"), "main");
501 assert_eq!(normalize_branch_name("feature-branch"), "feature-branch");
502 assert_eq!(normalize_branch_name("refs/heads/feat/auth"), "feat/auth");
503 }
504
505 #[test]
506 fn test_get_branch_name_error() {
507 assert_eq!(get_branch_name_error(""), "Branch name cannot be empty");
508 assert_eq!(
509 get_branch_name_error("@"),
510 "Branch name cannot be '@' alone"
511 );
512 assert_eq!(
513 get_branch_name_error("foo.lock"),
514 "Branch name cannot end with '.lock'"
515 );
516 assert_eq!(
517 get_branch_name_error("/foo"),
518 "Branch name cannot start or end with '/'"
519 );
520 assert_eq!(
521 get_branch_name_error("foo//bar"),
522 "Branch name cannot contain consecutive slashes '//'"
523 );
524 }
525}