cli/bridge/git_util.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Shared utilities and helpers for Git bridge operations.
3
4use std::collections::HashMap;
5
6use objects::object::{State, Status};
7
8use super::git_core::GitBridge;
9
10impl<'a> GitBridge<'a> {
11 /// Parse trailers from a commit message.
12 pub(crate) fn parse_trailers(message: &str) -> HashMap<String, String> {
13 let mut trailers = HashMap::new();
14
15 for line in message.lines().rev() {
16 if line.is_empty() {
17 break;
18 }
19
20 if let Some(pos) = line.find(':') {
21 let key = &line[..pos];
22 let value = line[pos + 1..].trim();
23
24 if key.starts_with("Heddle-") {
25 trailers.insert(key.to_string(), value.to_string());
26 }
27 } else if !line.trim().is_empty() {
28 break;
29 }
30 }
31
32 trailers
33 }
34
35 /// Extract intent (commit subject) from message.
36 pub(crate) fn extract_intent(message: &str) -> Option<String> {
37 let lines: Vec<&str> = message.lines().collect();
38
39 for line in &lines {
40 let trimmed = line.trim();
41 if trimmed.is_empty() {
42 continue;
43 }
44 if trimmed.starts_with("Heddle-") && trimmed.contains(':') {
45 break;
46 }
47 return Some(trimmed.to_string());
48 }
49
50 None
51 }
52
53 /// Convert Heddle state attribution to Git signature.
54 pub(crate) fn state_to_signature(state: &State) -> gix::actor::Signature {
55 gix::actor::Signature {
56 name: state.attribution.principal.name.as_str().into(),
57 email: state.attribution.principal.email.as_str().into(),
58 time: gix::date::Time {
59 seconds: state.created_at.timestamp(),
60 offset: 0,
61 },
62 }
63 }
64
65 /// Build a Git commit message from a Heddle state.
66 ///
67 /// Phase B (post-2026-05) onward: this is just the state's intent text,
68 /// verbatim. Heddle metadata (change_id, agent, confidence, status) is
69 /// carried out-of-band via `refs/notes/heddle` so that exported commit
70 /// SHAs match the SHAs of imported commits — a prerequisite for any
71 /// bidirectional sync where heddle and an upstream git host (e.g.
72 /// GitHub) need to agree on which commits already exist.
73 ///
74 /// The legacy `Heddle-Change-Id:` / `Heddle-Status:` / `Heddle-Agent:` /
75 /// `Heddle-Confidence:` trailers are no longer written. The parser
76 /// (`parse_trailers`) is retained so historical commits that still
77 /// carry trailers can be read; see `git_import::resolve_identity`.
78 pub(crate) fn build_commit_message(state: &State) -> String {
79 // Status is intentionally not surfaced here — published-vs-draft
80 // belongs in heddle's note, not the commit message body, since
81 // including it would change the commit SHA whenever a user toggles
82 // the status field.
83 let _ = Status::Draft;
84 state
85 .intent
86 .clone()
87 .unwrap_or_else(|| "No intent specified".to_string())
88 }
89
90 /// Build a commit message that includes the W2 footer (R6).
91 ///
92 /// Footer layout (always emitted, last block of the message):
93 ///
94 /// ```text
95 /// <body>
96 ///
97 /// Heddle-State: <hex change-id>
98 /// Heddle-URL: <hosted_url>/state/<id> (omitted when no hosted URL)
99 /// Heddle-Annotations-Omitted: <count>
100 /// ```
101 ///
102 /// The footer is the durable record — every reader on every host gets
103 /// it regardless of remote configuration. Richer per-scope metadata
104 /// rides on the opt-in git note (see [`super::git_notes`]).
105 pub(crate) fn build_commit_message_with_footer(
106 state: &State,
107 hosted_url: Option<&str>,
108 annotations_omitted: u32,
109 ) -> String {
110 let body = Self::build_commit_message(state);
111 let mut out = body;
112 if !out.ends_with('\n') {
113 out.push('\n');
114 }
115 out.push('\n');
116 out.push_str(&format!(
117 "Heddle-State: {}\n",
118 state.change_id.to_string_full()
119 ));
120 if let Some(url) = hosted_url
121 && !url.is_empty()
122 {
123 let trimmed = url.trim_end_matches('/');
124 out.push_str(&format!(
125 "Heddle-URL: {trimmed}/state/{}\n",
126 state.change_id.to_string_full()
127 ));
128 }
129 out.push_str(&format!(
130 "Heddle-Annotations-Omitted: {annotations_omitted}\n"
131 ));
132 out
133 }
134}
135
136/// Statistics for export operation.
137#[derive(Debug, Default)]
138pub struct ExportStats {
139 pub states_exported: usize,
140 pub threads_synced: usize,
141 pub markers_synced: usize,
142}
143
144/// Statistics for import operation.
145///
146/// `commits_imported` and `states_created` are equal in the current
147/// implementation (every commit walked produces one new state), but they are
148/// kept separate so future bridges can distinguish "commits seen during the
149/// walk" from "states actually written to the heddle store" — for example,
150/// when re-importing a previously exported repo, a commit may already map to
151/// an existing change_id and need no new state.
152#[derive(Debug, Default)]
153pub struct ImportStats {
154 /// Total commits walked and identified for import.
155 pub commits_imported: usize,
156 /// New state objects written to the heddle store during this import.
157 pub states_created: usize,
158 pub branches_synced: usize,
159 pub tags_synced: usize,
160 /// Refs (typically annotated tags) that point at a non-commit object —
161 /// most often a blob (e.g. `git/git`'s `refs/tags/junio-gpg-pub`
162 /// pointing at the maintainer's GPG public key blob) or a tree
163 /// (e.g. `git-lfs`'s `refs/tags/core-gpg-keys`).
164 ///
165 /// These are skipped during walk because heddle's marker model
166 /// currently requires the target to be a commit. The full-fidelity
167 /// fix is to extend the marker model with a non-commit-ref variant;
168 /// until then we record them here so callers can surface what was
169 /// skipped (and so a future export can restore them by reading the
170 /// preserved git mirror).
171 pub skipped_non_commit_refs: Vec<SkippedRef>,
172 /// Refs whose object reachability could not be fully copied into
173 /// the bridge mirror — see [`PartialMirrorRef`]. SHA-stable export
174 /// is degraded for these refs.
175 pub partial_mirror_refs: Vec<PartialMirrorRef>,
176}
177
178/// A ref that pointed at a non-commit object during import.
179#[derive(Debug, Clone)]
180pub struct SkippedRef {
181 pub name: String,
182 pub peeled_oid: String,
183 pub peeled_kind: String,
184}
185
186/// A ref whose object reachability could not be fully copied into the
187/// bridge mirror — typically because the source ODB is missing some
188/// object referenced from the ref's commit graph (a real-world failure
189/// mode in repos like `expressjs/express` and `git-lfs/git-lfs`, where
190/// pack data references objects that aren't actually present and that
191/// `git fsck` doesn't catch because they're not reachable from any
192/// other ref).
193///
194/// SHA-stable export will fall back to recreating commits from heddle
195/// state for the affected refs; their git_oids in the destination will
196/// be heddle-derived rather than verbatim copies.
197#[derive(Debug, Clone)]
198pub struct PartialMirrorRef {
199 pub name: String,
200 pub error: String,
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn test_parse_trailers() {
209 let message = r#"Add feature X
210
211This is the body.
212
213Heddle-Change-Id: hd-abc123
214Heddle-Agent: anthropic/claude
215Heddle-Confidence: 0.95
216"#;
217
218 let trailers = GitBridge::parse_trailers(message);
219 assert_eq!(
220 trailers.get("Heddle-Change-Id"),
221 Some(&"hd-abc123".to_string())
222 );
223 assert_eq!(
224 trailers.get("Heddle-Agent"),
225 Some(&"anthropic/claude".to_string())
226 );
227 assert_eq!(trailers.get("Heddle-Confidence"), Some(&"0.95".to_string()));
228 }
229
230 #[test]
231 fn test_extract_intent() {
232 let message = "Add feature X\n\nBody here\n\nHeddle-Change-Id: hd-abc123";
233 assert_eq!(
234 GitBridge::extract_intent(message),
235 Some("Add feature X".to_string())
236 );
237
238 let message2 = "Heddle-Change-Id: hd-abc123";
239 assert_eq!(GitBridge::extract_intent(message2), None);
240 }
241
242 // ── R6 — bridge footer ─────────────────────────────────────────────
243
244 use objects::object::{Attribution, ChangeId, ContentHash, Principal};
245
246 fn sample_state() -> State {
247 State::new_snapshot(
248 ContentHash::compute(b"tree"),
249 vec![],
250 Attribution::human(Principal::new("Alice", "alice@example.com")),
251 )
252 .with_intent("ship the auth rewrite")
253 }
254
255 #[test]
256 fn footer_emits_state_id_and_zero_omitted_when_no_url() {
257 let state = sample_state();
258 let msg = GitBridge::build_commit_message_with_footer(&state, None, 0);
259 assert!(msg.contains(&format!(
260 "Heddle-State: {}",
261 state.change_id.to_string_full()
262 )));
263 assert!(msg.contains("Heddle-Annotations-Omitted: 0"));
264 assert!(!msg.contains("Heddle-URL:"));
265 }
266
267 #[test]
268 fn footer_emits_url_when_hosted_configured() {
269 let state = sample_state();
270 let msg =
271 GitBridge::build_commit_message_with_footer(&state, Some("https://heddle.test/"), 3);
272 assert!(msg.contains(&format!(
273 "Heddle-URL: https://heddle.test/state/{}",
274 state.change_id.to_string_full()
275 )));
276 assert!(msg.contains("Heddle-Annotations-Omitted: 3"));
277 }
278
279 // The state_id from `change_id.to_string_full()` is referenced via
280 // `ChangeId` for the bound on `state.change_id` — keep the import.
281 #[test]
282 fn change_id_round_trips_through_footer() {
283 let state = sample_state();
284 let id_str = state.change_id.to_string_full();
285 let _: ChangeId = ChangeId::parse(&id_str).expect("round-trip parse");
286 }
287}