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