use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow, bail};
use repo::{Repository, RepositoryCapability};
use serde::Serialize;
use sley::Repository as SleyRepository;
use super::{
action_line::print_next,
advice::RecoveryAdvice,
command_catalog::ActionTemplate,
git_overlay_health::{RepositoryVerificationState, build_repository_verification_state},
import_progress::ImportProgress,
};
use crate::cli::{AdoptArgs, Cli, should_output_json, style};
#[derive(Debug, Serialize)]
struct AdoptOutput {
output_kind: &'static str,
status: &'static str,
action: &'static str,
adopted: bool,
initialized: bool,
path: PathBuf,
refs: Vec<String>,
commits_imported: usize,
states_created: usize,
branches_synced: usize,
tags_synced: usize,
skipped_non_commit_refs: usize,
already_in_sync: bool,
recommended_action: Option<String>,
recommended_action_template: Option<ActionTemplate>,
#[serde(rename = "verification")]
trust: RepositoryVerificationState,
}
#[derive(Debug)]
struct AdoptImportStats {
commits_imported: usize,
states_created: usize,
branches_synced: usize,
tags_synced: usize,
skipped_non_commit_refs: usize,
}
pub fn cmd_adopt(cli: &Cli, args: AdoptArgs) -> Result<()> {
let start = resolve_path(cli, args.path.as_deref())?;
let git_root = git_worktree_root(&start)?;
let initialized = !git_root.join(".heddle").exists();
if initialized {
preflight_importable_git_history(&git_root, &args.refs)?;
}
let repo = if initialized {
Repository::bootstrap_git_overlay(&git_root)?
} else {
Repository::open(&git_root)?
};
if repo.capability() != RepositoryCapability::GitOverlay {
bail!(
"`heddle adopt` is for Git repositories. This checkout is already a native Heddle repository."
);
}
let scope = if args.refs.is_empty() {
"all local branches and tags".to_string()
} else {
format!("{} ref(s): {}", args.refs.len(), args.refs.join(", "))
};
let source_label = repo.root().display().to_string();
let mut progress = ImportProgress::start(cli, &repo, &scope, &source_label);
progress.begin_commit_import();
let stats = import_git_history_for_adopt(&repo, &args.refs, &mut progress)?;
progress.begin_ref_write();
progress.finish();
let trust = build_repository_verification_state(&repo);
let already_in_sync = stats.states_created == 0 && stats.commits_imported > 0;
let recommended_action = action_value(&trust);
let heddle_dir = repo.heddle_dir();
let heddle_data_path = heddle_dir
.strip_prefix(repo.root())
.map(Path::to_path_buf)
.unwrap_or_else(|_| heddle_dir.to_path_buf());
let output = AdoptOutput {
output_kind: "adopt",
status: "completed",
action: "adopt",
adopted: true,
initialized,
path: heddle_data_path,
refs: args.refs,
commits_imported: stats.commits_imported,
states_created: stats.states_created,
branches_synced: stats.branches_synced,
tags_synced: stats.tags_synced,
skipped_non_commit_refs: stats.skipped_non_commit_refs,
already_in_sync,
recommended_action,
recommended_action_template: trust.recommended_action_template.clone(),
trust,
};
render_adopt(&output, should_output_json(cli, Some(repo.config())))
}
fn import_git_history_for_adopt(
repo: &Repository,
refs: &[String],
progress: &mut ImportProgress,
) -> Result<AdoptImportStats> {
let scope = if refs.is_empty() {
ingest::ImportScope::all()
} else {
ingest::ImportScope::refs(refs.to_vec())
};
import_ingest_for_adopt(repo, scope, progress)
}
fn import_ingest_for_adopt(
repo: &Repository,
scope: ingest::ImportScope,
progress: &mut ImportProgress,
) -> Result<AdoptImportStats> {
progress.checking_notes();
crate::bridge::git_core::GitBridge::hydrate_checkout_heddle_notes_without_mirror(repo.root());
progress.ordering_commits();
use ingest::{ImportOptions, import_git_into_scoped_with_options_and_progress};
let mut on_commit = |event: ingest::ImportProgressEvent| progress.commit_tick(event);
let (stats, _map) = import_git_into_scoped_with_options_and_progress(
repo.root(),
repo.root(),
ImportOptions::default(),
scope,
Some(&mut on_commit),
)
.map_err(|error| match error {
ingest::IngestError::ThreadDiverged { thread, branch, .. } => {
RecoveryAdvice::git_heddle_thread_diverged(&thread, &branch).into()
}
other => anyhow::Error::from(other),
})?;
Ok(AdoptImportStats {
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,
})
}
fn action_value(trust: &RepositoryVerificationState) -> Option<String> {
(!trust.recommended_action.trim().is_empty()).then(|| trust.recommended_action.clone())
}
fn preflight_importable_git_history(git_root: &Path, refs: &[String]) -> Result<()> {
if refs.is_empty() {
if git_repo_has_any_commit_ref(git_root)? {
return Ok(());
}
return Err(anyhow!(no_git_commits_to_adopt_advice(
git_root,
Vec::new()
)));
}
let missing = refs
.iter()
.filter(|name| !git_ref_points_to_commit(git_root, name).unwrap_or(false))
.cloned()
.collect::<Vec<_>>();
if missing.is_empty() {
return Ok(());
}
Err(anyhow!(no_git_commits_to_adopt_advice(git_root, missing)))
}
fn git_repo_has_any_commit_ref(git_root: &Path) -> Result<bool> {
let git = SleyRepository::discover(git_root)
.map_err(|error| anyhow!("failed to inspect Git refs: {error}"))?;
for reference in git
.references()
.list_refs()
.map_err(|error| anyhow!("failed to inspect Git refs: {error}"))?
{
let name = reference.name.as_str();
if (name.starts_with("refs/heads/") || name.starts_with("refs/tags/"))
&& git_ref_points_to_commit(git_root, name)?
{
return Ok(true);
}
}
Ok(false)
}
fn git_ref_points_to_commit(git_root: &Path, name: &str) -> Result<bool> {
let spec = format!("{name}^{{commit}}");
let git = SleyRepository::discover(git_root)
.map_err(|error| anyhow!("failed to inspect Git ref '{name}': {error}"))?;
Ok(git.rev_parse(&spec).is_ok())
}
fn no_git_commits_to_adopt_advice(git_root: &Path, missing_refs: Vec<String>) -> RecoveryAdvice {
let primary = "heddle init".to_string();
let detail = if missing_refs.is_empty() {
"no local branch or tag points at a Git commit".to_string()
} else {
format!(
"requested ref(s) do not point at Git commits: {}",
missing_refs.join(", ")
)
};
RecoveryAdvice::safety_refusal(
"git_history_empty",
"No Git commits are available to adopt",
"Run `heddle init` to start tracking this checkout before the first Git commit, or create the first Git commit and rerun `heddle adopt`.",
format!(
"Git repository at {} has no importable commit history; {detail}",
git_root.display()
),
"adopt would initialize Heddle metadata, but there is no Git commit to map into Heddle history",
"Git refs, Heddle metadata, and worktree files were left unchanged",
primary.clone(),
vec![primary],
)
}
fn resolve_path(cli: &Cli, positional: Option<&Path>) -> Result<PathBuf> {
let path = match (positional, cli.repo.as_deref()) {
(Some(positional), Some(repo_path)) => {
if absolute_path(positional)? != absolute_path(repo_path)? {
bail!(RecoveryAdvice::adopt_path_conflict(
&positional.display().to_string(),
&repo_path.display().to_string(),
));
}
positional.to_path_buf()
}
(Some(positional), None) => positional.to_path_buf(),
(None, Some(repo_path)) => repo_path.to_path_buf(),
(None, None) => std::env::current_dir()
.map_err(|error| anyhow!("Failed to determine current directory: {error}"))?,
};
Ok(path.canonicalize().unwrap_or(path))
}
fn absolute_path(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()
.map_err(|error| anyhow!("Failed to determine current directory: {error}"))?
.join(path))
}
}
fn git_worktree_root(start: &Path) -> Result<PathBuf> {
let git = SleyRepository::discover(start).map_err(|error| {
anyhow!(RecoveryAdvice::adopt_requires_git_worktree(Some(format!(
"Git inspection failed: {error}"
))))
})?;
let Some(workdir) = git.workdir() else {
bail!(RecoveryAdvice::adopt_requires_git_worktree(None));
};
Ok(workdir.to_path_buf())
}
fn render_adopt(output: &AdoptOutput, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string(output)?);
return Ok(());
}
if output.initialized {
println!(
"{} Heddle imported the requested Git history",
style::ok_marker()
);
} else if output.already_in_sync {
println!(
"{} Heddle already adopted this Git repo; history is in sync",
style::ok_marker()
);
} else {
println!("{} imported Git history into Heddle", style::ok_marker());
}
println!(
" {}",
style::field(
"Heddle data",
&style::dim(&output.path.display().to_string())
)
);
let scope = if output.refs.is_empty() {
"all local branches and tags".to_string()
} else {
output.refs.join(", ")
};
println!(" {}", style::field("Imported refs", &scope));
println!(
" {}",
style::field(
"Git commits inspected",
&style::bold(&output.commits_imported.to_string())
)
);
println!(
" {}",
style::field(
"New Heddle states",
&style::bold(&output.states_created.to_string())
)
);
println!(
" {}",
style::field(
"Branches ready",
&style::bold(&output.branches_synced.to_string()).to_string()
)
);
println!(
" {}",
style::field(
"Tags ready",
&style::bold(&output.tags_synced.to_string()).to_string()
)
);
if output.skipped_non_commit_refs > 0 {
println!(
"{} skipped {} Git names that do not point at commits",
style::warn_marker(),
style::bold(&output.skipped_non_commit_refs.to_string())
);
}
println!(
"Workspace: {}",
if output.trust.verified {
style::accent("verified")
} else {
style::warn(&output.trust.status)
}
);
if output.trust.worktree_state == "clean" {
println!(
"Git worktree: {}",
style::accent("stays clean; import wrote .heddle metadata and imported Git history")
);
} else {
println!(
"Git worktree: {}",
style::warn(
"left existing changes untouched; import wrote .heddle metadata and imported Git history"
)
);
}
if !output.trust.recommended_action.is_empty() {
print_next(&output.trust.recommended_action);
}
println!("New to Heddle from Git? Run `heddle help git-concepts`.");
Ok(())
}