use std::time::Instant;
use anyhow::{Context, Result, anyhow};
use heddle_git_projection::{GitProjection, WriteThroughOutcome};
use objects::{
HeddleError, RecoveryDetails,
lock::RepositoryLockExt,
object::{Agent, Attribution, ContentHash, Principal, State, StateId, Tree},
};
use oplog::{OpLogBackend, OpRecord};
use refs::Head;
use repo::{
GitCheckpointRecord, Hook, HookContext, HookManager, Repository, RepositoryCapability,
SnapshotProfile, WorktreeStatusOptions, refresh_active_thread_metadata,
};
use serde::Serialize;
use sley::Repository as SleyRepository;
use crate::{
RepositoryVerificationState, build_repository_verification_health_with_worktree_status,
build_repository_verification_state, build_repository_verification_state_with_worktree_status,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GitScope {
None,
Staged,
WorktreeAll,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SaveVerb {
Capture,
Commit,
Checkpoint,
}
#[derive(Debug)]
pub struct SavePlan {
pub verb: SaveVerb,
pub intent: Option<String>,
pub confidence: Option<f32>,
pub attribution: Attribution,
pub git_scope: GitScope,
pub supplied_tree: Option<Tree>,
pub reuse_current_state: bool,
pub require_clean_worktree: bool,
pub worktree_status_options: WorktreeStatusOptions,
pub run_hooks: bool,
pub commit_safe_post_verify: bool,
pub coalesce_snapshot_and_checkpoint: bool,
pub linearize_git_parent: bool,
pub precomputed_worktree_status:
Option<repo::Result<Option<objects::worktree::WorktreeStatus>>>,
}
impl SavePlan {
pub fn capture(intent: impl Into<String>, attribution: Attribution) -> Self {
Self {
verb: SaveVerb::Capture,
intent: Some(intent.into()),
confidence: None,
attribution,
git_scope: GitScope::None,
supplied_tree: None,
reuse_current_state: false,
require_clean_worktree: false,
worktree_status_options: WorktreeStatusOptions::default(),
run_hooks: true,
commit_safe_post_verify: false,
coalesce_snapshot_and_checkpoint: false,
linearize_git_parent: false,
precomputed_worktree_status: None,
}
}
pub fn commit(
intent: impl Into<String>,
attribution: Attribution,
git_scope: GitScope,
) -> Self {
Self {
verb: SaveVerb::Commit,
intent: Some(intent.into()),
confidence: None,
attribution,
git_scope,
supplied_tree: None,
reuse_current_state: false,
require_clean_worktree: matches!(git_scope, GitScope::WorktreeAll),
worktree_status_options: WorktreeStatusOptions::default(),
run_hooks: true,
commit_safe_post_verify: true,
coalesce_snapshot_and_checkpoint: matches!(
git_scope,
GitScope::Staged | GitScope::WorktreeAll
),
linearize_git_parent: false,
precomputed_worktree_status: None,
}
}
pub fn checkpoint(message: Option<String>, attribution: Attribution, staged: bool) -> Self {
Self {
verb: SaveVerb::Checkpoint,
intent: message,
confidence: None,
attribution,
git_scope: if staged {
GitScope::Staged
} else {
GitScope::WorktreeAll
},
supplied_tree: None,
reuse_current_state: true,
require_clean_worktree: !staged,
worktree_status_options: WorktreeStatusOptions::default(),
run_hooks: true,
commit_safe_post_verify: false,
coalesce_snapshot_and_checkpoint: false,
linearize_git_parent: false,
precomputed_worktree_status: None,
}
}
pub fn with_confidence(mut self, confidence: Option<f32>) -> Self {
self.confidence = confidence;
self
}
pub fn with_supplied_tree(mut self, tree: Tree) -> Self {
self.supplied_tree = Some(tree);
self
}
pub fn with_worktree_status_options(mut self, options: WorktreeStatusOptions) -> Self {
self.worktree_status_options = options;
self
}
pub fn with_precomputed_worktree_status(
mut self,
status: repo::Result<Option<objects::worktree::WorktreeStatus>>,
) -> Self {
self.precomputed_worktree_status = Some(status);
self
}
}
#[derive(Debug, Clone)]
pub struct SaveReport {
pub verb: SaveVerb,
pub state_id: StateId,
pub content_hash: ContentHash,
pub intent: Option<String>,
pub confidence: Option<f32>,
pub signed: bool,
pub git_commit: Option<String>,
pub git_previous_commit: Option<String>,
pub summary: String,
pub principal: Principal,
pub agent: Option<Agent>,
pub promotion_suggested: bool,
pub heavy_impact_paths: Vec<String>,
pub verification: RepositoryVerificationState,
pub created_new_state: bool,
pub git_checkpoint: Option<GitCheckpointRecord>,
pub snapshot_profile: SnapshotProfile,
pub thread_metadata_ms: u128,
}
pub fn plan_git_scope(
verb: SaveVerb,
capability: RepositoryCapability,
staged_index_paths: bool,
include_all_worktree: bool,
) -> GitScope {
match verb {
SaveVerb::Capture => GitScope::None,
SaveVerb::Checkpoint => {
if staged_index_paths {
GitScope::Staged
} else {
GitScope::WorktreeAll
}
}
SaveVerb::Commit => {
if capability != RepositoryCapability::GitOverlay {
GitScope::None
} else if staged_index_paths && !include_all_worktree {
GitScope::Staged
} else {
GitScope::WorktreeAll
}
}
}
}
pub fn plan_creates_new_state(plan: &SavePlan, has_current_state: bool) -> bool {
if plan.supplied_tree.is_some() {
return true;
}
if plan.reuse_current_state && has_current_state {
return false;
}
if plan.verb == SaveVerb::Checkpoint && has_current_state {
return false;
}
true
}
pub fn plan_writes_git_checkpoint(plan: &SavePlan, capability: RepositoryCapability) -> bool {
plan.git_scope != GitScope::None && capability == RepositoryCapability::GitOverlay
}
pub fn tree_leaf_name(path: &str) -> String {
path.rsplit('/').next().unwrap_or(path).to_string()
}
pub fn commit_next_action_from_trust(
recommended_action: &str,
verified: bool,
has_default_remote: bool,
) -> Option<String> {
if !recommended_action.trim().is_empty() {
return Some(recommended_action.to_string());
}
if !verified {
return Some("heddle verify".to_string());
}
has_default_remote.then(|| "heddle push".to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitGitIndexPlan {
pub commit_mode: &'static str,
pub has_staged_changes: bool,
pub staged_paths: Vec<String>,
pub unstaged_paths: Vec<String>,
pub untracked_paths: Vec<String>,
pub will_commit: Vec<String>,
pub preserved_after_commit: Vec<String>,
}
pub fn split_git_extra_paths(extra_paths: &[String]) -> (Vec<String>, Vec<String>) {
let mut unstaged_paths = Vec::new();
let mut untracked_paths = Vec::new();
for path in extra_paths {
if let Some(path) = path.strip_prefix("unstaged: ") {
unstaged_paths.push(path.to_string());
} else if let Some(path) = path.strip_prefix("untracked: ") {
untracked_paths.push(path.to_string());
}
}
(unstaged_paths, untracked_paths)
}
pub fn plan_commit_git_index(
staged_paths: &[String],
extra_paths: &[String],
include_all: bool,
) -> CommitGitIndexPlan {
let (unstaged_paths, untracked_paths) = split_git_extra_paths(extra_paths);
let has_staged_changes = !staged_paths.is_empty();
let mut will_commit = Vec::new();
if has_staged_changes {
will_commit.extend(staged_paths.iter().cloned());
}
if include_all || !has_staged_changes {
will_commit.extend(unstaged_paths.iter().cloned());
will_commit.extend(untracked_paths.iter().cloned());
}
let commit_mode = if has_staged_changes && include_all {
"worktree_all_explicit"
} else if has_staged_changes {
"staged_index"
} else if will_commit.is_empty() {
"none"
} else {
"worktree_all"
};
let preserved_after_commit = if has_staged_changes && !include_all {
extra_paths.to_vec()
} else {
Vec::new()
};
CommitGitIndexPlan {
commit_mode,
has_staged_changes,
staged_paths: staged_paths.to_vec(),
unstaged_paths,
untracked_paths,
will_commit,
preserved_after_commit,
}
}
pub fn plan_commit_git_index_only(
staged_paths: &[String],
extra_paths: &[String],
) -> CommitGitIndexPlan {
let (unstaged_paths, untracked_paths) = split_git_extra_paths(extra_paths);
CommitGitIndexPlan {
commit_mode: "staged_index",
has_staged_changes: !staged_paths.is_empty(),
staged_paths: staged_paths.to_vec(),
unstaged_paths,
untracked_paths,
will_commit: staged_paths.to_vec(),
preserved_after_commit: extra_paths.to_vec(),
}
}
pub fn commit_scope_text(commit_mode: &str) -> &'static str {
match commit_mode {
"staged_index" => {
"staged Git index only; unstaged and untracked paths stay in the worktree"
}
"worktree_all_explicit" => "all staged, unstaged, and untracked worktree changes (--all)",
"worktree_all" => "all unstaged and untracked worktree changes",
"none" => "no Git paths",
_ => "Git worktree changes",
}
}
pub fn staged_commit_summary(
summary: &str,
staged_path_count: usize,
extra_path_count: usize,
) -> String {
if extra_path_count == 0 {
return summary.to_string();
}
format!(
"{summary} (committed {staged_path_count} staged path(s); left {extra_path_count} unstaged/untracked path(s) in the worktree)"
)
}
pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
if plan.git_scope != GitScope::None && repo.capability() != RepositoryCapability::GitOverlay {
return Err(anyhow!(HeddleError::recovery(
RecoveryDetails::safety_refusal(
"native_checkpoint_unavailable",
"Git checkpointing is only available in Git-overlay repositories",
"Use `heddle capture -m \"...\"` to save Heddle state in a native checkout.",
"this checkout is not a Git-overlay repository",
"checkpoint would try to write a Git commit where no active Git store is bound",
"repository state, refs, and worktree files were left unchanged",
),
)));
}
let has_current = repo.current_state()?.is_some();
let mut created_new_state = false;
let mut snapshot_profile = SnapshotProfile::default();
let mut thread_metadata_ms = 0u128;
let mut promotion_suggested = false;
let mut heavy_impact_paths = Vec::new();
let mut snapshot_state_id: Option<StateId> = None;
let mut state = if plan_creates_new_state(&plan, has_current) {
created_new_state = true;
let execution = create_heddle_state(repo, &plan)?;
snapshot_profile = execution.profile;
thread_metadata_ms = execution.thread_metadata_ms;
promotion_suggested = execution.promotion_suggested;
heavy_impact_paths = execution.heavy_impact_paths;
snapshot_state_id = Some(execution.state.state_id);
execution.state
} else {
repo.current_state()?
.ok_or_else(|| anyhow!("no captured state found for save"))?
};
let mut git_commit = None;
let mut git_previous_commit = None;
let mut git_checkpoint = None;
if plan_writes_git_checkpoint(&plan, repo.capability()) {
if plan.require_clean_worktree {
let tree = repo.require_tree(&state.tree)?;
let status = repo.compare_worktree_cached_detailed_with_options(
&tree,
&plan.worktree_status_options,
)?;
if !status.is_clean() {
return Err(anyhow!(HeddleError::recovery(
RecoveryDetails::safety_refusal(
"dirty_worktree",
"Save worktree changes before committing",
"Save the work with `heddle capture -m \"...\"`, then retry the commit.",
"the current Heddle state was left unchanged; these paths have not been captured",
"commit would write Git history that does not include dirty worktree paths",
"the current Heddle state was left unchanged; these paths have not been captured",
),
)));
}
}
if let Some(existing) = repo.latest_git_checkpoint_for_state(&state.state_id)?
&& repo.pending_git_checkpoint_intent()?.is_none()
{
git_commit = Some(existing.git_commit.clone());
git_checkpoint = Some(existing);
} else {
let previous = repo
.pending_git_checkpoint_intent()?
.and_then(|intent| intent.previous_git_oid)
.or_else(|| git_rev_parse_head(repo.root()));
git_previous_commit = previous.clone();
let summary = checkpoint_summary(&plan, &state);
let record = write_git_checkpoint(repo, &state, summary, plan.linearize_git_parent)?;
if plan.coalesce_snapshot_and_checkpoint
&& let Some(state_id) = snapshot_state_id.as_ref()
{
coalesce_snapshot_and_checkpoint(repo, state_id, &record.git_commit)?;
}
git_commit = Some(record.git_commit.clone());
git_checkpoint = Some(record);
}
}
let mut verification = if created_new_state || git_checkpoint.is_some() {
build_repository_verification_state(repo)?
} else if let Some(status) = &plan.precomputed_worktree_status {
let health = build_repository_verification_health_with_worktree_status(repo, status);
build_repository_verification_state_with_worktree_status(repo, health, status)
} else {
build_repository_verification_state(repo)?
};
if plan.commit_safe_post_verify {
soften_commit_next_action(&mut verification);
}
let summary = match plan.verb {
SaveVerb::Capture => format!(
"Captured state {} ({})",
state.state_id.short(),
state.hash().short()
),
SaveVerb::Commit => plan
.intent
.clone()
.unwrap_or_else(|| format!("Commit {}", state.state_id.short())),
SaveVerb::Checkpoint => git_checkpoint
.as_ref()
.map(|r| r.summary.clone())
.unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short())),
};
Ok(SaveReport {
verb: plan.verb,
state_id: state.state_id,
content_hash: state.hash(),
intent: state.intent.clone(),
confidence: state.confidence,
signed: repo.get_state_signature(&state.id())?.is_some(),
git_commit,
git_previous_commit,
summary,
principal: state.attribution.principal.clone(),
agent: state.attribution.agent.clone(),
promotion_suggested,
heavy_impact_paths,
verification,
created_new_state,
git_checkpoint,
snapshot_profile,
thread_metadata_ms,
})
}
struct CreatedState {
state: State,
profile: SnapshotProfile,
thread_metadata_ms: u128,
promotion_suggested: bool,
heavy_impact_paths: Vec<String>,
}
fn create_heddle_state(repo: &Repository, plan: &SavePlan) -> Result<CreatedState> {
let hook_manager = HookManager::new(repo);
let hook_ctx = HookContext::new(repo);
if plan.run_hooks {
hook_manager.run(Hook::PreSnapshot, &hook_ctx)?;
let pre_capture_payload = serde_json::json!({
"thread": current_thread_name(repo),
"intent": plan.intent.clone().unwrap_or_default(),
});
let pre_capture_response = hook_manager.run_with_payload(
Hook::PreSnapshot,
&hook_ctx,
&pre_capture_payload,
std::time::Duration::from_secs(5),
)?;
if let Some(resp) = pre_capture_response
&& !resp.abort.is_empty()
{
return Err(anyhow!(HeddleError::recovery(
RecoveryDetails::safety_refusal(
"hook_veto",
format!("pre_capture hook vetoed: {}", resp.abort),
"Inspect `pre_capture` with `heddle hook list`, update the hook policy or inputs, then retry.",
format!("pre_capture hook vetoed capture: {}", resp.abort),
"capture would continue after repository policy explicitly aborted the operation",
"the operation stopped at the hook boundary before the protected action ran",
)
.with_recovery_commands(vec!["heddle hook list".to_string()]),
)));
}
}
let mut execution = if let Some(tree) = plan.supplied_tree.clone() {
repo.snapshot_tree_with_attribution_profiled(
tree,
plan.intent.clone(),
plan.confidence,
plan.attribution.clone(),
)?
} else {
repo.snapshot_with_attribution_profiled(
plan.intent.clone(),
plan.confidence,
plan.attribution.clone(),
)?
};
let thread_metadata_start = Instant::now();
let refresh = refresh_active_thread_metadata(repo, &execution.state, &execution.tree)?;
let thread_metadata_ms = thread_metadata_start.elapsed().as_millis();
if plan.run_hooks {
hook_manager.run(Hook::PostSnapshot, &hook_ctx)?;
let post_capture_payload = serde_json::json!({
"state_id": execution.state.state_id.to_string_full(),
});
if let Err(err) = hook_manager.run_with_payload(
Hook::PostSnapshot,
&hook_ctx,
&post_capture_payload,
std::time::Duration::from_secs(5),
) {
tracing::warn!(error = %err, "post_capture hook error swallowed");
}
}
Ok(CreatedState {
state: execution.state,
profile: std::mem::take(&mut execution.profile),
thread_metadata_ms,
promotion_suggested: refresh.promotion_suggested,
heavy_impact_paths: refresh.heavy_impact_paths,
})
}
fn write_git_checkpoint(
repo: &Repository,
state: &State,
summary: String,
linearize_git_parent: bool,
) -> Result<GitCheckpointRecord> {
let _lock = repo.locker().write()?;
objects::fault_inject::maybe_fail_at("git_checkpoint_before_write_through")?;
let mut bridge = GitProjection::new(repo);
if linearize_git_parent {
bridge.linearize_unmapped_tip_to_checkout();
}
let git_commit = match bridge
.write_through_current_checkout_with_message(state.state_id, summary.clone())?
{
WriteThroughOutcome::Wrote(git_commit) => git_commit.to_string(),
WriteThroughOutcome::Skipped(reason) => {
return Err(anyhow!(HeddleError::recovery(
RecoveryDetails::safety_refusal(
"checkpoint_git_write_skipped",
format!("Git checkpoint write-through was skipped: {reason}"),
"Inspect `heddle verify`, resolve the skip reason, then retry `heddle land`.",
format!("write-through skipped: {reason}"),
"checkpoint would need to write the current Heddle state into the Git branch and index",
"the current Heddle state was preserved; no Git checkpoint record was written",
),
)));
}
};
let intent = repo.pending_git_checkpoint_intent()?.ok_or_else(|| {
anyhow!("Git checkpoint published without its durable finalization intent")
})?;
if intent.phase != repo::GitCheckpointIntentPhase::Published
|| intent.state_id != state.state_id.to_string_full()
|| intent.new_git_oid != git_commit
{
return Err(anyhow!(
"published Git checkpoint does not match its durable finalization intent"
));
}
finalize_published_git_checkpoint(repo, &state.state_id, git_commit, summary, intent)
}
pub fn recover_published_git_checkpoint(
repo: &Repository,
state_id: &StateId,
) -> Result<Option<GitCheckpointRecord>> {
let _lock = repo.locker().write()?;
let Some(mut intent) = repo.pending_git_checkpoint_intent()? else {
return Ok(None);
};
if intent.state_id != state_id.to_string_full() {
return Ok(None);
}
let current_branch = repo.git_overlay_current_branch()?;
if current_branch.as_deref() != Some(intent.branch.as_str()) {
return Err(anyhow!(
"pending Git checkpoint targets branch '{}' but the checkout is on '{}'",
intent.branch,
current_branch.as_deref().unwrap_or("detached HEAD")
));
}
let current_oid = git_rev_parse_head(repo.root());
if intent.phase == repo::GitCheckpointIntentPhase::Prepared {
if current_oid == intent.previous_git_oid {
return Ok(None);
}
if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
return Err(anyhow!(
"prepared Git checkpoint expected HEAD at {} or {}, found {}",
intent.previous_git_oid.as_deref().unwrap_or("<unborn>"),
intent.new_git_oid,
current_oid.as_deref().unwrap_or("<unborn>")
));
}
let git_oid = intent.new_git_oid.clone();
intent = repo.mark_git_checkpoint_published(state_id, &git_oid)?;
}
if intent.phase != repo::GitCheckpointIntentPhase::Published {
return Ok(None);
}
if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
return Err(anyhow!(
"published Git checkpoint expected HEAD at {}, found {}",
intent.new_git_oid,
current_oid.as_deref().unwrap_or("<unborn>")
));
}
let git_commit = intent.new_git_oid.clone();
let summary = intent.summary.clone();
finalize_published_git_checkpoint(repo, state_id, git_commit, summary, intent).map(Some)
}
fn finalize_published_git_checkpoint(
repo: &Repository,
state_id: &StateId,
git_commit: String,
summary: String,
intent: repo::GitCheckpointIntent,
) -> Result<GitCheckpointRecord> {
let record = repo.record_git_checkpoint(state_id, git_commit.clone(), summary)?;
objects::fault_inject::maybe_panic_at("git_checkpoint_after_metadata_before_oplog");
let transaction_id = format!(
"git-checkpoint:v1:{}:{}",
state_id.to_string_full(),
git_commit
);
repo.oplog().record_batch_exactly_once(
vec![
OpRecord::GitCheckpoint {
branch: intent.branch,
state: *state_id,
previous_git_oid: intent.previous_git_oid,
new_git_oid: git_commit.clone(),
},
OpRecord::TransactionCommit {
transaction_id: transaction_id.clone(),
op_count: 1,
},
],
Some(&repo.op_scope()),
&transaction_id,
)?;
objects::fault_inject::maybe_panic_at("git_checkpoint_after_oplog_before_finalize");
repo.finish_git_checkpoint_intent(state_id, &git_commit)?;
Ok(record)
}
fn coalesce_snapshot_and_checkpoint(
repo: &Repository,
state_id: &StateId,
git_commit: &str,
) -> Result<()> {
let snapshot_batch = repo
.oplog()
.recent_batches_scoped(8, Some(&repo.op_scope()))?
.into_iter()
.find(|batch| {
batch.entries.iter().any(|entry| {
matches!(
&entry.operation,
OpRecord::Snapshot { new_state, .. } if new_state == state_id
)
})
})
.ok_or_else(|| anyhow!("capture succeeded but its oplog batch was not found"))?;
let checkpoint_batch = repo
.oplog()
.recent_batches_scoped(8, Some(&repo.op_scope()))?
.into_iter()
.find(|batch| {
batch.entries.iter().any(|entry| {
matches!(
&entry.operation,
OpRecord::GitCheckpoint { new_git_oid, .. } if new_git_oid == git_commit
)
})
})
.ok_or_else(|| anyhow!("Git checkpoint succeeded but its oplog batch was not found"))?;
repo.oplog()
.coalesce_batches(snapshot_batch.id, checkpoint_batch.id)
.context(
"commit completed but failed to record capture and Git checkpoint as one undo batch",
)?;
Ok(())
}
fn checkpoint_summary(plan: &SavePlan, state: &State) -> String {
plan.intent
.clone()
.or_else(|| state.intent.clone())
.unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short()))
}
fn current_thread_name(repo: &Repository) -> String {
match repo.head_ref() {
Ok(Head::Attached { thread }) => thread.to_string(),
_ => String::new(),
}
}
fn git_rev_parse_head(root: &std::path::Path) -> Option<String> {
let git = SleyRepository::discover(root).ok()?;
git.head().ok()?.oid.map(|id| id.to_string())
}
fn soften_commit_next_action(trust: &mut RepositoryVerificationState) {
if is_commit_action(&trust.recommended_action) {
trust.recommended_action = "heddle status".to_string();
trust.recommended_action_template = None;
}
for check in &mut trust.checks {
if check
.recommended_action
.as_deref()
.is_some_and(is_commit_action)
{
check.recommended_action = Some("heddle status".to_string());
check.recommended_action_template = None;
}
}
}
fn is_commit_action(action: &str) -> bool {
let trimmed = action.trim();
trimmed == "heddle capture" || trimmed.starts_with("heddle capture ")
}
#[cfg(test)]
mod tests {
use repo::RepositoryCapability;
use super::*;
#[test]
fn capture_always_uses_git_scope_none() {
assert_eq!(
plan_git_scope(
SaveVerb::Capture,
RepositoryCapability::GitOverlay,
true,
true
),
GitScope::None
);
assert_eq!(
plan_git_scope(
SaveVerb::Capture,
RepositoryCapability::NativeHeddle,
false,
false
),
GitScope::None
);
}
#[test]
fn commit_native_never_writes_git() {
assert_eq!(
plan_git_scope(
SaveVerb::Commit,
RepositoryCapability::NativeHeddle,
true,
true
),
GitScope::None
);
}
#[test]
fn commit_git_overlay_routes_staged_vs_worktree() {
assert_eq!(
plan_git_scope(
SaveVerb::Commit,
RepositoryCapability::GitOverlay,
true,
false
),
GitScope::Staged
);
assert_eq!(
plan_git_scope(
SaveVerb::Commit,
RepositoryCapability::GitOverlay,
true,
true
),
GitScope::WorktreeAll
);
assert_eq!(
plan_git_scope(
SaveVerb::Commit,
RepositoryCapability::GitOverlay,
false,
false
),
GitScope::WorktreeAll
);
}
#[test]
fn checkpoint_routes_staged_flag() {
assert_eq!(
plan_git_scope(
SaveVerb::Checkpoint,
RepositoryCapability::GitOverlay,
true,
false
),
GitScope::Staged
);
assert_eq!(
plan_git_scope(
SaveVerb::Checkpoint,
RepositoryCapability::GitOverlay,
false,
false
),
GitScope::WorktreeAll
);
}
#[test]
fn plan_creates_new_state_routing() {
let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
let capture = SavePlan::capture("wip", attr.clone());
assert!(plan_creates_new_state(&capture, true));
assert!(plan_creates_new_state(&capture, false));
let checkpoint = SavePlan::checkpoint(Some("cp".into()), attr.clone(), false);
assert!(!plan_creates_new_state(&checkpoint, true));
assert!(plan_creates_new_state(&checkpoint, false));
let staged =
SavePlan::commit("msg", attr, GitScope::Staged).with_supplied_tree(Tree::new());
assert!(plan_creates_new_state(&staged, true));
}
#[test]
fn plan_writes_git_checkpoint_respects_scope_and_capability() {
let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
let capture = SavePlan::capture("wip", attr.clone());
assert!(!plan_writes_git_checkpoint(
&capture,
RepositoryCapability::GitOverlay
));
let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
assert!(plan_writes_git_checkpoint(
&commit,
RepositoryCapability::GitOverlay
));
assert!(!plan_writes_git_checkpoint(
&commit,
RepositoryCapability::NativeHeddle
));
let none = SavePlan::commit("msg", attr, GitScope::None);
assert!(!plan_writes_git_checkpoint(
&none,
RepositoryCapability::GitOverlay
));
}
#[test]
fn save_plan_builders_set_expected_defaults() {
let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
let capture = SavePlan::capture("intent", attr.clone());
assert_eq!(capture.verb, SaveVerb::Capture);
assert_eq!(capture.git_scope, GitScope::None);
assert!(!capture.coalesce_snapshot_and_checkpoint);
let commit = SavePlan::commit("msg", attr.clone(), GitScope::WorktreeAll);
assert_eq!(commit.verb, SaveVerb::Commit);
assert!(commit.coalesce_snapshot_and_checkpoint);
assert!(commit.commit_safe_post_verify);
let staged = SavePlan::checkpoint(None, attr, true);
assert_eq!(staged.git_scope, GitScope::Staged);
assert!(!staged.require_clean_worktree);
assert!(staged.reuse_current_state);
}
#[test]
fn tree_leaf_name_and_commit_next_action() {
assert_eq!(tree_leaf_name("a/b/c.rs"), "c.rs");
assert_eq!(tree_leaf_name("solo"), "solo");
assert_eq!(
commit_next_action_from_trust("heddle push", false, false).as_deref(),
Some("heddle push")
);
assert_eq!(
commit_next_action_from_trust("", false, true).as_deref(),
Some("heddle verify")
);
assert_eq!(
commit_next_action_from_trust("", true, true).as_deref(),
Some("heddle push")
);
assert_eq!(commit_next_action_from_trust("", true, false), None);
}
#[test]
fn commit_git_index_plan_modes() {
let staged = vec!["a.rs".into()];
let extra = vec!["unstaged: b.rs".into(), "untracked: c.rs".into()];
let staged_only = plan_commit_git_index(&staged, &extra, false);
assert_eq!(staged_only.commit_mode, "staged_index");
assert_eq!(staged_only.will_commit, vec!["a.rs"]);
assert_eq!(staged_only.preserved_after_commit.len(), 2);
let all = plan_commit_git_index(&staged, &extra, true);
assert_eq!(all.commit_mode, "worktree_all_explicit");
assert_eq!(all.will_commit.len(), 3);
let index_only = plan_commit_git_index_only(&staged, &extra);
assert_eq!(index_only.commit_mode, "staged_index");
assert_eq!(index_only.will_commit, vec!["a.rs"]);
assert!(commit_scope_text("staged_index").contains("staged Git index"));
assert!(staged_commit_summary("ok", 1, 2).contains("left 2 unstaged/untracked"));
assert_eq!(staged_commit_summary("ok", 1, 0), "ok");
}
}