1use chrono::{DateTime, TimeZone, Utc};
7
8use super::git_import_graph::{GitObjectFormat, GitObjectId};
9use crate::{
10 error::{HeddleError, Result},
11 object::{
12 Agent, Attribution, ChangeId, ChangeLineage, ChangeLineageKind, ContentHash, HeddleNote,
13 Principal, State, StateId, Status,
14 },
15};
16
17#[derive(Clone, Debug)]
18pub struct GitImportSignature {
19 pub name: Vec<u8>,
20 pub email: Vec<u8>,
21 pub time: DateTime<Utc>,
22 pub tz_offset: i32,
23}
24
25#[derive(Clone, Debug)]
26pub struct GitImportCommit<'a> {
27 pub oid: &'a GitObjectId,
28 pub author: GitImportSignature,
29 pub committer: GitImportSignature,
30 pub message: &'a [u8],
31 pub extra_headers: &'a [(Vec<u8>, Vec<u8>)],
32 pub heddle_note: Option<&'a [u8]>,
33}
34
35pub struct GitImportRawCommit<'a> {
36 pub oid: &'a GitObjectId,
37 pub object_format: GitObjectFormat,
38 pub raw_commit: &'a [u8],
39 pub heddle_note: Option<&'a [u8]>,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum GitImportParentPolicy {
44 Validate,
45 PreserveEmbedded,
46}
47
48pub struct GitImportGraph;
49
50impl GitImportGraph {
51 pub fn convert_raw_commit(
54 commit: GitImportRawCommit<'_>,
55 tree: ContentHash,
56 parents: Vec<StateId>,
57 git_lossy: bool,
58 rewritten_parent: impl Fn(StateId) -> Result<Option<StateId>>,
59 ) -> Result<State> {
60 let actual_oid = sley_core::object_id_for_bytes(
61 commit.object_format.sley(),
62 "commit",
63 commit.raw_commit,
64 )
65 .map_err(|error| invalid(format!("hash Git commit: {error}")))?;
66 let claimed_oid = match commit.oid {
67 GitObjectId::Sha1(bytes) => bytes.as_slice(),
68 GitObjectId::Sha256(bytes) => bytes.as_slice(),
69 };
70 if actual_oid.as_bytes() != claimed_oid {
71 return Err(invalid("raw Git commit does not match its OID"));
72 }
73 let parsed = sley_object::Commit::parse_ref(commit.object_format.sley(), commit.raw_commit)
74 .map_err(|error| invalid(format!("invalid Git commit: {error}")))?;
75 let author = parse_signature(parsed.author)?;
76 let committer = parse_signature(parsed.committer)?;
77 let extra_headers = crate::object::parse_commit_extension_headers(commit.raw_commit);
78 Self::convert_commit(
79 GitImportCommit {
80 oid: commit.oid,
81 author,
82 committer,
83 message: parsed.message,
84 extra_headers: &extra_headers,
85 heddle_note: commit.heddle_note,
86 },
87 tree,
88 parents,
89 git_lossy,
90 GitImportParentPolicy::Validate,
91 rewritten_parent,
92 )
93 }
94
95 pub fn convert_commit(
99 commit: GitImportCommit<'_>,
100 tree: ContentHash,
101 parents: Vec<StateId>,
102 git_lossy: bool,
103 parent_policy: GitImportParentPolicy,
104 rewritten_parent: impl Fn(StateId) -> Result<Option<StateId>>,
105 ) -> Result<State> {
106 let message = String::from_utf8_lossy(commit.message);
107 let note = commit
108 .heddle_note
109 .map(HeddleNote::from_json_bytes)
110 .transpose()
111 .map_err(|error| invalid(format!("invalid Heddle note: {error}")))?;
112 if let Some(note) = note.as_ref()
113 && let Some(mut source_state) = note.source_state.clone()
114 {
115 let original = source_state.id();
116 let parent_mismatch =
117 parent_policy == GitImportParentPolicy::Validate && source_state.parents != parents;
118 let explained_rewrites = if parent_mismatch && !note.parents_rewritten {
119 if source_state.parents.len() != parents.len() {
120 false
121 } else {
122 let mut explained = true;
123 for (before, after) in source_state.parents.iter().zip(&parents) {
124 if before != after && rewritten_parent(*before)? != Some(*after) {
125 explained = false;
126 break;
127 }
128 }
129 explained
130 }
131 } else {
132 false
133 };
134 if original.to_string_full() != note.state_id
135 || source_state.change_id.to_string_full() != note.change_id
136 || source_state.tree != tree
137 || (parent_mismatch && !note.parents_rewritten && !explained_rewrites)
138 {
139 return Err(invalid(
140 "embedded Heddle State differs from note, tree, or Git parents",
141 ));
142 }
143 if parent_mismatch {
144 source_state.parents = parents;
145 source_state.state_id = source_state.id();
146 } else {
147 source_state.state_id = original;
148 }
149 return Ok(source_state);
150 }
151
152 let identity = resolve_identity(commit.oid, &message, note.as_ref())?;
153 let attribution = parse_git_attribution(&commit.author, &message, note.as_ref());
154 let mut state = State::new(tree, parents, attribution)
155 .with_change_id(identity)
156 .with_timestamp(commit.committer.time)
157 .with_authored_at(commit.author.time)
158 .with_intent(message.lines().next().unwrap_or("").trim().to_string())
159 .with_committer(Principal::new(
160 &commit.committer.name,
161 &commit.committer.email,
162 ))
163 .with_tz_offsets(commit.author.tz_offset, commit.committer.tz_offset)
164 .with_raw_message(commit.message)
165 .with_git_lossy(git_lossy)
166 .with_extra_headers(commit.extra_headers.to_vec())
167 .with_status(note_status(note.as_ref()));
168 if let Some(confidence) = note.as_ref().and_then(|value| value.confidence) {
169 state = state.with_confidence(confidence);
170 }
171 if let Some(note) = note {
172 let source_state = StateId::parse(¬e.state_id)
173 .map_err(|error| invalid(format!("invalid Heddle note StateId: {error}")))?;
174 if state.id() != source_state {
175 let source_change = state.change_id;
176 state = state.with_lineage(vec![ChangeLineage {
177 kind: ChangeLineageKind::GitProjection,
178 source_change,
179 source_state,
180 }]);
181 }
182 }
183 Ok(state)
184 }
185}
186
187fn parse_signature(raw: &[u8]) -> Result<GitImportSignature> {
188 let value = sley_core::Signature::from_ident_line(raw)
189 .ok_or_else(|| invalid("invalid Git author or committer signature"))?;
190 let time = Utc
191 .timestamp_opt(value.time.seconds, 0)
192 .single()
193 .ok_or_else(|| invalid("Git signature timestamp is out of range"))?;
194 Ok(GitImportSignature {
195 name: value.name.as_bytes().to_vec(),
196 email: value.email.as_bytes().to_vec(),
197 time,
198 tz_offset: i32::from(value.time.timezone_offset_minutes) * 60,
199 })
200}
201
202fn invalid(message: impl Into<String>) -> HeddleError {
203 HeddleError::InvalidObject(message.into())
204}
205
206fn resolve_identity(
207 oid: &GitObjectId,
208 message: &str,
209 note: Option<&HeddleNote>,
210) -> Result<ChangeId> {
211 if let Some(note) = note {
212 return ChangeId::parse(¬e.change_id)
213 .map_err(|error| invalid(format!("invalid Heddle note ChangeId: {error}")));
214 }
215 if let Some(change_id) = parse_trailers(message).get("Heddle-Change-Id") {
216 return ChangeId::parse(change_id)
217 .map_err(|error| invalid(format!("invalid Heddle-Change-Id trailer: {error}")));
218 }
219 let bytes = match oid {
220 GitObjectId::Sha1(bytes) => bytes.as_slice(),
221 GitObjectId::Sha256(bytes) => bytes.as_slice(),
222 };
223 let digest = ContentHash::compute_typed("git-change", bytes);
224 let mut identity = [0; 16];
225 identity.copy_from_slice(&digest.as_bytes()[..16]);
226 Ok(ChangeId::from_bytes(identity))
227}
228
229fn note_status(note: Option<&HeddleNote>) -> Status {
230 match note.map(|value| value.status.as_str()) {
231 Some("published") => Status::Published,
232 _ => Status::Draft,
233 }
234}
235
236fn parse_trailers(message: &str) -> std::collections::HashMap<String, String> {
237 let mut trailers = std::collections::HashMap::new();
238 for line in message.lines().rev() {
239 if line.is_empty() {
240 break;
241 }
242 if let Some(pos) = line.find(':') {
243 let key = &line[..pos];
244 if key.starts_with("Heddle-") {
245 trailers.insert(key.to_string(), line[pos + 1..].trim().to_string());
246 }
247 } else if !line.trim().is_empty() {
248 break;
249 }
250 }
251 trailers
252}
253
254pub fn parse_git_attribution(
255 author: &GitImportSignature,
256 message: &str,
257 note: Option<&HeddleNote>,
258) -> Attribution {
259 let principal = note
260 .and_then(|value| value.attribution.as_ref())
261 .map(|attribution| {
262 Principal::new(
263 attribution.principal_name.clone(),
264 attribution.principal_email.clone(),
265 )
266 })
267 .unwrap_or_else(|| Principal::new(&author.name, &author.email));
268 if let Some(agent) = note
269 .and_then(|value| value.attribution.as_ref())
270 .and_then(|attribution| attribution.agent.as_ref())
271 .or_else(|| note.and_then(|value| value.agent.as_ref()))
272 .cloned()
273 .or_else(|| detect_agent_in_message(message))
274 {
275 Attribution::with_agent(principal, agent)
276 } else {
277 Attribution::human(principal)
278 }
279}
280
281fn detect_agent_in_message(message: &str) -> Option<Agent> {
282 for line in message.lines().rev() {
283 let lower = line.to_ascii_lowercase();
284 if !lower.starts_with("co-authored-by:") {
285 continue;
286 }
287 let rest = line["co-authored-by:".len()..].trim();
288 let (name, email) = match (rest.rfind('<'), rest.rfind('>')) {
289 (Some(start), Some(end)) if end > start => {
290 (rest[..start].trim(), rest[start + 1..end].trim())
291 }
292 _ => (rest, ""),
293 };
294 let signal = format!(
295 "{} {}",
296 name.to_ascii_lowercase(),
297 email.to_ascii_lowercase()
298 );
299 if signal.contains("claude") || signal.contains("anthropic") {
300 return Some(Agent::new("anthropic", best_model_from(name, "claude")));
301 }
302 if signal.contains("codex") || signal.contains("chatgpt") || signal.contains("openai") {
303 return Some(Agent::new("openai", best_model_from(name, "codex")));
304 }
305 if signal.contains("copilot") {
306 return Some(Agent::new("github", best_model_from(name, "copilot")));
307 }
308 if signal.contains("gemini") || signal.contains("google") {
309 return Some(Agent::new("google", best_model_from(name, "gemini")));
310 }
311 }
312 None
313}
314
315fn best_model_from(name: &str, fallback: &str) -> String {
316 let trimmed = name.trim();
317 if trimmed.is_empty() {
318 fallback.to_string()
319 } else if trimmed.chars().any(|ch| ch.is_ascii_digit() || ch == '-') {
320 trimmed.to_string()
321 } else {
322 fallback.to_string()
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::object::Tree;
330
331 #[test]
332 fn projected_parent_rewrite_never_excuses_forged_identity_or_tree() {
333 let tree = Tree::new().hash();
334 let source = State::new(
335 tree,
336 vec![],
337 Attribution::human(Principal::new("Test", "test@example.com")),
338 );
339 let oid = GitObjectId::Sha1([4; 20]);
340 let raw = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor Test <test@example.com> 0 +0000\ncommitter Test <test@example.com> 0 +0000\n\nautomatic integration merge\n";
341 let raw_oid = sley_core::object_id_for_bytes(sley_core::ObjectFormat::Sha1, "commit", raw)
342 .expect("OID");
343 let raw_oid = GitObjectId::Sha1(raw_oid.as_bytes().try_into().expect("SHA-1"));
344 for field in ["state_id", "change_id", "tree"] {
345 let mut note = HeddleNote::from_projected_state(&source);
346 let mut mapped_tree = tree;
347 match field {
348 "state_id" => note.state_id = StateId::from_bytes([7; 32]).to_string_full(),
349 "change_id" => note.change_id = ChangeId::from_bytes([8; 16]).to_string_full(),
350 _ => mapped_tree = ContentHash::from_bytes([9; 32]),
351 }
352 let bytes = note.to_json_bytes().expect("note");
353 let signature = GitImportSignature {
354 name: b"Test".to_vec(),
355 email: b"test@example.com".to_vec(),
356 time: DateTime::UNIX_EPOCH,
357 tz_offset: 0,
358 };
359 assert!(
360 GitImportGraph::convert_commit(
361 GitImportCommit {
362 oid: &oid,
363 author: signature.clone(),
364 committer: signature,
365 message: b"automatic integration merge",
366 extra_headers: &[],
367 heddle_note: Some(&bytes)
368 },
369 mapped_tree,
370 vec![],
371 false,
372 GitImportParentPolicy::Validate,
373 |_| Ok(None),
374 )
375 .is_err(),
376 "local import accepted forged {field}"
377 );
378 assert!(
379 GitImportGraph::convert_raw_commit(
380 GitImportRawCommit {
381 oid: &raw_oid,
382 object_format: GitObjectFormat::Sha1,
383 raw_commit: raw,
384 heddle_note: Some(&bytes)
385 },
386 mapped_tree,
387 vec![],
388 false,
389 |_| Ok(None),
390 )
391 .is_err(),
392 "hosted import accepted forged {field}"
393 );
394 }
395 }
396
397 #[test]
398 fn embedded_integration_rejects_uncertified_parent_identity_count_and_order() {
399 let tree = Tree::new().hash();
400 let a = StateId::from_bytes([1; 32]);
401 let b = StateId::from_bytes([2; 32]);
402 let forged = StateId::from_bytes([3; 32]);
403 let source = State::new(
404 tree,
405 vec![a, b],
406 Attribution::human(Principal::new("Test", "test@example.com")),
407 )
408 .with_intent("automatic integration merge");
409 let bytes = HeddleNote::from_state(&source)
410 .to_json_bytes()
411 .expect("note");
412 let oid = GitObjectId::Sha1([4; 20]);
413 let convert = |parents, rewrite| {
414 let signature = GitImportSignature {
415 name: b"Test".to_vec(),
416 email: b"test@example.com".to_vec(),
417 time: DateTime::UNIX_EPOCH,
418 tz_offset: 0,
419 };
420 GitImportGraph::convert_commit(
421 GitImportCommit {
422 oid: &oid,
423 author: signature.clone(),
424 committer: signature,
425 message: b"automatic integration merge",
426 extra_headers: &[],
427 heddle_note: Some(&bytes),
428 },
429 tree,
430 parents,
431 false,
432 GitImportParentPolicy::Validate,
433 |id| Ok((rewrite && id == a).then_some(forged)),
434 )
435 };
436 assert!(
437 convert(vec![forged, b], false).is_err(),
438 "identity requires a certified rewrite"
439 );
440 assert!(
441 convert(vec![a], true).is_err(),
442 "a rewrite cannot explain a dropped parent"
443 );
444 assert!(
445 convert(vec![b, a], true).is_err(),
446 "a rewrite cannot explain reordered parents"
447 );
448 assert!(
449 convert(vec![a, forged], true).is_err(),
450 "a rewrite of a cannot explain forged b"
451 );
452 let repaired = convert(vec![forged, b], true).expect("certified identity rewrite");
453 assert_eq!(repaired.parents, vec![forged, b]);
454 assert_ne!(repaired.id(), source.id());
455 }
456
457 #[test]
458 fn typed_local_and_raw_conversion_are_byte_identical() {
459 let raw = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor Test <test@example.com> 0 +0000\ncommitter Test <test@example.com> 0 +0000\n\nmessage\n";
460 let oid = sley_core::object_id_for_bytes(sley_core::ObjectFormat::Sha1, "commit", raw)
461 .expect("OID");
462 let oid = GitObjectId::Sha1(oid.as_bytes().try_into().expect("SHA-1"));
463 let signature = GitImportSignature {
464 name: b"Test".to_vec(),
465 email: b"test@example.com".to_vec(),
466 time: DateTime::UNIX_EPOCH,
467 tz_offset: 0,
468 };
469 let tree = Tree::new().hash();
470 let typed = GitImportGraph::convert_commit(
471 GitImportCommit {
472 oid: &oid,
473 author: signature.clone(),
474 committer: signature,
475 message: b"message\n",
476 extra_headers: &[],
477 heddle_note: None,
478 },
479 tree,
480 Vec::new(),
481 false,
482 GitImportParentPolicy::Validate,
483 |_| Ok(None),
484 )
485 .expect("typed local State");
486 let raw_state = GitImportGraph::convert_raw_commit(
487 GitImportRawCommit {
488 oid: &oid,
489 object_format: GitObjectFormat::Sha1,
490 raw_commit: raw,
491 heddle_note: None,
492 },
493 tree,
494 Vec::new(),
495 false,
496 |_| Ok(None),
497 )
498 .expect("raw State");
499 assert_eq!(
500 typed.encode_current_msgpack().expect("typed bytes"),
501 raw_state.encode_current_msgpack().expect("raw bytes")
502 );
503 }
504}