Skip to main content

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    /// Refs this export could NOT publish, paired with why -- the fail-soft
132    /// counterpart of `branches`/`tags` (heddle#261). Export continues past a
133    /// failed ref and records it here rather than aborting on the first one,
134    /// so a single branch that moved on the Git side cannot mask the outcome
135    /// of every ref behind it. Keyed by FULL ref name (`refs/heads/foo`), the
136    /// same spelling the reconcile loops use. Mirrors the import side's
137    /// [`ImportStats::skipped_non_commit_refs`] reporting: the operation
138    /// succeeds at what it could do and reports the remainder, and the caller
139    /// exits non-zero when this is non-empty.
140    pub failed_refs: Vec<(String, FailedRefExportReason)>,
141}
142
143/// Why a single ref could not be published to the Git projection.
144///
145/// Every arm is a REPORT, never an attempted repair: heddle names what it found
146/// on the Git side and leaves the ref exactly as it is, for the operator to
147/// reconcile (import first, or force). Auto-resolving a ref another writer just
148/// moved is how that writer's commits get lost.
149///
150/// Variant names are heddle-native but map 1:1 onto jj's `FailedRefExportReason`
151/// (`lib/src/git.rs`), so a future port translates without re-deriving the
152/// taxonomy: `ModifiedConcurrentlyInGit` <-> jj `DeletedInJjModifiedInGit`,
153/// `DeletedInGit` <-> jj `ModifiedInJjDeletedInGit`, and `FailedToSet` /
154/// `FailedToDelete` <-> the same-named jj arms.
155#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
156pub enum FailedRefExportReason {
157    /// The Git ref does not hold the value heddle based its update on: it moved
158    /// underneath the export (a `git push`, `git branch -f`, or another tool
159    /// writing the mirror). Covers both the tip that had ALREADY diverged when
160    /// export read it and the one that changed inside the read-write window.
161    #[error("modified concurrently in git: found {found}, heddle was publishing {intended}")]
162    ModifiedConcurrentlyInGit {
163        /// What Git holds -- the value that lost heddle the compare-and-swap.
164        found: GitObjectId,
165        /// The tip heddle was publishing when it lost the race.
166        intended: GitObjectId,
167    },
168
169    /// heddle read the ref, then found it gone by the time it wrote: deleted on
170    /// the Git side mid-export.
171    #[error("deleted in git while heddle was publishing {intended}")]
172    DeletedInGit {
173        /// The tip heddle was publishing when the ref disappeared.
174        intended: GitObjectId,
175    },
176
177    /// The ref write failed for a reason that is NOT a demonstrable race -- a
178    /// locked ref, an IO error, an invalid name. Kept distinct so a lost race
179    /// is never inferred from a failure we cannot attribute to another writer.
180    #[error("failed to set: {0}")]
181    FailedToSet(String),
182
183    /// The ref delete failed, likewise for a non-race reason.
184    #[error("failed to delete: {0}")]
185    FailedToDelete(String),
186}
187
188/// A ref written to the export destination, paired with the commit it
189/// points at (so the export summary can render tip short-SHAs).
190#[derive(Debug, Clone)]
191pub struct ExportedRef {
192    pub name: String,
193    pub tip: GitObjectId,
194}
195
196/// Statistics for import operation.
197///
198/// `commits_imported` counts every commit visited by the ancestry walk;
199/// `states_created` counts only the commits whose heddle state did not
200/// yet exist in the store. They diverge whenever a ref is re-imported
201/// (the second `import git --ref X` against the same source
202/// reports `commits_imported = N` and `states_created = 0`) — that
203/// distinction is what surfaces "already in sync" instead of leaving
204/// the operator staring at a misleading `commits_imported: 0`
205/// (heddle#147).
206#[derive(Debug, Default)]
207pub struct ImportStats {
208    /// Total commits walked from the source refs, including ones whose
209    /// heddle state was already present. Mirrors what explicit Git projection
210    /// ingest` reports so the two verbs read the same way.
211    pub commits_imported: usize,
212    /// New state objects written to the heddle store during this
213    /// import. Stays at 0 when every visited commit already had a
214    /// heddle state — that's the signal the bridge is in sync.
215    pub states_created: usize,
216    pub branches_synced: usize,
217    pub tags_synced: usize,
218    /// Refs (typically annotated tags) that point at a non-commit object —
219    /// most often a blob (e.g. `git/git`'s `refs/tags/junio-gpg-pub`
220    /// pointing at the maintainer's GPG public key blob) or a tree
221    /// (e.g. `git-lfs`'s `refs/tags/core-gpg-keys`).
222    ///
223    /// These are skipped during walk because heddle's marker model
224    /// currently requires the target to be a commit. The full-fidelity
225    /// fix is to extend the marker model with a non-commit-ref variant;
226    /// until then we record them here so callers can surface what was
227    /// skipped.
228    pub skipped_non_commit_refs: usize,
229    /// Git tree entries converted under an explicit lossy import opt-in.
230    pub lossy_entries: Vec<LossyImportEntry>,
231}
232
233#[cfg(test)]
234mod tests {
235    // ── R6 — bridge footer ─────────────────────────────────────────────
236    use objects::object::{Attribution, ContentHash, Principal, StateId};
237
238    use super::*;
239
240    fn sample_state() -> State {
241        State::new_snapshot(
242            ContentHash::compute(b"tree"),
243            vec![],
244            Attribution::human(Principal::new("Alice", "alice@example.com")),
245        )
246        .with_intent("ship the auth rewrite")
247    }
248
249    #[test]
250    fn footer_emits_state_id_and_zero_omitted_when_no_url() {
251        let state = sample_state();
252        let msg = GitProjection::build_commit_message_with_footer(&state, None, 0);
253        assert!(msg.contains(&format!("Heddle-State: {}", state.id().to_string_full())));
254        assert!(msg.contains(&format!(
255            "Heddle-Change: {}",
256            state.change_id.to_string_full()
257        )));
258        assert!(msg.contains("Heddle-Annotations-Omitted: 0"));
259        assert!(!msg.contains("Heddle-URL:"));
260    }
261
262    #[test]
263    fn footer_emits_url_when_hosted_configured() {
264        let state = sample_state();
265        let msg = GitProjection::build_commit_message_with_footer(
266            &state,
267            Some("https://heddle.test/"),
268            3,
269        );
270        assert!(msg.contains(&format!(
271            "Heddle-URL: https://heddle.test/state/{}",
272            state.id().to_string_full()
273        )));
274        assert!(msg.contains("Heddle-Annotations-Omitted: 3"));
275    }
276
277    #[test]
278    fn state_id_round_trips_through_footer() {
279        let state = sample_state();
280        let id_str = state.id().to_string_full();
281        let _: StateId = StateId::parse(&id_str).expect("round-trip parse");
282    }
283}