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
27fn validate_clone_url(url: &str) -> Result<()> {
28    let lower = url.to_lowercase();
29    let allowed = ["https://", "http://", "git://", "ssh://", "git@"];
30    if !allowed.iter().any(|p| lower.starts_with(p)) {
31        bail!(
32            "git URL rejected: only https://, http://, git://, ssh://, and git@ URLs are \
33             permitted (got {url:?})"
34        );
35    }
36    Ok(())
37}
38
39/// Clone `url` into `dest`, or fetch all refs if the repo already exists.
40///
41/// # Errors
42/// Returns an error if the URL is rejected, the clone directory cannot be created,
43/// or the underlying `git clone` / `git fetch` command fails.
44pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
45    validate_clone_url(url)?;
46    if dest.join(".git").exists() {
47        run_git(dest, &["fetch", "--all", "--tags", "--prune"])?;
48    } else {
49        std::fs::create_dir_all(dest).context("failed to create clone directory")?;
50        let dest_str = dest.to_str().unwrap_or(".");
51        let parent = dest.parent().unwrap_or(dest);
52        run_git(
53            parent,
54            &["clone", "--no-single-branch", "--depth=50", url, dest_str],
55        )?;
56    }
57    Ok(())
58}
59
60/// Resolve `ref_name` to its full SHA in `repo`.
61///
62/// # Errors
63/// Returns an error if `git rev-parse` fails (e.g. the ref does not exist).
64pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
65    run_git(repo, &["rev-parse", ref_name])
66}
67
68// ── worktree helpers ──────────────────────────────────────────────────────────
69
70/// Create a detached worktree at `worktree_path` pointing at `ref_name`.
71///
72/// # Errors
73/// Returns an error if `git worktree add` fails.
74pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
75    let wt = worktree_path.to_str().unwrap_or(".");
76    run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
77    Ok(())
78}
79
80/// Remove a worktree previously created with [`create_worktree`].
81///
82/// # Errors
83/// This function always succeeds; the underlying git command failure is intentionally ignored.
84pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
85    let wt = worktree_path.to_str().unwrap_or(".");
86    let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
87    Ok(())
88}
89
90// ── ref listing ───────────────────────────────────────────────────────────────
91
92/// Return all branches, tags, and recent commits for `repo`.
93///
94/// # Errors
95/// Returns an error if any underlying git command fails.
96pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
97    Ok(RepoRefs {
98        branches: list_branches(repo)?,
99        tags: list_tags(repo)?,
100        recent_commits: list_commits(repo, "HEAD", 40)?,
101    })
102}
103
104fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
105    let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
106    // Use -r (remote-tracking only) to avoid local/remote duplicates.
107    // Strip the leading remote name (e.g. "origin/") from each ref so the
108    // displayed name matches what the upstream repository calls the branch.
109    let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
110    let refs = out
111        .lines()
112        .filter(|l| !l.trim().is_empty())
113        .map(|l| parse_ref_line(l, GitRefKind::Branch))
114        // Drop symbolic HEAD pointers (e.g. origin/HEAD).
115        .filter(|r| r.name != "HEAD" && !r.name.ends_with("/HEAD"))
116        .map(|mut r| {
117            // Strip the remote prefix ("origin/", "upstream/", etc.).
118            if let Some(slash) = r.name.find('/') {
119                r.name = r.name[slash + 1..].to_owned();
120            }
121            r
122        })
123        .collect::<Vec<_>>();
124    Ok(refs)
125}
126
127fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
128    let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
129    let out = run_git(
130        repo,
131        &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
132    )?;
133    Ok(out
134        .lines()
135        .filter(|l| !l.trim().is_empty())
136        .map(|l| parse_ref_line(l, GitRefKind::Tag))
137        .collect())
138}
139
140fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
141    let parts: Vec<&str> = line.splitn(4, '|').collect();
142    let name = parts.first().copied().unwrap_or("").to_owned();
143    let sha = parts.get(1).copied().unwrap_or("").to_owned();
144    let date = parts.get(2).copied().and_then(parse_git_date);
145    let message = parts.get(3).map(|s| (*s).to_owned());
146    GitRef {
147        kind,
148        name,
149        sha,
150        date,
151        message,
152    }
153}
154
155// ── commit listing ────────────────────────────────────────────────────────────
156
157/// Return up to `limit` commits reachable from `ref_name`.
158///
159/// # Errors
160/// Returns an error if `git log` fails.
161pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
162    let fmt = "%H|%h|%an|%aI|%s";
163    let n = format!("-{limit}");
164    let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
165    Ok(out
166        .lines()
167        .filter(|l| !l.trim().is_empty())
168        .map(parse_commit_line)
169        .collect())
170}
171
172fn parse_commit_line(line: &str) -> GitCommit {
173    let p: Vec<&str> = line.splitn(5, '|').collect();
174    let sha = p.first().copied().unwrap_or("").to_owned();
175    let short_sha = p.get(1).copied().unwrap_or("").to_owned();
176    let author = p.get(2).copied().unwrap_or("").to_owned();
177    let date = p
178        .get(3)
179        .copied()
180        .and_then(parse_git_date)
181        .unwrap_or_default();
182    let subject = p.get(4).copied().unwrap_or("").to_owned();
183    GitCommit {
184        sha,
185        short_sha,
186        author,
187        date,
188        subject,
189    }
190}
191
192fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
193    chrono::DateTime::parse_from_rfc3339(s)
194        .ok()
195        .map(|d| d.with_timezone(&chrono::Utc))
196}