Skip to main content

llman_core/
git_utils.rs

1//! Pure git plumbing shared across feature modules (sdd change binding,
2//! tool agents-md, skills config discovery, prompts paths).
3//!
4//! Function bodies are moved verbatim from their original homes
5//! (`sdd::change::git_native`, `skills::shared::git`) so error messages stay
6//! byte-identical. Sdd-specific binding semantics (ChangeGitBinding,
7//! read/write_binding, start/attach/checkpoint flows) remain in
8//! `sdd::change::git_native`.
9//!
10//! This module is a member of the top-level utility layer (future
11//! `llman-core`); it MUST NOT import feature modules (sdd/skills/tool/x).
12
13use anyhow::{Result, anyhow, bail};
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18pub fn run_git(root: &Path, args: &[&str]) -> Result<String> {
19    let output = Command::new("git")
20        .args(args)
21        .current_dir(root)
22        .output()
23        .map_err(|err| anyhow!("git {:?} failed to spawn: {err}", args))?;
24    if !output.status.success() {
25        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
26        if stderr.is_empty() {
27            bail!("git {:?} failed", args);
28        }
29        bail!("{stderr}");
30    }
31    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
32}
33
34pub fn current_branch(root: &Path) -> Result<Option<String>> {
35    let branch = run_git(root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
36    if branch.is_empty() || branch == "HEAD" {
37        // Detached HEAD: `--abbrev-ref HEAD` prints `HEAD`. Callers decide
38        // whether that is acceptable and own the user-facing message.
39        return Ok(None);
40    }
41    Ok(Some(branch))
42}
43
44pub fn current_head_sha(root: &Path) -> Result<String> {
45    run_git(root, &["rev-parse", "HEAD"])
46}
47
48pub fn resolve_default_branch_ref(root: &Path) -> Result<String> {
49    if let Ok(sym) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
50        && let Some(name) = sym.strip_prefix("refs/remotes/origin/")
51    {
52        let remote = format!("origin/{name}");
53        if git_ref_exists(root, &remote) {
54            return Ok(remote);
55        }
56        if git_ref_exists(root, name) {
57            return Ok(name.to_string());
58        }
59    }
60    for candidate in ["origin/main", "origin/master", "main", "master"] {
61        if git_ref_exists(root, candidate) {
62            return Ok(candidate.to_string());
63        }
64    }
65    bail!("unable to resolve default branch (tried origin/main, origin/master, main, master)");
66}
67
68// NOTE: do NOT insert `--` before `reference` here. `rev-parse --verify`
69// treats `--` as an end-of-options separator, which makes git interpret the
70// following argument as a PATH rather than a ref — so `-- origin/main` would
71// always fail. All callers pass validated refs (hardcoded literals or values
72// sanitized by `validate_user_git_ref`), so option injection is not a concern.
73pub fn git_ref_exists(root: &Path, reference: &str) -> bool {
74    Command::new("git")
75        .args(["rev-parse", "--verify", "--quiet", reference])
76        .current_dir(root)
77        .output()
78        .map(|o| o.status.success())
79        .unwrap_or(false)
80}
81
82pub fn is_default_branch(root: &Path, branch: &str) -> Result<bool> {
83    let default_ref = resolve_default_branch_ref(root)?;
84    let default_name = default_ref
85        .strip_prefix("origin/")
86        .unwrap_or(default_ref.as_str());
87    Ok(branch == default_name || branch == default_ref)
88}
89
90pub fn working_tree_clean(root: &Path) -> Result<bool> {
91    let status = run_git(root, &["status", "--porcelain"])?;
92    Ok(status.trim().is_empty())
93}
94
95pub fn merge_base_sha(root: &Path, base_ref: &str) -> Result<String> {
96    run_git(root, &["merge-base", base_ref, "HEAD"])
97}
98
99pub fn branch_diff(root: &Path, base_sha: &str) -> Result<String> {
100    run_git(
101        root,
102        &["diff", "--find-renames", &format!("{base_sha}...HEAD")],
103    )
104}
105
106pub fn branch_has_upstream(root: &Path) -> Result<bool> {
107    let output = Command::new("git")
108        .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
109        .current_dir(root)
110        .output()
111        .map_err(|err| anyhow!("git upstream check failed: {err}"))?;
112    Ok(output.status.success())
113}
114
115/// Resolve the absolute `.git` directory path.
116pub fn git_common_dir(root: &Path) -> Result<PathBuf> {
117    let output = Command::new("git")
118        .args(["rev-parse", "--git-common-dir"])
119        .current_dir(root)
120        .output()
121        .map_err(|e| anyhow!("git rev-parse --git-common-dir failed to spawn: {e}"))?;
122
123    if !output.status.success() {
124        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
125        bail!("not a git repository: {stderr}");
126    }
127
128    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
129    let path = Path::new(&raw);
130    if path.is_absolute() {
131        Ok(path.to_path_buf())
132    } else {
133        Ok(root.join(path))
134    }
135}
136
137/// Check if a directory is already a git worktree.
138pub fn worktree_exists(root: &Path, path: &Path) -> Result<bool> {
139    let output = Command::new("git")
140        .args(["worktree", "list", "--porcelain"])
141        .current_dir(root)
142        .output()
143        .map_err(|e| anyhow!("git worktree list failed to spawn: {e}"))?;
144
145    let stdout = String::from_utf8_lossy(&output.stdout);
146    let target = path.display().to_string();
147    Ok(stdout
148        .lines()
149        .any(|line| line.starts_with("worktree ") && line.contains(&target)))
150}
151
152/// `git worktree add <path> -b <branch> <base_sha>` (creates and checks out).
153pub fn worktree_add(root: &Path, path: &Path, branch: &str, base_sha: &str) -> Result<()> {
154    let output = Command::new("git")
155        .args([
156            "worktree",
157            "add",
158            path.to_str().unwrap(),
159            "-b",
160            branch,
161            base_sha,
162        ])
163        .current_dir(root)
164        .output()
165        .map_err(|e| anyhow!("git worktree add failed to spawn: {e}"))?;
166
167    if !output.status.success() {
168        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
169        bail!("worktree create failed: {stderr}");
170    }
171    Ok(())
172}
173
174/// Walk up from `start` looking for a `.git` entry (dir or worktree file).
175pub fn find_git_root(start: &Path) -> Option<PathBuf> {
176    let mut current = start.to_path_buf();
177    loop {
178        if is_git_root(&current) {
179            return Some(current);
180        }
181        if !current.pop() {
182            break;
183        }
184    }
185    None
186}
187
188fn is_git_root(path: &Path) -> bool {
189    let git = path.join(".git");
190    if let Ok(metadata) = fs::symlink_metadata(&git) {
191        return metadata.is_dir() || metadata.is_file();
192    }
193    false
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use tempfile::TempDir;
200
201    #[test]
202    fn test_find_git_root() {
203        let temp = TempDir::new().expect("temp dir");
204        let root = temp.path().join("repo");
205        let nested = root.join("a").join("b");
206        fs::create_dir_all(&nested).expect("create nested dirs");
207        fs::create_dir_all(root.join(".git")).expect("create git dir");
208
209        let found = find_git_root(&nested).expect("git root");
210        assert_eq!(found, root);
211    }
212
213    #[test]
214    fn test_find_git_root_none() {
215        let temp = TempDir::new().expect("temp dir");
216        let root = temp.path().join("repo");
217        fs::create_dir_all(&root).expect("create dir");
218        let found = find_git_root(&root);
219        assert!(found.is_none());
220    }
221
222    fn git(root: &Path, args: &[&str]) {
223        let out = Command::new("git")
224            .args(args)
225            .current_dir(root)
226            .output()
227            .expect("git");
228        assert!(
229            out.status.success(),
230            "git {args:?} failed: {}",
231            String::from_utf8_lossy(&out.stderr)
232        );
233    }
234
235    fn init_repo_with_commit(root: &Path) {
236        fs::create_dir_all(root).expect("create repo dir");
237        git(root, &["init", "-q", "-b", "main"]);
238        git(
239            root,
240            &[
241                "-c",
242                "user.email=t@t",
243                "-c",
244                "user.name=t",
245                "commit",
246                "--allow-empty",
247                "-m",
248                "init",
249            ],
250        );
251    }
252
253    #[test]
254    fn test_current_branch_some_on_branch() {
255        let temp = TempDir::new().expect("temp dir");
256        let root = temp.path().join("repo");
257        init_repo_with_commit(&root);
258        let branch = current_branch(&root)
259            .expect("current_branch")
260            .expect("branch");
261        assert_eq!(branch, "main");
262    }
263
264    #[test]
265    fn test_current_branch_none_when_detached() {
266        let temp = TempDir::new().expect("temp dir");
267        let root = temp.path().join("repo");
268        init_repo_with_commit(&root);
269        git(&root, &["checkout", "-q", "--detach"]);
270        let branch = current_branch(&root).expect("current_branch");
271        assert!(
272            branch.is_none(),
273            "detached HEAD must map to None, got {branch:?}"
274        );
275    }
276}