#![deny(clippy::cast_possible_truncation)]
use objects::{
object::{Principal, State},
store::{ObjectStore, StoreError},
};
use repo::Repository as HeddleRepository;
use sley::{
GitObjectType, ObjectFormat, ObjectId, Repository as SleyRepository,
plumbing::sley_object::EncodedObject,
};
use crate::bridge::{
git_core::{GitBridge, GitBridgeError, GitResult, SyncMapping, git_err},
git_export::export_tree,
};
pub fn frame_git_object(kind: &str, content: &[u8]) -> Vec<u8> {
let mut framed = Vec::with_capacity(kind.len() + 2 + 20 + content.len());
framed.extend_from_slice(kind.as_bytes());
framed.push(b' ');
framed.extend_from_slice(content.len().to_string().as_bytes());
framed.push(0);
framed.extend_from_slice(content);
framed
}
pub fn commit_object_id(content: &[u8]) -> ObjectId {
sley::plumbing::sley_core::object_id_for_bytes(ObjectFormat::Sha1, "commit", content)
.expect("SHA-1 commit object id over in-memory bytes cannot fail")
}
pub fn reconstruct_commit_bytes(
heddle_repo: &HeddleRepository,
repo: &SleyRepository,
mapping: &SyncMapping,
state: &State,
) -> GitResult<Vec<u8>> {
let tree_oid = export_tree(heddle_repo, repo, &state.tree)?;
let parent_oids = state
.parents
.iter()
.map(|parent| {
mapping
.get_git(parent)
.ok_or(GitBridgeError::StateNotFound(*parent))
})
.collect::<GitResult<Vec<_>>>()?;
build_commit_content(state, &tree_oid, &parent_oids)
}
pub fn write_commit_object(repo: &SleyRepository, content: &[u8]) -> GitResult<ObjectId> {
repo.write_object(EncodedObject::new(GitObjectType::Commit, content.to_vec()))
.map_err(git_err)
}
fn build_commit_content(
state: &State,
tree_oid: &ObjectId,
parent_oids: &[ObjectId],
) -> GitResult<Vec<u8>> {
let mut out = Vec::new();
out.extend_from_slice(b"tree ");
out.extend_from_slice(tree_oid.to_string().as_bytes());
out.push(b'\n');
for parent in parent_oids {
out.extend_from_slice(b"parent ");
out.extend_from_slice(parent.to_string().as_bytes());
out.push(b'\n');
}
let author_seconds = state.authored_at.unwrap_or(state.created_at).timestamp();
write_actor_line(
&mut out,
b"author",
&state.attribution.principal,
author_seconds,
state.authored_tz_offset,
)?;
let committer = state
.committer
.as_ref()
.unwrap_or(&state.attribution.principal);
write_actor_line(
&mut out,
b"committer",
committer,
state.created_at.timestamp(),
state.committer_tz_offset,
)?;
for (name, value) in &state.extra_headers {
out.extend_from_slice(name);
out.push(b' ');
append_folded(&mut out, value);
out.push(b'\n');
}
out.push(b'\n');
if let Some(message) = &state.raw_message {
out.extend_from_slice(message);
}
Ok(out)
}
fn write_actor_line(
out: &mut Vec<u8>,
label: &[u8],
who: &Principal,
seconds: i64,
tz_offset_secs: i32,
) -> GitResult<()> {
let seconds = checked_actor_timestamp(label, seconds, tz_offset_secs)?;
out.extend_from_slice(label);
out.push(b' ');
out.extend_from_slice(who.name.as_bytes());
out.extend_from_slice(b" <");
out.extend_from_slice(who.email.as_bytes());
out.extend_from_slice(b"> ");
out.extend_from_slice(seconds.to_string().as_bytes());
out.push(b' ');
out.extend_from_slice(format_tz_offset(tz_offset_secs).as_bytes());
out.push(b'\n');
Ok(())
}
fn checked_actor_timestamp(label: &[u8], seconds: i64, tz_offset_secs: i32) -> GitResult<i64> {
seconds
.checked_add(i64::from(tz_offset_secs))
.map(|_| seconds)
.ok_or_else(|| {
let label = String::from_utf8_lossy(label);
GitBridgeError::Store(StoreError::InvalidObject(format!(
"{label} timestamp {seconds} with timezone offset {tz_offset_secs} overflows i64"
)))
})
}
fn format_tz_offset(offset_secs: i32) -> String {
let sign = if offset_secs < 0 { '-' } else { '+' };
let minutes = offset_secs.unsigned_abs() / 60;
format!("{sign}{:02}{:02}", minutes / 60, minutes % 60)
}
fn append_folded(out: &mut Vec<u8>, value: &[u8]) {
let mut first = true;
for segment in value.split(|&b| b == b'\n') {
if first {
first = false;
} else {
out.push(b'\n');
out.push(b' ');
}
out.extend_from_slice(segment);
}
}
impl GitBridge<'_> {
pub fn reconstruction_repo(&mut self) -> GitResult<SleyRepository> {
self.init_mirror()?;
self.open_git_repo()
}
pub fn reconstruct_commit_bytes(
&self,
repo: &SleyRepository,
state: &State,
) -> GitResult<Vec<u8>> {
reconstruct_commit_bytes(self.heddle_repo, repo, &self.mapping, state)
}
pub fn reconstruct_and_write_commit(
&self,
repo: &SleyRepository,
state: &State,
) -> GitResult<ObjectId> {
let content = self.reconstruct_commit_bytes(repo, state)?;
write_commit_object(repo, &content)
}
pub fn reconstruct_commit_for_git_sha(
&self,
repo: &SleyRepository,
sha: &str,
) -> GitResult<Option<Vec<u8>>> {
let oid = ObjectId::from_hex(ObjectFormat::Sha1, sha).map_err(git_err)?;
let Some(change_id) = self.mapping.get_heddle(oid) else {
return Ok(None);
};
let Some(state) = self.heddle_repo.store().get_state(&change_id)? else {
return Ok(None);
};
Ok(Some(reconstruct_commit_bytes(
self.heddle_repo,
repo,
&self.mapping,
&state,
)?))
}
pub fn reconstruct_and_write_commit_for_git_sha(
&self,
repo: &SleyRepository,
sha: &str,
) -> GitResult<Option<ObjectId>> {
let oid = ObjectId::from_hex(ObjectFormat::Sha1, sha).map_err(git_err)?;
let Some(change_id) = self.mapping.get_heddle(oid) else {
return Ok(None);
};
let Some(state) = self.heddle_repo.store().get_state(&change_id)? else {
return Ok(None);
};
Ok(Some(self.reconstruct_and_write_commit(repo, &state)?))
}
}
#[cfg(test)]
mod tests {
use objects::object::parse_commit_extension_headers;
use super::*;
#[test]
fn tz_offset_renders_sign_hours_minutes() {
assert_eq!(format_tz_offset(0), "+0000");
assert_eq!(format_tz_offset(2 * 3600), "+0200");
assert_eq!(format_tz_offset(-8 * 3600), "-0800");
assert_eq!(format_tz_offset(-(8 * 3600 + 30 * 60)), "-0830");
assert_eq!(format_tz_offset(12 * 3600 + 45 * 60), "+1245");
assert_eq!(format_tz_offset(5 * 3600 + 30 * 60), "+0530");
}
#[test]
fn frame_prepends_kind_len_nul() {
assert_eq!(frame_git_object("commit", b"abc"), b"commit 3\0abc");
assert_eq!(frame_git_object("commit", b""), b"commit 0\0");
}
#[test]
fn fold_then_unfold_round_trips() {
let value: &[u8] =
b"-----BEGIN PGP SIGNATURE-----\n\niHUEsigbytes\nmoresig\n-----END PGP SIGNATURE-----";
let mut folded = Vec::new();
folded.extend_from_slice(b"gpgsig ");
append_folded(&mut folded, value);
folded.push(b'\n');
assert!(folded.windows(3).any(|w| w == b"\n \n"));
let mut content = Vec::new();
content.extend_from_slice(b"tree ");
content.extend_from_slice(&[b'0'; 40]);
content.push(b'\n');
content.extend_from_slice(b"author A <a@x> 1 +0000\n");
content.extend_from_slice(b"committer A <a@x> 1 +0000\n");
content.extend_from_slice(&folded);
content.extend_from_slice(b"\nbody\n");
let headers = parse_commit_extension_headers(&content);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0, b"gpgsig");
assert_eq!(headers[0].1, value);
}
#[test]
fn write_actor_line_rejects_overflowing_timestamp_offset_arithmetic() {
let principal = Principal::new("A", "a@example.com");
let mut out = Vec::new();
let error = write_actor_line(&mut out, b"author", &principal, i64::MAX, 1)
.expect_err("timestamp plus timezone offset must not overflow");
assert!(
matches!(&error, GitBridgeError::Store(StoreError::InvalidObject(message)) if message.contains("overflows i64")),
"expected InvalidObject overflow error, got: {error:?}",
);
assert!(
out.is_empty(),
"failed actor line must not emit partial bytes"
);
}
#[test]
fn write_actor_line_valid_timestamp_is_unchanged() {
let principal = Principal::new("A", "a@example.com");
let mut out = Vec::new();
write_actor_line(&mut out, b"author", &principal, 1_700_000_000, -8 * 3600)
.expect("valid timestamp should serialize");
assert_eq!(out, b"author A <a@example.com> 1700000000 -0800\n");
}
}