heddle-cli 0.2.1

An AI-native version control system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
// SPDX-License-Identifier: Apache-2.0
//! Import Git commits into Heddle states functionality.

use std::{collections::HashSet, path::Path};

use chrono::{TimeZone, Utc};
use objects::object::{Agent, Attribution, ChangeId, Principal, State, Status};
use repo::Repository as HeddleRepository;
use tracing::warn;

pub use super::git_import_tree::{GitTreeImporter, import_git_tree};
use crate::bridge::{
    git_core::{
        GitBridge, GitBridgeError, GitResult, RefNamespace, RefUpdate, SyncMapping,
        apply_ref_updates, copy_reachable_objects, git_err, open_repo,
        thread_is_unclaimed_bootstrap,
    },
    git_notes,
    git_util::{ImportStats, PartialMirrorRef, SkippedRef},
};

/// One source ref the import will consider, with both its immediate target
/// (the OID stored on disk for that ref — for annotated tags this is the
/// tag *object* OID) and the peeled commit OID we use to walk ancestry.
///
/// Keeping both is what lets the bridge round-trip annotated tags as actual
/// tag objects: we copy the tag object into the mirror and write the
/// mirror's ref pointing at it, and later `sync_marker_to_tag`'s
/// already-exists check sees the existing ref peel to the right commit and
/// preserves the annotated form unchanged.
struct RefPlan {
    short_name: String,
    namespace: RefNamespace,
    /// The OID the source ref points at directly. For lightweight tags
    /// and branches this is a commit; for annotated tags it's a tag
    /// object that wraps a commit.
    immediate_oid: gix::hash::ObjectId,
    /// The commit reachable by peeling `immediate_oid` through any tag
    /// chain. Used as a tip for ancestry walking.
    peeled_commit_oid: gix::hash::ObjectId,
}

/// Peel `reference` to its final OID and confirm the OID is a commit. If
/// it's a blob (e.g. `git/git`'s `refs/tags/junio-gpg-pub` pointing at a
/// GPG public key), a tree (e.g. `git-lfs`'s `refs/tags/core-gpg-keys`),
/// or anything else, return `Ok(None)`. The caller is expected to log
/// and record the skip via `SkippedRef`.
///
/// Heddle's marker model currently requires the target to be a commit;
/// the long-term fix is a `MarkerTarget::NonCommitRef { peeled_oid,
/// peeled_kind }` variant that round-trips losslessly. Until that lands,
/// this guard prevents the import from crashing on the very common
/// "tag-points-at-non-commit-blob" pattern in mature OSS repos.
fn peel_to_commit_oid(
    repo: &gix::Repository,
    reference: &mut gix::Reference,
) -> GitResult<Result<gix::hash::ObjectId, gix::objs::Kind>> {
    let oid = reference.peel_to_id().map_err(git_err)?.detach();
    let object = repo.find_object(oid).map_err(git_err)?;
    if object.kind == gix::objs::Kind::Commit {
        Ok(Ok(oid))
    } else {
        Ok(Err(object.kind))
    }
}

fn remote_tracking_ref_suggestions(
    repo: &gix::Repository,
    missing: &[String],
) -> GitResult<Vec<String>> {
    let missing = missing.iter().map(String::as_str).collect::<HashSet<_>>();
    let mut suggestions = Vec::new();

    for reference in repo
        .references()
        .map_err(git_err)?
        .prefixed("refs/remotes/")
        .map_err(git_err)?
    {
        let mut reference = reference.map_err(git_err)?;
        let Some(_) = reference.target().try_id() else {
            continue;
        };
        let short = reference.name().shorten().to_string();
        if short.ends_with("/HEAD") {
            continue;
        }
        if peel_to_commit_oid(repo, &mut reference)?.is_err() {
            continue;
        }
        let Some((_remote, branch)) = short.split_once('/') else {
            continue;
        };
        if missing.contains(branch) {
            suggestions.push(format!(
                "Remote-tracking branch '{short}' exists. Import it with `heddle bridge git import --ref {short}`. If you want a local branch with the shorter name later, create it in Heddle and sync it back through `heddle push`."
            ));
        }
    }

    suggestions.sort();
    suggestions.dedup();
    Ok(suggestions)
}

/// Resolve a heddle change_id for a git commit. Tried in order:
///   1. **Sidecar mapping** (already loaded into `mapping`): if the git_oid
///      is already known, reuse the change_id without scanning anything.
///   2. **`refs/notes/heddle`**: if a note attached to this commit carries
///      a change_id, adopt it. This is how identity survives a fresh
///      `git clone` of a heddle-exported repo.
///   3. **Legacy `Heddle-Change-Id:` trailer**: kept for backward
///      compatibility with commits exported by pre-Phase-B builds.
///   4. **Deterministic from git SHA**: the original heddle behavior —
///      take the first 16 bytes of the git SHA. Two heddle repos that
///      independently import the same git commit get the same change_id,
///      which is what we want.
fn resolve_identity(
    mapping: &SyncMapping,
    repo: &gix::Repository,
    git_oid: gix::hash::ObjectId,
    trailers: &std::collections::HashMap<String, String>,
) -> GitResult<(ChangeId, Option<git_notes::HeddleNote>)> {
    if let Some(existing) = mapping.get_heddle(git_oid) {
        return Ok((existing, None));
    }
    if let Some(note) = git_notes::read_note(repo, git_oid)? {
        let change_id = ChangeId::parse(&note.change_id)?;
        return Ok((change_id, Some(note)));
    }
    if let Some(id_str) = trailers.get(GitBridge::TRAILER_CHANGE_ID) {
        return Ok((ChangeId::parse(id_str)?, None));
    }
    let oid_hex = git_oid.to_hex_with_len(40).to_string();
    let bytes = hex::decode(&oid_hex[..32])
        .map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))?;
    let mut change_id_bytes = [0u8; 16];
    change_id_bytes.copy_from_slice(&bytes);
    Ok((ChangeId::from_bytes(change_id_bytes), None))
}

/// Import a single Git commit as a Heddle state.
pub fn import_commit(
    mapping: &mut SyncMapping,
    heddle_repo: &HeddleRepository,
    repo: &gix::Repository,
    tree_importer: &mut GitTreeImporter<'_>,
    git_oid: gix::hash::ObjectId,
) -> GitResult<ChangeId> {
    let commit = repo.find_commit(git_oid).map_err(git_err)?;
    let message = commit.message_raw_sloppy().to_string();
    let author = commit.author().map_err(git_err)?;
    let author_name = author.name.to_string();
    let author_email = author.email.to_string();
    let timestamp = author.time().map_err(git_err)?.seconds;
    let tree_id = commit.tree_id().map_err(git_err)?.detach();
    let parent_git_oids: Vec<gix::hash::ObjectId> =
        commit.parent_ids().map(|id| id.detach()).collect();

    let trailers = GitBridge::parse_trailers(&message);
    let (change_id, note) = resolve_identity(mapping, repo, git_oid, &trailers)?;

    let parent_oids: Vec<ChangeId> = parent_git_oids
        .iter()
        .map(|parent_oid| {
            mapping
                .get_heddle(*parent_oid)
                .ok_or_else(|| GitBridgeError::CommitNotFound(parent_oid.to_string()))
        })
        .collect::<GitResult<Vec<_>>>()?;

    let tree_hash = tree_importer.import_tree(tree_id)?;

    let principal = Principal::new(author_name, author_email);

    // Agent / confidence / status: prefer the note (Phase-B-and-later format)
    // and fall back to legacy trailers for pre-Phase-B history.
    let agent = note
        .as_ref()
        .and_then(|n| n.agent.as_ref())
        .map(|a| Agent::new(a.provider.clone(), a.model.clone()))
        .or_else(|| {
            trailers
                .get(GitBridge::TRAILER_AGENT)
                .and_then(|agent_str| {
                    let parts: Vec<&str> = agent_str.split('/').collect();
                    if parts.len() == 2 {
                        Some(Agent::new(parts[0], parts[1]))
                    } else {
                        None
                    }
                })
        });

    let attribution = if let Some(agent) = agent {
        Attribution::with_agent(principal, agent)
    } else {
        Attribution::human(principal)
    };

    let intent = GitBridge::extract_intent(&message);
    let confidence = note.as_ref().and_then(|n| n.confidence).or_else(|| {
        trailers
            .get(GitBridge::TRAILER_CONFIDENCE)
            .and_then(|c| c.parse::<f32>().ok())
            .map(|c| c.clamp(0.0, 1.0))
    });
    let status = note
        .as_ref()
        .map(|n| match n.status.as_str() {
            "published" => Status::Published,
            _ => Status::Draft,
        })
        .or_else(|| {
            trailers
                .get(GitBridge::TRAILER_STATUS)
                .map(|s| match s.as_str() {
                    "published" => Status::Published,
                    _ => Status::Draft,
                })
        })
        .unwrap_or(Status::Draft);

    let created_at = Utc.timestamp_opt(timestamp, 0).single().ok_or_else(|| {
        GitBridgeError::InvalidMapping(format!("invalid Git timestamp: {}", timestamp))
    })?;

    let state = State::new(tree_hash, parent_oids, attribution)
        .with_change_id(change_id)
        .with_intent(intent.unwrap_or_else(|| "Imported from Git".to_string()))
        .with_timestamp(created_at)
        .with_status(status);

    let state = if let Some(c) = confidence {
        state.with_confidence(c)
    } else {
        state
    };

    heddle_repo.store().put_state(&state)?;

    Ok(change_id)
}

/// Import Git commits into Heddle states.
pub fn import_all(bridge: &mut GitBridge, git_path: Option<&Path>) -> GitResult<ImportStats> {
    import_with_ref_filter(bridge, git_path, None)
}

pub fn import_selected_refs(
    bridge: &mut GitBridge,
    git_path: Option<&Path>,
    refs: &[String],
) -> GitResult<ImportStats> {
    let wanted = refs.iter().cloned().collect::<HashSet<_>>();
    import_with_ref_filter(bridge, git_path, Some(&wanted))
}

fn import_with_ref_filter(
    bridge: &mut GitBridge,
    git_path: Option<&Path>,
    wanted_refs: Option<&HashSet<String>>,
) -> GitResult<ImportStats> {
    let repo = if let Some(path) = git_path {
        open_repo(path)?
    } else {
        bridge.open_git_repo()?
    };

    let mut stats = ImportStats::default();
    let mut plans: Vec<RefPlan> = Vec::new();

    // Build per-ref plans for branches and tags. Each plan captures the
    // immediate target (annotated-tag-aware) and the peeled commit (for
    // ancestry walking). Non-commit-pointing refs are recorded in
    // `skipped_non_commit_refs` and excluded from the plan list.
    for reference in repo
        .references()
        .map_err(git_err)?
        .local_branches()
        .map_err(git_err)?
    {
        let mut reference = reference.map_err(git_err)?;
        let short = reference.name().shorten().to_string();
        if wanted_refs.is_some_and(|wanted| !wanted.contains(&short)) {
            continue;
        }
        let immediate = match reference.target().try_id() {
            Some(id) => id.to_owned(),
            None => continue, // symbolic ref (e.g. HEAD) — not a real ref to import
        };
        match peel_to_commit_oid(&repo, &mut reference)? {
            Ok(commit_oid) => plans.push(RefPlan {
                short_name: short,
                namespace: RefNamespace::Branch,
                immediate_oid: immediate,
                peeled_commit_oid: commit_oid,
            }),
            Err(kind) => {
                // A *branch* pointing at a non-commit is exceedingly rare
                // and strongly suggests upstream corruption. Record + skip.
                warn!(
                    "skipping local branch {} -> {} (not a commit, kind={:?})",
                    short, immediate, kind
                );
                stats.skipped_non_commit_refs.push(SkippedRef {
                    name: format!("refs/heads/{short}"),
                    peeled_oid: immediate.to_string(),
                    peeled_kind: format!("{kind:?}"),
                });
            }
        }
    }
    if wanted_refs.is_some() {
        for reference in repo
            .references()
            .map_err(git_err)?
            .prefixed("refs/remotes/")
            .map_err(git_err)?
        {
            let mut reference = reference.map_err(git_err)?;
            let short = reference.name().shorten().to_string();
            if short.ends_with("/HEAD") {
                continue;
            }
            if wanted_refs.is_some_and(|wanted| !wanted.contains(&short)) {
                continue;
            }
            let immediate = match reference.target().try_id() {
                Some(id) => id.to_owned(),
                None => continue,
            };
            match peel_to_commit_oid(&repo, &mut reference)? {
                Ok(commit_oid) => plans.push(RefPlan {
                    short_name: short,
                    namespace: RefNamespace::Branch,
                    immediate_oid: immediate,
                    peeled_commit_oid: commit_oid,
                }),
                Err(kind) => {
                    warn!(
                        "skipping remote-tracking branch {} -> {} (not a commit, kind={:?})",
                        short, immediate, kind
                    );
                    stats.skipped_non_commit_refs.push(SkippedRef {
                        name: format!("refs/remotes/{short}"),
                        peeled_oid: immediate.to_string(),
                        peeled_kind: format!("{kind:?}"),
                    });
                }
            }
        }
    }
    for reference in repo
        .references()
        .map_err(git_err)?
        .tags()
        .map_err(git_err)?
    {
        let mut reference = reference.map_err(git_err)?;
        let short = reference.name().shorten().to_string();
        if wanted_refs.is_some_and(|wanted| !wanted.contains(&short)) {
            continue;
        }
        let immediate = match reference.target().try_id() {
            Some(id) => id.to_owned(),
            None => continue,
        };
        match peel_to_commit_oid(&repo, &mut reference)? {
            Ok(commit_oid) => plans.push(RefPlan {
                short_name: short,
                namespace: RefNamespace::Tag,
                immediate_oid: immediate,
                peeled_commit_oid: commit_oid,
            }),
            Err(kind) => {
                // A tag pointing at a non-commit IS a real-world pattern
                // (junio-gpg-pub, core-gpg-keys, etc.). Skip with a
                // record so we don't lose track that this ref existed
                // upstream.
                warn!(
                    "skipping tag {} -> {} (not a commit, kind={:?}); \
                     non-commit-pointing tags are not yet representable in heddle's \
                     marker model",
                    short, immediate, kind
                );
                stats.skipped_non_commit_refs.push(SkippedRef {
                    name: format!("refs/tags/{short}"),
                    peeled_oid: immediate.to_string(),
                    peeled_kind: format!("{kind:?}"),
                });
            }
        }
    }

    if let Some(wanted_refs) = wanted_refs {
        let planned = plans
            .iter()
            .map(|plan| plan.short_name.clone())
            .collect::<HashSet<_>>();
        let mut missing = wanted_refs
            .iter()
            .filter(|name| !planned.contains(*name))
            .cloned()
            .collect::<Vec<_>>();
        missing.sort();
        if !missing.is_empty() {
            let mut message = format!(
                "requested ref(s) not found or not commit-pointing: {}",
                missing.join(", ")
            );
            let suggestions = remote_tracking_ref_suggestions(&repo, &missing)?;
            if !suggestions.is_empty() {
                message.push_str("\n\n");
                message.push_str(&suggestions.join("\n"));
            }
            return Err(GitBridgeError::CommitNotFound(message));
        }
    }

    // Populate the bridge mirror with the source's reachable objects AND
    // its refs verbatim (when we're importing from an external path
    // rather than the mirror itself).
    //
    // Mirror population enables two things downstream:
    //   1. **SHA-stable export**: `bridge export --destination Y`
    //      copies the original commit bytes verbatim from the mirror,
    //      so destination commits keep their original SHAs.
    //   2. **Annotated tag preservation**: writing the source ref into
    //      the mirror at its IMMEDIATE target (the tag object OID, not
    //      the peeled commit) makes the existing-ref check in
    //      `sync_marker_to_tag` skip the rewrite — leaving the
    //      annotated tag intact through to the destination push.
    //
    // We do this **per ref** rather than as a single bulk copy. A ref
    // whose ancestry references a missing object (a known failure mode
    // in real-world repos like git-lfs, where pack data carries dangling
    // references that `git fsck` doesn't catch) doesn't poison the rest
    // of the mirror — only that one ref loses SHA stability.
    if git_path.is_some() {
        bridge.init_mirror()?;
        let mirror_repo = bridge.open_git_repo()?;
        if mirror_repo.git_dir() != repo.git_dir() {
            let mut successful_updates: Vec<RefUpdate> = Vec::new();
            for plan in &plans {
                // Roots include both the immediate target (tag object for
                // annotated tags) and the peeled commit (so the walker
                // descends through commit→tree→blob even when the
                // immediate object is a tag).
                let roots = [plan.immediate_oid, plan.peeled_commit_oid];
                match copy_reachable_objects(&repo, &mirror_repo, roots) {
                    Ok(()) => successful_updates.push(RefUpdate {
                        name: plan.short_name.clone(),
                        target: plan.immediate_oid,
                        namespace: plan.namespace,
                    }),
                    Err(err) => {
                        let full = match plan.namespace {
                            RefNamespace::Branch => format!("refs/heads/{}", plan.short_name),
                            RefNamespace::Tag => format!("refs/tags/{}", plan.short_name),
                            RefNamespace::Note => format!("refs/notes/{}", plan.short_name),
                        };
                        warn!(
                            "partial mirror for {} (target {}): {}; \
                             SHA-stable export degraded for commits reachable only \
                             from this ref",
                            full, plan.immediate_oid, err
                        );
                        stats.partial_mirror_refs.push(PartialMirrorRef {
                            name: full,
                            error: err.to_string(),
                        });
                    }
                }
            }
            // Write source refs into the mirror. For annotated tags this
            // points refs/tags/<name> at the tag object (not the peeled
            // commit), which is what preserves the annotated form across
            // export.
            apply_ref_updates(
                &mirror_repo,
                &successful_updates,
                "heddle: import refs from source",
            )?;
        }
    }

    bridge.build_existing_mapping(Some(repo.path()))?;

    let mut tree_importer = GitTreeImporter::new(bridge.heddle_repo, &repo);
    bridge.heddle_repo.store().begin_snapshot_write_batch()?;
    let import_result = (|| -> GitResult<()> {
        let mut visiting = HashSet::new();
        let mut imported = HashSet::new();
        for plan in &plans {
            let tip = plan.peeled_commit_oid;
            import_commit_ancestry(
                bridge,
                &repo,
                &mut tree_importer,
                tip,
                &mut visiting,
                &mut imported,
                &mut stats,
            )?;
        }
        Ok(())
    })();
    match import_result {
        Ok(()) => {
            bridge.write_mapping_tmp_to_disk()?;
            bridge.heddle_repo.store().flush_snapshot_write_batch()?;
            bridge.commit_mapping_tmp_to_disk()?;
        }
        Err(error) => {
            bridge.heddle_repo.store().abort_snapshot_write_batch();
            return Err(error);
        }
    }

    for plan in plans
        .iter()
        .filter(|plan| plan.namespace == RefNamespace::Branch)
    {
        let name = &plan.short_name;
        if wanted_refs.is_some_and(|wanted| !wanted.contains(name.as_str())) {
            continue;
        }
        if let Some(change_id) = bridge.mapping.get_heddle(plan.peeled_commit_oid) {
            if let Some(existing) = bridge.heddle_repo.refs().get_thread(name.as_str())?
                && !thread_can_adopt_change(bridge.heddle_repo, &existing, &change_id)?
            {
                return Err(GitBridgeError::Conflict(format!(
                    "thread {} at {} differs from branch {} at {}. \
                     To recover, switch to '{}' and run `heddle sync` after \
                     resolving the divergent history, or explicitly reset the \
                     Heddle thread if the Git branch should replace it.",
                    name, existing, name, change_id, name
                )));
            }

            bridge
                .heddle_repo
                .refs()
                .set_thread(name.as_str(), &change_id)
                .map_err(|e| {
                    GitBridgeError::InvalidMapping(format!(
                        "set_thread failed for '{}': {}",
                        name, e
                    ))
                })?;
            stats.branches_synced += 1;
        }
    }

    for tag in repo
        .references()
        .map_err(git_err)?
        .tags()
        .map_err(git_err)?
    {
        let mut tag = tag.map_err(git_err)?;
        let name = tag.name().shorten().to_string();
        if wanted_refs.is_some_and(|wanted| !wanted.contains(&name)) {
            continue;
        }
        // Skip non-commit-pointing tags here too; the tips loop already
        // recorded them in `skipped_non_commit_refs`.
        let oid = match peel_to_commit_oid(&repo, &mut tag)? {
            Ok(oid) => oid,
            Err(_) => continue,
        };
        if let Some(change_id) = bridge.mapping.get_heddle(oid) {
            if let Ok(Some(existing)) = bridge.heddle_repo.refs().get_marker(&name)
                && existing != change_id
            {
                return Err(GitBridgeError::Conflict(format!(
                    "marker {} at {} differs from tag {} at {}",
                    name, existing, name, change_id
                )));
            }

            if let Err(e) = bridge.heddle_repo.refs().create_marker(&name, &change_id) {
                warn!(
                    "Failed to create marker '{}' during git import: {}",
                    name, e
                );
            }
            stats.tags_synced += 1;
        }
    }

    Ok(stats)
}

pub(crate) fn thread_can_adopt_change(
    heddle_repo: &HeddleRepository,
    existing: &ChangeId,
    change_id: &ChangeId,
) -> GitResult<bool> {
    if existing == change_id {
        return Ok(true);
    }
    if thread_is_unclaimed_bootstrap(heddle_repo, existing)? {
        return Ok(true);
    }
    proto::is_ancestor(heddle_repo.store(), *existing, *change_id)
        .map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))
}

/// Phase work for the iterative ancestry walker.
///
/// `Enter(oid)` schedules a commit for visit: discover its parents and
/// queue them. `Emit(oid)` finalizes a commit: import it as a heddle
/// state once all its parents have already been emitted.
///
/// We separate the phases because we need post-order traversal (parents
/// before children), and a single-marker stack can't express "I've
/// queued this commit's parents but haven't emitted the commit itself
/// yet" without keeping per-node state outside the stack.
enum WalkPhase {
    Enter(gix::hash::ObjectId),
    Emit(gix::hash::ObjectId),
}

/// Iterative ancestry walk — post-order DFS using an explicit stack
/// instead of recursion.
///
/// **Why this matters:** the previous version recursed once per parent
/// hop, so the call stack grew as deep as the longest chain in the
/// commit DAG. On `git/git` (84k commits) this overflowed the main
/// thread's 8MB stack after ~1 second and aborted with SIGABRT before
/// any state was written. With the explicit stack we're bounded only by
/// heap memory, which scales with the DAG's total node count rather
/// than its depth.
///
/// Behavior is otherwise unchanged: parents are processed before their
/// children, already-imported nodes are skipped, and re-entering a node
/// that's still in flight (a merge with two paths to the same ancestor)
/// is a no-op.
fn import_commit_ancestry(
    bridge: &mut GitBridge<'_>,
    repo: &gix::Repository,
    tree_importer: &mut GitTreeImporter<'_>,
    git_oid: gix::hash::ObjectId,
    visiting: &mut HashSet<gix::hash::ObjectId>,
    imported: &mut HashSet<gix::hash::ObjectId>,
    stats: &mut ImportStats,
) -> GitResult<()> {
    let mut stack: Vec<WalkPhase> = vec![WalkPhase::Enter(git_oid)];

    while let Some(phase) = stack.pop() {
        match phase {
            WalkPhase::Enter(oid) => {
                // Skip only if we've fully processed this OID earlier in
                // the same walk. We deliberately do NOT skip on
                // `mapping.has_git(oid)` here — even when the mapping
                // already knows the change_id (e.g. recovered from
                // refs/notes/heddle on a fresh re-import of an exported
                // repo), the heddle state for this commit may not yet
                // exist in the store. Letting the walk continue ensures
                // `import_commit` runs and writes the state.
                if imported.contains(&oid) {
                    continue;
                }
                if !visiting.insert(oid) {
                    // Already in flight via another merge path — its Emit
                    // is already scheduled, no need to re-queue.
                    continue;
                }

                let commit = repo.find_commit(oid).map_err(git_err)?;
                let parent_git_oids: Vec<gix::hash::ObjectId> =
                    commit.parent_ids().map(|id| id.detach()).collect();

                // Schedule emit AFTER all parents are processed. Stack is
                // LIFO so the Emit goes on first; then parents on top of
                // it pop first. Reverse so the original parent order is
                // preserved.
                stack.push(WalkPhase::Emit(oid));
                for parent_oid in parent_git_oids.into_iter().rev() {
                    stack.push(WalkPhase::Enter(parent_oid));
                }
            }
            WalkPhase::Emit(oid) => {
                // Decide whether to call import_commit by checking the
                // *store*, not the mapping: the mapping can carry an
                // entry recovered from a note that has no matching state
                // object yet. `import_commit` is idempotent — if the
                // change_id (from mapping or trailer or derived) already
                // has a state in the store, `put_state` overwrites it
                // with identical bytes.
                let existing_change_id = bridge.mapping.get_heddle(oid);
                let needs_state = match existing_change_id {
                    Some(cid) => bridge.heddle_repo.store().get_state(&cid)?.is_none(),
                    None => true,
                };
                if needs_state {
                    let change_id = import_commit(
                        &mut bridge.mapping,
                        bridge.heddle_repo,
                        repo,
                        tree_importer,
                        oid,
                    )?;
                    bridge.mapping.insert(change_id, oid);
                    stats.commits_imported += 1;
                    stats.states_created += 1;
                }
                visiting.remove(&oid);
                imported.insert(oid);
            }
        }
    }

    Ok(())
}