use std::{
io::{self, Write},
path::{Path, PathBuf},
};
use anyhow::{Result, bail};
use ingest::ImportOptions;
use objects::object::{Principal, ThreadName, Tree};
use refs::Head;
use repo::{Repository, RepositoryCapability, ThreadId};
use serde::Serialize;
use sley::{FullName, GitObjectType, ObjectId, ReferenceTarget, Repository as SleyRepository};
use tracing::{debug, info};
use super::{
RecoveryAdvice,
action_line::print_next,
checkpoint::create_git_checkpoint,
git_overlay_health::{RepositoryVerificationState, build_repository_verification_state},
snapshot::{
SnapshotAgentOverrides, create_snapshot, is_placeholder_principal,
placeholder_principal_warning,
},
};
use crate::{
bridge::{
GitBridge, WriteThroughOutcome, git_core::git_config_identity_with_global_fallback,
git_ingest::import_git_history,
},
cli::{Cli, InitArgs, is_tty, should_output_json, style, worktree_status_options},
config::UserConfig,
};
const QUICKSTART_PLACEHOLDER: &str = "\
# Quickstart
This repository was bootstrapped with `heddle init --quickstart`.
Heddle captured this file as your first state so `heddle log` has
something to show. Replace it with your own work and run
`heddle capture -m \"...\"` to record your next step.
Next:
heddle log # see the history Heddle is tracking
heddle status # check what changed
";
#[derive(Serialize)]
struct InitOutput {
output_kind: &'static str,
status: String,
action: String,
path: PathBuf,
repository_mode: String,
git_detected: bool,
heddle_initialized: bool,
installed_heddleignore: bool,
principal_configured: bool,
principal_status: String,
principal_source: Option<String>,
principal: Option<InitPrincipalOutput>,
principal_recommended_action: Option<String>,
#[serde(skip)]
placeholder_principal_warning: Option<String>,
side_effects: Vec<String>,
message: String,
next_action: Option<String>,
recommended_action: Option<String>,
#[serde(skip)]
quickstart: Option<QuickstartSummary>,
#[allow(dead_code)]
#[serde(skip_serializing)]
#[serde(rename = "verification")]
trust: RepositoryVerificationState,
}
#[derive(Serialize)]
struct InitPrincipalOutput {
name: String,
email: String,
}
struct QuickstartSummary {
thread: String,
change_id: String,
git_commit: Option<String>,
wrote_placeholder: bool,
}
struct QuickstartPreflight {
proceed: bool,
persist_principal: Option<(String, String)>,
attachment: QuickstartAttachmentPlan,
harness_install: Vec<String>,
}
impl Default for QuickstartPreflight {
fn default() -> Self {
Self {
proceed: true,
persist_principal: None,
attachment: QuickstartAttachmentPlan::SkipUnborn,
harness_install: Vec::new(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum QuickstartAttachmentPlan {
Attach,
SkipUnborn,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum QuickstartAttachmentDecision {
Attach,
SkipUnborn,
RefuseCollision,
}
enum QuickstartTarget {
Existing { root: PathBuf, git_overlay: bool },
FreshGitOverlay { root: PathBuf },
FreshNative { root: PathBuf },
}
impl QuickstartTarget {
fn root(&self) -> &Path {
match self {
QuickstartTarget::Existing { root, .. }
| QuickstartTarget::FreshGitOverlay { root }
| QuickstartTarget::FreshNative { root } => root,
}
}
fn is_git_overlay(&self) -> bool {
match self {
QuickstartTarget::Existing { git_overlay, .. } => *git_overlay,
QuickstartTarget::FreshGitOverlay { .. } => true,
QuickstartTarget::FreshNative { .. } => false,
}
}
}
fn resolve_quickstart_target(path: &Path) -> Result<QuickstartTarget> {
let mut discovered_git_root: Option<PathBuf> = None;
let mut current: Option<&Path> = Some(path);
while let Some(dir) = current {
if discovered_git_root.is_none() && dir_is_git_root(dir) {
discovered_git_root = Some(dir.to_path_buf());
}
let heddle = dir.join(".heddle");
if heddle.is_dir()
&& (heddle.join("objects").is_dir() || heddle.join("objectstore").is_file())
{
if let Some(git_root) = discovered_git_root.as_ref()
&& git_root != dir
&& git_root.starts_with(dir)
&& !git_root.join(".heddle").exists()
{
return Ok(QuickstartTarget::FreshGitOverlay {
root: git_root.clone(),
});
}
return Ok(QuickstartTarget::Existing {
root: dir.to_path_buf(),
git_overlay: dir_is_git_root(dir),
});
}
current = dir.parent();
}
match discovered_git_root {
Some(root) => Ok(QuickstartTarget::FreshGitOverlay { root }),
None => Ok(QuickstartTarget::FreshNative {
root: path.to_path_buf(),
}),
}
}
fn dir_is_git_root(dir: &Path) -> bool {
let dot_git = dir.join(".git");
(dot_git.is_dir() || dot_git.is_file()) && SleyRepository::discover(dir).is_ok()
}
pub fn cmd_init(cli: &Cli, args: InitArgs) -> Result<()> {
let path = match (args.path.clone(), cli.repo.clone()) {
(Some(positional), Some(repo_path)) => {
if absolute_path(&positional)? != absolute_path(&repo_path)? {
bail!(RecoveryAdvice::init_path_conflict(
&positional.display().to_string(),
&repo_path.display().to_string(),
));
}
positional
}
(Some(positional), None) => positional,
(None, Some(repo_path)) => repo_path,
(None, None) => std::env::current_dir()
.map_err(|e| anyhow::anyhow!("Failed to determine current directory: {}", e))?,
};
let path = path.canonicalize().unwrap_or(path.clone());
info!(path = %path.display(), "Initializing repository");
let has_git = SleyRepository::discover(&path).is_ok();
let target = if args.quickstart {
Some(resolve_quickstart_target(&path)?)
} else {
None
};
let preflight = match target.as_ref() {
Some(target) => quickstart_preflight(cli, &args, target)?,
None => QuickstartPreflight::default(),
};
if !preflight.proceed {
return Ok(());
}
let repo = match target.as_ref() {
Some(QuickstartTarget::Existing { root, .. }) => Repository::open(root)?,
Some(QuickstartTarget::FreshGitOverlay { root }) => {
Repository::bootstrap_git_overlay(root)?
}
Some(QuickstartTarget::FreshNative { root }) => Repository::init_default(root)?,
None if has_git => Repository::bootstrap_git_overlay(&path)?,
None => Repository::init_default(&path)?,
};
debug!(heddle_dir = %repo.heddle_dir().display(), "Repository initialized");
let installed_heddleignore = false;
let mut user_config = UserConfig::load_default()?;
let mut principal_configured = false;
let mut repo = repo;
let repo_root = repo.root().to_path_buf();
if args.quickstart {
if let Some((name, email)) = &preflight.persist_principal {
let config_path = repo.heddle_dir().join("config.toml");
let mut repo_config = repo::RepoConfig::load(&config_path).unwrap_or_default();
repo_config.set_principal(name.clone(), email.clone());
repo_config.save(&config_path)?;
info!(principal_name = %name, principal_email = %email, "Principal configured");
debug!(config_path = %config_path.display(), "Repo config updated");
repo = Repository::open(&repo_root)?;
principal_configured = true;
}
} else if args.principal_name.is_some() || args.principal_email.is_some() {
let name = args.principal_name.clone().ok_or_else(|| {
anyhow::anyhow!(RecoveryAdvice::init_principal_field_required(
"--principal-name"
))
})?;
let email = args.principal_email.clone().ok_or_else(|| {
anyhow::anyhow!(RecoveryAdvice::init_principal_field_required(
"--principal-email"
))
})?;
user_config.set_principal(name.clone(), email.clone());
let config_path = user_config.save_default()?;
info!(principal_name = %name, principal_email = %email, "Principal configured");
debug!(config_path = %config_path.display(), "User config updated");
principal_configured = true;
}
let quickstart = if args.quickstart {
let summary = run_quickstart_actions(&repo, &args, preflight.attachment)?;
super::perform_init_install(cli, &repo, &args, &preflight.harness_install)?;
Some(summary)
} else {
super::maybe_prompt_init_install(cli, &repo, &args)?;
None
};
let repo_is_git_overlay = if args.quickstart {
repo.capability() == RepositoryCapability::GitOverlay
} else {
has_git
};
let message = if repo_is_git_overlay {
format!(
"Initialized Heddle data in {} for Git-overlay workflows",
repo.heddle_dir().display()
)
} else {
format!(
"Initialized Heddle repository in {}",
repo.heddle_dir().display()
)
};
let trust = build_repository_verification_state(&repo);
let next_action = if quickstart.is_some() {
Some("heddle log".to_string())
} else if !trust.recommended_action.is_empty() {
Some(trust.recommended_action.clone())
} else {
Some("heddle commit -m \"...\"".to_string())
};
let principal_status = init_principal_status(&repo, &user_config)?;
let placeholder_principal_warning = principal_status
.principal
.as_ref()
.map(|principal| Principal::new(&principal.name, &principal.email))
.filter(is_placeholder_principal)
.map(|principal| placeholder_principal_warning(&principal));
let output = InitOutput {
output_kind: "init",
status: "initialized".to_string(),
action: "init".to_string(),
path: repo.heddle_dir().to_path_buf(),
repository_mode: repo.capability_label().to_string(),
git_detected: repo_is_git_overlay,
heddle_initialized: true,
installed_heddleignore,
principal_configured,
principal_status: principal_status.status,
principal_source: principal_status.source,
principal: principal_status.principal,
principal_recommended_action: principal_status.recommended_action,
placeholder_principal_warning,
side_effects: init_side_effects(repo_is_git_overlay, principal_configured),
message,
next_action: next_action.clone(),
recommended_action: next_action,
quickstart,
trust,
};
render_init(&output, should_output_json(cli, Some(repo.config())))
}
fn absolute_path(path: &std::path::Path) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()
.map_err(|e| anyhow::anyhow!("Failed to determine current directory: {}", e))?
.join(path))
}
}
fn render_init(output: &InitOutput, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string(output)?);
} else {
println!("{}", output.message);
match output.principal.as_ref() {
Some(principal) => {
let source = output
.principal_source
.as_deref()
.map(|source| format!(" from {source}"))
.unwrap_or_default();
println!(
"Principal: {} <{}>{source}",
principal.name, principal.email
);
}
None => {
println!("Principal: not configured");
if let Some(action) = output.principal_recommended_action.as_deref() {
println!(" set with: {action}");
}
}
}
if let Some(warning) = output.placeholder_principal_warning.as_deref() {
eprintln!("{}", style::warn(warning));
}
if !output.side_effects.is_empty() {
println!("Side effects:");
for effect in &output.side_effects {
println!(" - {effect}");
}
}
if let Some(quickstart) = output.quickstart.as_ref() {
if quickstart.wrote_placeholder {
println!(
"Wrote {} and captured it as your first state.",
style::accent("QUICKSTART.md")
);
}
println!("Thread: {}", style::bold(&quickstart.thread));
println!("Captured: {}", style::change_id(&quickstart.change_id));
if let Some(commit) = quickstart.git_commit.as_deref() {
println!(
"Checkpoint: {}",
style::dim(&commit[..commit.len().min(12)])
);
}
}
if let Some(next) = output.recommended_action.as_deref() {
print_next(next);
}
}
Ok(())
}
struct InitPrincipalStatus {
status: String,
source: Option<String>,
principal: Option<InitPrincipalOutput>,
recommended_action: Option<String>,
}
fn init_principal_status(
repo: &Repository,
user_config: &UserConfig,
) -> Result<InitPrincipalStatus> {
if let Some(principal) = Principal::from_env()
&& !principal_is_unconfigured(&principal)
{
return Ok(configured_principal_status("environment", principal));
}
if let Some(config) = &repo.config().principal {
let principal = Principal::new(&config.name, &config.email);
if !principal_is_unconfigured(&principal) {
return Ok(configured_principal_status("repository", principal));
}
}
if repo.capability() == RepositoryCapability::GitOverlay {
let principal = repo.get_principal()?;
if !principal_is_unconfigured(&principal) {
return Ok(configured_principal_status("git_config", principal));
}
}
if let Some(config) = &user_config.principal {
let principal = Principal::new(&config.name, &config.email);
if !principal_is_unconfigured(&principal) {
return Ok(configured_principal_status("user_config", principal));
}
}
Ok(InitPrincipalStatus {
status: "not_configured".to_string(),
source: None,
principal: None,
recommended_action: Some(set_principal_command().to_string()),
})
}
fn configured_principal_status(source: &str, principal: Principal) -> InitPrincipalStatus {
InitPrincipalStatus {
status: "configured".to_string(),
source: Some(source.to_string()),
principal: Some(InitPrincipalOutput {
name: principal.name,
email: principal.email,
}),
recommended_action: None,
}
}
fn principal_is_unconfigured(principal: &Principal) -> bool {
principal.name.trim().is_empty()
|| principal.email.trim().is_empty()
|| (principal.name.trim() == "Unknown" && principal.email.trim() == "unknown@example.com")
}
fn set_principal_command() -> &'static str {
"heddle init --principal-name <name> --principal-email <email>"
}
fn init_side_effects(has_git: bool, principal_configured: bool) -> Vec<String> {
let mut side_effects = Vec::new();
if has_git {
side_effects.push("created Heddle sidecar for the existing Git repository".to_string());
side_effects.push("updated .git/info/exclude for Heddle metadata".to_string());
side_effects.push("left Git-tracked files untouched".to_string());
} else {
side_effects.push("created Heddle repository metadata".to_string());
}
if principal_configured {
side_effects.push("updated default principal attribution".to_string());
}
side_effects
}
fn quickstart_preflight(
cli: &Cli,
args: &InitArgs,
target: &QuickstartTarget,
) -> Result<QuickstartPreflight> {
let root = target.root();
let is_git_overlay = target.is_git_overlay();
let repo_config = resolve_existing_repo_config(root);
let json = should_output_json(cli, repo_config.as_ref());
if is_git_overlay && git_head_is_detached(root) {
bail!(quickstart_detached_head_advice());
}
if is_git_overlay && git_has_commits(root) && git_is_shallow(root) {
bail!(quickstart_shallow_clone_advice());
}
let thread = args.quickstart_thread.as_deref().unwrap_or("quickstart");
if let Err(err) = ThreadId::new(thread) {
bail!(RecoveryAdvice::invalid_usage(
"quickstart_thread_name_invalid",
err.to_string(),
"Choose a thread name using only letters, digits, and _ - . / @ : + = \
(no spaces or shell metacharacters).",
"heddle init --quickstart --quickstart-thread <name>",
));
}
if is_git_overlay && !git_branch_name_is_valid(thread) {
bail!(RecoveryAdvice::invalid_usage(
"quickstart_thread_name_invalid",
format!("'{thread}' is not a valid Git branch name"),
"Choose a thread name Git accepts as a branch: no spaces, '~', '^', ':', '?', '*', '[', backslashes, control characters, a leading '-', or the reserved name 'HEAD'.",
"heddle init --quickstart --quickstart-thread <name>",
));
}
let attachment = match quickstart_attachment_decision(root, is_git_overlay, thread) {
QuickstartAttachmentDecision::Attach => QuickstartAttachmentPlan::Attach,
QuickstartAttachmentDecision::SkipUnborn => QuickstartAttachmentPlan::SkipUnborn,
QuickstartAttachmentDecision::RefuseCollision => {
bail!(quickstart_thread_branch_collision_advice(thread));
}
};
let heddle_exists = root.join(".heddle").exists();
let git_nonempty = is_git_overlay && git_has_commits(root);
if (heddle_exists || git_nonempty) && !args.yes {
if !json {
println!(
"{}",
style::warn(
"heddle init --quickstart would act on a directory that already has work:"
)
);
if heddle_exists {
println!(" - existing .heddle/ data is present");
}
if git_nonempty {
println!(" - this Git repository already has commits");
}
println!(
"It would resolve your identity, start the '{thread}' thread, capture once, and (on Git-overlay) checkpoint once."
);
println!("Existing files are not modified.");
}
if json || cli.quiet || !is_tty() {
bail!(quickstart_needs_confirmation_advice());
}
print!("Proceed? [y/N] ");
io::stdout().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
println!("Aborted; no changes made.");
return Ok(QuickstartPreflight {
proceed: false,
..QuickstartPreflight::default()
});
}
}
let persist_principal = resolve_quickstart_identity(cli, args, root, is_git_overlay, json)?;
let harness_install = super::prompt_init_install_decision(cli, root, args, json)?;
Ok(QuickstartPreflight {
proceed: true,
persist_principal,
attachment,
harness_install,
})
}
fn resolve_quickstart_identity(
cli: &Cli,
args: &InitArgs,
root: &Path,
is_git_overlay: bool,
json: bool,
) -> Result<Option<(String, String)>> {
if let Some(env_principal) = Principal::from_env()
&& principal_is_unconfigured(&env_principal)
{
bail!(quickstart_identity_required_advice());
}
let flag_principal = match (args.principal_name.clone(), args.principal_email.clone()) {
(Some(name), Some(email)) => Some((name, email)),
(Some(_), None) => {
bail!(RecoveryAdvice::init_principal_field_required(
"--principal-email"
))
}
(None, Some(_)) => {
bail!(RecoveryAdvice::init_principal_field_required(
"--principal-name"
))
}
(None, None) => None,
};
if let Some((name, email)) = flag_principal {
if principal_is_unconfigured(&Principal::new(&name, &email)) {
bail!(quickstart_identity_required_advice());
}
return Ok(Some((name, email)));
}
let resolved = resolve_quickstart_principal(root, is_git_overlay);
if !principal_is_unconfigured(&resolved) {
return Ok(None);
}
if is_tty() && !cli.quiet && !json {
let name = prompt_line("Your name: ")?;
let email = prompt_line("Your email: ")?;
if principal_is_unconfigured(&Principal::new(&name, &email)) {
bail!(quickstart_identity_required_advice());
}
return Ok(Some((name, email)));
}
bail!(quickstart_identity_required_advice())
}
fn resolve_quickstart_principal(root: &Path, is_git_overlay: bool) -> Principal {
if let Some(principal) = Principal::from_env() {
return principal;
}
if let Some(repo_config) = resolve_existing_repo_config(root)
&& let Some(config) = &repo_config.principal
{
return Principal::new(&config.name, &config.email);
}
if is_git_overlay && let Ok(Some(identity)) = git_config_identity_with_global_fallback(root) {
let principal = Principal::new(&identity.name, &identity.email);
if !principal_is_unconfigured(&principal) {
return principal;
}
}
if let Some(principal) = quickstart_shared_checkout_parent_principal(root)
&& !principal_is_unconfigured(&principal)
{
return principal;
}
if let Ok(user_config) = UserConfig::load_default()
&& let Some(config) = &user_config.principal
{
return Principal::new(&config.name, &config.email);
}
Principal::new("Unknown", "unknown@example.com")
}
fn quickstart_shared_checkout_parent_principal(root: &Path) -> Option<Principal> {
let pointer = root.join(".heddle").join("objectstore");
if !pointer.is_file() {
return None;
}
let content = std::fs::read_to_string(&pointer).ok()?;
let shared = parse_objectstore_pointer(&content)?.canonicalize().ok()?;
let parent = shared.parent()?;
if parent == root {
return None;
}
let identity = git_config_identity_with_global_fallback(parent).ok()??;
Some(Principal::new(&identity.name, &identity.email))
}
fn prompt_line(label: &str) -> Result<String> {
print!("{label}");
io::stdout().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
}
fn git_has_commits(path: &Path) -> bool {
let Ok(repo) = SleyRepository::discover(path) else {
return false;
};
if repo.head().ok().and_then(|head| head.oid).is_some() {
return true;
}
let Ok(refs) = repo.references().list_refs() else {
return false;
};
for reference in refs {
let ReferenceTarget::Direct(oid) = reference.target else {
continue;
};
if object_peels_to_commit(&repo, oid) {
return true;
}
}
false
}
fn object_peels_to_commit(repo: &SleyRepository, mut oid: ObjectId) -> bool {
loop {
let Ok(object) = repo.read_object(&oid) else {
return false;
};
match object.object_type {
GitObjectType::Commit => return true,
GitObjectType::Tag => {
let Ok(tag) = repo.read_tag(&oid) else {
return false;
};
oid = tag.object;
}
_ => return false,
}
}
}
fn quickstart_attachment_decision(
path: &Path,
is_git_overlay: bool,
thread: &str,
) -> QuickstartAttachmentDecision {
if !is_git_overlay || !path.join(".git").exists() || !git_has_commits(path) {
return QuickstartAttachmentDecision::SkipUnborn;
}
let Ok(repo) = SleyRepository::discover(path) else {
return QuickstartAttachmentDecision::SkipUnborn;
};
let Some(head) = repo.head().ok().and_then(|head| head.oid) else {
return QuickstartAttachmentDecision::SkipUnborn;
};
let Ok(Some(reference)) = repo.find_reference(&format!("refs/heads/{thread}")) else {
return QuickstartAttachmentDecision::Attach;
};
let Ok(Some(branch_tip)) = reference.peeled_oid(&repo) else {
return QuickstartAttachmentDecision::Attach;
};
if head == branch_tip {
QuickstartAttachmentDecision::Attach
} else {
QuickstartAttachmentDecision::RefuseCollision
}
}
fn git_branch_name_is_valid(name: &str) -> bool {
if FullName::try_from(format!("refs/heads/{name}").as_str()).is_err() {
return false;
}
!(name == "HEAD" || name == "@" || name.starts_with('-'))
}
fn git_is_shallow(path: &Path) -> bool {
SleyRepository::discover(path)
.ok()
.map(|repo| repo.git_dir().join("shallow").is_file())
.unwrap_or(false)
}
fn git_head_is_detached(path: &Path) -> bool {
SleyRepository::discover(path)
.ok()
.and_then(|repo| repo.head().ok().map(|head| head.is_detached()))
.unwrap_or(false)
}
fn resolve_existing_repo_config(path: &Path) -> Option<repo::RepoConfig> {
let heddle_dir = path.join(".heddle");
if !heddle_dir.is_dir() {
return None;
}
let pointer = heddle_dir.join("objectstore");
let config_path = if pointer.is_file() {
let content = std::fs::read_to_string(&pointer).ok()?;
let shared = parse_objectstore_pointer(&content)?;
shared.canonicalize().ok()?.join("config.toml")
} else {
heddle_dir.join("config.toml")
};
repo::RepoConfig::load(&config_path).ok()
}
fn parse_objectstore_pointer(content: &str) -> Option<PathBuf> {
content.lines().find_map(|line| {
line.strip_prefix("objectstore:")
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
})
}
fn run_quickstart_actions(
repo: &Repository,
args: &InitArgs,
attachment: QuickstartAttachmentPlan,
) -> Result<QuickstartSummary> {
if repo.capability() == RepositoryCapability::GitOverlay && git_has_commits(repo.root()) {
let mut bridge = GitBridge::new(repo);
import_git_history(
&mut bridge,
Some(repo.root()),
&[],
ImportOptions::default(),
None,
)?;
}
let thread = args
.quickstart_thread
.clone()
.unwrap_or_else(|| "quickstart".to_string());
ensure_quickstart_thread(repo, &thread)?;
match attachment {
QuickstartAttachmentPlan::Attach => {
let mut bridge = GitBridge::new(repo);
if let WriteThroughOutcome::Skipped(reason) =
bridge.write_through_thread_checkout(&thread)?
{
bail!(RecoveryAdvice::safety_refusal(
"quickstart_thread_checkout_skipped",
format!("Could not attach the Git checkout to thread '{thread}': {reason}"),
"Resolve the Git checkout issue and re-run `heddle init --quickstart`.",
reason.to_string(),
"quickstart would capture and checkpoint on the requested thread, but the Git checkout could not be attached to its branch",
"the current Heddle state was preserved",
"heddle init --quickstart",
vec!["heddle init --quickstart".to_string()],
));
}
}
QuickstartAttachmentPlan::SkipUnborn => {}
}
let user_config = UserConfig::load_default().unwrap_or_default();
let wrote_placeholder = ensure_capturable_content(repo)?;
let snapshot = create_snapshot(
repo,
&user_config,
Some("quickstart: initial capture".to_string()),
None,
SnapshotAgentOverrides {
provider: None,
model: None,
session: None,
segment: None,
policy: None,
no_policy: false,
no_agent: false,
},
)?;
let git_commit = if repo.capability() == RepositoryCapability::GitOverlay {
let record = create_git_checkpoint(
repo,
Some("quickstart: first commit"),
worktree_status_options(Some(repo.config())),
)?;
Some(record.git_commit)
} else {
None
};
Ok(QuickstartSummary {
thread,
change_id: snapshot.change_id,
git_commit,
wrote_placeholder,
})
}
fn ensure_quickstart_thread(repo: &Repository, name: &str) -> Result<()> {
let target = ThreadName::new(name);
let already_attached =
matches!(repo.head_ref()?, Head::Attached { thread } if thread == target);
if !already_attached && let Some(state) = repo.current_state()? {
repo.refs().set_thread(&target, &state.change_id)?;
}
if !already_attached {
repo.refs().write_head(&Head::Attached { thread: target })?;
}
Ok(())
}
fn ensure_capturable_content(repo: &Repository) -> Result<bool> {
let options = worktree_status_options(Some(repo.config()));
let (status, _) = repo.compare_worktree_cached_profiled_with_options(&Tree::new(), &options)?;
if !status.added.is_empty() {
return Ok(false);
}
let placeholder = repo.root().join("QUICKSTART.md");
if !placeholder.exists() {
std::fs::write(&placeholder, QUICKSTART_PLACEHOLDER)?;
}
Ok(true)
}
fn quickstart_needs_confirmation_advice() -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"quickstart_needs_confirmation",
"Refusing to run --quickstart non-interactively against a directory that already has Heddle data or Git history",
"Re-run with `--yes` to confirm, or run `heddle init --quickstart` in an interactive terminal to answer the prompt.",
"the target directory already has .heddle/ data or non-empty Git history and no interactive terminal is available to confirm",
"quickstart would start a thread and capture in a directory that already holds work",
"no repository objects, refs, metadata, or worktree files were changed",
"heddle init --quickstart --yes",
vec!["heddle init --quickstart --yes".to_string()],
)
}
fn quickstart_thread_branch_collision_advice(thread: &str) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"quickstart_thread_branch_collision",
format!(
"Refusing to run --quickstart: a Git branch named '{thread}' already exists at a different commit than the current checkout"
),
format!(
"Pass `--quickstart-thread <name>` to use a different thread name, or switch to '{thread}' (`git switch {thread}`) and run the normal capture flow."
),
format!(
"a Git branch '{thread}' already exists and points at history unrelated to the current branch"
),
format!(
"quickstart would attach the '{thread}' thread to the current branch's state and move refs/heads/{thread} onto it, silently discarding the existing branch's history"
),
"no repository objects, refs, metadata, or worktree files were changed",
"heddle init --quickstart --quickstart-thread <name>",
vec![
"heddle init --quickstart --quickstart-thread <name>".to_string(),
format!("git switch {thread}"),
],
)
}
fn quickstart_detached_head_advice() -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"quickstart_detached_head",
"Refusing to run --quickstart on a detached Git HEAD",
"Attach a branch first with `git switch -c <branch>` (or `git switch <branch>`), then re-run `heddle init --quickstart`.",
"Git HEAD points directly at a commit instead of an attached branch",
"quickstart would import history and write a Git checkpoint through a branch, but a detached HEAD has no branch to advance and could reattach or move the wrong ref",
"no repository objects, refs, metadata, or worktree files were changed",
"git switch -c <branch>",
vec![
"git switch -c <branch>".to_string(),
"heddle init --quickstart".to_string(),
],
)
}
fn quickstart_shallow_clone_advice() -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"quickstart_shallow_clone",
"Refusing to run --quickstart on a shallow Git clone",
"Fetch full history first with `git fetch --unshallow`, then re-run `heddle init --quickstart`.",
"the Git checkout is shallow (.git/shallow is present)",
"quickstart would import Git history, but Heddle cannot import a shallow clone until its full ancestry is available",
"no repository objects, refs, metadata, or worktree files were changed",
"git fetch --unshallow",
vec![
"git fetch --unshallow".to_string(),
"heddle init --quickstart".to_string(),
],
)
}
fn quickstart_identity_required_advice() -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"quickstart_identity_required",
"Refusing to run --quickstart without an accountable identity",
"Pass `--principal-name <name> --principal-email <email>`, configure identity first, or run in an interactive terminal to be prompted.",
"no principal was resolvable from flags, environment, user config, or Git config, and no interactive terminal is available to prompt",
"quickstart would capture history attributed to Unknown <unknown@example.com>",
"no repository objects, refs, metadata, or worktree files were changed",
"heddle init --quickstart --principal-name <name> --principal-email <email>",
vec![
"heddle init --quickstart --principal-name <name> --principal-email <email>"
.to_string(),
],
)
}