Skip to main content

heddle_git_projection/
git_notes.rs

1// SPDX-License-Identifier: Apache-2.0
2#![deny(clippy::cast_possible_truncation)]
3
4//! Git notes attached at `refs/notes/heddle` carry Heddle state metadata
5//! (change_id, agent, confidence, status) without polluting the commit
6//! message — and so without changing the commit SHA.
7//!
8//! This is the history-carrying half of the export identity model. The
9//! `git-projection-mapping.json` sidecar is a local served/export cache; notes are
10//! the portable source that survives plain Git clones and exports.
11//!
12//! Sley provides the tree-backed notes plumbing; this module owns Heddle's
13//! JSON payload and the fixed `refs/notes/heddle` location.
14
15use std::{
16    collections::HashMap,
17    time::{SystemTime, UNIX_EPOCH},
18};
19
20use objects::object::{State, StateId, Status};
21use serde::{Deserialize, Serialize};
22use sley::{ObjectId, Repository};
23
24use super::git_core::{GitProjectionError, GitProjectionResult, git_err};
25
26/// The notes ref heddle uses. Git-compatible notes readers can opt into
27/// this location, while Heddle reads and writes it natively.
28pub const NOTES_REF: &str = "refs/notes/heddle";
29
30/// JSON payload stored inside each note blob.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct HeddleNote {
33    pub state_id: String,
34    pub change_id: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub source_state: Option<State>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub agent: Option<NoteAgent>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub confidence: Option<f32>,
41    /// Either "draft" or "published".
42    pub status: String,
43    // --- W2/R6 tail fields below; new fields go here. All optional + skip-if-none. ---
44    /// Per-scope counts of annotations dropped at export because their
45    /// visibility exceeded the export's audience tier. Populated when the
46    /// caller exports with `--notes` and `--audience`.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub omitted_annotations_breakdown: Option<OmittedBreakdown>,
49    /// Per-module signal counts on the state at export time. Read-only
50    /// metadata for downstream tooling.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub signal_counts: Option<SignalCounts>,
53    /// Author + agent attribution rolled up into a richer shape than the
54    /// commit's own author signature can carry.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub attribution: Option<NoteAttribution>,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct NoteAgent {
61    pub provider: String,
62    pub model: String,
63}
64
65/// Per-scope omitted-annotation counts emitted alongside `refs/notes/heddle`.
66#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
67pub struct OmittedBreakdown {
68    #[serde(default)]
69    pub internal: u32,
70    #[serde(default)]
71    pub team: u32,
72    #[serde(default)]
73    pub restricted: u32,
74}
75
76/// Per-module risk-signal fire counts on this state.
77#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
78pub struct SignalCounts {
79    #[serde(default)]
80    pub novelty: u32,
81    #[serde(default)]
82    pub test_reachability: u32,
83    #[serde(default)]
84    pub pattern_deviation: u32,
85    #[serde(default)]
86    pub invariant_adjacency: u32,
87    #[serde(default)]
88    pub self_flagged_uncertainty: u32,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct NoteAttribution {
93    pub principal_name: String,
94    pub principal_email: String,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub agent: Option<NoteAgent>,
97}
98
99impl HeddleNote {
100    /// Construct a note from a heddle state (the form written on export).
101    pub fn from_state(state: &State) -> Self {
102        let status = match state.status {
103            Status::Draft => "draft".to_string(),
104            Status::Published => "published".to_string(),
105        };
106        let agent = state.attribution.agent.as_ref().map(|a| NoteAgent {
107            provider: a.provider.clone(),
108            model: a.model.clone(),
109        });
110        Self {
111            state_id: state.id().to_string_full(),
112            change_id: state.change_id.to_string_full(),
113            source_state: Some(state.clone()),
114            agent,
115            confidence: state.confidence,
116            status,
117            omitted_annotations_breakdown: None,
118            signal_counts: None,
119            attribution: None,
120        }
121    }
122
123    /// R6 builder: set the per-scope omitted-annotation breakdown.
124    pub fn with_omitted_breakdown(mut self, breakdown: OmittedBreakdown) -> Self {
125        self.omitted_annotations_breakdown = Some(breakdown);
126        self
127    }
128
129    /// R6 builder: set the per-module signal counts.
130    pub fn with_signal_counts(mut self, counts: SignalCounts) -> Self {
131        self.signal_counts = Some(counts);
132        self
133    }
134
135    /// R6 builder: set richer attribution (principal + agent).
136    pub fn with_attribution(mut self, attribution: NoteAttribution) -> Self {
137        self.attribution = Some(attribution);
138        self
139    }
140
141    pub fn to_json_bytes(&self) -> GitProjectionResult<Vec<u8>> {
142        serde_json::to_vec_pretty(self)
143            .map_err(|e| GitProjectionError::Git(format!("note serialize: {e}")))
144    }
145
146    pub fn from_json_bytes(bytes: &[u8]) -> GitProjectionResult<Self> {
147        serde_json::from_slice(bytes)
148            .map_err(|e| GitProjectionError::Git(format!("note parse: {e}")))
149    }
150}
151
152fn notes_ref() -> sley::notes::NotesRef {
153    sley::notes::NotesRef::expand(NOTES_REF)
154}
155
156/// Attach `note` to `commit_oid` in `repo` under `refs/notes/heddle`.
157///
158/// Each call creates one new notes commit on top of any previous notes
159/// history. The notes ref is updated atomically via sley's notes plumbing.
160pub fn write_note(
161    repo: &Repository,
162    commit_oid: ObjectId,
163    note: &HeddleNote,
164) -> GitProjectionResult<()> {
165    let json = note.to_json_bytes()?;
166    let notes_ref = notes_ref();
167    let refs = repo.references();
168    sley::notes::upsert_note_bytes_for(
169        repo.git_dir(),
170        repo.object_format(),
171        &refs,
172        &notes_ref,
173        &commit_oid,
174        &json,
175        "heddle: state metadata",
176        &git_projection_notes_identity(),
177        sley::notes::notes_ref_expected(&refs, &notes_ref).map_err(git_err)?,
178    )
179    .map_err(git_err)?;
180    Ok(())
181}
182
183/// Retract the notes attached to `commit_oids` from `refs/notes/heddle`.
184///
185/// The notes ref copies to the public mirror alongside branches and tags
186/// (`collect_ref_updates` picks up `refs/notes/*`), so a note left behind for a
187/// commit that has since been embargoed/retracted is a metadata leak: the
188/// mirror keeps publishing a note whose payload (and tree entry) references the
189/// withheld commit. This is the notes-ref sibling of the branch/tag retraction
190/// the exporter already performs (heddle#316).
191///
192/// Writes a single new notes commit dropping every present entry, then advances
193/// `refs/notes/heddle` to it. A genuine fast-forward (the new commit descends
194/// from the prior notes head), so it survives the bridge's FF guard on push.
195/// No-op — no new commit, no ref churn — when the notes ref is absent or none
196/// of `commit_oids` actually has an entry.
197pub fn remove_notes(
198    repo: &Repository,
199    commit_oids: &std::collections::HashSet<ObjectId>,
200) -> GitProjectionResult<()> {
201    if commit_oids.is_empty() {
202        return Ok(());
203    }
204    let notes_ref = notes_ref();
205    let refs = repo.references();
206    let annotated: Vec<ObjectId> = commit_oids.iter().copied().collect();
207    sley::notes::remove_notes_for(
208        repo.git_dir(),
209        repo.object_format(),
210        &refs,
211        &notes_ref,
212        &annotated,
213        "heddle: retract state metadata",
214        &git_projection_notes_identity(),
215        sley::notes::notes_ref_expected(&refs, &notes_ref).map_err(git_err)?,
216    )
217    .map_err(git_err)?;
218    Ok(())
219}
220
221/// Look up the note attached to `commit_oid`, if any.
222pub fn read_note(
223    repo: &Repository,
224    commit_oid: ObjectId,
225) -> GitProjectionResult<Option<HeddleNote>> {
226    let Some(bytes) = repo
227        .read_note_bytes(&notes_ref(), &commit_oid)
228        .map_err(git_err)?
229    else {
230        return Ok(None);
231    };
232    HeddleNote::from_json_bytes(&bytes).map(Some)
233}
234
235/// Read every portable Git↔Heddle identity recorded under `refs/notes/heddle`.
236pub fn read_identity_mappings(repo: &Repository) -> GitProjectionResult<Vec<(StateId, ObjectId)>> {
237    read_all_notes(repo)?
238        .into_iter()
239        .map(|(oid, note)| {
240            Ok((
241                StateId::parse(&note.state_id)
242                    .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?,
243                oid,
244            ))
245        })
246        .collect()
247}
248
249/// Read every (commit_oid → note) entry under `refs/notes/heddle`.
250pub fn read_all_notes(repo: &Repository) -> GitProjectionResult<HashMap<ObjectId, HeddleNote>> {
251    let mut out = HashMap::new();
252    for note_entry in repo.list_notes(&notes_ref()).map_err(git_err)? {
253        let object = repo.read_object(&note_entry.blob).map_err(git_err)?;
254        // Skip entries that aren't well-formed heddle notes — could be left
255        // over from `git notes --ref=heddle add` by an external tool.
256        if object.object_type != sley::GitObjectType::Blob {
257            continue;
258        }
259        if let Ok(note) = HeddleNote::from_json_bytes(&object.body) {
260            out.insert(note_entry.annotated, note);
261        }
262    }
263    Ok(out)
264}
265
266fn git_projection_notes_identity() -> sley::notes::NotesCommitIdentity {
267    let seconds = SystemTime::now()
268        .duration_since(UNIX_EPOCH)
269        .map(|d| d.as_secs() as i64)
270        .unwrap_or(0);
271    let ident = format!("Heddle <heddle@local> {seconds} +0000").into_bytes();
272    sley::notes::NotesCommitIdentity {
273        author: ident.clone(),
274        committer: ident,
275    }
276}