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
48/// Resolve the default branch ref, **local-first** (git-native-v2 D1): local
49/// `main` → local `master` → `origin/HEAD` target → `origin/main` →
50/// `origin/master`. The local ref is the anchor for all range semantics so
51/// long-lived local work without push never drifts the base (the previous
52/// origin-first order left every change's base at the last push position).
53pub fn resolve_default_branch_ref(root: &Path) -> Result<String> {
54    for candidate in ["main", "master"] {
55        if git_ref_exists(root, candidate) {
56            return Ok(candidate.to_string());
57        }
58    }
59    if let Ok(sym) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
60        && let Some(name) = sym.strip_prefix("refs/remotes/origin/")
61    {
62        let remote = format!("origin/{name}");
63        if git_ref_exists(root, &remote) {
64            return Ok(remote);
65        }
66        if git_ref_exists(root, name) {
67            return Ok(name.to_string());
68        }
69    }
70    for candidate in ["origin/main", "origin/master"] {
71        if git_ref_exists(root, candidate) {
72            return Ok(candidate.to_string());
73        }
74    }
75    bail!("unable to resolve default branch (tried main, master, origin/main, origin/master)");
76}
77
78/// Local/remote divergence INFO hint (deduplicated per process): printed once
79/// when the resolved local default ref leads its `origin/*` counterpart, so
80/// users know the effective range base is ahead of the remote. `pub(crate)`
81/// only in core — callers outside the crate use [`effective_range_base`].
82fn hint_local_ahead_of_remote(root: &Path, default_ref: &str) {
83    use std::sync::atomic::{AtomicBool, Ordering};
84    static HINT_SHOWN: AtomicBool = AtomicBool::new(false);
85    if HINT_SHOWN.swap(true, Ordering::Relaxed) {
86        return;
87    }
88    let Some(remote) = default_ref
89        .strip_prefix("main")
90        .map(|_| "origin/main")
91        .or_else(|| default_ref.strip_prefix("master").map(|_| "origin/master"))
92    else {
93        return;
94    };
95    if !git_ref_exists(root, remote) {
96        return;
97    }
98    let local_ahead = run_git(
99        root,
100        &["rev-list", "--count", &format!("{remote}..{default_ref}")],
101    )
102    .map(|s| s.trim().parse::<i64>().unwrap_or(0))
103    .unwrap_or(0);
104    if local_ahead <= 0 {
105        return;
106    }
107    let remote_ahead = run_git(
108        root,
109        &["rev-list", "--count", &format!("{default_ref}..{remote}")],
110    )
111    .map(|s| s.trim().parse::<i64>().unwrap_or(0))
112    .unwrap_or(0);
113    if remote_ahead > 0 {
114        eprintln!(
115            "INFO: local `{default_ref}` diverged from `{remote}` (local +{local_ahead}/remote +{remote_ahead}); range anchors use the local ref"
116        );
117    } else {
118        eprintln!(
119            "INFO: local `{default_ref}` is ahead of `{remote}` (+{local_ahead}); range anchors use the local ref"
120        );
121    }
122}
123
124/// Effective diff-range base (git-native-v2 D1): the **live** merge-base of
125/// the local default branch with HEAD, the single entry point for all range
126/// semantics (locked-rule gate, specs landing, change diff/commit count,
127/// staleness). Computing it on demand means the range always covers exactly
128/// the branch's own work and shrinks automatically after merge/rebase — no
129/// stored state, immune to unpushed accumulation. Fails when git is
130/// unavailable or no default ref exists; callers fall back to the stored
131/// base_sha (fail-open, same as the pre-v2 behavior).
132pub fn effective_range_base(root: &Path) -> Result<String> {
133    let default_ref = resolve_default_branch_ref(root)?;
134    if default_ref.starts_with("main") || default_ref.starts_with("master") {
135        hint_local_ahead_of_remote(root, &default_ref);
136    }
137    merge_base_sha(root, &default_ref)
138}
139
140// NOTE: do NOT insert `--` before `reference` here. `rev-parse --verify`
141// treats `--` as an end-of-options separator, which makes git interpret the
142// following argument as a PATH rather than a ref — so `-- origin/main` would
143// always fail. All callers pass validated refs (hardcoded literals or values
144// sanitized by `validate_user_git_ref`), so option injection is not a concern.
145pub fn git_ref_exists(root: &Path, reference: &str) -> bool {
146    Command::new("git")
147        .args(["rev-parse", "--verify", "--quiet", reference])
148        .current_dir(root)
149        .output()
150        .map(|o| o.status.success())
151        .unwrap_or(false)
152}
153
154pub fn is_default_branch(root: &Path, branch: &str) -> Result<bool> {
155    let default_ref = resolve_default_branch_ref(root)?;
156    let default_name = default_ref
157        .strip_prefix("origin/")
158        .unwrap_or(default_ref.as_str());
159    Ok(branch == default_name || branch == default_ref)
160}
161
162pub fn working_tree_clean(root: &Path) -> Result<bool> {
163    let status = run_git(root, &["status", "--porcelain"])?;
164    Ok(status.trim().is_empty())
165}
166
167pub fn merge_base_sha(root: &Path, base_ref: &str) -> Result<String> {
168    run_git(root, &["merge-base", base_ref, "HEAD"])
169}
170
171pub fn branch_diff(root: &Path, base_sha: &str) -> Result<String> {
172    run_git(
173        root,
174        &["diff", "--find-renames", &format!("{base_sha}...HEAD")],
175    )
176}
177
178pub fn branch_has_upstream(root: &Path) -> Result<bool> {
179    let output = Command::new("git")
180        .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
181        .current_dir(root)
182        .output()
183        .map_err(|err| anyhow!("git upstream check failed: {err}"))?;
184    Ok(output.status.success())
185}
186
187/// Resolve the absolute `.git` directory path.
188pub fn git_common_dir(root: &Path) -> Result<PathBuf> {
189    let output = Command::new("git")
190        .args(["rev-parse", "--git-common-dir"])
191        .current_dir(root)
192        .output()
193        .map_err(|e| anyhow!("git rev-parse --git-common-dir failed to spawn: {e}"))?;
194
195    if !output.status.success() {
196        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
197        bail!("not a git repository: {stderr}");
198    }
199
200    let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
201    let path = Path::new(&raw);
202    if path.is_absolute() {
203        Ok(path.to_path_buf())
204    } else {
205        Ok(root.join(path))
206    }
207}
208
209/// Check if a directory is already a git worktree.
210pub fn worktree_exists(root: &Path, path: &Path) -> Result<bool> {
211    let output = Command::new("git")
212        .args(["worktree", "list", "--porcelain"])
213        .current_dir(root)
214        .output()
215        .map_err(|e| anyhow!("git worktree list failed to spawn: {e}"))?;
216
217    let stdout = String::from_utf8_lossy(&output.stdout);
218    let target = path.display().to_string();
219    Ok(stdout
220        .lines()
221        .any(|line| line.starts_with("worktree ") && line.contains(&target)))
222}
223
224/// `git worktree add <path> -b <branch> <base_sha>` (creates and checks out).
225pub fn worktree_add(root: &Path, path: &Path, branch: &str, base_sha: &str) -> Result<()> {
226    let output = Command::new("git")
227        .args([
228            "worktree",
229            "add",
230            path.to_str().unwrap(),
231            "-b",
232            branch,
233            base_sha,
234        ])
235        .current_dir(root)
236        .output()
237        .map_err(|e| anyhow!("git worktree add failed to spawn: {e}"))?;
238
239    if !output.status.success() {
240        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
241        bail!("worktree create failed: {stderr}");
242    }
243    Ok(())
244}
245
246/// Walk up from `start` looking for a `.git` entry (dir or worktree file).
247pub fn find_git_root(start: &Path) -> Option<PathBuf> {
248    let mut current = start.to_path_buf();
249    loop {
250        if is_git_root(&current) {
251            return Some(current);
252        }
253        if !current.pop() {
254            break;
255        }
256    }
257    None
258}
259
260fn is_git_root(path: &Path) -> bool {
261    let git = path.join(".git");
262    if let Ok(metadata) = fs::symlink_metadata(&git) {
263        return metadata.is_dir() || metadata.is_file();
264    }
265    false
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use tempfile::TempDir;
272
273    #[test]
274    fn test_find_git_root() {
275        let temp = TempDir::new().expect("temp dir");
276        let root = temp.path().join("repo");
277        let nested = root.join("a").join("b");
278        fs::create_dir_all(&nested).expect("create nested dirs");
279        fs::create_dir_all(root.join(".git")).expect("create git dir");
280
281        let found = find_git_root(&nested).expect("git root");
282        assert_eq!(found, root);
283    }
284
285    #[test]
286    fn test_find_git_root_none() {
287        let temp = TempDir::new().expect("temp dir");
288        let root = temp.path().join("repo");
289        fs::create_dir_all(&root).expect("create dir");
290        let found = find_git_root(&root);
291        assert!(found.is_none());
292    }
293
294    fn git(root: &Path, args: &[&str]) {
295        let out = Command::new("git")
296            .args(args)
297            .current_dir(root)
298            .output()
299            .expect("git");
300        assert!(
301            out.status.success(),
302            "git {args:?} failed: {}",
303            String::from_utf8_lossy(&out.stderr)
304        );
305    }
306
307    fn init_repo_with_commit(root: &Path) {
308        fs::create_dir_all(root).expect("create repo dir");
309        git(root, &["init", "-q", "-b", "main"]);
310        git(
311            root,
312            &[
313                "-c",
314                "user.email=t@t",
315                "-c",
316                "user.name=t",
317                "commit",
318                "--allow-empty",
319                "-m",
320                "init",
321            ],
322        );
323    }
324
325    #[test]
326    fn test_current_branch_some_on_branch() {
327        let temp = TempDir::new().expect("temp dir");
328        let root = temp.path().join("repo");
329        init_repo_with_commit(&root);
330        let branch = current_branch(&root)
331            .expect("current_branch")
332            .expect("branch");
333        assert_eq!(branch, "main");
334    }
335
336    #[test]
337    fn test_current_branch_none_when_detached() {
338        let temp = TempDir::new().expect("temp dir");
339        let root = temp.path().join("repo");
340        init_repo_with_commit(&root);
341        git(&root, &["checkout", "-q", "--detach"]);
342        let branch = current_branch(&root).expect("current_branch");
343        assert!(
344            branch.is_none(),
345            "detached HEAD must map to None, got {branch:?}"
346        );
347    }
348
349    /// git-native-v2 D1: the default ref resolves LOCAL-first, and
350    /// `effective_range_base` = merge-base(local default, HEAD) — unpushed
351    /// local accumulation must NOT drift the range anchor to origin.
352    #[test]
353    fn effective_range_base_is_local_first_and_live() {
354        let temp = TempDir::new().expect("temp dir");
355        let root = temp.path().join("repo");
356        init_repo_with_commit(&root);
357        // origin/main exists at the same commit; local main then moves ahead
358        // (long-lived local work, never pushed).
359        git(&root, &["remote", "add", "origin", root.to_str().unwrap()]);
360        git(&root, &["fetch", "-q", "origin"]);
361        git(
362            &root,
363            &[
364                "-c",
365                "user.email=t@t",
366                "-c",
367                "user.name=t",
368                "commit",
369                "--allow-empty",
370                "-qm",
371                "local-only",
372            ],
373        );
374        let local_main = run_git(&root, &["rev-parse", "main"]).unwrap();
375        assert_eq!(
376            resolve_default_branch_ref(&root).unwrap(),
377            "main",
378            "local main must win over origin/main"
379        );
380        assert_eq!(
381            effective_range_base(&root).unwrap(),
382            local_main,
383            "effective base must be the LOCAL main merge-base (== HEAD here)"
384        );
385        // On a feature branch fork point, the base is the live fork point.
386        git(&root, &["checkout", "-q", "-b", "sdd/c1"]);
387        git(
388            &root,
389            &[
390                "-c",
391                "user.email=t@t",
392                "-c",
393                "user.name=t",
394                "commit",
395                "--allow-empty",
396                "-qm",
397                "branch work",
398            ],
399        );
400        assert_eq!(
401            effective_range_base(&root).unwrap(),
402            local_main,
403            "fork point stays the live merge-base after branch commits"
404        );
405        // Merge main in: the base advances to the new main tip (range shrinks).
406        git(&root, &["checkout", "-q", "main"]);
407        git(
408            &root,
409            &[
410                "-c",
411                "user.email=t@t",
412                "-c",
413                "user.name=t",
414                "commit",
415                "--allow-empty",
416                "-qm",
417                "main moves again",
418            ],
419        );
420        let new_main = run_git(&root, &["rev-parse", "main"]).unwrap();
421        git(&root, &["checkout", "-q", "sdd/c1"]);
422        git(
423            &root,
424            &[
425                "-c",
426                "user.email=t@t",
427                "-c",
428                "user.name=t",
429                "merge",
430                "-q",
431                "--no-edit",
432                "main",
433            ],
434        );
435        assert_eq!(
436            effective_range_base(&root).unwrap(),
437            new_main,
438            "after merge, base must shrink to the new main tip (merge-base)"
439        );
440    }
441}