cli/git_projection_engine/
git_reconstruct.rs1#![deny(clippy::cast_possible_truncation)]
3
4use objects::{
21 object::{Principal, State},
22 store::{ObjectStore, StoreError},
23};
24use repo::Repository as HeddleRepository;
25use sley::{
26 GitObjectType, ObjectFormat, ObjectId, Repository as SleyRepository,
27 plumbing::sley_object::EncodedObject,
28};
29
30use crate::git_projection_engine::{
31 git_core::{GitProjection, GitProjectionError, GitProjectionResult, SyncMapping, git_err},
32 git_export::export_tree,
33};
34
35pub fn frame_git_object(kind: &str, content: &[u8]) -> Vec<u8> {
41 let mut framed = Vec::with_capacity(kind.len() + 2 + 20 + content.len());
42 framed.extend_from_slice(kind.as_bytes());
43 framed.push(b' ');
44 framed.extend_from_slice(content.len().to_string().as_bytes());
45 framed.push(0);
46 framed.extend_from_slice(content);
47 framed
48}
49
50pub fn commit_object_id(content: &[u8]) -> ObjectId {
54 sley::plumbing::sley_core::object_id_for_bytes(ObjectFormat::Sha1, "commit", content)
55 .expect("SHA-1 commit object id over in-memory bytes cannot fail")
56}
57
58pub fn reconstruct_commit_bytes(
68 heddle_repo: &HeddleRepository,
69 repo: &SleyRepository,
70 mapping: &SyncMapping,
71 state: &State,
72) -> GitProjectionResult<Vec<u8>> {
73 let tree_oid = export_tree(heddle_repo, repo, &state.tree)?;
74 let parent_oids = state
75 .parents
76 .iter()
77 .map(|parent| {
78 mapping
79 .get_git(parent)
80 .ok_or(GitProjectionError::StateNotFound(*parent))
81 })
82 .collect::<GitProjectionResult<Vec<_>>>()?;
83 build_commit_content(state, &tree_oid, &parent_oids)
84}
85
86pub fn write_commit_object(repo: &SleyRepository, content: &[u8]) -> GitProjectionResult<ObjectId> {
98 repo.write_object(EncodedObject::new(GitObjectType::Commit, content.to_vec()))
99 .map_err(git_err)
100}
101
102fn build_commit_content(
106 state: &State,
107 tree_oid: &ObjectId,
108 parent_oids: &[ObjectId],
109) -> GitProjectionResult<Vec<u8>> {
110 let mut out = Vec::new();
111
112 out.extend_from_slice(b"tree ");
114 out.extend_from_slice(tree_oid.to_string().as_bytes());
115 out.push(b'\n');
116
117 for parent in parent_oids {
119 out.extend_from_slice(b"parent ");
120 out.extend_from_slice(parent.to_string().as_bytes());
121 out.push(b'\n');
122 }
123
124 let author_seconds = state.authored_at.unwrap_or(state.created_at).timestamp();
130 write_actor_line(
131 &mut out,
132 b"author",
133 &state.attribution.principal,
134 author_seconds,
135 state.authored_tz_offset,
136 )?;
137 let committer = state
138 .committer
139 .as_ref()
140 .unwrap_or(&state.attribution.principal);
141 write_actor_line(
142 &mut out,
143 b"committer",
144 committer,
145 state.created_at.timestamp(),
146 state.committer_tz_offset,
147 )?;
148
149 for (name, value) in &state.extra_headers {
155 out.extend_from_slice(name);
156 out.push(b' ');
157 append_folded(&mut out, value);
158 out.push(b'\n');
159 }
160
161 out.push(b'\n');
164
165 if let Some(message) = &state.raw_message {
168 out.extend_from_slice(message);
169 }
170
171 Ok(out)
172}
173
174fn write_actor_line(
176 out: &mut Vec<u8>,
177 label: &[u8],
178 who: &Principal,
179 seconds: i64,
180 tz_offset_secs: i32,
181) -> GitProjectionResult<()> {
182 let seconds = checked_actor_timestamp(label, seconds, tz_offset_secs)?;
183 out.extend_from_slice(label);
184 out.push(b' ');
185 out.extend_from_slice(who.name.as_bytes());
186 out.extend_from_slice(b" <");
187 out.extend_from_slice(who.email.as_bytes());
188 out.extend_from_slice(b"> ");
189 out.extend_from_slice(seconds.to_string().as_bytes());
190 out.push(b' ');
191 out.extend_from_slice(format_tz_offset(tz_offset_secs).as_bytes());
192 out.push(b'\n');
193 Ok(())
194}
195
196fn checked_actor_timestamp(
197 label: &[u8],
198 seconds: i64,
199 tz_offset_secs: i32,
200) -> GitProjectionResult<i64> {
201 seconds
205 .checked_add(i64::from(tz_offset_secs))
206 .map(|_| seconds)
207 .ok_or_else(|| {
208 let label = String::from_utf8_lossy(label);
209 GitProjectionError::Store(StoreError::InvalidObject(format!(
210 "{label} timestamp {seconds} with timezone offset {tz_offset_secs} overflows i64"
211 )))
212 })
213}
214
215fn format_tz_offset(offset_secs: i32) -> String {
220 let sign = if offset_secs < 0 { '-' } else { '+' };
221 let minutes = offset_secs.unsigned_abs() / 60;
222 format!("{sign}{:02}{:02}", minutes / 60, minutes % 60)
223}
224
225fn append_folded(out: &mut Vec<u8>, value: &[u8]) {
231 let mut first = true;
232 for segment in value.split(|&b| b == b'\n') {
233 if first {
234 first = false;
235 } else {
236 out.push(b'\n');
237 out.push(b' ');
238 }
239 out.extend_from_slice(segment);
240 }
241}
242
243impl GitProjection<'_> {
244 pub fn reconstruction_repo(&mut self) -> GitProjectionResult<SleyRepository> {
248 self.init_mirror()?;
249 self.open_git_repo()
250 }
251
252 pub fn reconstruct_commit_bytes(
256 &self,
257 repo: &SleyRepository,
258 state: &State,
259 ) -> GitProjectionResult<Vec<u8>> {
260 reconstruct_commit_bytes(self.heddle_repo, repo, &self.mapping, state)
261 }
262
263 pub fn reconstruct_and_write_commit(
269 &self,
270 repo: &SleyRepository,
271 state: &State,
272 ) -> GitProjectionResult<ObjectId> {
273 let content = self.reconstruct_commit_bytes(repo, state)?;
274 write_commit_object(repo, &content)
275 }
276
277 pub fn reconstruct_commit_for_git_sha(
282 &self,
283 repo: &SleyRepository,
284 sha: &str,
285 ) -> GitProjectionResult<Option<Vec<u8>>> {
286 let oid = ObjectId::from_hex(ObjectFormat::Sha1, sha).map_err(git_err)?;
287 let Some(change_id) = self.mapping.get_heddle(oid) else {
288 return Ok(None);
289 };
290 let Some(state) = self.heddle_repo.store().get_state(&change_id)? else {
291 return Ok(None);
292 };
293 Ok(Some(reconstruct_commit_bytes(
294 self.heddle_repo,
295 repo,
296 &self.mapping,
297 &state,
298 )?))
299 }
300
301 pub fn reconstruct_and_write_commit_for_git_sha(
309 &self,
310 repo: &SleyRepository,
311 sha: &str,
312 ) -> GitProjectionResult<Option<ObjectId>> {
313 let oid = ObjectId::from_hex(ObjectFormat::Sha1, sha).map_err(git_err)?;
314 let Some(change_id) = self.mapping.get_heddle(oid) else {
315 return Ok(None);
316 };
317 let Some(state) = self.heddle_repo.store().get_state(&change_id)? else {
318 return Ok(None);
319 };
320 Ok(Some(self.reconstruct_and_write_commit(repo, &state)?))
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use objects::object::parse_commit_extension_headers;
327
328 use super::*;
329
330 #[test]
331 fn tz_offset_renders_sign_hours_minutes() {
332 assert_eq!(format_tz_offset(0), "+0000");
333 assert_eq!(format_tz_offset(2 * 3600), "+0200");
334 assert_eq!(format_tz_offset(-8 * 3600), "-0800");
335 assert_eq!(format_tz_offset(-(8 * 3600 + 30 * 60)), "-0830");
337 assert_eq!(format_tz_offset(12 * 3600 + 45 * 60), "+1245");
338 assert_eq!(format_tz_offset(5 * 3600 + 30 * 60), "+0530");
339 }
340
341 #[test]
342 fn frame_prepends_kind_len_nul() {
343 assert_eq!(frame_git_object("commit", b"abc"), b"commit 3\0abc");
344 assert_eq!(frame_git_object("commit", b""), b"commit 0\0");
345 }
346
347 #[test]
348 fn fold_then_unfold_round_trips() {
349 let value: &[u8] =
353 b"-----BEGIN PGP SIGNATURE-----\n\niHUEsigbytes\nmoresig\n-----END PGP SIGNATURE-----";
354
355 let mut folded = Vec::new();
357 folded.extend_from_slice(b"gpgsig ");
358 append_folded(&mut folded, value);
359 folded.push(b'\n');
360
361 assert!(folded.windows(3).any(|w| w == b"\n \n"));
364
365 let mut content = Vec::new();
368 content.extend_from_slice(b"tree ");
369 content.extend_from_slice(&[b'0'; 40]);
370 content.push(b'\n');
371 content.extend_from_slice(b"author A <a@x> 1 +0000\n");
372 content.extend_from_slice(b"committer A <a@x> 1 +0000\n");
373 content.extend_from_slice(&folded);
374 content.extend_from_slice(b"\nbody\n");
375
376 let headers = parse_commit_extension_headers(&content);
377 assert_eq!(headers.len(), 1);
378 assert_eq!(headers[0].0, b"gpgsig");
379 assert_eq!(headers[0].1, value);
380 }
381
382 #[test]
383 fn write_actor_line_rejects_overflowing_timestamp_offset_arithmetic() {
384 let principal = Principal::new("A", "a@example.com");
385 let mut out = Vec::new();
386
387 let error = write_actor_line(&mut out, b"author", &principal, i64::MAX, 1)
388 .expect_err("timestamp plus timezone offset must not overflow");
389
390 assert!(
391 matches!(&error, GitProjectionError::Store(StoreError::InvalidObject(message)) if message.contains("overflows i64")),
392 "expected InvalidObject overflow error, got: {error:?}",
393 );
394 assert!(
395 out.is_empty(),
396 "failed actor line must not emit partial bytes"
397 );
398 }
399
400 #[test]
401 fn write_actor_line_valid_timestamp_is_unchanged() {
402 let principal = Principal::new("A", "a@example.com");
403 let mut out = Vec::new();
404
405 write_actor_line(&mut out, b"author", &principal, 1_700_000_000, -8 * 3600)
406 .expect("valid timestamp should serialize");
407
408 assert_eq!(out, b"author A <a@example.com> 1700000000 -0800\n");
409 }
410}