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/// Return the list of files with unresolved merge/rebase conflicts at `path`
400/// as a newline-joined string. `None` when there are no conflicts or the git
401/// call itself fails — callers treat both cases as "no diagnostic available".
402pub fn list_conflicted_files(path: &Path) -> Option<String> {
403    git_command(
404        &["diff", "--name-only", "--diff-filter=U"],
405        Some(path),
406        false,
407        true,
408    )
409    .ok()
410    .and_then(|r| {
411        if r.returncode == 0 && !r.stdout.trim().is_empty() {
412            Some(r.stdout.trim().to_string())
413        } else {
414            None
415        }
416    })
417}
418
419/// Check if a command is available in PATH.
420pub fn has_command(name: &str) -> bool {
421    if let Ok(path_var) = std::env::var("PATH") {
422        for dir in std::env::split_paths(&path_var) {
423            let candidate = dir.join(name);
424            if candidate.is_file() {
425                return true;
426            }
427            // On Windows, try with .exe extension
428            #[cfg(target_os = "windows")]
429            {
430                let with_ext = dir.join(format!("{}.exe", name));
431                if with_ext.is_file() {
432                    return true;
433                }
434            }
435        }
436    }
437    false
438}
439
440/// Check if running in non-interactive environment.
441pub fn is_non_interactive() -> bool {
442    // Explicit flag
443    if let Ok(val) = std::env::var("CW_NON_INTERACTIVE") {
444        let val = val.to_lowercase();
445        if val == "1" || val == "true" || val == "yes" {
446            return true;
447        }
448    }
449
450    // Check stdin is not a TTY
451    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
452        return true;
453    }
454
455    // CI environment variables
456    let ci_vars = [
457        "CI",
458        "GITHUB_ACTIONS",
459        "GITLAB_CI",
460        "JENKINS_HOME",
461        "CIRCLECI",
462        "TRAVIS",
463        "BUILDKITE",
464        "DRONE",
465        "BITBUCKET_PIPELINE",
466        "CODEBUILD_BUILD_ID",
467    ];
468
469    ci_vars.iter().any(|var| std::env::var(var).is_ok())
470}
471
472/// Check if a branch name is valid according to git rules.
473pub fn is_valid_branch_name(branch_name: &str, repo: Option<&Path>) -> bool {
474    if branch_name.is_empty() {
475        return false;
476    }
477    git_command(
478        &["check-ref-format", "--branch", branch_name],
479        repo,
480        false,
481        true,
482    )
483    .map(|r| r.returncode == 0)
484    .unwrap_or(false)
485}
486
487/// Get descriptive error message for invalid branch name.
488pub fn get_branch_name_error(branch_name: &str) -> String {
489    if branch_name.is_empty() {
490        return "Branch name cannot be empty".to_string();
491    }
492    if branch_name == "@" {
493        return "Branch name cannot be '@' alone".to_string();
494    }
495    if branch_name.ends_with(".lock") {
496        return "Branch name cannot end with '.lock'".to_string();
497    }
498    if branch_name.starts_with('/') || branch_name.ends_with('/') {
499        return "Branch name cannot start or end with '/'".to_string();
500    }
501    if branch_name.contains("//") {
502        return "Branch name cannot contain consecutive slashes '//'".to_string();
503    }
504    if branch_name.contains("..") {
505        return "Branch name cannot contain consecutive dots '..'".to_string();
506    }
507    if branch_name.contains("@{") {
508        return "Branch name cannot contain '@{'".to_string();
509    }
510
511    let invalid_chars: &[char] = &['~', '^', ':', '?', '*', '[', '\\'];
512    let found: Vec<char> = invalid_chars
513        .iter()
514        .filter(|&&c| branch_name.contains(c))
515        .copied()
516        .collect();
517    if !found.is_empty() {
518        let chars_display: Vec<String> = found.iter().map(|c| format!("{:?}", c)).collect();
519        return format!(
520            "Branch name contains invalid characters: {}",
521            chars_display.join(", ")
522        );
523    }
524
525    if branch_name.chars().any(|c| (c as u32) < 32 || c == ' ') {
526        return "Branch name cannot contain spaces or control characters".to_string();
527    }
528
529    format!(
530        "'{}' is not a valid branch name. See 'git check-ref-format --help' for rules",
531        branch_name
532    )
533}
534
535/// Remove a git worktree with platform-safe fallback.
536pub fn remove_worktree_safe(worktree_path: &Path, repo: &Path, force: bool) -> Result<()> {
537    let worktree_str = canonicalize_or(worktree_path).to_string_lossy().to_string();
538
539    let mut args = vec!["worktree", "remove", &worktree_str];
540    if force {
541        args.push("--force");
542    }
543
544    let result = git_command(&args, Some(repo), false, true)?;
545
546    if result.returncode == 0 {
547        return Ok(());
548    }
549
550    // Windows fallback for "Directory not empty"
551    #[cfg(target_os = "windows")]
552    {
553        if result.stdout.contains("Directory not empty") {
554            let path = PathBuf::from(&worktree_str);
555            if path.exists() {
556                std::fs::remove_dir_all(&path).map_err(|e| {
557                    CwError::Git(format!(
558                        "Failed to remove worktree directory on Windows: {}\nError: {}",
559                        worktree_str, e
560                    ))
561                })?;
562            }
563            git_command(&["worktree", "prune"], Some(repo), true, false)?;
564            return Ok(());
565        }
566    }
567
568    Err(CwError::Git(format!(
569        "Command failed: {}\n{}",
570        args.join(" "),
571        result.stdout
572    )))
573}
574
575/// Check if a branch has been merged into the base branch.
576///
577/// Uses `git branch --merged <base>` and checks if the feature branch is in the list.
578pub fn is_branch_merged(feature_branch: &str, base_branch: &str, repo: Option<&Path>) -> bool {
579    // First try against remote base
580    let remote_base = format!("origin/{}", base_branch);
581    if let Ok(r) = git_command(&["branch", "--merged", &remote_base], repo, false, true) {
582        if r.returncode == 0 {
583            for line in r.stdout.lines() {
584                let name = line.trim().trim_start_matches("* ");
585                if name == feature_branch {
586                    return true;
587                }
588            }
589        }
590    }
591
592    // Fallback: check against local base
593    if let Ok(r) = git_command(&["branch", "--merged", base_branch], repo, false, true) {
594        if r.returncode == 0 {
595            for line in r.stdout.lines() {
596                let name = line.trim().trim_start_matches("* ");
597                if name == feature_branch {
598                    return true;
599                }
600            }
601        }
602    }
603
604    false
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    #[cfg(not(windows))]
613    fn test_canonicalize_or_existing_path() {
614        // /tmp should exist on all Unix systems
615        let path = Path::new("/tmp");
616        let result = canonicalize_or(path);
617        // Should resolve to a real path (e.g., /private/tmp on macOS)
618        assert!(result.is_absolute());
619    }
620
621    #[test]
622    fn test_canonicalize_or_nonexistent_path() {
623        let path = Path::new("/nonexistent/path/that/does/not/exist");
624        let result = canonicalize_or(path);
625        // Should return the original path as-is
626        assert_eq!(result, path);
627    }
628
629    #[test]
630    fn test_canonicalize_or_relative_path() {
631        let path = Path::new("relative/path");
632        let result = canonicalize_or(path);
633        // Non-existent relative path should return as-is
634        assert_eq!(result, path);
635    }
636
637    #[test]
638    fn test_normalize_branch_name() {
639        assert_eq!(normalize_branch_name("refs/heads/main"), "main");
640        assert_eq!(normalize_branch_name("feature-branch"), "feature-branch");
641        assert_eq!(normalize_branch_name("refs/heads/feat/auth"), "feat/auth");
642    }
643
644    #[test]
645    fn test_get_branch_name_error() {
646        assert_eq!(get_branch_name_error(""), "Branch name cannot be empty");
647        assert_eq!(
648            get_branch_name_error("@"),
649            "Branch name cannot be '@' alone"
650        );
651        assert_eq!(
652            get_branch_name_error("foo.lock"),
653            "Branch name cannot end with '.lock'"
654        );
655        assert_eq!(
656            get_branch_name_error("/foo"),
657            "Branch name cannot start or end with '/'"
658        );
659        assert_eq!(
660            get_branch_name_error("foo//bar"),
661            "Branch name cannot contain consecutive slashes '//'"
662        );
663    }
664}