Skip to main content

rac_engine/
gitinfo.rs

1//! Git-derived recency and staleness — a port of the git touchpoint in
2//! `src/asdecided/services/recency.py`, per PORT-CONTRACT.d/08 §4.
3//!
4//! Recency is *derived* from `git log`, never stored (ADR-045). This module
5//! shells out to the real `git` binary with the exact argv the oracle uses and
6//! reproduces its degrade-to-`None` posture: outside a repo, with no git
7//! binary, or for an untracked file, every value is `None` — no error crosses
8//! the boundary.
9//!
10//! Landmines (PORT-CONTRACT.d/08 §4.2–4.3):
11//! - `git log --format=%cI` renders the **committer's stored timezone offset**
12//!   and ignores `TZ`. `last_committed` is kept **verbatim** (offset preserved,
13//!   never normalized to UTC).
14//! - `age_days = (reference - last_committed).days` uses Python
15//!   `timedelta.days`, which **floors toward negative infinity** (a future
16//!   commit yields a negative age). This is whole-day truncation, not rounding.
17//! - `stale = age_days > threshold_days` — strictly greater-than, so exactly at
18//!   the threshold is **not** stale.
19//! - Unknown date -> `Staleness { None, None, None }`.
20
21use std::collections::{HashMap, HashSet};
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25/// The default "stale after" window (`DEFAULT_STALE_AFTER_DAYS`).
26pub const DEFAULT_STALE_AFTER_DAYS: i64 = 180;
27
28/// Run `git <args>` with the given working directory. Returns the raw stdout
29/// on exit code 0, or `None` for a non-zero exit or a missing binary
30/// (`FileNotFoundError` in the oracle).
31fn run_git(args: &[&str], cwd: &Path) -> Option<String> {
32    let output = Command::new("git").args(args).current_dir(cwd).output().ok()?;
33    if !output.status.success() {
34        return None;
35    }
36    Some(String::from_utf8_lossy(&output.stdout).into_owned())
37}
38
39/// `run_git` with Python `text=True` universal-newline decoding (`\r\n` and
40/// lone `\r` → `\n`). The recency callers in this module only trim `%cI`
41/// stamps and the toplevel path, so they stay on the raw form; the decided-mcp
42/// provenance surface parses `git show` file content, where the
43/// normalization is load-bearing.
44pub fn run_git_text(args: &[&str], cwd: &Path) -> Option<String> {
45    run_git(args, cwd).map(|t| t.replace("\r\n", "\n").replace('\r', "\n"))
46}
47
48/// The work-tree root containing `directory`, or `None` if it is not a repo /
49/// git is unavailable. Mirrors `git rev-parse --show-toplevel`.
50pub fn repository_root(directory: &Path) -> Option<PathBuf> {
51    let out = run_git(&["rev-parse", "--show-toplevel"], directory)?;
52    let root = out.trim();
53    if root.is_empty() {
54        None
55    } else {
56        Some(PathBuf::from(root))
57    }
58}
59
60/// `path` made relative to `repo_root` (via `canonicalize`, like Python's
61/// `Path.resolve()`); if it lies outside the work tree, the absolute path is
62/// passed through unchanged.
63pub fn pathspec(repo_root: &Path, path: &Path) -> String {
64    let abspath = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
65    let root = repo_root.canonicalize().unwrap_or_else(|_| repo_root.to_path_buf());
66    match abspath.strip_prefix(&root) {
67        Ok(rel) => rel.to_string_lossy().into_owned(),
68        Err(_) => abspath.to_string_lossy().into_owned(),
69    }
70}
71
72/// The most recent commit time for `path` as the verbatim `%cI` string
73/// (committer offset preserved), or `None` when the file is untracked /
74/// uncommitted / outside a repo. Mirrors `git log -1 --format=%cI -- <path>`.
75pub fn last_committed(repo_root: &Path, path: &Path) -> Option<String> {
76    let spec = pathspec(repo_root, path);
77    let out = run_git(&["log", "-1", "--format=%cI", "--", &spec], repo_root)?;
78    let stamp = out.trim();
79    if stamp.is_empty() {
80        None
81    } else {
82        Some(stamp.to_string())
83    }
84}
85
86/// The earliest commit time for `path` as the verbatim `%cI` string of the
87/// first non-blank line (committer offset preserved), or `None` when the
88/// file is untracked / uncommitted / outside a repo. Mirrors
89/// `git log --reverse --format=%cI -- <path>` (oldest first, first line is
90/// the creation commit) — used by the OKF export's `created` field.
91pub fn first_committed(repo_root: &Path, path: &Path) -> Option<String> {
92    let spec = pathspec(repo_root, path);
93    let out = run_git(
94        &["log", "--reverse", "--format=%cI", "--", &spec],
95        repo_root,
96    )?;
97    out.lines()
98        .map(str::trim)
99        .find(|l| !l.is_empty())
100        .map(str::to_string)
101}
102
103/// Last-committed time for each of `paths` (the raw recency primitive). Every
104/// path maps to `None` when `directory` is not a repo. Order preserved.
105pub fn last_committed_for_paths(
106    directory: &Path,
107    paths: &[PathBuf],
108) -> Vec<(PathBuf, Option<String>)> {
109    match repository_root(directory) {
110        None => paths.iter().map(|p| (p.clone(), None)).collect(),
111        Some(root) => last_committed_for_paths_in_repo(&root, paths),
112    }
113}
114
115/// Batched form of [`last_committed_for_paths`] for callers that already
116/// resolved the repository root. A newest-first `git log --name-only` walk
117/// assigns the first observed commit stamp to each path, reproducing
118/// `git log -1 --format=%cI -- <path>` without one subprocess per artifact.
119pub fn last_committed_for_paths_in_repo(
120    repo_root: &Path,
121    paths: &[PathBuf],
122) -> Vec<(PathBuf, Option<String>)> {
123    const MAX_PATHSPEC_BYTES: usize = 64 * 1024;
124    const MAX_PATHS_PER_RUN: usize = 2_048;
125
126    let specs: Vec<String> = paths.iter().map(|path| pathspec(repo_root, path)).collect();
127    let mut unique = Vec::new();
128    let mut seen = HashSet::new();
129    for spec in &specs {
130        // `pathspec` passes an absolute path through when it lies outside the
131        // work tree. An individual git call returns no history for it; omit it
132        // here so it cannot make the whole batch fail.
133        if !Path::new(spec).is_absolute() && seen.insert(spec.clone()) {
134            unique.push(spec.clone());
135        }
136    }
137
138    let mut committed: HashMap<String, String> = HashMap::new();
139    let mut start = 0;
140    while start < unique.len() {
141        let mut end = start;
142        let mut bytes = 0;
143        while end < unique.len() && end - start < MAX_PATHS_PER_RUN {
144            let next = unique[end].len() + 1;
145            if end > start && bytes + next > MAX_PATHSPEC_BYTES {
146                break;
147            }
148            bytes += next;
149            end += 1;
150        }
151        collect_last_committed(repo_root, &unique[start..end], &mut committed);
152        start = end;
153    }
154
155    paths
156        .iter()
157        .zip(specs)
158        .map(|(path, spec)| (path.clone(), committed.get(&spec).cloned()))
159        .collect()
160}
161
162fn collect_last_committed(
163    repo_root: &Path,
164    specs: &[String],
165    committed: &mut HashMap<String, String>,
166) {
167    if specs.is_empty() {
168        return;
169    }
170    let mut args = vec!["log", "-z", "--format=%x1e%cI", "--name-only", "--"];
171    args.extend(specs.iter().map(String::as_str));
172    let Some(output) = run_git(&args, repo_root) else {
173        return;
174    };
175    let wanted: HashSet<&str> = specs.iter().map(String::as_str).collect();
176    let mut stamp: Option<&str> = None;
177    let mut first_name = false;
178    for token in output.split('\0') {
179        if let Some(value) = token.strip_prefix('\x1e') {
180            stamp = Some(value.trim());
181            first_name = true;
182            continue;
183        }
184        let Some(current) = stamp else {
185            continue;
186        };
187        // Git places one formatting newline before the first name in each
188        // commit. `-z` keeps the filename itself otherwise byte-delimited.
189        let name = if first_name {
190            first_name = false;
191            token.strip_prefix('\n').unwrap_or(token)
192        } else {
193            token
194        };
195        if wanted.contains(name) {
196            committed
197                .entry(name.to_string())
198                .or_insert_with(|| current.to_string());
199        }
200    }
201}
202
203/// One artifact's freshness: its verbatim last-committed date and the derived
204/// indicators. All-`None` when the date is unknown. Mirrors `Staleness`.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct Staleness {
207    /// The verbatim `%cI` string, or `None`.
208    pub last_committed: Option<String>,
209    /// Whole days between `reference` and `last_committed`, floored toward
210    /// negative infinity (Python `timedelta.days`).
211    pub age_days: Option<i64>,
212    /// `age_days > threshold_days` (strictly greater; boundary is not stale).
213    pub stale: Option<bool>,
214}
215
216impl Staleness {
217    /// The unknown-date result: `{None, None, None}`.
218    pub fn unknown() -> Self {
219        Staleness {
220            last_committed: None,
221            age_days: None,
222            stale: None,
223        }
224    }
225}
226
227/// Staleness of one last-committed date against `threshold_days`, evaluated at
228/// `reference_epoch_secs` (Unix seconds, UTC). An unknown / unparseable date
229/// yields the all-`None` result.
230///
231/// `reference_epoch_secs` stands in for the oracle's `reference` datetime
232/// (`datetime.now(UTC)` in production; injectable for determinism). Passing it
233/// as an epoch keeps this function clock-free and portable.
234pub fn staleness(
235    last_committed: Option<&str>,
236    threshold_days: i64,
237    reference_epoch_secs: i64,
238) -> Staleness {
239    let stamp = match last_committed {
240        None => return Staleness::unknown(),
241        Some(s) => s,
242    };
243    let committed_epoch = match parse_iso8601_epoch(stamp) {
244        Some(e) => e,
245        None => return Staleness::unknown(),
246    };
247    // Python `timedelta.days` = floor(total_seconds / 86400) toward -inf.
248    let delta = reference_epoch_secs - committed_epoch;
249    let age_days = floor_div(delta, 86_400);
250    Staleness {
251        last_committed: Some(stamp.to_string()),
252        age_days: Some(age_days),
253        stale: Some(age_days > threshold_days),
254    }
255}
256
257/// Floor division toward negative infinity (Rust `/` truncates toward zero).
258fn floor_div(a: i64, b: i64) -> i64 {
259    let q = a / b;
260    let r = a % b;
261    if (r != 0) && ((r < 0) != (b < 0)) {
262        q - 1
263    } else {
264        q
265    }
266}
267
268/// Python `datetime.fromisoformat(stamp).isoformat()` round trip of a git
269/// `%cI` stamp: verbatim for the `±HH:MM` form git emits; a trailing `Z`
270/// re-serializes as `+00:00`, a colonless `±HHMM` gains its colon, `±HH`
271/// becomes `±HH:00`, and a space separator becomes `T`.
272pub fn isoformat_roundtrip(stamp: &str) -> String {
273    let mut s = stamp.to_string();
274    if s.len() > 10 && s.as_bytes()[10] == b' ' {
275        s.replace_range(10..11, "T");
276    }
277    if s.ends_with('Z') || s.ends_with('z') {
278        s.truncate(s.len() - 1);
279        s.push_str("+00:00");
280        return s;
281    }
282    // Find the offset sign after the time part (beyond index 10 to skip the
283    // date's hyphens).
284    if let Some(pos) = s.rfind(['+', '-']) {
285        if pos > 10 {
286            let body = &s[pos + 1..];
287            if body.len() == 4 && body.bytes().all(|b| b.is_ascii_digit()) {
288                let fixed = format!("{}:{}", &body[..2], &body[2..]);
289                s.replace_range(pos + 1.., &fixed);
290            } else if body.len() == 2 && body.bytes().all(|b| b.is_ascii_digit()) {
291                let fixed = format!("{body}:00");
292                s.replace_range(pos + 1.., &fixed);
293            }
294        }
295    }
296    s
297}
298
299/// Parse a strict ISO-8601 timestamp with an explicit offset (`%cI` form:
300/// `YYYY-MM-DDTHH:MM:SS[.ffffff](Z|±HH:MM|±HHMM)`) into Unix epoch seconds
301/// (UTC). Fractional seconds are ignored for whole-day math (git `%cI` has
302/// none). Returns `None` on any structural surprise (treated as "unknown",
303/// matching the oracle's `fromisoformat` `ValueError` -> `None`).
304pub fn parse_iso8601_epoch(s: &str) -> Option<i64> {
305    let bytes = s.as_bytes();
306    if bytes.len() < 19 {
307        return None;
308    }
309    // Date: YYYY-MM-DD
310    let year: i64 = s.get(0..4)?.parse().ok()?;
311    if bytes[4] != b'-' {
312        return None;
313    }
314    let month: i64 = s.get(5..7)?.parse().ok()?;
315    if bytes[7] != b'-' {
316        return None;
317    }
318    let day: i64 = s.get(8..10)?.parse().ok()?;
319    // Separator: 'T' or ' '
320    if bytes[10] != b'T' && bytes[10] != b' ' {
321        return None;
322    }
323    // Time: HH:MM:SS
324    let hour: i64 = s.get(11..13)?.parse().ok()?;
325    if bytes[13] != b':' {
326        return None;
327    }
328    let minute: i64 = s.get(14..16)?.parse().ok()?;
329    if bytes[16] != b':' {
330        return None;
331    }
332    let second: i64 = s.get(17..19)?.parse().ok()?;
333
334    // Remainder: optional fractional seconds, then the offset.
335    let mut rest = &s[19..];
336    if let Some(stripped) = rest.strip_prefix('.') {
337        // Skip fractional digits.
338        let non_digit = stripped
339            .char_indices()
340            .find(|(_, c)| !c.is_ascii_digit())
341            .map(|(i, _)| i)
342            .unwrap_or(stripped.len());
343        rest = &stripped[non_digit..];
344    }
345
346    let offset_secs = parse_offset(rest)?;
347
348    let days = days_from_civil(year, month, day);
349    let local_secs = days * 86_400 + hour * 3_600 + minute * 60 + second;
350    // The stamp's civil time is UTC + offset, so UTC = local - offset.
351    Some(local_secs - offset_secs)
352}
353
354/// Parse a trailing timezone offset (`Z`, `±HH:MM`, or `±HHMM`) to seconds.
355fn parse_offset(rest: &str) -> Option<i64> {
356    if rest == "Z" || rest == "z" {
357        return Some(0);
358    }
359    let bytes = rest.as_bytes();
360    if bytes.is_empty() {
361        return None; // %cI always carries an explicit offset
362    }
363    let sign = match bytes[0] {
364        b'+' => 1,
365        b'-' => -1,
366        _ => return None,
367    };
368    let body = &rest[1..];
369    let (hh, mm) = if body.len() == 5 && body.as_bytes()[2] == b':' {
370        (&body[0..2], &body[3..5]) // ±HH:MM
371    } else if body.len() == 4 {
372        (&body[0..2], &body[2..4]) // ±HHMM
373    } else if body.len() == 2 {
374        (&body[0..2], "00") // ±HH
375    } else {
376        return None;
377    };
378    let h: i64 = hh.parse().ok()?;
379    let m: i64 = mm.parse().ok()?;
380    Some(sign * (h * 3_600 + m * 60))
381}
382
383/// Days from the Unix epoch (1970-01-01) to the civil date `y-m-d`, via Howard
384/// Hinnant's algorithm. Correct for the proleptic Gregorian calendar and any
385/// year range git can emit.
386fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
387    let y = if m <= 2 { y - 1 } else { y };
388    let era = if y >= 0 { y } else { y - 399 } / 400;
389    let yoe = y - era * 400; // [0, 399]
390    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365]
391    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
392    era * 146_097 + doe - 719_468
393}