heddle_git_projection/git_util.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Shared utilities and helpers for Git Projection operations.
3
4use ingest::LossyImportEntry;
5use objects::object::{State, Status};
6use sley::ObjectId as GitObjectId;
7
8use super::git_core::GitProjection;
9
10impl<'a> GitProjection<'a> {
11 /// Build a Git commit message from a Heddle state.
12 ///
13 /// Phase B (post-2026-05) onward: this is just the state's intent text,
14 /// verbatim. Heddle metadata (change_id, agent, confidence, status) is
15 /// carried out-of-band via `refs/notes/heddle` so that exported commit
16 /// SHAs match the SHAs of imported commits — a prerequisite for any
17 /// bidirectional sync where heddle and an upstream git host (e.g.
18 /// GitHub) need to agree on which commits already exist.
19 pub fn build_commit_message(state: &State) -> String {
20 // Status is intentionally not surfaced here — published-vs-draft
21 // belongs in heddle's note, not the commit message body, since
22 // including it would change the commit SHA whenever a user toggles
23 // the status field.
24 let _ = Status::Draft;
25 state
26 .intent
27 .clone()
28 .unwrap_or_else(|| "No intent specified".to_string())
29 }
30
31 /// Build a commit message that includes the W2 footer (R6).
32 ///
33 /// Footer layout (always emitted, last block of the message):
34 ///
35 /// ```text
36 /// <body>
37 ///
38 /// Heddle-State: <hex change-id>
39 /// Heddle-URL: <hosted_url>/state/<id> (omitted when no hosted URL)
40 /// Heddle-Annotations-Omitted: <count>
41 /// ```
42 ///
43 /// The footer is the durable record — every reader on every host gets
44 /// it regardless of remote configuration. Richer per-scope metadata
45 /// rides on the opt-in git note (see [`super::git_notes`]).
46 pub fn build_commit_message_with_footer(
47 state: &State,
48 hosted_url: Option<&str>,
49 annotations_omitted: u32,
50 ) -> String {
51 let body = Self::build_commit_message(state);
52 Self::build_commit_message_with_footer_with_body(
53 state,
54 &body,
55 hosted_url,
56 annotations_omitted,
57 )
58 }
59
60 pub fn build_commit_message_with_footer_with_body(
61 state: &State,
62 body: &str,
63 hosted_url: Option<&str>,
64 annotations_omitted: u32,
65 ) -> String {
66 let mut out = body.to_string();
67 if !out.ends_with('\n') {
68 out.push('\n');
69 }
70 out.push('\n');
71 out.push_str(&format!("Heddle-State: {}\n", state.id().to_string_full()));
72 out.push_str(&format!(
73 "Heddle-Change: {}\n",
74 state.change_id.to_string_full()
75 ));
76 if let Some(url) = hosted_url
77 && !url.is_empty()
78 {
79 let trimmed = url.trim_end_matches('/');
80 out.push_str(&format!(
81 "Heddle-URL: {trimmed}/state/{}\n",
82 state.id().to_string_full()
83 ));
84 }
85 out.push_str(&format!(
86 "Heddle-Annotations-Omitted: {annotations_omitted}\n"
87 ));
88 out
89 }
90}
91
92/// Statistics for export operation.
93///
94/// `commits_total` counts the commits that actually land in the
95/// destination: it is derived from the same branch/tag ref set
96/// (`collect_ref_updates`) that `copy_mirror_to_path` copies, by walking
97/// the commit ancestry of those tips. Counting from the copy path — rather
98/// than a parallel walk over current Heddle refs — guarantees the reported
99/// total equals what's copied, including a stale mirror ref left behind by
100/// a dropped thread (export does not prune mirror refs, so that commit
101/// still travels and is still counted). `states_exported` is the
102/// freshly-minted *subset of that same copied ref set* — both counts are
103/// partitions of one walk, so `states_exported + already == commits_total`
104/// holds by construction and a state minted into the mirror but reachable
105/// from no copied ref (an orphan dropped-thread history) inflates neither.
106/// They diverge whenever the destination is already populated: an overlay
107/// re-export reports `commits_total = N` and `states_exported = 0` — the
108/// signal that surfaces "already in sync" instead of a misleading bare
109/// "exported 0 states" (heddle#289, mirroring the import-side
110/// `commits_imported`/`states_created` split from heddle#147).
111#[derive(Debug, Default)]
112pub struct ExportStats {
113 /// Freshly-minted git commits that land in the destination — the
114 /// subset of the copied ref set's commits minted during this export
115 /// (no preserved git_oid). Stays at 0 when every copied commit was
116 /// already mapped to an existing commit. A minted commit reachable
117 /// from no copied ref is excluded (it never reaches the destination).
118 pub states_exported: usize,
119 /// Unique commits reachable from the branch/tag tips copied to the
120 /// destination, including ones whose commit already existed and any
121 /// carried by a stale mirror ref. Mirrors
122 /// [`ImportStats::commits_imported`].
123 pub commits_total: usize,
124 pub threads_synced: usize,
125 pub markers_synced: usize,
126 /// Branches written to the destination, paired with their tip
127 /// commit so the summary can show tip short-SHAs.
128 pub branches: Vec<ExportedRef>,
129 /// Tags written to the destination, paired with their tip commit.
130 pub tags: Vec<ExportedRef>,
131}
132
133/// A ref written to the export destination, paired with the commit it
134/// points at (so the export summary can render tip short-SHAs).
135#[derive(Debug, Clone)]
136pub struct ExportedRef {
137 pub name: String,
138 pub tip: GitObjectId,
139}
140
141/// Statistics for import operation.
142///
143/// `commits_imported` counts every commit visited by the ancestry walk;
144/// `states_created` counts only the commits whose heddle state did not
145/// yet exist in the store. They diverge whenever a ref is re-imported
146/// (the second `import git --ref X` against the same source
147/// reports `commits_imported = N` and `states_created = 0`) — that
148/// distinction is what surfaces "already in sync" instead of leaving
149/// the operator staring at a misleading `commits_imported: 0`
150/// (heddle#147).
151#[derive(Debug, Default)]
152pub struct ImportStats {
153 /// Total commits walked from the source refs, including ones whose
154 /// heddle state was already present. Mirrors what explicit Git projection
155 /// ingest` reports so the two verbs read the same way.
156 pub commits_imported: usize,
157 /// New state objects written to the heddle store during this
158 /// import. Stays at 0 when every visited commit already had a
159 /// heddle state — that's the signal the bridge is in sync.
160 pub states_created: usize,
161 pub branches_synced: usize,
162 pub tags_synced: usize,
163 /// Refs (typically annotated tags) that point at a non-commit object —
164 /// most often a blob (e.g. `git/git`'s `refs/tags/junio-gpg-pub`
165 /// pointing at the maintainer's GPG public key blob) or a tree
166 /// (e.g. `git-lfs`'s `refs/tags/core-gpg-keys`).
167 ///
168 /// These are skipped during walk because heddle's marker model
169 /// currently requires the target to be a commit. The full-fidelity
170 /// fix is to extend the marker model with a non-commit-ref variant;
171 /// until then we record them here so callers can surface what was
172 /// skipped.
173 pub skipped_non_commit_refs: usize,
174 /// Git tree entries converted under an explicit lossy import opt-in.
175 pub lossy_entries: Vec<LossyImportEntry>,
176}
177
178#[cfg(test)]
179mod tests {
180 // ── R6 — bridge footer ─────────────────────────────────────────────
181 use objects::object::{Attribution, ContentHash, Principal, StateId};
182
183 use super::*;
184
185 fn sample_state() -> State {
186 State::new_snapshot(
187 ContentHash::compute(b"tree"),
188 vec![],
189 Attribution::human(Principal::new("Alice", "alice@example.com")),
190 )
191 .with_intent("ship the auth rewrite")
192 }
193
194 #[test]
195 fn footer_emits_state_id_and_zero_omitted_when_no_url() {
196 let state = sample_state();
197 let msg = GitProjection::build_commit_message_with_footer(&state, None, 0);
198 assert!(msg.contains(&format!("Heddle-State: {}", state.id().to_string_full())));
199 assert!(msg.contains(&format!(
200 "Heddle-Change: {}",
201 state.change_id.to_string_full()
202 )));
203 assert!(msg.contains("Heddle-Annotations-Omitted: 0"));
204 assert!(!msg.contains("Heddle-URL:"));
205 }
206
207 #[test]
208 fn footer_emits_url_when_hosted_configured() {
209 let state = sample_state();
210 let msg = GitProjection::build_commit_message_with_footer(
211 &state,
212 Some("https://heddle.test/"),
213 3,
214 );
215 assert!(msg.contains(&format!(
216 "Heddle-URL: https://heddle.test/state/{}",
217 state.id().to_string_full()
218 )));
219 assert!(msg.contains("Heddle-Annotations-Omitted: 3"));
220 }
221
222 #[test]
223 fn state_id_round_trips_through_footer() {
224 let state = sample_state();
225 let id_str = state.id().to_string_full();
226 let _: StateId = StateId::parse(&id_str).expect("round-trip parse");
227 }
228}