Skip to main content

git_worktree_manager/
git.rs

1/// Git operations wrapper utilities.
2///
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output};
5
6use crate::constants::sanitize_branch_name;
7use crate::error::{CwError, Result};
8
9/// Canonicalize a path, falling back to the original path on failure.
10pub fn canonicalize_or(path: &Path) -> PathBuf {
11    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
12}
13
14/// Result of running a command, with stdout captured as String.
15#[derive(Debug)]
16pub struct CommandResult {
17    pub stdout: String,
18    pub returncode: i32,
19}
20
21/// Run a shell command.
22pub 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        // Merge stderr into stdout like Python's STDOUT redirect
55        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
79/// Run a git command.
80pub 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
91/// Get the root directory of the git repository.
92pub 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
100/// Get the current branch name.
101pub 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
110/// Auto-detect the repository's default branch.
111///
112/// Priority:
113/// 1. `origin/HEAD` symref (most reliable — set by `git clone`)
114/// 2. Local `main` branch exists
115/// 3. Local `master` branch exists
116/// 4. Config fallback
117pub fn detect_default_branch(repo: Option<&Path>) -> String {
118    // 1. origin/HEAD
119    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    // 2. main
138    if branch_exists("main", repo) {
139        return "main".to_string();
140    }
141
142    // 3. master
143    if branch_exists("master", repo) {
144        return "master".to_string();
145    }
146
147    // 4. fallback
148    "main".to_string()
149}
150
151/// Check if a branch exists.
152pub 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
158/// Check if a branch exists on a remote.
159pub 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
166/// Get a git config value (local scope).
167pub 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
179/// Set a git config value (local scope).
180pub 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
185/// Unset a git config value.
186pub 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
195/// Normalize branch name by removing refs/heads/ prefix if present.
196pub fn normalize_branch_name(branch: &str) -> &str {
197    branch.strip_prefix("refs/heads/").unwrap_or(branch)
198}
199
200/// Parsed worktree entry: (branch_or_detached, path).
201pub type WorktreeEntry = (String, PathBuf);
202
203/// Parse `git worktree list --porcelain` output.
204pub 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    // Handle last entry (no trailing blank line)
226    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
234/// Get feature worktrees, excluding main repo and detached entries.
235pub 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
259/// Get main repository path, even when called from a worktree.
260pub fn get_main_repo_root(repo: Option<&Path>) -> Result<PathBuf> {
261    let current_root = get_repo_root(repo)?;
262    let worktrees = parse_worktrees(&current_root)?;
263    if let Some(first) = worktrees.first() {
264        Ok(first.1.clone())
265    } else {
266        Ok(current_root)
267    }
268}
269
270/// Find worktree path by branch name.
271pub 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
279/// Find worktree by directory name.
280pub 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
292/// Find worktree path by intended branch name (from metadata).
293pub 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    // Strategy 1: Direct lookup by current branch name
300    if let Some(path) = find_worktree_by_branch(repo, intended_branch)? {
301        return Ok(Some(path));
302    }
303    // Also try with refs/heads/ prefix
304    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    // Strategy 2: Search all intended branch metadata
310    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                // Extract branch name from key: worktree.<branch>.intendedBranch
329                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    // Strategy 3: Fallback — check path naming convention
354    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
376/// Fetch from remote and determine the rebase target for a base branch.
377///
378/// Returns `(fetch_ok, rebase_target)` where `rebase_target` is `origin/<base>`
379/// if fetch succeeded and the remote ref exists, otherwise just `<base>`.
380pub 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
399/// Check if a command is available in PATH.
400pub 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            // On Windows, try with .exe extension
408            #[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
420/// Check if running in non-interactive environment.
421pub fn is_non_interactive() -> bool {
422    // Explicit flag
423    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    // Check stdin is not a TTY
431    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
432        return true;
433    }
434
435    // CI environment variables
436    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
452/// Check if a branch name is valid according to git rules.
453pub 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
467/// Get descriptive error message for invalid branch name.
468pub 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
515/// Remove a git worktree with platform-safe fallback.
516pub 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    // Windows fallback for "Directory not empty"
531    #[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
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    #[cfg(not(windows))]
561    fn test_canonicalize_or_existing_path() {
562        // /tmp should exist on all Unix systems
563        let path = Path::new("/tmp");
564        let result = canonicalize_or(path);
565        // Should resolve to a real path (e.g., /private/tmp on macOS)
566        assert!(result.is_absolute());
567    }
568
569    #[test]
570    fn test_canonicalize_or_nonexistent_path() {
571        let path = Path::new("/nonexistent/path/that/does/not/exist");
572        let result = canonicalize_or(path);
573        // Should return the original path as-is
574        assert_eq!(result, path);
575    }
576
577    #[test]
578    fn test_canonicalize_or_relative_path() {
579        let path = Path::new("relative/path");
580        let result = canonicalize_or(path);
581        // Non-existent relative path should return as-is
582        assert_eq!(result, path);
583    }
584
585    #[test]
586    fn test_normalize_branch_name() {
587        assert_eq!(normalize_branch_name("refs/heads/main"), "main");
588        assert_eq!(normalize_branch_name("feature-branch"), "feature-branch");
589        assert_eq!(normalize_branch_name("refs/heads/feat/auth"), "feat/auth");
590    }
591
592    #[test]
593    fn test_get_branch_name_error() {
594        assert_eq!(get_branch_name_error(""), "Branch name cannot be empty");
595        assert_eq!(
596            get_branch_name_error("@"),
597            "Branch name cannot be '@' alone"
598        );
599        assert_eq!(
600            get_branch_name_error("foo.lock"),
601            "Branch name cannot end with '.lock'"
602        );
603        assert_eq!(
604            get_branch_name_error("/foo"),
605            "Branch name cannot start or end with '/'"
606        );
607        assert_eq!(
608            get_branch_name_error("foo//bar"),
609            "Branch name cannot contain consecutive slashes '//'"
610        );
611    }
612}