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