Skip to main content

cli/bridge/
git_notes.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Git notes attached at `refs/notes/heddle` carry Heddle state metadata
3//! (change_id, agent, confidence, status) without polluting the commit
4//! message — and so without changing the commit SHA.
5//!
6//! This is the "fallback channel" half of the Phase B identity model. The
7//! primary channel is the `bridge-mapping.json` sidecar; notes are consulted
8//! when the sidecar is missing or empty (e.g., a developer ran `git clone
9//! <url>` of a heddle-exported repo without copying the heddle dir).
10//!
11//! gix v0.80 has no high-level notes API; we hand-roll the standard tree
12//! layout (entry name = full 40-hex commit SHA, entry blob = serialized JSON)
13//! using the same primitives the rest of the bridge already relies on
14//! (`write_blob`, `edit_tree`, `new_commit_as`, `set_reference`).
15
16use std::{
17    collections::HashMap,
18    time::{SystemTime, UNIX_EPOCH},
19};
20
21use gix::{hash::ObjectId, refs::transaction::PreviousValue};
22use objects::object::{State, Status};
23use serde::{Deserialize, Serialize};
24
25use super::git_core::{GitBridgeError, GitResult, git_err, set_reference};
26
27/// The notes ref heddle uses. Standard `git notes --ref=heddle` reads
28/// from this location, and `git log --notes=heddle` displays them inline.
29pub const NOTES_REF: &str = "refs/notes/heddle";
30
31/// JSON payload stored inside each note blob.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct HeddleNote {
34    /// The heddle change_id this commit corresponds to.
35    pub change_id: String,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub agent: Option<NoteAgent>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub confidence: Option<f32>,
40    /// Either "draft" or "published".
41    pub status: String,
42    // --- W2/R6 tail fields below; new fields go here. All optional + skip-if-none. ---
43    /// Per-scope counts of annotations dropped at export because their
44    /// visibility exceeded the export's audience tier. Populated when the
45    /// caller exports with `--notes` and `--audience`.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub omitted_annotations_breakdown: Option<OmittedBreakdown>,
48    /// Per-module signal counts on the state at export time. Read-only
49    /// metadata for downstream tooling.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub signal_counts: Option<SignalCounts>,
52    /// Author + agent attribution rolled up into a richer shape than the
53    /// commit's own author signature can carry.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub attribution: Option<NoteAttribution>,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct NoteAgent {
60    pub provider: String,
61    pub model: String,
62}
63
64/// Per-scope omitted-annotation counts emitted alongside `refs/notes/heddle`.
65#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
66pub struct OmittedBreakdown {
67    #[serde(default)]
68    pub internal: u32,
69    #[serde(default)]
70    pub team: u32,
71    #[serde(default)]
72    pub restricted: u32,
73}
74
75/// Per-module risk-signal fire counts on this state.
76#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
77pub struct SignalCounts {
78    #[serde(default)]
79    pub novelty: u32,
80    #[serde(default)]
81    pub test_reachability: u32,
82    #[serde(default)]
83    pub pattern_deviation: u32,
84    #[serde(default)]
85    pub invariant_adjacency: u32,
86    #[serde(default)]
87    pub self_flagged_uncertainty: u32,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct NoteAttribution {
92    pub principal_name: String,
93    pub principal_email: String,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub agent: Option<NoteAgent>,
96}
97
98impl HeddleNote {
99    /// Construct a note from a heddle state (the form written on export).
100    pub fn from_state(state: &State) -> Self {
101        let status = match state.status {
102            Status::Draft => "draft".to_string(),
103            Status::Published => "published".to_string(),
104        };
105        let agent = state.attribution.agent.as_ref().map(|a| NoteAgent {
106            provider: a.provider.clone(),
107            model: a.model.clone(),
108        });
109        Self {
110            change_id: state.change_id.to_string_full(),
111            agent,
112            confidence: state.confidence,
113            status,
114            omitted_annotations_breakdown: None,
115            signal_counts: None,
116            attribution: None,
117        }
118    }
119
120    /// R6 builder: set the per-scope omitted-annotation breakdown.
121    pub fn with_omitted_breakdown(mut self, breakdown: OmittedBreakdown) -> Self {
122        self.omitted_annotations_breakdown = Some(breakdown);
123        self
124    }
125
126    /// R6 builder: set the per-module signal counts.
127    pub fn with_signal_counts(mut self, counts: SignalCounts) -> Self {
128        self.signal_counts = Some(counts);
129        self
130    }
131
132    /// R6 builder: set richer attribution (principal + agent).
133    pub fn with_attribution(mut self, attribution: NoteAttribution) -> Self {
134        self.attribution = Some(attribution);
135        self
136    }
137
138    pub fn to_json_bytes(&self) -> GitResult<Vec<u8>> {
139        serde_json::to_vec_pretty(self)
140            .map_err(|e| GitBridgeError::Git(format!("note serialize: {e}")))
141    }
142
143    pub fn from_json_bytes(bytes: &[u8]) -> GitResult<Self> {
144        serde_json::from_slice(bytes).map_err(|e| GitBridgeError::Git(format!("note parse: {e}")))
145    }
146}
147
148/// Resolve the current notes-commit OID and the tree it points at. Returns
149/// `(None, empty_tree)` when `refs/notes/heddle` does not yet exist.
150fn read_notes_head(repo: &gix::Repository) -> GitResult<(Option<ObjectId>, ObjectId)> {
151    let parent = match repo.find_reference(NOTES_REF) {
152        Ok(reference) => {
153            let mut reference = reference;
154            Some(reference.peel_to_id().map_err(git_err)?.detach())
155        }
156        Err(_) => None,
157    };
158    let tree_oid = if let Some(commit_oid) = parent {
159        let commit = repo.find_commit(commit_oid).map_err(git_err)?;
160        commit.tree_id().map_err(git_err)?.detach()
161    } else {
162        gix::hash::ObjectId::empty_tree(repo.object_hash())
163    };
164    Ok((parent, tree_oid))
165}
166
167/// Attach `note` to `commit_oid` in `repo` under `refs/notes/heddle`.
168///
169/// Each call creates one new notes commit on top of any previous notes
170/// history. The notes ref is updated atomically via the bridge's standard
171/// `set_reference` helper.
172pub fn write_note(
173    repo: &gix::Repository,
174    commit_oid: ObjectId,
175    note: &HeddleNote,
176) -> GitResult<()> {
177    let json = note.to_json_bytes()?;
178    let blob_oid = repo.write_blob(&json).map_err(git_err)?.detach();
179
180    let (parent_commit, current_tree_oid) = read_notes_head(repo)?;
181    let mut editor = repo.edit_tree(current_tree_oid).map_err(git_err)?;
182    let entry_name = commit_oid.to_hex_with_len(40).to_string();
183    editor
184        .upsert(
185            entry_name.as_str(),
186            gix::object::tree::EntryKind::Blob,
187            blob_oid,
188        )
189        .map_err(git_err)?;
190    let new_tree_oid = editor.write().map_err(git_err)?.detach();
191
192    let signature = bridge_notes_signature();
193    let mut author_buf = gix::date::parse::TimeBuf::default();
194    let mut committer_buf = gix::date::parse::TimeBuf::default();
195    let parents: Vec<ObjectId> = parent_commit.iter().copied().collect();
196    let new_commit = repo
197        .new_commit_as(
198            signature.to_ref(&mut committer_buf),
199            signature.to_ref(&mut author_buf),
200            "heddle: state metadata",
201            new_tree_oid,
202            parents,
203        )
204        .map_err(git_err)?;
205
206    let constraint = match parent_commit {
207        Some(prev) => PreviousValue::ExistingMustMatch(gix::refs::Target::Object(prev)),
208        None => PreviousValue::MustNotExist,
209    };
210    set_reference(
211        repo,
212        NOTES_REF,
213        new_commit.id,
214        constraint,
215        "heddle: write state note",
216    )?;
217    Ok(())
218}
219
220/// Look up the note attached to `commit_oid`, if any.
221pub fn read_note(repo: &gix::Repository, commit_oid: ObjectId) -> GitResult<Option<HeddleNote>> {
222    let Ok(reference) = repo.find_reference(NOTES_REF) else {
223        return Ok(None);
224    };
225    let mut reference = reference;
226    let notes_commit_oid = reference.peel_to_id().map_err(git_err)?.detach();
227    let notes_commit = repo.find_commit(notes_commit_oid).map_err(git_err)?;
228    let notes_tree_oid = notes_commit.tree_id().map_err(git_err)?.detach();
229    let notes_tree = repo.find_tree(notes_tree_oid).map_err(git_err)?;
230
231    let target_name = commit_oid.to_hex_with_len(40).to_string();
232    for entry in notes_tree.iter() {
233        let entry = entry.map_err(git_err)?;
234        if *entry.filename() == *target_name.as_bytes() {
235            let object = repo.find_object(entry.object_id()).map_err(git_err)?;
236            return HeddleNote::from_json_bytes(&object.data).map(Some);
237        }
238    }
239    Ok(None)
240}
241
242/// Read every (commit_oid → note) entry under `refs/notes/heddle`. Used by
243/// the import path's identity-recovery scan.
244pub fn read_all_notes(repo: &gix::Repository) -> GitResult<HashMap<ObjectId, HeddleNote>> {
245    let mut out = HashMap::new();
246    let Ok(reference) = repo.find_reference(NOTES_REF) else {
247        return Ok(out);
248    };
249    let mut reference = reference;
250    let notes_commit_oid = reference.peel_to_id().map_err(git_err)?.detach();
251    let notes_commit = repo.find_commit(notes_commit_oid).map_err(git_err)?;
252    let notes_tree_oid = notes_commit.tree_id().map_err(git_err)?.detach();
253    let notes_tree = repo.find_tree(notes_tree_oid).map_err(git_err)?;
254
255    for entry in notes_tree.iter() {
256        let entry = entry.map_err(git_err)?;
257        let name = entry.filename().to_string();
258        let Ok(target_oid) = name.parse::<ObjectId>() else {
259            continue;
260        };
261        let object = repo.find_object(entry.object_id()).map_err(git_err)?;
262        // Skip entries that aren't well-formed heddle notes — could be left
263        // over from `git notes --ref=heddle add` by an external tool.
264        if let Ok(note) = HeddleNote::from_json_bytes(&object.data) {
265            out.insert(target_oid, note);
266        }
267    }
268    Ok(out)
269}
270
271fn bridge_notes_signature() -> gix::actor::Signature {
272    let seconds = SystemTime::now()
273        .duration_since(UNIX_EPOCH)
274        .map(|d| d.as_secs() as i64)
275        .unwrap_or(0);
276    gix::actor::Signature {
277        name: "Heddle".into(),
278        email: "heddle@local".into(),
279        time: gix::date::Time { seconds, offset: 0 },
280    }
281}