Skip to main content

heddle_object_model/object/thread_replication/
git_import_converter.rs

1// SPDX-License-Identifier: Apache-2.0
2//! One canonical Git-commit-to-State converter for local and hosted import.
3//! Git tree/blob conversion supplies the mapped tree; this converter owns
4//! identity, ordered parents, metadata, and embedded Heddle note repair.
5
6use 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    /// Parse a raw Git commit and feed the same converter as local ingest.
52    /// Invalid identities or timestamps fail.
53    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    /// Convert one Git commit after its tree and ordered parents are mapped.
96    /// A rewritten embedded note can change State identity; descendants may
97    /// refer to that original ID only through the durable rewrite lookup.
98    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(&note.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(&note.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}