Skip to main content

sloc_git/
ops.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4use std::path::Path;
5
6use anyhow::{bail, Context, Result};
7
8use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
9
10// ── low-level git runner ───────────────────────────────────────────────────────
11
12fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
13    let out = std::process::Command::new("git")
14        .args(args)
15        .current_dir(repo)
16        .output()
17        .context("failed to spawn git process")?;
18    if !out.status.success() {
19        let stderr = String::from_utf8_lossy(&out.stderr);
20        bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
21    }
22    Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
23}
24
25// ── clone / fetch ─────────────────────────────────────────────────────────────
26
27/// Clone `url` into `dest`, or fetch all refs if the repo already exists.
28pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
29    if dest.join(".git").exists() {
30        run_git(dest, &["fetch", "--all", "--tags", "--prune"])?;
31    } else {
32        std::fs::create_dir_all(dest).context("failed to create clone directory")?;
33        let dest_str = dest.to_str().unwrap_or(".");
34        let parent = dest.parent().unwrap_or(dest);
35        run_git(
36            parent,
37            &["clone", "--no-single-branch", "--depth=50", url, dest_str],
38        )?;
39    }
40    Ok(())
41}
42
43/// Resolve `ref_name` to its full SHA in `repo`.
44pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
45    run_git(repo, &["rev-parse", ref_name])
46}
47
48// ── worktree helpers ──────────────────────────────────────────────────────────
49
50/// Create a detached worktree at `worktree_path` pointing at `ref_name`.
51pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
52    let wt = worktree_path.to_str().unwrap_or(".");
53    run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
54    Ok(())
55}
56
57/// Remove a worktree previously created with [`create_worktree`].
58pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
59    let wt = worktree_path.to_str().unwrap_or(".");
60    let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
61    Ok(())
62}
63
64// ── ref listing ───────────────────────────────────────────────────────────────
65
66/// Return all branches, tags, and recent commits for `repo`.
67pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
68    Ok(RepoRefs {
69        branches: list_branches(repo)?,
70        tags: list_tags(repo)?,
71        recent_commits: list_commits(repo, "HEAD", 40)?,
72    })
73}
74
75fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
76    let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
77    // Use -r (remote-tracking only) to avoid local/remote duplicates.
78    // Strip the leading remote name (e.g. "origin/") from each ref so the
79    // displayed name matches what the upstream repository calls the branch.
80    let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
81    let refs = out
82        .lines()
83        .filter(|l| !l.trim().is_empty())
84        .map(|l| parse_ref_line(l, GitRefKind::Branch))
85        .collect::<Result<Vec<_>>>()?
86        .into_iter()
87        // Drop symbolic HEAD pointers (e.g. origin/HEAD).
88        .filter(|r| r.name != "HEAD" && !r.name.ends_with("/HEAD"))
89        .map(|mut r| {
90            // Strip the remote prefix ("origin/", "upstream/", etc.).
91            if let Some(slash) = r.name.find('/') {
92                r.name = r.name[slash + 1..].to_owned();
93            }
94            r
95        })
96        .collect::<Vec<_>>();
97    Ok(refs)
98}
99
100fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
101    let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
102    let out = run_git(
103        repo,
104        &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
105    )?;
106    out.lines()
107        .filter(|l| !l.trim().is_empty())
108        .map(|l| parse_ref_line(l, GitRefKind::Tag))
109        .collect()
110}
111
112fn parse_ref_line(line: &str, kind: GitRefKind) -> Result<GitRef> {
113    let parts: Vec<&str> = line.splitn(4, '|').collect();
114    let name = parts.first().copied().unwrap_or("").to_owned();
115    let sha = parts.get(1).copied().unwrap_or("").to_owned();
116    let date = parts.get(2).copied().and_then(parse_git_date);
117    let message = parts.get(3).map(|s| (*s).to_owned());
118    Ok(GitRef {
119        kind,
120        name,
121        sha,
122        date,
123        message,
124    })
125}
126
127// ── commit listing ────────────────────────────────────────────────────────────
128
129/// Return up to `limit` commits reachable from `ref_name`.
130pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
131    let fmt = "%H|%h|%an|%aI|%s";
132    let n = format!("-{limit}");
133    let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
134    out.lines()
135        .filter(|l| !l.trim().is_empty())
136        .map(parse_commit_line)
137        .collect()
138}
139
140fn parse_commit_line(line: &str) -> Result<GitCommit> {
141    let p: Vec<&str> = line.splitn(5, '|').collect();
142    let sha = p.first().copied().unwrap_or("").to_owned();
143    let short_sha = p.get(1).copied().unwrap_or("").to_owned();
144    let author = p.get(2).copied().unwrap_or("").to_owned();
145    let date = p
146        .get(3)
147        .copied()
148        .and_then(parse_git_date)
149        .unwrap_or_default();
150    let subject = p.get(4).copied().unwrap_or("").to_owned();
151    Ok(GitCommit {
152        sha,
153        short_sha,
154        author,
155        date,
156        subject,
157    })
158}
159
160fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
161    chrono::DateTime::parse_from_rfc3339(s)
162        .ok()
163        .map(|d| d.with_timezone(&chrono::Utc))
164}