use std::{collections::BTreeSet, path::Path};
use anyhow::Result;
use chrono::Utc;
use objects::{object::ThreadName, store::ObjectStore};
use repo::{
GitOverlayImportHint, GitRemoteTrackingStatus, OperationKind, OperationScope, Repository,
RepositoryOperationStatus, ThreadFreshness, ThreadIntegrationPolicy, ThreadManager,
ThreadState, shell_quote, update_thread_state_from_state,
};
use serde::{Serialize, Serializer, ser::SerializeStruct};
use sley::{IndexStage, Repository as SleyRepository};
use super::{
git_overlay_health::{
RepositoryVerificationState, action_template, repository_verification_blockers,
repository_verification_primary_command,
},
next_action::{NextActionInput, effective_next_action},
rebase::{
OperatorContinueStatus, cmd_rebase_silent, continue_rebase_for_operator,
has_persisted_rebase_state,
},
resolve::abort_merge_state,
snapshot::{SnapshotAgentOverrides, create_snapshot},
};
use crate::config::UserConfig;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OperatorAction {
Abort,
Bisect,
CherryPick,
#[default]
Continue,
Land,
Merge,
Ready,
Rebase,
Revert,
Sync,
ThreadCleanup,
ThreadDrop,
ThreadPromote,
ThreadRefresh,
ThreadResolve,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct OperatorEmission {
pub(crate) command: &'static [&'static str],
pub(crate) output_kind: OperatorAction,
}
pub(crate) const ABORT_OPERATOR_EMISSION: OperatorEmission = OperatorEmission {
command: &["abort"],
output_kind: OperatorAction::Abort,
};
pub(crate) const CONTINUE_OPERATOR_EMISSION: OperatorEmission = OperatorEmission {
command: &["continue"],
output_kind: OperatorAction::Continue,
};
pub(crate) const SYNC_OPERATOR_EMISSION: OperatorEmission = OperatorEmission {
command: &["sync"],
output_kind: OperatorAction::Sync,
};
pub(crate) const OPERATOR_EMISSIONS: &[OperatorEmission] = &[
ABORT_OPERATOR_EMISSION,
CONTINUE_OPERATOR_EMISSION,
SYNC_OPERATOR_EMISSION,
];
pub fn operator_emission_output_kinds() -> Vec<(String, String)> {
OPERATOR_EMISSIONS
.iter()
.map(|emission| {
(
emission.command.join(" "),
emission.output_kind.wire_value().to_string(),
)
})
.collect()
}
impl OperatorAction {
const fn wire_value(self) -> &'static str {
match self {
Self::Abort => "abort",
Self::Bisect => "bisect",
Self::CherryPick => "cherry-pick",
Self::Continue => "continue",
Self::Land => "land",
Self::Merge => "merge",
Self::Ready => "ready",
Self::Rebase => "rebase",
Self::Revert => "revert",
Self::Sync => "sync",
Self::ThreadCleanup => "thread_cleanup",
Self::ThreadDrop => "thread_drop",
Self::ThreadPromote => "thread_promote",
Self::ThreadRefresh => "thread_refresh",
Self::ThreadResolve => "thread_resolve",
}
}
}
impl Serialize for OperatorAction {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.wire_value())
}
}
impl From<&OperationKind> for OperatorAction {
fn from(kind: &OperationKind) -> Self {
match kind {
OperationKind::Merge => Self::Merge,
OperationKind::Rebase => Self::Rebase,
OperationKind::CherryPick => Self::CherryPick,
OperationKind::Revert => Self::Revert,
OperationKind::Bisect => Self::Bisect,
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct OperatorCommandOutput {
pub status: String,
pub action: OperatorAction,
pub message: String,
pub blockers: Vec<String>,
pub warnings: Vec<String>,
pub next_action: Option<String>,
pub recommended_action: Option<String>,
}
impl OperatorCommandOutput {
pub(crate) fn blocked_by_repository_verification(
action: OperatorAction,
message: impl Into<String>,
trust: &RepositoryVerificationState,
) -> Self {
let recommended_action = repository_verification_primary_command(trust);
Self {
status: "blocked".to_string(),
action,
message: message.into(),
blockers: repository_verification_blockers(trust),
warnings: Vec::new(),
next_action: Some(recommended_action.clone()),
recommended_action: Some(recommended_action),
}
}
pub(crate) fn block_success_claim_if_verification_blocked(
&mut self,
trust: &RepositoryVerificationState,
local_context: impl Into<String>,
policy: VerificationClaimPolicy,
) {
if repository_verification_allows_success_claim(self, trust, policy) {
return;
}
*self = Self::blocked_by_repository_verification(
self.action,
format!(
"{} reached local checks, but repository verification is blocked: {}",
local_context.into(),
trust.summary
),
trust,
);
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct VerificationClaimPolicy {
allow_land_publish_followup: bool,
allow_matching_workflow_action: bool,
}
impl VerificationClaimPolicy {
pub(crate) fn strict() -> Self {
Self::default()
}
pub(crate) fn allow_land_publish_followup(mut self) -> Self {
self.allow_land_publish_followup = true;
self
}
pub(crate) fn allow_matching_workflow_action(mut self) -> Self {
self.allow_matching_workflow_action = true;
self
}
}
fn repository_verification_allows_success_claim(
output: &OperatorCommandOutput,
trust: &RepositoryVerificationState,
policy: VerificationClaimPolicy,
) -> bool {
if trust.verified || matches!(output.status.as_str(), "blocked" | "failed") {
return true;
}
if policy.allow_land_publish_followup
&& output.action == OperatorAction::Land
&& output.status == "landed"
&& trust.recommended_action == "heddle push"
&& matches!(
trust.remote_drift.as_str(),
"remote_untracked" | "remote_ahead"
)
{
return true;
}
if policy.allow_matching_workflow_action
&& trust.workflow_status == "ready"
&& output
.recommended_action
.as_deref()
.is_some_and(|action| action == trust.recommended_action)
{
return true;
}
false
}
pub(crate) fn blocked_operator_exit_code(status: &str) -> Option<i32> {
matches!(status, "blocked" | "failed").then_some(1)
}
pub(crate) fn exit_if_blocked_operator_status(status: &str) {
if let Some(code) = blocked_operator_exit_code(status) {
std::process::exit(code);
}
}
impl Serialize for OperatorCommandOutput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.serialize_with_output_kind(serializer, self.action)
}
}
impl OperatorCommandOutput {
pub(crate) fn envelope_for_command(
&self,
output_kind: OperatorAction,
) -> OperatorCommandEnvelope<'_> {
OperatorCommandEnvelope {
output: self,
output_kind,
}
}
fn serialize_with_output_kind<S>(
&self,
serializer: S,
output_kind: OperatorAction,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let next_action = normalized_action(self.next_action.as_deref());
let recommended_action = normalized_action(self.recommended_action.as_deref());
let next_action_template = next_action.and_then(action_template);
let recommended_action_template = recommended_action.and_then(action_template);
let mut len = 8;
if !self.blockers.is_empty() {
len += 1;
}
if !self.warnings.is_empty() {
len += 1;
}
let mut state = serializer.serialize_struct("OperatorCommandOutput", len)?;
state.serialize_field("output_kind", &output_kind.wire_value())?;
state.serialize_field("status", &self.status)?;
state.serialize_field("action", &self.action)?;
state.serialize_field("message", &self.message)?;
if !self.blockers.is_empty() {
state.serialize_field("blockers", &self.blockers)?;
}
if !self.warnings.is_empty() {
state.serialize_field("warnings", &self.warnings)?;
}
state.serialize_field("next_action", &next_action)?;
state.serialize_field("next_action_template", &next_action_template)?;
state.serialize_field("recommended_action", &recommended_action)?;
state.serialize_field("recommended_action_template", &recommended_action_template)?;
state.end()
}
}
pub(crate) struct OperatorCommandEnvelope<'a> {
output: &'a OperatorCommandOutput,
output_kind: OperatorAction,
}
impl Serialize for OperatorCommandEnvelope<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.output
.serialize_with_output_kind(serializer, self.output_kind)
}
}
fn normalized_action(action: Option<&str>) -> Option<&str> {
action.filter(|action| !action.trim().is_empty())
}
impl super::compact::CompactProjection for OperatorCommandOutput {
fn compact(&self) -> super::compact::CompactOutput {
self.compact_with_output_kind(self.action)
}
}
impl OperatorCommandOutput {
fn compact_with_output_kind(
&self,
output_kind: OperatorAction,
) -> super::compact::CompactOutput {
let action = normalized_action(self.recommended_action.as_deref())
.or_else(|| normalized_action(self.next_action.as_deref()));
let mut compact = super::compact::CompactOutput::new(output_kind.wire_value());
compact.status = Some(self.status.clone());
compact.blockers = self.blockers.clone();
compact.next_action = action.map(str::to_string);
compact.next_action_template = action.and_then(action_template);
compact
}
}
impl super::compact::CompactProjection for OperatorCommandEnvelope<'_> {
fn compact(&self) -> super::compact::CompactOutput {
self.output.compact_with_output_kind(self.output_kind)
}
}
pub(crate) fn open_operator_repo_from_path(path: &Path) -> Result<Repository> {
let cwd_repo = Repository::open(path)?;
let target_path = cwd_repo.active_worktree_path()?;
if target_path == *cwd_repo.root() {
Ok(cwd_repo)
} else {
Ok(Repository::open(&target_path)?)
}
}
pub(crate) fn continue_operator(repo: &Repository) -> Result<OperatorCommandOutput> {
if repo.merge_state_manager().is_merge_in_progress() {
let unresolved = repo.merge_state_manager().unresolved()?;
if !unresolved.is_empty() {
let recommended_action = format!("heddle resolve {}", shell_quote(&unresolved[0]));
return Ok(OperatorCommandOutput {
status: "blocked".to_string(),
action: OperatorAction::Merge,
message: format!(
"Merge still has unresolved conflicts: {}. After removing conflict markers, mark each file resolved with `heddle resolve <path>`.",
unresolved.join(", ")
),
blockers: unresolved,
warnings: Vec::new(),
next_action: Some("heddle resolve --list".to_string()),
recommended_action: Some(recommended_action),
});
}
create_snapshot(
repo,
&UserConfig::load_default().unwrap_or_default(),
Some("Continue merge".to_string()),
None,
SnapshotAgentOverrides {
provider: None,
model: None,
session: None,
segment: None,
policy: None,
no_policy: false,
no_agent: false,
},
)?;
let next_action = complete_current_thread_manual_resolution(repo)?;
return Ok(OperatorCommandOutput {
status: "continued".to_string(),
action: OperatorAction::Merge,
message: "Completed the in-progress Heddle merge".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: next_action.clone(),
recommended_action: next_action,
});
}
if let Some(operation) = repo.operation_status()? {
return continue_from_operation(repo, &operation);
}
Ok(OperatorCommandOutput {
status: "noop".to_string(),
action: OperatorAction::Continue,
message: "No in-progress operation needs continuing".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: None,
recommended_action: None,
})
}
pub(crate) fn abort_operator(repo: &Repository) -> Result<OperatorCommandOutput> {
if repo.merge_state_manager().is_merge_in_progress() {
abort_merge_state(repo, &repo.merge_state_manager())?;
return Ok(OperatorCommandOutput {
status: "aborted".to_string(),
action: OperatorAction::Merge,
message: "Aborted the in-progress Heddle merge".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: None,
recommended_action: None,
});
}
if has_persisted_rebase_state(repo) {
cmd_rebase_silent(repo, None, true, false)?;
return Ok(OperatorCommandOutput {
status: "aborted".to_string(),
action: OperatorAction::Rebase,
message: "Aborted the in-progress Heddle rebase".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: None,
recommended_action: None,
});
}
if let Some(operation) = repo.operation_status()? {
return abort_from_operation(repo, &operation);
}
Ok(OperatorCommandOutput {
status: "noop".to_string(),
action: OperatorAction::Abort,
message: "No in-progress operation can be aborted".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: None,
recommended_action: None,
})
}
pub(crate) fn complete_current_thread_manual_resolution(
repo: &Repository,
) -> Result<Option<String>> {
let Some(current_thread) = repo.current_lane()? else {
return Ok(None);
};
let Some(current_state) = repo.head()? else {
return Ok(None);
};
let Some(current_state_obj) = repo.store().get_state(¤t_state)? else {
return Ok(None);
};
let manager = ThreadManager::new(repo.heddle_dir());
let Some(mut thread) = manager.find_by_thread(¤t_thread)? else {
return Ok(None);
};
let Some(target_thread) = thread.target_thread.clone() else {
return Ok(None);
};
let Some(target_state) = repo.refs().get_thread(&ThreadName::new(&target_thread))? else {
return Ok(None);
};
let Some(target_state_obj) = repo.store().get_state(&target_state)? else {
return Ok(None);
};
let before_update = super::thread_cmd::capture_thread_update_before(repo, &manager, &thread)?;
thread.base_state = target_state.short();
thread.base_root = target_state_obj.tree.short();
update_thread_state_from_state(&mut thread, ¤t_state_obj);
thread.state = ThreadState::Ready;
thread.freshness = ThreadFreshness::Current;
thread.integration_policy_result = ThreadIntegrationPolicy {
status: Some("manual_resolved".to_string()),
reason: Some("manual conflict resolution captured".to_string()),
manual_resolution_state: Some(current_state.short()),
conflicts_resolved_manually: true,
};
thread.updated_at = Utc::now();
let thread_id = thread.id.clone();
let target = thread.target_thread.clone();
super::thread_cmd::save_thread_update_with_oplog(
repo,
&manager,
&thread,
before_update,
current_state,
)?;
let action = super::thread_landing::land_command_for_thread(repo, &thread_id);
Ok(Some(super::thread::contextual_thread_action(
repo,
&thread_id,
target.as_deref(),
&action,
)))
}
fn continue_from_operation(
repo: &Repository,
operation: &RepositoryOperationStatus,
) -> Result<OperatorCommandOutput> {
match (&operation.scope, &operation.kind) {
(OperationScope::Heddle, OperationKind::Rebase) => {
Ok(match continue_rebase_for_operator(repo)? {
OperatorContinueStatus::Blocked => OperatorCommandOutput {
status: "blocked".to_string(),
action: OperatorAction::Rebase,
message:
"Rebase still needs a captured manual resolution before it can continue"
.to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: Some("heddle commit -m \"Manual resolution\"".to_string()),
recommended_action: Some("heddle commit -m \"Manual resolution\"".to_string()),
},
OperatorContinueStatus::Continued => OperatorCommandOutput {
status: "continued".to_string(),
action: OperatorAction::Rebase,
message: "Continued the in-progress Heddle rebase".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: None,
recommended_action: None,
},
OperatorContinueStatus::Completed => OperatorCommandOutput {
status: "completed".to_string(),
action: OperatorAction::Rebase,
message: "Completed the in-progress Heddle rebase".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: None,
recommended_action: None,
},
})
}
(OperationScope::Heddle, OperationKind::Bisect) => Ok(OperatorCommandOutput {
status: "blocked".to_string(),
action: OperatorAction::Bisect,
message: "A stale bisect state from an older Heddle version is present; \
the bisect command has been removed. Abort to clear it."
.to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: Some("heddle abort".to_string()),
recommended_action: Some("heddle abort".to_string()),
}),
(OperationScope::Git, OperationKind::Rebase) => {
let unresolved = git_unmerged_paths(repo)?;
Ok(raw_git_operation_handoff("continue", operation, unresolved))
}
(OperationScope::Git, OperationKind::Merge) => {
let unresolved = git_unmerged_paths(repo)?;
Ok(raw_git_operation_handoff("continue", operation, unresolved))
}
(OperationScope::Git, OperationKind::CherryPick) => {
let unresolved = git_unmerged_paths(repo)?;
Ok(raw_git_operation_handoff("continue", operation, unresolved))
}
(OperationScope::Git, OperationKind::Revert) => {
let unresolved = git_unmerged_paths(repo)?;
Ok(raw_git_operation_handoff("continue", operation, unresolved))
}
(OperationScope::Git, OperationKind::Bisect) => {
Ok(raw_git_operation_handoff("continue", operation, Vec::new()))
}
(OperationScope::Heddle, OperationKind::Merge) => unreachable!(),
_ => Ok(OperatorCommandOutput {
status: "noop".to_string(),
action: OperatorAction::Continue,
message: "No in-progress operation needs continuing".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: None,
recommended_action: None,
}),
}
}
fn abort_from_operation(
repo: &Repository,
operation: &RepositoryOperationStatus,
) -> Result<OperatorCommandOutput> {
match (&operation.scope, &operation.kind) {
(OperationScope::Heddle, OperationKind::Rebase) => {
cmd_rebase_silent(repo, None, true, false)?;
}
(OperationScope::Heddle, OperationKind::Bisect) => {
let state_path = repo.heddle_dir().join("BISECT_STATE");
if state_path.exists() {
std::fs::remove_file(&state_path)?;
}
}
(OperationScope::Git, _) => {
let unresolved = git_unmerged_paths(repo).unwrap_or_default();
return Ok(raw_git_operation_handoff("abort", operation, unresolved));
}
_ => {}
}
Ok(OperatorCommandOutput {
status: "aborted".to_string(),
action: OperatorAction::from(&operation.kind),
message: format!(
"Aborted the in-progress {} {}",
operation.scope, operation.kind
),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: None,
recommended_action: None,
})
}
fn raw_git_operation_handoff(
attempted_action: &str,
operation: &RepositoryOperationStatus,
unresolved: Vec<String>,
) -> OperatorCommandOutput {
let primary = raw_git_preservation_command();
let mut blockers = vec![format!(
"externally-started Git {} is {}",
operation.kind, operation.state
)];
blockers.extend(unresolved.iter().map(|path| format!("unresolved: {path}")));
let unresolved_summary = if unresolved.is_empty() {
String::new()
} else {
format!(" Unresolved paths: {}.", unresolved.join(", "))
};
let recovery_text = raw_git_operation_recovery_text(&operation.kind, &primary);
OperatorCommandOutput {
status: "blocked".to_string(),
action: OperatorAction::from(&operation.kind),
message: format!(
"Cannot {attempted_action} the active raw Git {} inside Heddle's no-git runtime. Heddle did not start this Git sequencer operation, so it left Git metadata, refs, index, and worktree files unchanged.{unresolved_summary} {recovery_text}",
operation.kind
),
blockers,
warnings: Vec::new(),
next_action: Some(primary.clone()),
recommended_action: Some(primary),
}
}
fn raw_git_preservation_command() -> String {
"heddle bridge git status".to_string()
}
fn raw_git_operation_recovery_text(kind: &OperationKind, primary_command: &str) -> String {
format!(
"Inspect it with `{primary_command}`. Heddle did not start this raw Git {kind}, so finish or abort it with the Git-compatible tool that started it, then run `heddle verify` for the exact adoption command."
)
}
pub(crate) fn recommend_next_action(
operation: Option<&RepositoryOperationStatus>,
remote_tracking: Option<&GitRemoteTrackingStatus>,
import_hint: Option<&GitOverlayImportHint>,
fallback: Option<&str>,
) -> String {
effective_next_action(NextActionInput::default(
operation,
remote_tracking,
import_hint,
fallback,
))
}
fn git_unmerged_paths(repo: &Repository) -> Result<Vec<String>> {
let git = match SleyRepository::discover(repo.root()) {
Ok(git) => git,
Err(_) => return Ok(Vec::new()),
};
let index = match git.open_index() {
Ok(Some(index)) => index,
Ok(None) => return Ok(Vec::new()),
Err(_) => return Ok(Vec::new()),
};
let mut paths = BTreeSet::new();
for entry in index.entries {
if entry.stage() != IndexStage::Normal {
paths.insert(String::from_utf8_lossy(entry.path.as_bytes()).into_owned());
}
}
Ok(paths.into_iter().collect())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::cli::commands::git_overlay_health::{VerificationCheck, machine_contract_coverage};
#[test]
fn validated_resolve_action_with_spaced_path_passes_only_when_quoted() {
use repo::shell_quote;
use crate::cli::commands::next_action::{
NextActionValidationContext, validated_json_string,
};
let path = "my conflicted file.txt";
let context = NextActionValidationContext::without_repo(&["continue"]);
let quoted = OperatorCommandOutput {
status: "blocked".to_string(),
action: OperatorAction::Continue,
message: "conflicts remain".to_string(),
blockers: vec![path.to_string()],
warnings: Vec::new(),
next_action: Some("heddle resolve --list".to_string()),
recommended_action: Some(format!("heddle resolve {}", shell_quote(path))),
};
let json = validated_json_string("ed, context)
.expect("a shell-quoted conflict path must pass next_action validation");
assert!(
json.contains("heddle resolve 'my conflicted file.txt'"),
"the serialized recommended_action must carry the quoted path: {json}"
);
let bare = OperatorCommandOutput {
recommended_action: Some(format!("heddle resolve {path}")),
..quoted.clone()
};
assert!(
validated_json_string(&bare, context).is_err(),
"an unquoted spaced path must fail validation"
);
}
#[test]
fn blocked_land_with_unvalidated_thread_id_passes_only_when_quoted() {
use repo::shell_quote;
use crate::cli::commands::next_action::{
NextActionValidationContext, validated_json_string,
};
let unsafe_id = "bad;echo pwn";
let context = NextActionValidationContext::without_repo(&["land"]);
let quoted = OperatorCommandOutput {
status: "blocked".to_string(),
action: OperatorAction::Land,
message: format!("Thread '{unsafe_id}' must be synced manually"),
blockers: vec!["thread is stale".to_string()],
warnings: Vec::new(),
next_action: Some(format!("heddle sync --thread {}", shell_quote(unsafe_id))),
recommended_action: Some(format!("heddle sync --thread {}", shell_quote(unsafe_id))),
};
let json = validated_json_string("ed, context).expect(
"a shell-quoted unvalidated thread id must pass next_action validation (the P1 fix)",
);
assert!(
json.contains("heddle sync --thread 'bad;echo pwn'"),
"both action fields must carry the quoted, single-token thread id: {json}"
);
let bare = OperatorCommandOutput {
next_action: Some(format!("heddle sync --thread {unsafe_id}")),
recommended_action: Some(format!("heddle sync --thread {unsafe_id}")),
..quoted.clone()
};
assert!(
validated_json_string(&bare, context).is_err(),
"a bare unvalidated thread id must fail validation — proving quoting is what closes the hole"
);
}
#[test]
fn raw_git_operation_handoff_recommends_heddle_preservation_not_git_cli() {
let operation = RepositoryOperationStatus {
scope: OperationScope::Git,
kind: OperationKind::Merge,
in_progress: true,
state: "in-progress".to_string(),
message: "Git merge is in progress".to_string(),
next_action: raw_git_preservation_command(),
};
let output =
raw_git_operation_handoff("continue", &operation, vec!["conflict.txt".to_string()]);
assert_eq!(output.status, "blocked");
assert_eq!(
output.recommended_action.as_deref(),
Some("heddle bridge git status")
);
assert!(output.message.contains("no-git runtime"));
assert!(output.message.contains("conflict.txt"));
assert!(
output
.blockers
.iter()
.any(|path| path == "unresolved: conflict.txt")
);
assert!(
!output
.recommended_action
.as_deref()
.is_some_and(|action| action.starts_with("git "))
);
}
#[test]
fn verification_claim_gate_blocks_local_success_claims() {
let trust = verification_state(false, "needs_checkpoint", "heddle commit -m \"...\"");
let mut output = OperatorCommandOutput {
status: "synced".to_string(),
action: OperatorAction::Sync,
message: "Thread is already current".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: None,
recommended_action: None,
};
output.block_success_claim_if_verification_blocked(
&trust,
"sync",
VerificationClaimPolicy::strict(),
);
assert_eq!(output.status, "blocked");
assert_eq!(
output.recommended_action.as_deref(),
Some("heddle commit -m \"...\"")
);
assert!(
output
.message
.contains("repository verification is blocked")
);
}
#[test]
fn verification_claim_gate_allows_land_publish_followup_only_by_policy() {
let trust = verification_state(false, "remote_ahead", "heddle push");
let landed = || OperatorCommandOutput {
status: "landed".to_string(),
action: OperatorAction::Land,
message: "Landed thread 'feature'".to_string(),
blockers: Vec::new(),
warnings: Vec::new(),
next_action: Some("heddle push".to_string()),
recommended_action: Some("heddle push".to_string()),
};
let mut strict = landed();
strict.block_success_claim_if_verification_blocked(
&trust,
"land",
VerificationClaimPolicy::strict(),
);
assert_eq!(strict.status, "blocked");
let mut allowed = landed();
allowed.block_success_claim_if_verification_blocked(
&trust,
"land",
VerificationClaimPolicy::strict().allow_land_publish_followup(),
);
assert_eq!(allowed.status, "landed");
assert_eq!(allowed.recommended_action.as_deref(), Some("heddle push"));
}
fn verification_state(
verified: bool,
status: &str,
recommended_action: &str,
) -> RepositoryVerificationState {
let check = VerificationCheck {
name: "Worktree".to_string(),
status: status.to_string(),
clean: verified,
summary: "repository verification fixture".to_string(),
recommended_action: (!verified).then(|| recommended_action.to_string()),
recommended_action_template: None,
recovery_commands: if verified {
Vec::new()
} else {
vec![recommended_action.to_string()]
},
recovery_action_templates: Vec::new(),
details: BTreeMap::new(),
};
RepositoryVerificationState {
verified,
status: status.to_string(),
repository_mode: "git-overlay".to_string(),
heddle_initialized: true,
git_branch: Some("main".to_string()),
heddle_thread: Some("main".to_string()),
worktree_dirty: false,
worktree_state: "clean".to_string(),
import_state: "clean".to_string(),
mapping_state: "clean".to_string(),
remote_drift: status.to_string(),
active_operation: None,
default_remote: Some("origin".to_string()),
clone_verification: "not_applicable".to_string(),
machine_contract: "available".to_string(),
machine_contract_coverage: machine_contract_coverage(),
workflow_status: "clean".to_string(),
workflow_summary: "workflow fixture".to_string(),
summary: "repository verification fixture".to_string(),
recommended_action: if verified {
String::new()
} else {
recommended_action.to_string()
},
recommended_action_template: None,
recovery_commands: if verified {
Vec::new()
} else {
vec![recommended_action.to_string()]
},
recovery_action_templates: Vec::new(),
checks: vec![check],
}
}
}