Skip to main content

cli/git_projection_engine/
git_mapping.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Persistence and discovery for Git Projection Mapping.
3
4use std::{
5    collections::HashSet,
6    fs::{self, File},
7    io::Write,
8    path::{Path, PathBuf},
9};
10
11use objects::{object::ChangeId, store::ObjectStore};
12use serde::{Deserialize, Serialize};
13use sley::{ObjectFormat, ObjectId as SleyObjectId, ReferenceTarget, Repository as SleyRepository};
14
15use super::git_core::{
16    GitProjection, GitProjectionError, GitProjectionResult, SyncMapping, git_err,
17};
18
19#[derive(Debug, Serialize, Deserialize)]
20struct MappingEntry {
21    change_id: String,
22    git_oid: String,
23}
24
25#[derive(Debug, Serialize, Deserialize, Default)]
26struct MappingFile {
27    entries: Vec<MappingEntry>,
28}
29
30#[derive(Debug, Default)]
31struct GitIdentityIndex {
32    mapping: SyncMapping,
33}
34
35impl GitIdentityIndex {
36    fn from_notes(repo: &SleyRepository) -> GitProjectionResult<Self> {
37        let mut index = Self::default();
38        for (change_id, git_oid) in super::git_notes::read_identity_mappings(repo)? {
39            index.mapping.insert_checked(change_id, git_oid)?;
40        }
41        Ok(index)
42    }
43
44    fn fill_gaps_from_cache(&mut self, cache: &SyncMapping) {
45        for (change_id, git_oid) in cache.iter() {
46            if self.mapping.get_git(change_id) == Some(*git_oid) {
47                continue;
48            }
49            if self.mapping.has_heddle(change_id) || self.mapping.has_git(*git_oid) {
50                continue;
51            }
52            self.mapping.insert(*change_id, *git_oid);
53        }
54    }
55
56    fn into_mapping(self) -> SyncMapping {
57        self.mapping
58    }
59}
60
61impl<'a> GitProjection<'a> {
62    pub(crate) fn mapping_path(&self) -> PathBuf {
63        self.heddle_repo
64            .heddle_dir()
65            .join("git-projection")
66            .join("git-projection-mapping.json")
67    }
68
69    pub(crate) fn mapping_tmp_path(&self) -> PathBuf {
70        self.mapping_path().with_extension("json.tmp")
71    }
72
73    fn read_mapping_cache_from_disk(&self) -> GitProjectionResult<SyncMapping> {
74        self.recover_mapping_tmp()?;
75        let path = self.mapping_path();
76        if !path.exists() {
77            return Ok(SyncMapping::new());
78        }
79
80        let data = fs::read_to_string(&path)?;
81        let file: MappingFile = serde_json::from_str(&data)
82            .map_err(|err| GitProjectionError::InvalidMapping(err.to_string()))?;
83
84        let mut mapping = SyncMapping::new();
85        for entry in file.entries {
86            let change_id = ChangeId::parse(&entry.change_id)?;
87            let git_oid = parse_stored_git_oid(&entry.git_oid)?;
88            mapping.insert_checked(change_id, git_oid)?;
89        }
90
91        Ok(mapping)
92    }
93
94    fn recover_mapping_tmp(&self) -> GitProjectionResult<()> {
95        let path = self.mapping_path();
96        let tmp_path = self.mapping_tmp_path();
97        if !tmp_path.exists() {
98            return Ok(());
99        }
100        if !path.exists() {
101            fs::rename(&tmp_path, &path)?;
102        } else {
103            fs::remove_file(&tmp_path)?;
104        }
105        Ok(())
106    }
107
108    fn mapping_bytes(mapping: &SyncMapping) -> GitProjectionResult<Vec<u8>> {
109        let entries = mapping
110            .iter()
111            .map(|(change_id, git_oid)| MappingEntry {
112                change_id: change_id.to_string_full(),
113                git_oid: git_oid.to_string(),
114            })
115            .collect();
116
117        let file = MappingFile { entries };
118        serde_json::to_vec_pretty(&file)
119            .map_err(|err| GitProjectionError::InvalidMapping(err.to_string()))
120    }
121
122    pub(crate) fn write_mapping_tmp_to_disk(&self) -> GitProjectionResult<PathBuf> {
123        self.write_mapping_tmp_value_to_disk(&self.mapping)
124    }
125
126    fn write_mapping_tmp_value_to_disk(
127        &self,
128        mapping: &SyncMapping,
129    ) -> GitProjectionResult<PathBuf> {
130        let path = self.mapping_path();
131        let tmp_path = self.mapping_tmp_path();
132        if let Some(parent) = path.parent() {
133            fs::create_dir_all(parent)?;
134            let parent_file = File::open(parent)?;
135            parent_file.sync_all()?;
136        }
137
138        let data = Self::mapping_bytes(mapping)?;
139        let mut file = File::create(&tmp_path)?;
140        file.write_all(&data)?;
141        file.sync_all()?;
142        Ok(tmp_path)
143    }
144
145    pub(crate) fn commit_mapping_tmp_to_disk(&self) -> GitProjectionResult<()> {
146        let path = self.mapping_path();
147        let tmp_path = self.mapping_tmp_path();
148        if !tmp_path.exists() {
149            return Err(GitProjectionError::InvalidMapping(format!(
150                "mapping temp file is missing: {}",
151                tmp_path.display()
152            )));
153        }
154        fs::rename(&tmp_path, &path)?;
155        if let Some(parent) = path.parent() {
156            let parent_file = File::open(parent)?;
157            parent_file.sync_all()?;
158        }
159        Ok(())
160    }
161
162    pub(crate) fn save_mapping_to_disk(&self) -> GitProjectionResult<()> {
163        self.write_mapping_tmp_to_disk()?;
164        // Fault-injection checkpoint: a crash here leaves the
165        // sidecar in tmp form (`git-projection-mapping.json.tmp`) without a
166        // committed `git-projection-mapping.json`. The next mapping-cache read
167        // atomically renames the tmp into place. Tested by
168        // `bridge_recovers_from_crash_after_tmp_before_commit`.
169        objects::fault_inject::maybe_panic_at("mapping_after_tmp_before_commit");
170        self.commit_mapping_tmp_to_disk()
171    }
172
173    /// Build the export identity mapping from portable metadata and the served
174    /// bridge cache. `refs/notes/heddle` is authoritative because it travels
175    /// with Git history; `git-projection-mapping.json` is the local served/export cache
176    /// after visibility filtering. Ingest identity lives separately at
177    /// `.heddle/ingest/sha_map.sqlite` and is intentionally not folded in here.
178    pub(crate) fn build_existing_mapping(
179        &mut self,
180        git_repo_path: Option<&Path>,
181    ) -> GitProjectionResult<()> {
182        let repo = match git_repo_path {
183            Some(path) => super::git_core::open_repo(path)?,
184            None => self.open_git_repo()?,
185        };
186
187        let cache = self.read_mapping_cache_from_disk()?;
188        let live_cache = self.mapping.clone();
189        let mut index = GitIdentityIndex::from_notes(&repo)?;
190        index.fill_gaps_from_cache(&live_cache);
191        index.fill_gaps_from_cache(&cache);
192        self.mapping = index.into_mapping();
193        Ok(())
194    }
195
196    pub(crate) fn seed_ingest_identity_mappings_from_mirror(
197        &mut self,
198        repo: &SleyRepository,
199    ) -> GitProjectionResult<()> {
200        let ingest = self.heddle_repo.git_overlay_ingest_commit_mapping()?;
201        for (git_sha, change_id) in ingest {
202            let change_id = ChangeId::parse(&change_id)?;
203            if self.heddle_repo.store().get_state(&change_id)?.is_none() {
204                continue;
205            }
206            if self.mapping.has_heddle(&change_id) {
207                continue;
208            }
209            let git_oid = parse_stored_git_oid(&git_sha)?;
210            if self.mapping.has_git(git_oid) || repo.read_object(&git_oid).is_err() {
211                continue;
212            }
213            self.mapping.insert(change_id, git_oid);
214        }
215        Ok(())
216    }
217
218    #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
219    pub(crate) fn prune_unreachable_mapping_entries(&mut self) -> GitProjectionResult<usize> {
220        let repo = self.open_git_repo()?;
221        self.mapping = self.read_mapping_cache_from_disk()?;
222        let reachable: HashSet<_> = collect_commit_oids(&repo)?.into_iter().collect();
223        let removed = self.mapping.retain_git_object_set(&reachable);
224        if removed > 0 {
225            self.save_mapping_to_disk()?;
226        }
227        Ok(removed)
228    }
229
230    /// Consolidate the legacy Bridge Mirror (`.heddle/git`) — the bare Sley repo used
231    /// by explicit Git projection import/export/sync paths — by packing every
232    /// on-disk object into a single pack and dropping the now-redundant loose
233    /// copies.
234    ///
235    /// The mirror accumulates one loose object per minted/imported commit, tree,
236    /// and blob (thousands on a real clone). Loose-object reads dominate legacy Bridge Mirror import/export
237    /// and reconstruction paths. Active Git-overlay status
238    /// and checkpoint paths use the checkout's real `.git` repository, not this
239    /// mirror. `heddle maintenance gc` already consolidates Heddle's native
240    /// store; this brings the legacy Bridge Mirror to parity.
241    ///
242    /// Correctness: this uses [`repack_all_objects`], which gathers EVERY object
243    /// on disk (every loose object and every pack), not the reachability closure
244    /// of any ref set. That matters because the mirror holds more than the
245    /// current checkout — every thread's `refs/heads/*`, markers, `refs/notes/heddle`,
246    /// and the served-frontier record — AND because some lossy/non-UTF8 imports'
247    /// verbatim bytes live ONLY in the mirror and cannot be re-minted from heddle
248    /// state (see `git_export.rs` `commit_is_byte_faithful`). Packing everything
249    /// on disk preserves all of them and is content-addressed, so OIDs are
250    /// byte-for-byte unchanged. The prune only drops loose objects whose canonical
251    /// copy is now in the new pack, so it is lossless and fsck stays clean.
252    /// Idempotent: a second run finds nothing new loose and is a no-op.
253    ///
254    /// Returns the number of loose objects consolidated into the pack (and thus
255    /// removed from disk). `Ok(0)` when the mirror has no objects to pack.
256    #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
257    pub(crate) fn consolidate_mirror(&self) -> GitProjectionResult<usize> {
258        use sley::plumbing::sley_odb::{install_repack_result, repack_all_objects};
259
260        let repo = self.open_git_repo()?;
261        let git_dir = repo.git_dir().to_path_buf();
262        let format = repo.object_format();
263
264        let Some(result) = repack_all_objects(&git_dir, format).map_err(git_err)? else {
265            return Ok(0);
266        };
267        let pruned_loose = result.packed_loose.len();
268        // prune = true: write the new pack, then drop the loose objects and
269        // superseded packs the new pack now serves (install-before-delete; the
270        // installer validates the new pack's checksum before removing anything).
271        install_repack_result(&git_dir, format, &result, true).map_err(git_err)?;
272        Ok(pruned_loose)
273    }
274}
275
276/// Walk all branch- and tag-tipped commit ancestry. Skips refs that peel
277/// to non-commit objects (annotated-tag-points-at-blob/tree), matching the
278/// marker model's current commit-target-only constraint.
279fn collect_commit_oids(repo: &SleyRepository) -> GitProjectionResult<Vec<SleyObjectId>> {
280    let mut tips = Vec::new();
281
282    for reference in repo.references().list_refs().map_err(git_err)? {
283        if !(reference.name.starts_with("refs/heads/") || reference.name.starts_with("refs/tags/"))
284        {
285            continue;
286        }
287        let oid = match reference.target {
288            ReferenceTarget::Direct(oid) => oid,
289            ReferenceTarget::Symbolic(_) => {
290                let Some(reference) = repo.find_reference(&reference.name).map_err(git_err)? else {
291                    continue;
292                };
293                let Some(oid) = reference.peeled_oid(repo).map_err(git_err)? else {
294                    continue;
295                };
296                oid
297            }
298        };
299        if let Ok(commit_oid) = sley::plumbing::sley_rev::peel_to_commit(
300            repo.objects().as_ref(),
301            repo.object_format(),
302            &oid,
303        ) {
304            tips.push(commit_oid);
305        }
306    }
307
308    let mut seen = HashSet::new();
309    let mut stack = tips;
310    while let Some(oid) = stack.pop() {
311        if !seen.insert(oid) {
312            continue;
313        }
314        let commit = repo.read_commit(&oid).map_err(git_err)?;
315        stack.extend(commit.parents);
316    }
317
318    Ok(seen.into_iter().collect())
319}
320
321fn parse_stored_git_oid(value: &str) -> GitProjectionResult<SleyObjectId> {
322    let format = match value.len() {
323        40 => ObjectFormat::Sha1,
324        64 => ObjectFormat::Sha256,
325        _ => {
326            return Err(GitProjectionError::InvalidMapping(format!(
327                "invalid git oid length for {value}"
328            )));
329        }
330    };
331    SleyObjectId::from_hex(format, value)
332        .map_err(|err| GitProjectionError::InvalidMapping(err.to_string()))
333}