Skip to main content

cli/bridge/
git_export.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Export Heddle states to Git commits functionality.
3
4use objects::{
5    error::HeddleError,
6    object::{ChangeId, ContentHash, FileMode},
7};
8use repo::Repository as HeddleRepository;
9
10use crate::bridge::{
11    git_core::{GitBridge, GitBridgeError, GitResult, SyncMapping, git_err},
12    git_notes,
13    git_sync::{sync_marker_to_tag, sync_track_to_branch},
14    git_util::ExportStats,
15};
16
17const SUBMODULE_PREFIX: &str = "heddle-submodule:";
18
19/// Export a single state to Git.
20pub fn export_state(
21    mapping: &mut SyncMapping,
22    heddle_repo: &HeddleRepository,
23    repo: &gix::Repository,
24    state_id: &ChangeId,
25) -> GitResult<gix::hash::ObjectId> {
26    let state = heddle_repo
27        .store()
28        .get_state(state_id)?
29        .ok_or(GitBridgeError::StateNotFound(*state_id))?;
30
31    let git_tree_oid = export_tree(heddle_repo, repo, &state.tree)?;
32    // R6: emit the W2 footer on every exported commit. The footer is
33    // durable across remotes; per-scope breakdowns ride on the opt-in
34    // git note. For first-pass we audit nothing about the state's
35    // annotation set (the audience defaults to "public"); a follow-up
36    // landed with `bridge git export --audience` threads the count
37    // through here. See `git_util::build_commit_message_with_footer`.
38    let hosted_url = heddle_repo
39        .config()
40        .hosted
41        .upstream_url
42        .as_deref()
43        .filter(|s| !s.is_empty());
44    let message =
45        GitBridge::build_commit_message_with_footer(&state, hosted_url, /*omitted=*/ 0);
46    let parent_oids: Vec<gix::hash::ObjectId> = state
47        .parents
48        .iter()
49        .map(|parent_id| {
50            mapping
51                .get_git(parent_id)
52                .ok_or(GitBridgeError::StateNotFound(*parent_id))
53        })
54        .collect::<GitResult<Vec<_>>>()?;
55
56    let sig = GitBridge::state_to_signature(&state);
57    let mut committer_buf = gix::date::parse::TimeBuf::default();
58    let mut author_buf = gix::date::parse::TimeBuf::default();
59    let commit = repo
60        .new_commit_as(
61            sig.to_ref(&mut committer_buf),
62            sig.to_ref(&mut author_buf),
63            &message,
64            git_tree_oid,
65            parent_oids,
66        )
67        .map_err(git_err)?;
68    Ok(commit.id)
69}
70
71/// Export a Heddle tree to Git.
72pub fn export_tree(
73    heddle_repo: &HeddleRepository,
74    repo: &gix::Repository,
75    tree_hash: &ContentHash,
76) -> GitResult<gix::hash::ObjectId> {
77    let tree = heddle_repo
78        .store()
79        .get_tree(tree_hash)?
80        .ok_or_else(|| HeddleError::NotFound(format!("tree {}", tree_hash)))?;
81
82    let empty_tree = gix::hash::ObjectId::empty_tree(repo.object_hash());
83    let mut editor = repo.edit_tree(empty_tree).map_err(git_err)?;
84
85    for entry in tree.entries() {
86        let (kind, id) = if entry.is_tree() {
87            (
88                gix::object::tree::EntryKind::Tree,
89                export_tree(heddle_repo, repo, &entry.hash)?,
90            )
91        } else {
92            // Redaction safety: if the blob carries an active redaction
93            // record, export the stub instead of the bytes. This is the
94            // single chokepoint between Heddle-side redactions and any
95            // downstream Git remote (GitHub, internal mirrors, ...).
96            // Bytes that escape via the bridge are bytes that escape,
97            // full stop — we cannot retroactively scrub them from
98            // outside repos. The check sits *here*, not in
99            // `materialize_blob`, because export reads `blob.content()`
100            // directly (we never touch the materialize path) and writes
101            // the raw bytes through `repo.write_blob`.
102            let stub = heddle_repo
103                .redaction_stub_for_blob(&entry.hash)
104                .map_err(|err| HeddleError::Config(format!("redaction lookup failed: {err}")))?;
105
106            if let Some(stub_text) = stub {
107                // Stubs are text-only; ASCII safe across newline/BOM
108                // quirks and submodule-pointer detection.
109                let kind = match entry.mode {
110                    FileMode::Symlink => gix::object::tree::EntryKind::Link,
111                    FileMode::Executable => gix::object::tree::EntryKind::BlobExecutable,
112                    _ => gix::object::tree::EntryKind::Blob,
113                };
114                let oid = repo
115                    .write_blob(stub_text.as_bytes())
116                    .map_err(git_err)?
117                    .detach();
118                (kind, oid)
119            } else {
120                let blob = heddle_repo
121                    .store()
122                    .get_blob(&entry.hash)?
123                    .ok_or_else(|| HeddleError::NotFound(format!("blob {}", entry.hash)))?;
124
125                if entry.mode == FileMode::Normal
126                    && let Some(oid) = submodule_oid_from_blob(blob.content())
127                {
128                    (gix::object::tree::EntryKind::Commit, oid)
129                } else {
130                    let kind = match entry.mode {
131                        FileMode::Normal => gix::object::tree::EntryKind::Blob,
132                        FileMode::Executable => gix::object::tree::EntryKind::BlobExecutable,
133                        FileMode::Symlink => gix::object::tree::EntryKind::Link,
134                    };
135                    let oid = repo.write_blob(blob.content()).map_err(git_err)?.detach();
136                    (kind, oid)
137                }
138            }
139        };
140
141        editor.upsert(&entry.name, kind, id).map_err(git_err)?;
142    }
143
144    Ok(editor.write().map_err(git_err)?.detach())
145}
146
147/// Export all Heddle states to Git commits.
148pub fn export_all(bridge: &mut GitBridge) -> GitResult<ExportStats> {
149    bridge.init_mirror()?;
150
151    let states = bridge.heddle_repo.store().list_states()?;
152    let mut stats = ExportStats::default();
153
154    bridge.build_existing_mapping(None)?;
155
156    let sorted_states = bridge.sort_states_topologically(&states)?;
157    let repo = bridge.open_git_repo()?;
158    bridge.mapping.retain_git_objects(&repo);
159
160    for state_id in sorted_states {
161        // Skip states already mapped to a git object that exists in the
162        // mirror — that's the common case for git-imported states whose
163        // original commit bytes are already present (and whose SHAs we
164        // want to preserve verbatim, which means NOT recreating them).
165        if bridge.mapping.has_heddle(&state_id) {
166            continue;
167        }
168        let git_oid = export_state(&mut bridge.mapping, bridge.heddle_repo, &repo, &state_id)?;
169        bridge.mapping.insert(state_id, git_oid);
170        stats.states_exported += 1;
171
172        // Attach a heddle note to the freshly-created commit so the
173        // change_id survives a fresh `git clone` of the destination
174        // (when only the git side travels, without our sidecar).
175        if let Some(state) = bridge.heddle_repo.store().get_state(&state_id)? {
176            let note = git_notes::HeddleNote::from_state(&state);
177            git_notes::write_note(&repo, git_oid, &note)?;
178        }
179    }
180
181    // For states whose git_oid was already in the mapping (the SHA-stable
182    // path above), make sure the note is present too. This covers two
183    // cases: (a) the state was imported from a non-heddle git source and
184    // never had a note, and (b) the note was deleted from the mirror.
185    let note_targets: Vec<(ChangeId, gix::hash::ObjectId)> =
186        bridge.mapping.iter().map(|(c, o)| (*c, *o)).collect();
187    for (change_id, git_oid) in note_targets {
188        if git_notes::read_note(&repo, git_oid)?.is_none()
189            && let Some(state) = bridge.heddle_repo.store().get_state(&change_id)?
190        {
191            let note = git_notes::HeddleNote::from_state(&state);
192            git_notes::write_note(&repo, git_oid, &note)?;
193        }
194    }
195
196    let threads = bridge.heddle_repo.refs().list_threads()?;
197    for track_name in threads {
198        if let Some(state_id) = bridge.heddle_repo.refs().get_thread(&track_name)?
199            && let Some(git_oid) = bridge.mapping.get_git(&state_id)
200        {
201            sync_track_to_branch(&repo, &track_name, git_oid)?;
202            stats.threads_synced += 1;
203        }
204    }
205
206    let markers = bridge.heddle_repo.refs().list_markers()?;
207    for marker_name in markers {
208        if let Some(state_id) = bridge.heddle_repo.refs().get_marker(&marker_name)?
209            && let Some(git_oid) = bridge.mapping.get_git(&state_id)
210        {
211            sync_marker_to_tag(&repo, &marker_name, git_oid)?;
212            stats.markers_synced += 1;
213        }
214    }
215
216    bridge.save_mapping_to_disk()?;
217
218    Ok(stats)
219}
220
221fn submodule_oid_from_blob(content: &[u8]) -> Option<gix::hash::ObjectId> {
222    let text = std::str::from_utf8(content).ok()?;
223    let text = text.trim();
224    let trimmed = text.strip_prefix(SUBMODULE_PREFIX)?.trim();
225
226    trimmed.parse().ok()
227}