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(parent, &["clone", "--no-single-branch", url, dest_str])?;
36    }
37    Ok(())
38}
39
40/// Resolve `ref_name` to its full SHA in `repo`.
41pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
42    run_git(repo, &["rev-parse", ref_name])
43}
44
45// ── worktree helpers ──────────────────────────────────────────────────────────
46
47/// Create a detached worktree at `worktree_path` pointing at `ref_name`.
48pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
49    let wt = worktree_path.to_str().unwrap_or(".");
50    run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
51    Ok(())
52}
53
54/// Remove a worktree previously created with [`create_worktree`].
55pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
56    let wt = worktree_path.to_str().unwrap_or(".");
57    let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
58    Ok(())
59}
60
61// ── ref listing ───────────────────────────────────────────────────────────────
62
63/// Return all branches, tags, and recent commits for `repo`.
64pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
65    Ok(RepoRefs {
66        branches: list_branches(repo)?,
67        tags: list_tags(repo)?,
68        recent_commits: list_commits(repo, "HEAD", 40)?,
69    })
70}
71
72fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
73    let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso8601)|%(subject)";
74    let out = run_git(repo, &["branch", "-a", &format!("--format={fmt}")])?;
75    out.lines()
76        .filter(|l| !l.trim().is_empty())
77        .map(|l| parse_ref_line(l, GitRefKind::Branch))
78        .collect()
79}
80
81fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
82    let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso8601)|%(subject)";
83    let out = run_git(
84        repo,
85        &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
86    )?;
87    out.lines()
88        .filter(|l| !l.trim().is_empty())
89        .map(|l| parse_ref_line(l, GitRefKind::Tag))
90        .collect()
91}
92
93fn parse_ref_line(line: &str, kind: GitRefKind) -> Result<GitRef> {
94    let parts: Vec<&str> = line.splitn(4, '|').collect();
95    let name = parts.first().copied().unwrap_or("").to_owned();
96    let sha = parts.get(1).copied().unwrap_or("").to_owned();
97    let date = parts.get(2).copied().and_then(parse_git_date);
98    let message = parts.get(3).map(|s| (*s).to_owned());
99    Ok(GitRef {
100        kind,
101        name,
102        sha,
103        date,
104        message,
105    })
106}
107
108// ── commit listing ────────────────────────────────────────────────────────────
109
110/// Return up to `limit` commits reachable from `ref_name`.
111pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
112    let fmt = "%H|%h|%an|%aI|%s";
113    let n = format!("-{limit}");
114    let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
115    out.lines()
116        .filter(|l| !l.trim().is_empty())
117        .map(parse_commit_line)
118        .collect()
119}
120
121fn parse_commit_line(line: &str) -> Result<GitCommit> {
122    let p: Vec<&str> = line.splitn(5, '|').collect();
123    let sha = p.first().copied().unwrap_or("").to_owned();
124    let short_sha = p.get(1).copied().unwrap_or("").to_owned();
125    let author = p.get(2).copied().unwrap_or("").to_owned();
126    let date = p
127        .get(3)
128        .copied()
129        .and_then(parse_git_date)
130        .unwrap_or_default();
131    let subject = p.get(4).copied().unwrap_or("").to_owned();
132    Ok(GitCommit {
133        sha,
134        short_sha,
135        author,
136        date,
137        subject,
138    })
139}
140
141fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
142    chrono::DateTime::parse_from_rfc3339(s)
143        .ok()
144        .map(|d| d.with_timezone(&chrono::Utc))
145}