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/// Check if a branch exists.
111pub 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
117/// Check if a branch exists on a remote.
118pub 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
125/// Get a git config value (local scope).
126pub 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
138/// Set a git config value (local scope).
139pub 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
144/// Unset a git config value.
145pub 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
154/// Normalize branch name by removing refs/heads/ prefix if present.
155pub fn normalize_branch_name(branch: &str) -> &str {
156    branch.strip_prefix("refs/heads/").unwrap_or(branch)
157}
158
159/// Parsed worktree entry: (branch_or_detached, path).
160pub type WorktreeEntry = (String, PathBuf);
161
162/// Parse `git worktree list --porcelain` output.
163pub 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    // Handle last entry (no trailing blank line)
185    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
193/// Get feature worktrees, excluding main repo and detached entries.
194pub 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
218/// Get main repository path, even when called from a worktree.
219pub fn get_main_repo_root(repo: Option<&Path>) -> Result<PathBuf> {
220    let current_root = get_repo_root(repo)?;
221    let worktrees = parse_worktrees(&current_root)?;
222    if let Some(first) = worktrees.first() {
223        Ok(first.1.clone())
224    } else {
225        Ok(current_root)
226    }
227}
228
229/// Find worktree path by branch name.
230pub 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
238/// Find worktree by directory name.
239pub 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
251/// Find worktree path by intended branch name (from metadata).
252pub 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    // Strategy 1: Direct lookup by current branch name
259    if let Some(path) = find_worktree_by_branch(repo, intended_branch)? {
260        return Ok(Some(path));
261    }
262    // Also try with refs/heads/ prefix
263    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    // Strategy 2: Search all intended branch metadata
269    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                // Extract branch name from key: worktree.<branch>.intendedBranch
288                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    // Strategy 3: Fallback — check path naming convention
313    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
335/// Fetch from remote and determine the rebase target for a base branch.
336///
337/// Returns `(fetch_ok, rebase_target)` where `rebase_target` is `origin/<base>`
338/// if fetch succeeded and the remote ref exists, otherwise just `<base>`.
339pub 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
358/// Check if a command is available in PATH.
359pub 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            // On Windows, try with .exe extension
367            #[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
379/// Check if running in non-interactive environment.
380pub fn is_non_interactive() -> bool {
381    // Explicit flag
382    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    // Check stdin is not a TTY
390    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
391        return true;
392    }
393
394    // CI environment variables
395    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
411/// Check if a branch name is valid according to git rules.
412pub 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
426/// Get descriptive error message for invalid branch name.
427pub 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
474/// Remove a git worktree with platform-safe fallback.
475pub 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    // Windows fallback for "Directory not empty"
490    #[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        // /tmp should exist on all Unix systems
522        let path = Path::new("/tmp");
523        let result = canonicalize_or(path);
524        // Should resolve to a real path (e.g., /private/tmp on macOS)
525        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        // Should return the original path as-is
533        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        // Non-existent relative path should return as-is
541        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}