Skip to main content

supercode_harness/
git_metadata.rs

1//! P4e (COMPOSABLE-HARNESS-DESIGN.md §1.6/§3.1 `core.session.git_metadata`,
2//! catalog:331 "Git integration (metadata, diff, PR)"): a persisted, TYPED
3//! record of the git branch/sha/dirty state a session was RUNNING under,
4//! captured ONCE at session start (closes the loop catalog:331 flags —
5//! supercode already preserves a foreign session's own git-shaped fields
6//! byte-for-byte on IMPORT via `Session::raw`'s verbatim capture; this is
7//! the WRITE half: supercode's OWN sessions get the same provenance).
8//! Deliberately flat/typed (not a formatted string), the exact same
9//! rationale as [`crate::usage_log::UsageRecord`]/
10//! [`crate::model_change::ModelChangeRecord`] (§1.13): a translatable,
11//! lossless session-data channel, not a lossy notice — so it survives a
12//! save/load round trip byte-for-byte, and a future reader (a translator,
13//! `doctor`/`inspect stats`) can consume it without re-parsing prose.
14
15use serde::{Deserialize, Serialize};
16
17/// One session's git provenance, best-effort captured at construction time
18/// (`Agent::with_parts`, gated by `Config::session_git_metadata`).
19#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
20pub struct GitMetadataRecord {
21    /// `git rev-parse --abbrev-ref HEAD`, if `cwd` is inside a git repo and
22    /// `git` is on `PATH`. `None` otherwise — never blocks capture.
23    pub branch: Option<String>,
24    /// `git rev-parse HEAD` (the full 40-char sha), same availability as
25    /// [`Self::branch`].
26    pub sha: Option<String>,
27    /// Whether `git status --porcelain` reported any changes. `false` when
28    /// git information couldn't be read at all (an honest "unknown treated
29    /// as clean", matching `agent::env_context_git_status`'s existing
30    /// posture).
31    #[serde(default)]
32    pub dirty: bool,
33    /// Unix-ms wall-clock time the record was captured.
34    #[serde(default)]
35    pub captured_at_ms: i64,
36}
37
38/// Best-effort capture of `cwd`'s git branch/sha/dirty state. `None` when
39/// `cwd` isn't inside a git repo, `git` isn't on `PATH`, or the repo has no
40/// commits yet (`rev-parse HEAD` fails on an empty repo) — this is
41/// informational provenance, never worth failing agent construction over,
42/// the same posture `agent::env_context_git_status` already established.
43pub fn capture(cwd: &std::path::Path, timestamp_ms: i64) -> Option<GitMetadataRecord> {
44    let branch_out = std::process::Command::new("git")
45        .args(["rev-parse", "--abbrev-ref", "HEAD"])
46        .current_dir(cwd)
47        .output()
48        .ok()?;
49    if !branch_out.status.success() {
50        return None;
51    }
52    let branch = String::from_utf8_lossy(&branch_out.stdout)
53        .trim()
54        .to_string();
55    if branch.is_empty() {
56        return None;
57    }
58    let sha = std::process::Command::new("git")
59        .args(["rev-parse", "HEAD"])
60        .current_dir(cwd)
61        .output()
62        .ok()
63        .filter(|o| o.status.success())
64        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
65        .filter(|s| !s.is_empty());
66    let dirty = std::process::Command::new("git")
67        .args(["status", "--porcelain"])
68        .current_dir(cwd)
69        .output()
70        .ok()
71        .map(|o| !o.stdout.is_empty())
72        .unwrap_or(false);
73    Some(GitMetadataRecord {
74        branch: Some(branch),
75        sha,
76        dirty,
77        captured_at_ms: timestamp_ms,
78    })
79}
80
81/// Serialize a record as single-line JSON — the `<name>.git.json` sidecar
82/// shape [`crate::store::SessionStore::save_git_metadata`] writes (a
83/// single-record file, like `<name>.reduction.json`, not a JSONL log: git
84/// state is captured once per session, not once per turn).
85pub fn to_json(record: &GitMetadataRecord) -> crate::Result<String> {
86    serde_json::to_string(record).map_err(crate::Error::Decode)
87}
88
89/// Parse a `<name>.git.json` sidecar back into a record — the exact inverse
90/// of [`to_json`].
91pub fn from_json(text: &str) -> crate::Result<GitMetadataRecord> {
92    serde_json::from_str(text).map_err(crate::Error::Decode)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    /// Lossless round-trip (§1.13): every field survives a to_json/from_json
100    /// cycle byte-for-byte.
101    #[test]
102    fn record_round_trips_losslessly() {
103        let record = GitMetadataRecord {
104            branch: Some("main".to_string()),
105            sha: Some("abc123def456".to_string()),
106            dirty: true,
107            captured_at_ms: 1_700_000_000_000,
108        };
109        let json = to_json(&record).unwrap();
110        let back = from_json(&json).unwrap();
111        assert_eq!(back, record);
112    }
113
114    /// Boundary: a record with no branch/sha (git unavailable) still
115    /// round-trips.
116    #[test]
117    fn record_with_no_git_info_round_trips() {
118        let record = GitMetadataRecord {
119            branch: None,
120            sha: None,
121            dirty: false,
122            captured_at_ms: 0,
123        };
124        let json = to_json(&record).unwrap();
125        let back = from_json(&json).unwrap();
126        assert_eq!(back, record);
127    }
128
129    /// Happy path: capturing inside this very repo (a git checkout) finds a
130    /// branch.
131    #[test]
132    fn capture_finds_branch_in_a_real_repo() {
133        let cwd = std::env::current_dir().unwrap();
134        // Walk up until a `.git` is found, or give up (CI sandboxes vary).
135        let mut dir = cwd.as_path();
136        loop {
137            if dir.join(".git").exists() {
138                break;
139            }
140            match dir.parent() {
141                Some(p) => dir = p,
142                None => return, // not in a git checkout at all; skip silently
143            }
144        }
145        if let Some(record) = capture(dir, 42) {
146            assert!(record.branch.is_some());
147            assert_eq!(record.captured_at_ms, 42);
148        }
149    }
150
151    /// A directory that isn't a git repo at all yields `None` rather than
152    /// panicking or fabricating a record.
153    #[test]
154    fn capture_returns_none_outside_a_repo() {
155        let tmp = std::env::temp_dir().join(format!(
156            "sc-git-metadata-test-{}-{}",
157            std::process::id(),
158            std::time::SystemTime::now()
159                .duration_since(std::time::UNIX_EPOCH)
160                .map(|d| d.as_nanos())
161                .unwrap_or(0)
162        ));
163        std::fs::create_dir_all(&tmp).unwrap();
164        assert!(capture(&tmp, 0).is_none());
165        let _ = std::fs::remove_dir_all(&tmp);
166    }
167}