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
//! 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)
}