Skip to main content

cli/bridge/
git_mapping.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Persistence and discovery for Git bridge mappings.
3
4use std::{
5    collections::HashSet,
6    fs::{self, File},
7    io::Write,
8    path::{Path, PathBuf},
9};
10
11use objects::object::ChangeId;
12use serde::{Deserialize, Serialize};
13
14use super::git_core::{GitBridge, GitBridgeError, GitResult, git_err};
15
16#[derive(Debug, Serialize, Deserialize)]
17struct MappingEntry {
18    change_id: String,
19    git_oid: String,
20}
21
22#[derive(Debug, Serialize, Deserialize, Default)]
23struct MappingFile {
24    entries: Vec<MappingEntry>,
25}
26
27impl<'a> GitBridge<'a> {
28    pub(crate) fn mapping_path(&self) -> PathBuf {
29        self.heddle_repo
30            .heddle_dir()
31            .join("git-bridge")
32            .join("bridge-mapping.json")
33    }
34
35    pub(crate) fn mapping_tmp_path(&self) -> PathBuf {
36        self.mapping_path().with_extension("json.tmp")
37    }
38
39    fn legacy_mapping_path(&self) -> PathBuf {
40        self.heddle_repo
41            .heddle_dir()
42            .join("git")
43            .join("bridge-mapping.json")
44    }
45
46    fn remove_legacy_mapping_file(&self) -> GitResult<()> {
47        let legacy_path = self.legacy_mapping_path();
48        if !legacy_path.exists() {
49            return Ok(());
50        }
51
52        fs::remove_file(&legacy_path)?;
53        Ok(())
54    }
55
56    fn migrate_legacy_mapping_if_needed(&self) -> GitResult<PathBuf> {
57        let path = self.mapping_path();
58        let legacy_path = self.legacy_mapping_path();
59
60        if path.exists() {
61            self.remove_legacy_mapping_file()?;
62            return Ok(path);
63        }
64
65        if !legacy_path.exists() {
66            return Ok(path);
67        }
68
69        if let Some(parent) = path.parent() {
70            fs::create_dir_all(parent)?;
71        }
72
73        fs::rename(&legacy_path, &path)?;
74        Ok(path)
75    }
76
77    pub(crate) fn load_mapping_from_disk(&mut self) -> GitResult<()> {
78        self.recover_mapping_tmp()?;
79        let path = self.migrate_legacy_mapping_if_needed()?;
80        if !path.exists() {
81            return Ok(());
82        }
83
84        let data = fs::read_to_string(&path)?;
85        let file: MappingFile = serde_json::from_str(&data)
86            .map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))?;
87
88        for entry in file.entries {
89            let change_id = ChangeId::parse(&entry.change_id)?;
90            let git_oid = entry
91                .git_oid
92                .parse::<gix::hash::ObjectId>()
93                .map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))?;
94            self.mapping.insert_checked(change_id, git_oid)?;
95        }
96
97        Ok(())
98    }
99
100    fn recover_mapping_tmp(&self) -> GitResult<()> {
101        let path = self.mapping_path();
102        let tmp_path = self.mapping_tmp_path();
103        if !tmp_path.exists() {
104            return Ok(());
105        }
106        if !path.exists() {
107            fs::rename(&tmp_path, &path)?;
108        } else {
109            fs::remove_file(&tmp_path)?;
110        }
111        Ok(())
112    }
113
114    fn mapping_bytes(&self) -> GitResult<Vec<u8>> {
115        let entries = self
116            .mapping
117            .iter()
118            .map(|(change_id, git_oid)| MappingEntry {
119                change_id: change_id.to_string_full(),
120                git_oid: git_oid.to_string(),
121            })
122            .collect();
123
124        let file = MappingFile { entries };
125        serde_json::to_vec_pretty(&file)
126            .map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))
127    }
128
129    pub(crate) fn write_mapping_tmp_to_disk(&self) -> GitResult<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()?;
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) -> GitResult<()> {
146        let path = self.mapping_path();
147        let tmp_path = self.mapping_tmp_path();
148        if !tmp_path.exists() {
149            return Err(GitBridgeError::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        self.remove_legacy_mapping_file()?;
160        Ok(())
161    }
162
163    pub(crate) fn save_mapping_to_disk(&self) -> GitResult<()> {
164        self.write_mapping_tmp_to_disk()?;
165        // Fault-injection checkpoint: a crash here leaves the
166        // sidecar in tmp form (`bridge-mapping.json.tmp`) without a
167        // committed `bridge-mapping.json`. The next `save_mapping_to_disk`
168        // call invokes `recover_mapping_tmp` (in `load_mapping_from_disk`)
169        // which atomically renames the tmp into place. Tested by
170        // `bridge_recovers_from_crash_after_tmp_before_commit`.
171        objects::fault_inject::maybe_panic_at("mapping_after_tmp_before_commit");
172        self.commit_mapping_tmp_to_disk()
173    }
174
175    /// Build the mapping from existing commits and persisted data. Sources,
176    /// in order:
177    ///   1. The on-disk sidecar (`bridge-mapping.json`).
178    ///   2. The git notes ref (`refs/notes/heddle`) — Phase B and later.
179    ///   3. Legacy `Heddle-Change-Id:` trailers in commit messages.
180    ///
181    /// Sources 2 and 3 must agree with anything already in the sidecar (via
182    /// `insert_checked`) or the build aborts — this is what catches a
183    /// corrupted sidecar that disagrees with the git side of the bridge.
184    pub(crate) fn build_existing_mapping(&mut self, git_repo_path: Option<&Path>) -> GitResult<()> {
185        self.load_mapping_from_disk()?;
186
187        let repo = match git_repo_path {
188            Some(path) => super::git_core::open_repo(path)?,
189            None => self.open_git_repo()?,
190        };
191
192        // Phase B: scan refs/notes/heddle first. Notes carry change_ids
193        // without altering commit SHAs, so they're our preferred fallback
194        // source after the sidecar.
195        let notes = super::git_notes::read_all_notes(&repo)?;
196        for (oid, note) in &notes {
197            let change_id = ChangeId::parse(&note.change_id)?;
198            self.mapping.insert_checked(change_id, *oid)?;
199        }
200
201        // Legacy: scan commit-message trailers for any commits not already
202        // covered by the sidecar or notes. Future-proofing for repos that
203        // were exported by pre-Phase-B builds.
204        let commit_oids = collect_commit_oids(&repo)?;
205        for oid in commit_oids {
206            if self.mapping.has_git(oid) {
207                continue;
208            }
209            let commit = repo.find_commit(oid).map_err(git_err)?;
210            let message = commit.message_raw_sloppy();
211            let trailers = GitBridge::parse_trailers(&message.to_string());
212            if let Some(change_id) = trailers.get(GitBridge::TRAILER_CHANGE_ID) {
213                let change_id = ChangeId::parse(change_id)?;
214                self.mapping.insert_checked(change_id, oid)?;
215            }
216        }
217
218        self.save_mapping_to_disk()?;
219        Ok(())
220    }
221
222    #[cfg_attr(not(feature = "git-overlay"), allow(dead_code))]
223    pub(crate) fn prune_unreachable_mapping_entries(&mut self) -> GitResult<usize> {
224        let repo = self.open_git_repo()?;
225        self.load_mapping_from_disk()?;
226        let reachable: HashSet<_> = collect_commit_oids(&repo)?.into_iter().collect();
227        let removed = self.mapping.retain_git_object_set(&reachable);
228        if removed > 0 {
229            self.save_mapping_to_disk()?;
230        }
231        Ok(removed)
232    }
233}
234
235/// Walk all branch- and tag-tipped commit ancestry. Skips refs that peel
236/// to non-commit objects (annotated-tag-points-at-blob/tree); see
237/// `git_import::peel_to_commit_oid` for the full rationale and the
238/// `SkippedRef` recording layer.
239fn collect_commit_oids(repo: &gix::Repository) -> GitResult<Vec<gix::hash::ObjectId>> {
240    let mut tips = Vec::new();
241    for reference in repo
242        .references()
243        .map_err(git_err)?
244        .local_branches()
245        .map_err(git_err)?
246    {
247        let mut reference = reference.map_err(git_err)?;
248        let oid = reference.peel_to_id().map_err(git_err)?.detach();
249        if let Ok(object) = repo.find_object(oid)
250            && object.kind == gix::objs::Kind::Commit
251        {
252            tips.push(oid);
253        }
254    }
255    for reference in repo
256        .references()
257        .map_err(git_err)?
258        .tags()
259        .map_err(git_err)?
260    {
261        let mut reference = reference.map_err(git_err)?;
262        let oid = reference.peel_to_id().map_err(git_err)?.detach();
263        if let Ok(object) = repo.find_object(oid)
264            && object.kind == gix::objs::Kind::Commit
265        {
266            tips.push(oid);
267        }
268    }
269
270    let mut seen = HashSet::new();
271    for info in repo.rev_walk(tips).all().map_err(git_err)? {
272        seen.insert(info.map_err(git_err)?.id);
273    }
274
275    Ok(seen.into_iter().collect())
276}