heddle-cli 0.3.1

An AI-native version control system
// SPDX-License-Identifier: Apache-2.0
//! Ingest-backed Git history import for bridge-facing commands.

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

use objects::{object::ChangeId, store::ObjectStore};
use sley::{GitObjectType, ObjectId, Repository as SleyRepository};

use super::{
    git_core::{
        collect_import_source_ref_updates, open_repo, GitBridge, GitBridgeError, GitResult,
    },
    git_notes,
    git_util::ImportStats,
};

pub(crate) fn import_git_history(
    bridge: &mut GitBridge<'_>,
    git_path: Option<&Path>,
    refs: &[String],
    options: ingest::ImportOptions,
    progress: Option<&mut dyn FnMut(ingest::ImportProgressEvent)>,
) -> GitResult<ImportStats> {
    let source = git_path.unwrap_or_else(|| bridge.heddle_repo.root());
    reject_shallow_source(source, refs)?;
    let scope = if refs.is_empty() {
        ingest::ImportScope::all()
    } else {
        ingest::ImportScope::refs(refs.to_vec())
    };
    let (stats, _map) = ingest::import_git_into_scoped_with_options_and_progress(
        source,
        bridge.heddle_repo.root(),
        options,
        scope,
        progress,
    )
    .map_err(map_ingest_error)?;
    bridge.stage_ingest_source_in_mirror(source, refs)?;
    if refs.is_empty() {
        bridge.build_existing_mapping(Some(source))?;
    } else {
        bridge.build_existing_mapping(None)?;
    }
    let mirror_repo = bridge.open_git_repo()?;
    bridge.seed_ingest_identity_mappings_from_mirror(&mirror_repo)?;
    backfill_ingest_identity_notes_in_mirror(bridge, &mirror_repo, refs)?;
    Ok(import_stats_from_ingest(stats))
}

fn map_ingest_error(error: ingest::IngestError) -> GitBridgeError {
    match error {
        ingest::IngestError::ThreadDiverged {
            thread,
            branch,
            existing,
            incoming,
        } => GitBridgeError::GitHeddleThreadDiverged {
            thread,
            branch,
            thread_change: existing,
            branch_change: incoming,
        },
        other => GitBridgeError::Git(other.to_string()),
    }
}

fn reject_shallow_source(source: &Path, refs: &[String]) -> GitResult<()> {
    let repo = open_repo(source)?;
    if repo.git_dir().join("shallow").is_file() {
        let wanted = (!refs.is_empty()).then(|| refs.iter().cloned().collect::<HashSet<_>>());
        return Err(GitBridgeError::ShallowClone {
            repository: repo
                .workdir()
                .unwrap_or_else(|| repo.git_dir().to_path_buf()),
            retry_command: shallow_import_retry_command(wanted.as_ref()),
        });
    }
    Ok(())
}

fn shallow_import_retry_command(wanted_refs: Option<&HashSet<String>>) -> String {
    match wanted_refs.and_then(|refs| refs.iter().next()) {
        Some(_) => "heddle bridge git import --path <full-git-repo> --ref <ref>".to_string(),
        None => "heddle bridge git import --path <full-git-repo>".to_string(),
    }
}

fn import_stats_from_ingest(stats: ingest::ImportStats) -> ImportStats {
    ImportStats {
        commits_imported: stats.commits_imported,
        states_created: stats.states_created,
        branches_synced: stats.refs.threads_written,
        tags_synced: stats.refs.markers_written,
        skipped_non_commit_refs: stats.refs_seen.non_commit_skipped,
        lossy_entries: stats.lossy_entries,
    }
}

fn backfill_ingest_identity_notes_in_mirror(
    bridge: &GitBridge<'_>,
    mirror_repo: &SleyRepository,
    refs: &[String],
) -> GitResult<()> {
    let scoped_commits = if refs.is_empty() {
        None
    } else {
        let updates = collect_import_source_ref_updates(mirror_repo, refs)?;
        Some(reachable_commits_from_updates(mirror_repo, updates)?)
    };

    for (git_sha, change_id) in bridge.heddle_repo.git_overlay_ingest_commit_mapping()? {
        let change_id = ChangeId::parse(&change_id)?;
        let git_oid = git_sha
            .parse::<ObjectId>()
            .map_err(|err| GitBridgeError::InvalidMapping(err.to_string()))?;
        if scoped_commits
            .as_ref()
            .is_some_and(|commits| !commits.contains(&git_oid))
        {
            continue;
        }
        if mirror_repo.read_object(&git_oid).is_err() {
            continue;
        }
        if git_notes::read_note(mirror_repo, git_oid)?.is_some() {
            continue;
        }
        let tier = bridge
            .heddle_repo
            .effective_visibility_tier(&change_id)
            .map_err(|error| {
                GitBridgeError::Git(format!("resolve visibility for {change_id}: {error:#}"))
            })?;
        if !repo::visible(&tier, &repo::AudienceTier::Public) {
            continue;
        }
        let Some(state) = bridge.heddle_repo.store().get_state(&change_id)? else {
            continue;
        };
        git_notes::write_note(
            mirror_repo,
            git_oid,
            &git_notes::HeddleNote::from_state(&state),
        )?;
    }
    Ok(())
}

fn reachable_commits_from_updates(
    repo: &SleyRepository,
    updates: Vec<super::git_core::RefUpdate>,
) -> GitResult<HashSet<ObjectId>> {
    let mut stack = updates
        .into_iter()
        .map(|update| update.target)
        .collect::<Vec<_>>();
    let mut seen = HashSet::new();
    let mut commits = HashSet::new();
    while let Some(oid) = stack.pop() {
        if !seen.insert(oid) {
            continue;
        }
        let object = repo.read_object(&oid).map_err(super::git_core::git_err)?;
        match object.object_type {
            GitObjectType::Commit => {
                commits.insert(oid);
                let commit = repo.read_commit(&oid).map_err(super::git_core::git_err)?;
                stack.extend(commit.parents);
            }
            GitObjectType::Tag => {
                let tag = repo.read_tag(&oid).map_err(super::git_core::git_err)?;
                stack.push(tag.object);
            }
            GitObjectType::Tree | GitObjectType::Blob => {}
        }
    }
    Ok(commits)
}