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, 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    /// 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 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    /// Convert one Git commit after its tree and ordered parents are mapped.
97    /// A rewritten embedded note can change State identity; descendants may
98    /// refer to that original ID only through the durable rewrite lookup.
99    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(&note.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
188/// Parse a raw Git `author`/`committer` value for `commit`, preserving what
189/// reconstruction needs for a byte-identical commit (the `-0000` timezone sign).
190/// Identities that cannot be reconstructed exactly are rejected with an error
191/// naming the commit and the header, never imported lossily:
192/// - no `<email>`;
193/// - a timestamp outside the range [`DateTime<Utc>`] can hold;
194/// - an empty name written with a separating space (`author  <e> …`), since a
195///   `State` records only the empty name and rebuilds it as `author <e> …`.
196pub 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(&note.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}