Skip to main content

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}