Skip to main content

git_worktree_manager/
git.rs

1/// Git operations wrapper utilities.
2///
3/// Mirrors src/git_worktree_manager/git_utils.py (676 lines).
4use std::path::{Path, PathBuf};
5use std::process::{Command, Output};
6
7use crate::constants::sanitize_branch_name;
8use crate::error::{CwError, Result};
9
10/// Result of running a command, with stdout captured as String.
11#[derive(Debug)]
12pub struct CommandResult {
13    pub stdout: String,
14    pub returncode: i32,
15}
16
17/// Run a shell command.
18pub 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        // Merge stderr into stdout like Python's STDOUT redirect
51        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
75/// Run a git command.
76pub 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
87/// Get the root directory of the git repository.
88pub 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
96/// Get the current branch name.
97pub 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
106/// Check if a branch exists.
107pub 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
113/// Check if a branch exists on a remote.
114pub 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
121/// Get a git config value (local scope).
122pub 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
134/// Set a git config value (local scope).
135pub 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
140/// Unset a git config value.
141pub 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
150/// Normalize branch name by removing refs/heads/ prefix if present.
151pub fn normalize_branch_name(branch: &str) -> &str {
152    branch.strip_prefix("refs/heads/").unwrap_or(branch)
153}
154
155/// Parsed worktree entry: (branch_or_detached, path).
156pub type WorktreeEntry = (String, PathBuf);
157
158/// Parse `git worktree list --porcelain` output.
159pub 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    // Handle last entry (no trailing blank line)
181    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
189/// Get feature worktrees, excluding main repo and detached entries.
190pub 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
217/// Get main repository path, even when called from a worktree.
218pub fn get_main_repo_root(repo: Option<&Path>) -> Result<PathBuf> {
219    let current_root = get_repo_root(repo)?;
220    let worktrees = parse_worktrees(&current_root)?;
221    if let Some(first) = worktrees.first() {
222        Ok(first.1.clone())
223    } else {
224        Ok(current_root)
225    }
226}
227
228/// Find worktree path by branch name.
229pub 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
237/// Find worktree by directory name.
238pub 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
250/// Find worktree path by intended branch name (from metadata).
251pub 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    // Strategy 1: Direct lookup by current branch name
258    if let Some(path) = find_worktree_by_branch(repo, intended_branch)? {
259        return Ok(Some(path));
260    }
261    // Also try with refs/heads/ prefix
262    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    // Strategy 2: Search all intended branch metadata
268    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                // Extract branch name from key: worktree.<branch>.intendedBranch
287                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    // Strategy 3: Fallback — check path naming convention
312    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
334/// Check if a command is available in PATH.
335pub 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            // On Windows, try with .exe extension
343            #[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
355/// Check if running in non-interactive environment.
356pub fn is_non_interactive() -> bool {
357    // Explicit flag
358    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    // Check stdin is not a TTY
366    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
367        return true;
368    }
369
370    // CI environment variables
371    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
387/// Check if a branch name is valid according to git rules.
388pub 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
402/// Get descriptive error message for invalid branch name.
403pub 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
450/// Remove a git worktree with platform-safe fallback.
451pub 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    // Windows fallback for "Directory not empty"
470    #[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}