1use std::time::Instant;
11
12use anyhow::{Context, Result, anyhow};
13use chrono::Utc;
14use heddle_git_projection::{GitProjection, WriteThroughOutcome};
15use objects::{
16 HeddleError, RecoveryDetails,
17 lock::RepositoryLockExt,
18 object::{Agent, Attribution, ContentHash, Principal, State, StateId, ThreadName, Tree},
19 store::ObjectStore,
20 worktree::WorktreeStatus,
21};
22use oplog::{OpLogBackend, OpRecord};
23use refs::Head;
24use repo::{
25 ActorPresenceStore, CommitGraphIndex, GitCheckpointRecord, Hook, HookContext, HookManager,
26 OperationScope, Repository, RepositoryCapability, SessionManager, SnapshotProfile, Thread,
27 ThreadFreshness, ThreadIntegrationPolicy, ThreadManager, ThreadMode, ThreadState,
28 WorktreeStateLookupProfile, WorktreeStatusOptions, refresh_active_thread_metadata,
29 update_thread_state_from_state,
30};
31use schemars::JsonSchema;
32use serde::Serialize;
33use sley::Repository as SleyRepository;
34
35use crate::{
36 ActionTemplate, ExecutionContext, HeddleReport, IdentityCursor, MachineContractInput,
37 MachineOutputKind, OutputDiscriminator, ReportContract, RepositoryVerificationState,
38 SegmentRotation, attach_published_segment_fields,
39 build_repository_verification_health_with_worktree_status, build_repository_verification_state,
40 build_repository_verification_state_with_machine_contract,
41 build_repository_verification_state_with_worktree_status,
42 build_repository_verification_state_with_worktree_status_and_machine_contract,
43 cursor_segment_rotation, published_field, read_identity_cursor, schema_for_report,
44 stamp_identity_cursor,
45 status::next_action::{
46 contextual_thread_action, heddle_action, import_guidance_includes_active_branch,
47 remote_tracking_status,
48 },
49 verify::action_template,
50};
51
52const BULK_CAPTURE_WARNING_THRESHOLD: usize = 500;
53
54#[derive(Debug)]
56pub struct CaptureOptions {
57 pub intent: String,
58 pub confidence: Option<f32>,
59 pub force: bool,
60 pub agent: CaptureAgentOptions,
61 pub machine_contract_input: Option<MachineContractInput>,
62}
63
64#[derive(Debug, Clone, Default)]
70pub struct CaptureAgentOptions {
71 pub provider: Option<String>,
72 pub model: Option<String>,
73 pub session: Option<String>,
74 pub segment: Option<String>,
75 pub policy: Option<String>,
76 pub environment_policy: Option<String>,
77 pub default_policy: Option<String>,
78 pub identity_patch: IdentityCursor,
79 pub no_policy: bool,
80 pub no_agent: bool,
81}
82
83#[derive(Debug, Clone)]
85struct CaptureAttribution {
86 attribution: Attribution,
87 principal_source: String,
88 harness_session_id: Option<String>,
91 warnings: Vec<String>,
92}
93
94#[derive(Debug, Clone, Copy, Default)]
96pub struct CaptureProfile {
97 pub worktree_status_ms: u128,
98 pub preflight_ms: u128,
99 pub attribution_ms: u128,
100 pub execute_save_ms: u128,
101}
102
103#[derive(Debug, Clone, Serialize, JsonSchema)]
108pub struct CaptureReport {
109 pub output_kind: &'static str,
110 pub state_id: String,
111 pub content_hash: String,
112 pub git_checkpoint: Option<String>,
114 pub intent: Option<String>,
115 pub confidence: Option<f32>,
116 pub task_assignment_id: Option<String>,
117 pub principal: CapturePrincipalReport,
118 pub principal_source: String,
119 pub agent: Option<CaptureAgentReport>,
120 pub promotion_suggested: bool,
121 pub heavy_impact_paths: Vec<String>,
122 pub captured_path_count: usize,
123 pub warnings: Vec<String>,
124 pub signed: bool,
125 pub message: String,
126 pub recommended_action: Option<String>,
127 pub recommended_action_template: Option<ActionTemplate>,
128 pub verification: RepositoryVerificationState,
129 #[serde(skip)]
130 #[schemars(skip)]
131 pub captured_thread_targets_integration: bool,
132 #[serde(skip)]
133 #[schemars(skip)]
134 pub diagnostics: CaptureDiagnostics,
135}
136
137impl CaptureReport {
138 pub const CONTRACT: ReportContract = ReportContract {
139 schema_name: "capture",
140 machine_output_kind: MachineOutputKind::Json,
141 output_discriminator: Some(OutputDiscriminator {
142 field: "output_kind",
143 value: "capture",
144 }),
145 schema: schema_for_report::<CaptureReport>,
146 };
147}
148
149impl HeddleReport for CaptureReport {
150 const CONTRACT: ReportContract = CaptureReport::CONTRACT;
151}
152
153#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
154pub struct CapturePrincipalReport {
155 pub name: String,
156 pub email: String,
157}
158
159#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
160pub struct CaptureAgentReport {
161 pub provider: String,
162 pub model: String,
163 pub session_id: Option<String>,
164 pub segment_id: Option<String>,
165 pub policy_id: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub thought_level: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub parent: Option<String>,
170}
171
172#[derive(Debug, Clone)]
173pub struct CaptureDiagnostics {
174 pub profile: CaptureProfile,
175 pub snapshot_profile: SnapshotProfile,
176 pub state_create_ms: u128,
177 pub captured_path_count_ms: u128,
178 pub post_verification_ms: u128,
179 pub thread_metadata_ms: u128,
180 pub previous_state_ms: u128,
181 pub previous_state_profile: WorktreeStateLookupProfile,
182 pub signature_lookup_ms: u128,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
187#[serde(rename_all = "snake_case")]
188pub enum GitScope {
189 None,
191 Staged,
193 WorktreeAll,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
199#[serde(rename_all = "snake_case")]
200pub enum SaveVerb {
201 Capture,
202 Checkpoint,
203}
204
205#[derive(Debug)]
208pub struct SavePlan {
209 pub verb: SaveVerb,
210 pub intent: Option<String>,
211 pub confidence: Option<f32>,
212 pub attribution: Attribution,
213 pub git_scope: GitScope,
214 pub supplied_tree: Option<Tree>,
217 pub reuse_current_state: bool,
219 pub require_clean_worktree: bool,
221 pub require_worktree_change: bool,
225 pub worktree_status_options: WorktreeStatusOptions,
226 pub known_worktree_changes: Option<WorktreeStatus>,
230 pub run_hooks: bool,
232 pub coalesce_snapshot_and_checkpoint: bool,
234 pub linearize_git_parent: bool,
237 pub precomputed_worktree_status:
240 Option<repo::Result<Option<objects::worktree::WorktreeStatus>>>,
241 pub machine_contract_input: Option<MachineContractInput>,
245}
246
247impl SavePlan {
248 pub fn capture(intent: impl Into<String>, attribution: Attribution) -> Self {
249 Self {
250 verb: SaveVerb::Capture,
251 intent: Some(intent.into()),
252 confidence: None,
253 attribution,
254 git_scope: GitScope::None,
255 supplied_tree: None,
256 reuse_current_state: false,
257 require_clean_worktree: false,
258 require_worktree_change: false,
259 worktree_status_options: WorktreeStatusOptions::default(),
260 known_worktree_changes: None,
261 run_hooks: true,
262 coalesce_snapshot_and_checkpoint: false,
263 linearize_git_parent: false,
264 precomputed_worktree_status: None,
265 machine_contract_input: None,
266 }
267 }
268
269 pub fn checkpoint(message: Option<String>, attribution: Attribution, staged: bool) -> Self {
270 Self {
271 verb: SaveVerb::Checkpoint,
272 intent: message,
273 confidence: None,
274 attribution,
275 git_scope: if staged {
276 GitScope::Staged
277 } else {
278 GitScope::WorktreeAll
279 },
280 supplied_tree: None,
281 reuse_current_state: true,
282 require_clean_worktree: !staged,
283 require_worktree_change: false,
284 worktree_status_options: WorktreeStatusOptions::default(),
285 known_worktree_changes: None,
286 run_hooks: true,
287 coalesce_snapshot_and_checkpoint: false,
288 linearize_git_parent: false,
289 precomputed_worktree_status: None,
290 machine_contract_input: None,
291 }
292 }
293
294 pub fn with_confidence(mut self, confidence: Option<f32>) -> Self {
295 self.confidence = confidence;
296 self
297 }
298
299 pub fn with_supplied_tree(mut self, tree: Tree) -> Self {
300 self.supplied_tree = Some(tree);
301 self
302 }
303
304 pub fn with_worktree_status_options(mut self, options: WorktreeStatusOptions) -> Self {
305 self.worktree_status_options = options;
306 self
307 }
308
309 pub fn with_precomputed_worktree_status(
310 mut self,
311 status: repo::Result<Option<objects::worktree::WorktreeStatus>>,
312 ) -> Self {
313 self.precomputed_worktree_status = Some(status);
314 self
315 }
316}
317
318#[derive(Debug, Clone)]
320pub struct SaveReport {
321 pub verb: SaveVerb,
322 pub state_id: StateId,
323 pub content_hash: ContentHash,
324 pub intent: Option<String>,
325 pub confidence: Option<f32>,
326 pub signed: bool,
327 pub git_commit: Option<String>,
328 pub git_previous_commit: Option<String>,
329 pub summary: String,
330 pub principal: Principal,
331 pub agent: Option<Agent>,
332 pub promotion_suggested: bool,
333 pub heavy_impact_paths: Vec<String>,
334 pub captured_path_count: usize,
337 pub verification: RepositoryVerificationState,
338 pub created_new_state: bool,
339 pub git_checkpoint: Option<GitCheckpointRecord>,
340 pub snapshot_profile: SnapshotProfile,
341 pub state_create_ms: u128,
342 pub captured_path_count_ms: u128,
343 pub post_verification_ms: u128,
344 pub thread_metadata_ms: u128,
345 pub previous_state_ms: u128,
346 pub previous_state_profile: WorktreeStateLookupProfile,
347 pub signature_lookup_ms: u128,
348}
349
350pub fn plan_creates_new_state(plan: &SavePlan, has_current_state: bool) -> bool {
352 if plan.supplied_tree.is_some() {
353 return true;
354 }
355 if plan.reuse_current_state && has_current_state {
356 return false;
357 }
358 if plan.verb == SaveVerb::Checkpoint && has_current_state {
360 return false;
361 }
362 true
363}
364
365pub fn plan_writes_git_checkpoint(plan: &SavePlan, capability: RepositoryCapability) -> bool {
367 plan.git_scope != GitScope::None && capability == RepositoryCapability::GitOverlay
368}
369
370pub fn tree_leaf_name(path: &str) -> String {
372 path.rsplit('/').next().unwrap_or(path).to_string()
373}
374
375pub fn capture(ctx: &ExecutionContext, options: CaptureOptions) -> Result<CaptureReport> {
382 let repo = ctx.require_repo()?;
383 if options.intent.trim().is_empty() {
384 return Err(capture_refusal(
385 "missing_capture_intent",
386 "refusing to capture without an intent",
387 "Provide a short intent with `heddle capture -m \"...\"`.",
388 "no capture intent was supplied with -m/--message/--intent",
389 "capturing without intent would create a weak provenance record",
390 "repository state, refs, metadata, and worktree files were left unchanged",
391 vec!["heddle capture -m \"...\"".to_string()],
392 ));
393 }
394
395 preflight_unimported_git_history(repo, "capture")?;
396 let complete_thread_resolution = merge_resolution_is_complete(repo)?;
397 let worktree_status_started = Instant::now();
398 let mut reuse_current_state = false;
399 let mut no_changes = false;
400 let known_worktree_changes = if complete_thread_resolution {
401 None
402 } else {
403 let status = capture_worktree_status(repo, &ctx.worktree_status_options())?;
404 if repo.capability() == RepositoryCapability::GitOverlay && status.is_clean() {
405 if repo.pending_git_checkpoint_intent()?.is_some()
406 || uncheckpointed_state_is_ahead_of_git(repo)?
407 {
408 reuse_current_state = true;
412 } else {
413 no_changes = true;
414 }
415 }
416 Some(status)
417 };
418 if let Some(path) = known_worktree_changes
419 .as_ref()
420 .and_then(reserved_capture_path)
421 {
422 return Err(capture_refusal(
423 "reserved_materialization_path",
424 format!("Refusing to capture reserved path `{path}`"),
425 "Inject secrets with `heddle env run --profile <name> -- <cmd>` instead of writing `.env` files.",
426 format!("`{path}` is a reserved confidential-runtime materialization path"),
427 "capture would ingest reserved plaintext into Source History",
428 "repository state, refs, metadata, and worktree files were left unchanged",
429 vec!["heddle env run --profile <name> -- <cmd>".to_string()],
430 ));
431 }
432
433 let worktree_status = repo.git_overlay_worktree_status();
434 let worktree_status_ms = worktree_status_started.elapsed().as_millis();
435
436 let preflight_started = Instant::now();
437 preflight_large_capture(options.force, &worktree_status)?;
438 preflight_capture_mutation(
439 repo,
440 &worktree_status,
441 options.machine_contract_input.as_ref(),
442 )?;
443 if no_changes {
444 return Err(map_capture_error(anyhow!(HeddleError::NoChanges)));
445 }
446 let preflight_ms = preflight_started.elapsed().as_millis();
447 let attribution_started = Instant::now();
448 let resolved_attribution = resolve_capture_attribution(
449 repo,
450 ctx.principal_fallback(),
451 ctx.hosted_principal(),
452 ctx.hosted_account_unclaimed(),
453 &options.agent,
454 )?;
455 let harness_session_id = resolved_attribution
456 .attribution
457 .agent
458 .is_some()
459 .then(|| resolved_attribution.harness_session_id.clone())
460 .flatten();
461 let mut warnings = resolved_attribution.warnings;
462 let pending_context_receipt = if resolved_attribution.attribution.agent.is_some() {
463 repo.pending_context_receipt()?
464 } else {
465 None
466 };
467 if pending_context_receipt.is_some() {
468 repo.ensure_context_receipt_signer()?;
469 }
470 let attribution_ms = attribution_started.elapsed().as_millis();
471
472 let git_overlay = repo.capability() == RepositoryCapability::GitOverlay;
473 let plan = SavePlan {
474 verb: SaveVerb::Capture,
475 intent: Some(options.intent),
476 confidence: options.confidence,
477 attribution: resolved_attribution.attribution,
478 git_scope: if git_overlay {
479 GitScope::WorktreeAll
480 } else {
481 GitScope::None
482 },
483 supplied_tree: None,
484 reuse_current_state,
485 require_clean_worktree: git_overlay,
486 require_worktree_change: repo.capability() == RepositoryCapability::NativeHeddle
491 && !complete_thread_resolution,
492 worktree_status_options: ctx.worktree_status_options(),
493 known_worktree_changes,
494 run_hooks: true,
495 coalesce_snapshot_and_checkpoint: git_overlay,
496 linearize_git_parent: git_overlay,
501 precomputed_worktree_status: Some(worktree_status),
502 machine_contract_input: options.machine_contract_input,
503 };
504 let execute_save_started = Instant::now();
505 let save = execute_save(repo, plan).map_err(map_capture_error)?;
506 let execute_save_ms = execute_save_started.elapsed().as_millis();
507 if let Some(session_id) = harness_session_id
508 && let Err(error) = crate::record_last_turn_capture(repo, &session_id, save.state_id)
509 {
510 warnings.push(format!(
511 "could not update the reconstructible last-turn anchor: {error}"
512 ));
513 }
514
515 let manual_resolution_action = if complete_thread_resolution {
516 complete_current_thread_manual_resolution(repo)?
517 } else {
518 None
519 };
520
521 let current_thread = current_thread(repo)?;
522 if let (Some(receipt), Some(thread)) =
523 (pending_context_receipt.as_ref(), current_thread.as_ref())
524 && receipt.thread == thread.thread
525 && save.created_new_state
526 {
527 repo.attach_context_receipt(save.state_id, receipt)?;
528 }
529 let captured_thread_targets_integration = current_thread
530 .as_ref()
531 .and_then(|thread| thread.target_thread.as_ref())
532 .is_some();
533 let task_assignment_id = active_task_assignment_id(repo, current_thread.as_ref())?;
534 let principal_source = resolved_attribution.principal_source;
535 warnings.extend(bulk_capture_warning(save.captured_path_count));
536
537 let mut recommended_action = non_empty_action(&save.verification.recommended_action);
538 let mut recommended_action_template = recommended_action
539 .as_deref()
540 .and_then(action_template)
541 .or_else(|| save.verification.recommended_action_template.clone());
542 if let Some(action) = manual_resolution_action {
543 recommended_action_template = action_template(&action);
544 recommended_action = Some(action);
545 }
546
547 let principal = CapturePrincipalReport {
548 name: save.principal.name_lossy().into_owned(),
549 email: save.principal.email_lossy().into_owned(),
550 };
551 let agent = save.agent.as_ref().map(|agent| CaptureAgentReport {
552 provider: agent.provider.clone(),
553 model: agent.model.clone(),
554 session_id: agent.session_id.clone(),
555 segment_id: agent.segment_id.clone(),
556 policy_id: agent.policy_id.clone(),
557 thought_level: agent.thought_level.clone(),
558 parent: agent.parent.clone(),
559 });
560 Ok(CaptureReport {
561 output_kind: "capture",
562 state_id: save.state_id.short(),
563 content_hash: save.content_hash.short(),
564 git_checkpoint: save.git_commit.clone(),
565 intent: save.intent.clone(),
566 confidence: save.confidence,
567 task_assignment_id,
568 principal,
569 principal_source,
570 agent,
571 promotion_suggested: save.promotion_suggested,
572 heavy_impact_paths: save.heavy_impact_paths.clone(),
573 captured_path_count: save.captured_path_count,
574 warnings,
575 signed: save.signed,
576 message: save.summary.clone(),
577 recommended_action,
578 recommended_action_template,
579 verification: save.verification.clone(),
580 captured_thread_targets_integration,
581 diagnostics: CaptureDiagnostics {
582 profile: CaptureProfile {
583 worktree_status_ms,
584 preflight_ms,
585 attribution_ms,
586 execute_save_ms,
587 },
588 snapshot_profile: save.snapshot_profile,
589 state_create_ms: save.state_create_ms,
590 captured_path_count_ms: save.captured_path_count_ms,
591 post_verification_ms: save.post_verification_ms,
592 thread_metadata_ms: save.thread_metadata_ms,
593 previous_state_ms: save.previous_state_ms,
594 previous_state_profile: save.previous_state_profile,
595 signature_lookup_ms: save.signature_lookup_ms,
596 },
597 })
598}
599
600fn resolve_capture_attribution(
606 repo: &Repository,
607 principal_fallback: Option<(&str, &str)>,
608 hosted_principal: Option<(&str, &str)>,
609 hosted_account_unclaimed: bool,
610 options: &CaptureAgentOptions,
611) -> Result<CaptureAttribution> {
612 let resolved_principal = crate::apply_hosted_principal_fallback(
613 crate::resolve_principal(repo, principal_fallback)?,
614 hosted_principal,
615 );
616 let principal_source = resolved_principal
617 .source
618 .unwrap_or("not_configured")
619 .to_string();
620 let Some(principal) = resolved_principal.principal else {
621 let (summary, commands) = if hosted_account_unclaimed {
622 (
623 "Run `heddle claim` to attach the signed-in account to a human identity with an email, or configure a local principal, then retry the capture.",
624 vec![
625 "heddle claim".to_string(),
626 "heddle init --principal-name <name> --principal-email <email>".to_string(),
627 "heddle capture -m \"...\"".to_string(),
628 ],
629 )
630 } else {
631 (
632 "Set `HEDDLE_PRINCIPAL_NAME` and `HEDDLE_PRINCIPAL_EMAIL`, or run `heddle init --principal-name <name> --principal-email <email>`, then retry the capture.",
633 vec![
634 "heddle init --principal-name <name> --principal-email <email>".to_string(),
635 "heddle capture -m \"...\"".to_string(),
636 ],
637 )
638 };
639 return Err(capture_refusal(
640 "capture_identity_required",
641 "Refusing to capture: no accountable identity is configured",
642 summary,
643 "No accountable name/email is configured for capture attribution",
644 "capture would create durable Heddle history without a real principal",
645 "Heddle refs, captured states, Git refs, index, and worktree files were left unchanged",
646 commands,
647 ));
648 };
649
650 if options.no_agent {
651 return Ok(CaptureAttribution {
652 attribution: Attribution::human(principal),
653 principal_source,
654 harness_session_id: None,
655 warnings: Vec::new(),
656 });
657 }
658
659 let (frozen, warnings) = match freeze_identity_for_capture(repo, &options.identity_patch) {
664 Ok(frozen) => (frozen, Vec::new()),
665 Err(error) => (
666 FrozenCaptureIdentity::default(),
667 vec![format!(
668 "could not freeze agent identity for this capture; recorded human attribution: {error}"
669 )],
670 ),
671 };
672 let harness_session_id = frozen.session.clone();
673 let current_session = SessionManager::new(repo.root()).get_current_session()?;
674 let provider = options
675 .provider
676 .clone()
677 .or(frozen.provider.clone())
678 .and_then(clean_attribution_value);
679 let model = options
680 .model
681 .clone()
682 .or(frozen.model.clone())
683 .and_then(clean_attribution_value);
684 let session_policy = current_session
685 .as_ref()
686 .and_then(|session| session.current_segment())
687 .and_then(|segment| segment.policy_id.clone())
688 .and_then(clean_attribution_value);
689 let session_id = options
691 .session
692 .clone()
693 .or_else(|| current_session.as_ref().map(|session| session.id.clone()));
694 let segment_id = options
695 .segment
696 .clone()
697 .or(frozen.segment_id.clone())
698 .or_else(|| {
699 current_session
700 .as_ref()
701 .and_then(|session| session.current_segment_id.clone())
702 });
703 let policy = if options.no_policy {
704 None
705 } else {
706 options
707 .policy
708 .clone()
709 .or_else(|| options.environment_policy.clone())
710 .and_then(clean_attribution_value)
711 .or(session_policy)
712 .or_else(|| options.default_policy.clone())
713 .or_else(|| repo.config().policies.default_policy.clone())
714 };
715
716 let attribution = match (provider, model) {
717 (Some(provider), Some(model)) => {
718 let mut agent = Agent::new(provider, model);
719 if let (Some(session_id), Some(segment_id)) = (session_id, segment_id) {
720 agent = agent.with_session(session_id, segment_id);
721 }
722 if let Some(policy) = policy {
723 agent = agent.with_policy(policy);
724 }
725 if let Some(thought_level) = frozen.thought_level {
726 agent = agent.with_thought_level(thought_level);
727 }
728 if let Some(parent) = frozen.parent {
729 agent = agent.with_parent(parent);
730 }
731 Attribution::with_agent(principal, agent)
732 }
733 _ => Attribution::human(principal),
734 };
735 Ok(CaptureAttribution {
736 attribution,
737 principal_source,
738 harness_session_id,
739 warnings,
740 })
741}
742
743pub fn resolve_capture_author(
748 repo: &Repository,
749 principal_fallback: Option<(&str, &str)>,
750 options: &CaptureAgentOptions,
751) -> Result<Attribution> {
752 resolve_capture_attribution(repo, principal_fallback, None, false, options)
753 .map(|resolved| resolved.attribution)
754}
755
756#[derive(Clone, Debug, Default)]
757struct FrozenCaptureIdentity {
758 provider: Option<String>,
759 model: Option<String>,
760 thought_level: Option<String>,
761 session: Option<String>,
762 parent: Option<String>,
763 segment_id: Option<String>,
764}
765
766fn freeze_identity_for_capture(
767 repo: &Repository,
768 identity_patch: &IdentityCursor,
769) -> Result<FrozenCaptureIdentity> {
770 let _guard = repo.locker().write()?;
771 let mut cursor = read_identity_cursor(repo.root());
772 if !identity_patch.is_empty() {
773 cursor = stamp_identity_cursor(repo.root(), identity_patch)?;
774 }
775 let mut manager = SessionManager::new(repo.root());
776 let session = manager.get_current_session()?;
777 let segment_id = apply_capture_segment_policy(&mut manager, session.as_ref(), &cursor)?;
778 Ok(FrozenCaptureIdentity {
779 provider: cursor.provider,
780 model: cursor.model,
781 thought_level: cursor.thought_level,
782 session: cursor.session,
783 parent: cursor.parent,
784 segment_id,
785 })
786}
787
788fn apply_capture_segment_policy(
789 manager: &mut SessionManager,
790 session: Option<&objects::object::Session>,
791 cursor: &IdentityCursor,
792) -> Result<Option<String>> {
793 let Some(session) = session else {
794 return Ok(None);
795 };
796 let current = session.current_segment();
797 let rotation = cursor_segment_rotation(
798 current.map(|segment| segment.provider.as_str()),
799 current.map(|segment| segment.model.as_str()),
800 current.and_then(|segment| segment.thought_level.as_deref()),
801 cursor.provider.as_deref(),
802 cursor.model.as_deref(),
803 cursor.thought_level.as_deref(),
804 );
805 if rotation == SegmentRotation::Rotate {
806 let provider = cursor
807 .provider
808 .clone()
809 .or_else(|| current.map(|segment| segment.provider.clone()))
810 .unwrap_or_default();
811 let model = cursor
812 .model
813 .clone()
814 .or_else(|| current.map(|segment| segment.model.clone()))
815 .unwrap_or_default();
816 if published_field(Some(&provider)).is_some() && published_field(Some(&model)).is_some() {
817 let segment = manager.add_segment(&session.id, provider, model, None)?;
818 if let Some(thought_level) = cursor.thought_level.clone()
819 && let Some(mut updated) = manager.get_session(&session.id)?
820 {
821 if let Some(current) = updated.current_segment_mut() {
822 current.thought_level = Some(thought_level);
823 }
824 manager.save_session(&updated)?;
825 }
826 return Ok(Some(segment.id));
827 }
828 } else if rotation == SegmentRotation::Attach
829 && let Some(mut updated) = manager.get_session(&session.id)?
830 {
831 if let Some(segment) = updated.current_segment_mut() {
832 attach_published_segment_fields(
833 segment,
834 cursor.provider.as_deref(),
835 cursor.model.as_deref(),
836 cursor.thought_level.as_deref(),
837 );
838 }
839 manager.save_session(&updated)?;
840 }
841 Ok(session.current_segment_id.clone())
842}
843
844fn clean_attribution_value(value: String) -> Option<String> {
845 let trimmed = value.trim();
846 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
847 None
848 } else {
849 Some(value)
850 }
851}
852
853fn preflight_unimported_git_history(repo: &Repository, action: &str) -> Result<()> {
854 if repo.capability() != RepositoryCapability::GitOverlay {
855 return Ok(());
856 }
857 let Some(guidance) = repo.git_import_guidance()? else {
858 return Ok(());
859 };
860 if !import_guidance_includes_active_branch(&guidance) {
861 return Ok(());
862 }
863 let branches = preview_paths(&guidance.missing_branches);
864 let command = guidance.recommended_command;
865 Err(capture_refusal(
866 "git_history_needs_import",
867 format!("Refusing to {action}: Git history has not been imported into Heddle"),
868 format!("Run `{command}` before retrying `heddle {action}`."),
869 format!("Git branch(es) waiting for Heddle import: {branches}"),
870 format!(
871 "{action} would write new Heddle state before Heddle has adopted the existing Git history"
872 ),
873 "Git refs, Heddle refs, and worktree files were left unchanged",
874 vec![command],
875 ))
876}
877
878fn preflight_capture_mutation(
879 repo: &Repository,
880 worktree_status: &repo::Result<Option<WorktreeStatus>>,
881 machine_contract_input: Option<&MachineContractInput>,
882) -> Result<()> {
883 if repo.capability() != RepositoryCapability::GitOverlay {
884 return Ok(());
885 }
886 if repo.git_overlay_head_is_detached()? {
887 let primary = detached_head_primary_recovery(repo);
888 return Err(capture_refusal(
889 "git_head_detached",
890 "Refusing to capture: Git HEAD is detached",
891 format!("Run `{primary}` before retrying `heddle capture`."),
892 "Git HEAD points directly to a commit instead of an attached branch",
893 "capture would need to write a Git checkpoint through a branch and could reattach or advance the wrong ref",
894 "Git refs, Heddle refs, Git checkpoints, and worktree files were left unchanged",
895 vec![primary],
896 ));
897 }
898 if let Some(operation) = repo.operation_status()?
899 && matches!(operation.scope, OperationScope::Git)
900 {
901 return Err(capture_refusal(
902 "raw_git_operation_in_progress",
903 format!(
904 "Refusing to capture: an externally-started Git {} is in progress",
905 operation.kind
906 ),
907 format!(
908 "Inspect with `heddle verify`. Heddle did not start this raw Git {}, so finish or abort it with the Git-compatible tool that started it, then run `heddle verify` for the exact adoption command before retrying `heddle capture`.",
909 operation.kind
910 ),
911 format!(
912 "Git {} is {}; Heddle cannot safely turn sequencer state into a saved change inside the no-git runtime",
913 operation.kind, operation.state
914 ),
915 "capture would capture worktree/index contents while Git still has unresolved sequencer metadata",
916 "Git refs, Git sequencer files, Heddle refs, and worktree files were left unchanged",
917 vec!["heddle verify".to_string()],
918 ));
919 }
920
921 let health = build_repository_verification_health_with_worktree_status(repo, worktree_status);
922 let trust = if let Some(input) = machine_contract_input {
923 build_repository_verification_state_with_worktree_status_and_machine_contract(
924 repo,
925 health,
926 worktree_status,
927 input,
928 )
929 } else {
930 build_repository_verification_state_with_worktree_status(repo, health, worktree_status)
931 };
932 if !checkpoint_trust_allows_ref_update(&trust)
933 && !checkpoint_can_close_integrated_remote_gap(repo, &trust)
934 {
935 let (primary, recovery_commands) = capture_blocked_recovery(repo, &trust);
936 return Err(capture_refusal(
937 "git_checkpoint_preflight_blocked",
938 "Refusing to capture: Git checkpoint preflight is blocked",
939 format!("Run `{primary}` before retrying `heddle capture`."),
940 format!(
941 "repository verification status is {}; remote drift is {}: {}",
942 trust.status, trust.remote_drift, trust.summary
943 ),
944 "capture would write Heddle state before the Git checkpoint ref update is known to be safe",
945 "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
946 recovery_commands,
947 ));
948 }
949 if trust.status != "needs_reconcile" || uncheckpointed_state_is_ahead_of_git(repo)? {
950 return Ok(());
951 }
952 let (primary, recovery_commands) = capture_blocked_recovery(repo, &trust);
953 Err(capture_refusal(
954 "repository_verification_blocked",
955 format!(
956 "Refusing to capture: repository verification is blocked ({})",
957 trust.status
958 ),
959 format!("Run `{primary}` before retrying `heddle capture`."),
960 format!(
961 "repository verification status is {}: {}",
962 trust.status, trust.summary
963 ),
964 "capture would write new Heddle or Git state while Git and Heddle disagree",
965 "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
966 recovery_commands,
967 ))
968}
969
970fn detached_head_primary_recovery(repo: &Repository) -> String {
971 if let Ok(Head::Attached { thread }) = repo.refs().read_head()
972 && !thread.trim().is_empty()
973 {
974 return if thread.starts_with('-') {
975 heddle_action(["thread", "switch", "--", thread.as_str()])
976 } else {
977 heddle_action(["thread", "switch", thread.as_str()])
978 };
979 }
980 if let Ok(Some(detached_commit)) = repo.git_overlay_detached_head_commit()
981 && let Ok(branch_tips) = repo.git_overlay_branch_tips()
982 && let Some(tip) = branch_tips
983 .iter()
984 .filter(|tip| tip.history_imported)
985 .find(|tip| tip.git_commit == detached_commit)
986 {
987 return heddle_action(["thread", "switch", tip.branch.as_str()]);
988 }
989 heddle_action(["status"])
990}
991
992fn capture_blocked_recovery(
993 repo: &Repository,
994 trust: &RepositoryVerificationState,
995) -> (String, Vec<String>) {
996 if let Some(recovery) = capture_remote_drift_recovery(repo) {
997 return recovery;
998 }
999 let primary =
1000 non_empty_action(&trust.recommended_action).unwrap_or_else(|| "heddle verify".to_string());
1001 let recovery_commands = if trust.recovery_commands.is_empty() {
1002 vec![primary.clone()]
1003 } else {
1004 trust.recovery_commands.clone()
1005 };
1006 (primary, recovery_commands)
1007}
1008
1009fn capture_remote_drift_recovery(repo: &Repository) -> Option<(String, Vec<String>)> {
1010 let remote = repo.git_remote_tracking_status().ok().flatten()?;
1011 let status = remote_tracking_status(&remote);
1012 if !matches!(
1013 status,
1014 "remote_behind" | "remote_diverged" | "remote_contains_undone_checkpoint"
1015 ) {
1016 return None;
1017 }
1018 let recovery_commands = crate::status::remote_drift_recovery_commands(repo, &remote, status);
1019 let primary = recovery_commands.first()?.clone();
1020 Some((primary, recovery_commands))
1021}
1022
1023fn checkpoint_trust_allows_ref_update(trust: &RepositoryVerificationState) -> bool {
1024 let status_allows_checkpoint = matches!(
1025 trust.status.as_str(),
1026 "clean" | "dirty_worktree" | "needs_checkpoint" | "remote_ahead" | "remote_untracked"
1027 );
1028 let remote_allows_checkpoint = matches!(
1029 trust.remote_drift.as_str(),
1030 "clean" | "remote_ahead" | "remote_untracked"
1031 );
1032 status_allows_checkpoint && remote_allows_checkpoint
1033}
1034
1035fn checkpoint_can_close_integrated_remote_gap(
1036 repo: &Repository,
1037 trust: &RepositoryVerificationState,
1038) -> bool {
1039 if trust.status != "needs_checkpoint"
1040 || !matches!(
1041 trust.remote_drift.as_str(),
1042 "remote_behind" | "remote_diverged"
1043 )
1044 {
1045 return false;
1046 }
1047 let Some(remote) = repo.git_remote_tracking_status().ok().flatten() else {
1048 return false;
1049 };
1050 let upstream = remote.upstream.trim();
1051 if upstream.is_empty() {
1052 return false;
1053 }
1054 let Ok(Some(upstream_state)) = repo.refs().get_thread(&ThreadName::new(upstream)) else {
1055 return false;
1056 };
1057 let Ok(Some(current_state)) = repo.head() else {
1058 return false;
1059 };
1060 let mut graph = CommitGraphIndex::new(repo);
1061 graph
1062 .is_ancestor(&upstream_state, ¤t_state)
1063 .unwrap_or(false)
1064}
1065
1066fn uncheckpointed_state_is_ahead_of_git(repo: &Repository) -> Result<bool> {
1067 let Some(branch) = repo.git_overlay_current_branch()? else {
1068 return Ok(false);
1069 };
1070 let Some(current) = repo.current_state()? else {
1071 return Ok(false);
1072 };
1073 if repo
1074 .latest_git_checkpoint_for_state(¤t.state_id)?
1075 .is_some()
1076 {
1077 return Ok(false);
1078 }
1079 let Some(tip) = repo.git_overlay_branch_tip(&branch)? else {
1083 return Ok(true);
1084 };
1085 let Some(mapped) = tip.mapped_state else {
1086 return Ok(false);
1087 };
1088 if mapped == current.state_id {
1089 return Ok(false);
1090 }
1091 let mut graph = CommitGraphIndex::new(repo);
1092 if !graph
1093 .is_ancestor(&mapped, ¤t.state_id)
1094 .unwrap_or(false)
1095 || graph
1096 .is_ancestor(¤t.state_id, &mapped)
1097 .unwrap_or(false)
1098 {
1099 return Ok(false);
1100 }
1101 Ok(true)
1102}
1103
1104fn preflight_large_capture(
1105 force: bool,
1106 worktree_status: &repo::Result<Option<WorktreeStatus>>,
1107) -> Result<()> {
1108 if force {
1109 return Ok(());
1110 }
1111 let Ok(Some(status)) = worktree_status else {
1112 return Ok(());
1113 };
1114 let total = status.change_count();
1115 let delete_count = status.deleted.len();
1116 let add_count = status.added.len();
1117 if !crate::large_capture_requires_force(total, delete_count, add_count) {
1118 return Ok(());
1119 }
1120 let sample = status
1121 .deleted
1122 .iter()
1123 .chain(status.added.iter())
1124 .chain(status.modified.iter())
1125 .take(5)
1126 .map(|path| path.display().to_string())
1127 .collect::<Vec<_>>()
1128 .join(", ");
1129 let sample = if sample.is_empty() {
1130 "no sample paths available".to_string()
1131 } else {
1132 sample
1133 };
1134 Err(capture_refusal(
1135 "large_capture_requires_force",
1136 format!(
1137 "Large capture safety check: this would capture {total} changed paths ({delete_count} deletions, {add_count} additions)"
1138 ),
1139 "If this is intentional, rerun with `heddle capture --force -m \"...\"`.",
1140 format!("sample changed paths: {sample}"),
1141 "capture would preserve an unusually large Git-overlay worktree change without an explicit confirmation",
1142 "repository state, refs, metadata, and worktree files were left unchanged",
1143 vec!["heddle capture --force -m \"...\"".to_string()],
1144 ))
1145}
1146
1147fn merge_resolution_is_complete(repo: &Repository) -> Result<bool> {
1148 Ok(repo
1149 .merge_state_manager()
1150 .load()?
1151 .is_some_and(|merge_state| {
1152 merge_state
1153 .conflicts
1154 .iter()
1155 .all(|path| merge_state.resolved.contains(path))
1156 }))
1157}
1158
1159fn capture_worktree_status(
1165 repo: &Repository,
1166 options: &WorktreeStatusOptions,
1167) -> Result<WorktreeStatus> {
1168 if repo.current_state_for_worktree_status()?.is_none()
1169 && let Some(status) = repo.git_overlay_worktree_status()?
1170 {
1171 return Ok(status);
1172 }
1173 let tree = match repo.current_state_for_worktree_status()? {
1174 Some(state) => repo.require_tree_for_worktree_status(&state.tree)?,
1175 None => Tree::new(),
1176 };
1177 Ok(repo.compare_worktree_cached_with_options(&tree, options)?)
1178}
1179
1180pub fn complete_current_thread_manual_resolution(repo: &Repository) -> Result<Option<String>> {
1185 let Some(current_thread) = repo.current_lane()? else {
1186 return Ok(None);
1187 };
1188 let Some(current_state) = repo.head()? else {
1189 return Ok(None);
1190 };
1191 let Some(current_state_object) = repo.store().get_state(¤t_state)? else {
1192 return Ok(None);
1193 };
1194 let manager = ThreadManager::new(repo.heddle_dir());
1195 let Some(mut thread) = manager.find_by_thread(¤t_thread)? else {
1196 return Ok(None);
1197 };
1198 let Some(target_thread) = thread.target_thread.clone() else {
1199 return Ok(None);
1200 };
1201 let Some(target_state) = repo.refs().get_thread(&ThreadName::new(&target_thread))? else {
1202 return Ok(None);
1203 };
1204 let Some(target_state_object) = repo.store().get_state(&target_state)? else {
1205 return Ok(None);
1206 };
1207 let before = crate::capture_thread_update_before(repo, &manager, &thread)?;
1208
1209 thread.base_state = target_state.short();
1210 thread.base_root = target_state_object.tree.short();
1211 update_thread_state_from_state(&mut thread, ¤t_state_object);
1212 thread.state = ThreadState::Ready;
1213 thread.freshness = ThreadFreshness::Current;
1214 thread.integration_policy_result = ThreadIntegrationPolicy {
1215 status: Some("manual_resolved".to_string()),
1216 reason: Some("manual conflict resolution captured".to_string()),
1217 manual_resolution_state: Some(current_state.short()),
1218 conflicts_resolved_manually: true,
1219 };
1220 thread.updated_at = Utc::now();
1221 let thread_name = thread.thread.clone();
1222 let target = thread.target_thread.clone();
1223 crate::save_thread_update(repo, &manager, &thread, before, current_state)?;
1224
1225 Ok(Some(manual_resolution_land_action(
1226 repo,
1227 &thread_name,
1228 target.as_deref(),
1229 )))
1230}
1231
1232fn manual_resolution_land_action(
1233 repo: &Repository,
1234 thread_id: &str,
1235 target_thread: Option<&str>,
1236) -> String {
1237 let action = crate::status::next_action::land_local_command(thread_id);
1238 contextual_thread_action(repo, thread_id, target_thread, &action)
1239}
1240
1241fn current_thread(repo: &Repository) -> Result<Option<Thread>> {
1242 let manager = ThreadManager::new(repo.heddle_dir());
1243 if let Some(thread) = manager.find_by_execution_root(repo.root())? {
1244 return Ok(Some(thread));
1245 }
1246 let Head::Attached { thread } = repo.head_ref()? else {
1247 return Ok(None);
1248 };
1249 let current_state_id = repo.refs().get_thread(&thread)?;
1250 let current_state = current_state_id.map(|state| state.short());
1251 let base_root = current_state_id
1252 .and_then(|state| repo.store().get_state(&state).ok().flatten())
1253 .map(|state| state.tree.short())
1254 .unwrap_or_default();
1255 let thread = thread.to_string();
1256 Ok(Some(Thread {
1257 id: thread.clone(),
1258 thread,
1259 target_thread: None,
1260 parent_thread: None,
1261 mode: ThreadMode::Materialized,
1262 state: ThreadState::Active,
1263 base_state: current_state.clone().unwrap_or_default(),
1264 base_root,
1265 current_state,
1266 merged_state: None,
1267 task: None,
1268 execution_path: repo.root().to_path_buf(),
1269 materialized_path: None,
1270 changed_paths: Vec::new(),
1271 impact_categories: Vec::new(),
1272 heavy_impact_paths: Vec::new(),
1273 promotion_suggested: false,
1274 freshness: ThreadFreshness::Unknown,
1275 verification_summary: Default::default(),
1276 confidence_summary: Default::default(),
1277 integration_policy_result: Default::default(),
1278 created_at: Utc::now(),
1279 updated_at: Utc::now(),
1280 ephemeral: None,
1281 auto: false,
1282 shared_target_dir: None,
1283 }))
1284}
1285
1286fn active_task_assignment_id(repo: &Repository, thread: Option<&Thread>) -> Result<Option<String>> {
1287 let Some(thread) = thread else {
1288 return Ok(None);
1289 };
1290 let store = ActorPresenceStore::new(repo.heddle_dir());
1291 Ok(store
1292 .active_entries()?
1293 .into_iter()
1294 .filter(|entry| entry.thread == thread.thread || entry.thread == thread.id)
1295 .max_by_key(|entry| entry.started_at)
1296 .and_then(|entry| entry.task_assignment_id))
1297}
1298
1299fn bulk_capture_warning(captured_path_count: usize) -> Option<String> {
1300 (captured_path_count >= BULK_CAPTURE_WARNING_THRESHOLD).then(|| {
1301 format!(
1302 "captured {captured_path_count} paths in one operation; check root .gitignore and .heddleignore rules if build artifacts or tool state were included"
1303 )
1304 })
1305}
1306
1307fn reserved_capture_path(status: &WorktreeStatus) -> Option<String> {
1312 status
1313 .added
1314 .iter()
1315 .chain(&status.modified)
1316 .map(|path| path.to_string_lossy())
1317 .find(|path| env_store::is_reserved_materialization_path(path))
1318 .map(|path| path.into_owned())
1319}
1320
1321fn non_empty_action(action: &str) -> Option<String> {
1322 (!action.trim().is_empty()).then(|| action.to_string())
1323}
1324
1325fn map_capture_error(error: anyhow::Error) -> anyhow::Error {
1326 if error.chain().any(|cause| {
1327 cause
1328 .downcast_ref::<HeddleError>()
1329 .is_some_and(|error| matches!(error, HeddleError::NoChanges))
1330 }) {
1331 return capture_refusal(
1332 "nothing_to_capture",
1333 "nothing to capture: worktree has no changes eligible for Heddle capture",
1334 "Inspect the worktree with `heddle status`; make changes before running `heddle capture -m \"...\"`.",
1335 "the worktree has no modified, deleted, or untracked paths relative to the current Heddle state",
1336 "capture would not create a meaningful Heddle state",
1337 "repository state was left unchanged",
1338 vec!["heddle status".to_string()],
1339 );
1340 }
1341 if error.chain().any(|cause| {
1342 cause
1343 .downcast_ref::<std::io::Error>()
1344 .is_some_and(objects::fs_atomic::is_out_of_space)
1345 }) {
1346 return capture_refusal(
1347 "capture_out_of_space",
1348 format!("Capture aborted because the filesystem is out of space: {error:#}"),
1349 "Free disk space and re-run `heddle capture`. Your working tree changes are intact.",
1350 "the filesystem reported no remaining space while Heddle was writing captured objects",
1351 "retrying before freeing space may fail again or leave another incomplete object write",
1352 "the working tree was not modified; already-committed repository data remains behind atomic write boundaries",
1353 vec!["heddle capture -m \"...\"".to_string()],
1354 );
1355 }
1356 error
1357}
1358
1359#[allow(clippy::too_many_arguments)]
1360fn capture_refusal(
1361 kind: &'static str,
1362 error: impl Into<String>,
1363 hint: impl Into<String>,
1364 unsafe_condition: impl Into<String>,
1365 would_change: impl Into<String>,
1366 preserved: impl Into<String>,
1367 recovery_commands: Vec<String>,
1368) -> anyhow::Error {
1369 anyhow!(HeddleError::recovery(
1370 RecoveryDetails::safety_refusal(
1371 kind,
1372 error,
1373 hint,
1374 unsafe_condition,
1375 would_change,
1376 preserved,
1377 )
1378 .with_recovery_commands(recovery_commands),
1379 ))
1380}
1381
1382fn preview_paths(paths: &[String]) -> String {
1383 let shown = paths
1384 .iter()
1385 .take(12)
1386 .cloned()
1387 .collect::<Vec<_>>()
1388 .join(", ");
1389 let hidden = paths.len().saturating_sub(12);
1390 if hidden == 0 {
1391 shown
1392 } else {
1393 format!("{shown}, and {hidden} more")
1394 }
1395}
1396
1397pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
1403 if plan.git_scope != GitScope::None && repo.capability() != RepositoryCapability::GitOverlay {
1409 return Err(anyhow!(HeddleError::recovery(
1410 RecoveryDetails::safety_refusal(
1411 "native_checkpoint_unavailable",
1412 "Git checkpointing is only available in Git-overlay repositories",
1413 "Use `heddle capture -m \"...\"` to save Heddle state in a native checkout.",
1414 "this checkout is not a Git-overlay repository",
1415 "checkpoint would try to write a Git commit where no active Git store is bound",
1416 "repository state, refs, and worktree files were left unchanged",
1417 ),
1418 )));
1419 }
1420
1421 let previous_state_started = Instant::now();
1422 let (previous_state, previous_state_profile) =
1423 repo.current_state_for_worktree_status_profiled()?;
1424 let mut previous_state_ms = previous_state_started.elapsed().as_millis();
1425 let native_thread_name = match repo.head_ref()? {
1426 refs::Head::Attached { thread } => Some(thread.to_string()),
1427 refs::Head::Detached { .. } => None,
1428 };
1429 if let (Some(name), Some(previous)) = (native_thread_name.as_deref(), previous_state.as_ref())
1430 && repo.native_thread(name).is_err()
1431 {
1432 repo.create_native_thread(name, previous.state_id, None, "")?;
1433 }
1434 let expected_writer_lease = repo.checkout_writer_lease_id()?;
1435 let (pre_snapshot_ran, hooks_ran) = if plan_creates_new_state(&plan, previous_state.is_some()) {
1436 run_pre_snapshot_hooks(repo, &plan)?
1437 } else {
1438 (false, false)
1439 };
1440 let actor = format!("cli:{}", std::process::id());
1441 let checkout_writer = match native_thread_name.as_deref() {
1442 Some(name) => match repo.native_thread(name) {
1443 Ok(replica) => Some(repo.acquire_checkout_writer(
1444 replica.thread_id(),
1445 &actor,
1446 expected_writer_lease.as_deref(),
1447 )?),
1448 Err(_) => None,
1449 },
1450 None => match repo::thread_replication::checkout::ThreadCheckout::open(repo.root()) {
1451 Ok(checkout) => Some(repo.acquire_checkout_writer(
1452 checkout.binding.thread,
1453 &actor,
1454 expected_writer_lease.as_deref(),
1455 )?),
1456 Err(_) => None,
1457 },
1458 };
1459 let (previous_state, previous_state_profile) = if hooks_ran {
1462 let reread_started = Instant::now();
1463 let current = repo.current_state_for_worktree_status_profiled()?;
1464 previous_state_ms += reread_started.elapsed().as_millis();
1465 current
1466 } else {
1467 (previous_state, previous_state_profile)
1468 };
1469 let has_current = previous_state.is_some();
1470 let mut created_new_state = false;
1471 let mut snapshot_profile = SnapshotProfile::default();
1472 let mut thread_metadata_ms = 0u128;
1473 let mut promotion_suggested = false;
1474 let mut heavy_impact_paths = Vec::new();
1475 let mut snapshot_state_id: Option<StateId> = None;
1476 let mut captured_path_count = 0usize;
1477 let mut state_create_ms = 0u128;
1478 let mut captured_path_count_ms = 0u128;
1479
1480 let mut state = if plan_creates_new_state(&plan, has_current) {
1481 created_new_state = true;
1482 let state_create_started = Instant::now();
1483 let execution = create_heddle_state(repo, &plan, pre_snapshot_ran)?;
1484 state_create_ms = state_create_started.elapsed().as_millis();
1485 snapshot_profile = execution.profile;
1486 thread_metadata_ms = execution.thread_metadata_ms;
1487 promotion_suggested = execution.promotion_suggested;
1488 heavy_impact_paths = execution.heavy_impact_paths;
1489 snapshot_state_id = Some(execution.state.state_id);
1490 let previous_tree = match previous_state.as_ref() {
1491 Some(state) => state.tree,
1492 None => repo.store().put_tree(&Tree::new())?,
1493 };
1494 let captured_path_count_started = Instant::now();
1495 captured_path_count = repo
1496 .diff_trees(&previous_tree, &execution.state.tree)?
1497 .len();
1498 captured_path_count_ms = captured_path_count_started.elapsed().as_millis();
1499 execution.state
1500 } else {
1501 repo.current_state()?
1502 .ok_or_else(|| anyhow!("no captured state found for save"))?
1503 };
1504
1505 let mut git_commit = None;
1506 let mut git_previous_commit = None;
1507 let mut git_checkpoint = None;
1508
1509 if plan_writes_git_checkpoint(&plan, repo.capability()) {
1510 if plan.require_clean_worktree {
1511 let tree = repo.require_tree(&state.tree)?;
1512 let status = repo.compare_worktree_cached_detailed_with_options(
1513 &tree,
1514 &plan.worktree_status_options,
1515 )?;
1516 if !status.is_clean() {
1517 return Err(anyhow!(HeddleError::recovery(
1518 RecoveryDetails::safety_refusal(
1519 "dirty_worktree",
1520 "Save worktree changes before checkpointing",
1521 "Save the work with `heddle capture -m \"...\"`.",
1522 "the current Heddle state was left unchanged; these paths have not been captured",
1523 "a Git checkpoint would omit dirty worktree paths",
1524 "the current Heddle state was left unchanged; these paths have not been captured",
1525 ),
1526 )));
1527 }
1528 }
1529
1530 if let Some(existing) = repo.latest_git_checkpoint_for_state(&state.state_id)?
1531 && repo.pending_git_checkpoint_intent()?.is_none()
1532 {
1533 git_commit = Some(existing.git_commit.clone());
1534 git_checkpoint = Some(existing);
1535 } else {
1536 let previous = repo
1537 .pending_git_checkpoint_intent()?
1538 .and_then(|intent| intent.previous_git_oid)
1539 .or_else(|| git_rev_parse_head(repo.root()));
1540 git_previous_commit = previous.clone();
1541 let summary = checkpoint_summary(&plan, &state);
1542 let record = write_git_checkpoint(repo, &state, summary, plan.linearize_git_parent)?;
1543 if plan.coalesce_snapshot_and_checkpoint
1544 && let Some(state_id) = snapshot_state_id.as_ref()
1545 {
1546 coalesce_snapshot_and_checkpoint(repo, state_id, &record.git_commit)?;
1547 }
1548 git_commit = Some(record.git_commit.clone());
1549 git_checkpoint = Some(record);
1550 }
1551 }
1552
1553 let captured_native_worktree = created_new_state
1557 && plan.supplied_tree.is_none()
1558 && repo.capability() == RepositoryCapability::NativeHeddle;
1559 let captured_worktree_status = Ok(Some(objects::worktree::WorktreeStatus::default()));
1560 let verification_started = Instant::now();
1561 let verification = if captured_native_worktree && git_checkpoint.is_none() {
1562 let health = build_repository_verification_health_with_worktree_status(
1563 repo,
1564 &captured_worktree_status,
1565 );
1566 if let Some(input) = &plan.machine_contract_input {
1567 build_repository_verification_state_with_worktree_status_and_machine_contract(
1568 repo,
1569 health,
1570 &captured_worktree_status,
1571 input,
1572 )
1573 } else {
1574 build_repository_verification_state_with_worktree_status(
1575 repo,
1576 health,
1577 &captured_worktree_status,
1578 )
1579 }
1580 } else if created_new_state || git_checkpoint.is_some() {
1581 if let Some(input) = &plan.machine_contract_input {
1582 build_repository_verification_state_with_machine_contract(repo, input)?
1583 } else {
1584 build_repository_verification_state(repo)?
1585 }
1586 } else if let Some(status) = &plan.precomputed_worktree_status {
1587 let health = build_repository_verification_health_with_worktree_status(repo, status);
1588 if let Some(input) = &plan.machine_contract_input {
1589 build_repository_verification_state_with_worktree_status_and_machine_contract(
1590 repo, health, status, input,
1591 )
1592 } else {
1593 build_repository_verification_state_with_worktree_status(repo, health, status)
1594 }
1595 } else {
1596 if let Some(input) = &plan.machine_contract_input {
1597 build_repository_verification_state_with_machine_contract(repo, input)?
1598 } else {
1599 build_repository_verification_state(repo)?
1600 }
1601 };
1602 let post_verification_ms = verification_started.elapsed().as_millis();
1603
1604 let summary = match plan.verb {
1605 SaveVerb::Capture => format!(
1606 "Captured state {} ({})",
1607 state.state_id.short(),
1608 state.hash().short()
1609 ),
1610 SaveVerb::Checkpoint => git_checkpoint
1611 .as_ref()
1612 .map(|r| r.summary.clone())
1613 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short())),
1614 };
1615
1616 if created_new_state && let Some(name) = native_thread_name.as_deref() {
1617 if repo.native_thread(name).is_err() {
1618 repo.create_native_thread(
1619 name,
1620 previous_state
1621 .as_ref()
1622 .map(|previous| previous.state_id)
1623 .unwrap_or(state.state_id),
1624 None,
1625 "",
1626 )?;
1627 }
1628 let genesis_base = repo.native_thread(name)?.genesis()?.base;
1629 if state.state_id != genesis_base && !state.parents.is_empty() {
1630 repo.record_native_capture(name, state.state_id)?;
1631 }
1632 }
1633 let signature_lookup_started = Instant::now();
1634 let signed = repo.get_state_signature(&state.id())?.is_some();
1635 let signature_lookup_ms = signature_lookup_started.elapsed().as_millis();
1636 let report = SaveReport {
1637 verb: plan.verb,
1638 state_id: state.state_id,
1639 content_hash: state.hash(),
1640 intent: state.intent.clone(),
1641 confidence: state.confidence,
1642 signed,
1643 git_commit,
1644 git_previous_commit,
1645 summary,
1646 principal: state.attribution.principal.clone(),
1647 agent: state.attribution.agent.clone(),
1648 promotion_suggested,
1649 heavy_impact_paths,
1650 captured_path_count,
1651 verification,
1652 created_new_state,
1653 git_checkpoint,
1654 snapshot_profile,
1655 state_create_ms,
1656 captured_path_count_ms,
1657 post_verification_ms,
1658 thread_metadata_ms,
1659 previous_state_ms,
1660 previous_state_profile,
1661 signature_lookup_ms,
1662 };
1663 if let Some(writer) = checkout_writer {
1666 writer.finish()?;
1667 }
1668 if created_new_state && plan.run_hooks {
1669 run_post_snapshot_hooks(repo, &state)?;
1670 }
1671 Ok(report)
1672}
1673
1674struct CreatedState {
1675 state: State,
1676 profile: SnapshotProfile,
1677 thread_metadata_ms: u128,
1678 promotion_suggested: bool,
1679 heavy_impact_paths: Vec<String>,
1680}
1681
1682fn run_pre_snapshot_hooks(repo: &Repository, plan: &SavePlan) -> Result<(bool, bool)> {
1683 if !plan.run_hooks {
1684 return Ok((false, false));
1685 }
1686 let hook_manager = HookManager::new(repo);
1687 let hook_ctx = HookContext::new(repo);
1688 let pre_snapshot_ran = hook_manager.run(Hook::PreSnapshot, &hook_ctx)?;
1689 let pre_capture_payload = serde_json::json!({
1690 "thread": current_thread_name(repo),
1691 "intent": plan.intent.clone().unwrap_or_default(),
1692 });
1693 let pre_capture_response = hook_manager.run_with_payload(
1694 Hook::PreSnapshot,
1695 &hook_ctx,
1696 &pre_capture_payload,
1697 std::time::Duration::from_secs(5),
1698 )?;
1699 let pre_capture_ran = pre_capture_response.is_some();
1700 if let Some(resp) = pre_capture_response
1701 && !resp.abort.is_empty()
1702 {
1703 return Err(anyhow!(HeddleError::recovery(
1704 RecoveryDetails::safety_refusal(
1705 "hook_veto",
1706 format!("pre_capture hook vetoed: {}", resp.abort),
1707 "Inspect `pre_capture` with `heddle hook list`, update the hook policy or inputs, then retry.",
1708 format!("pre_capture hook vetoed capture: {}", resp.abort),
1709 "capture would continue after repository policy explicitly aborted the operation",
1710 "the operation stopped at the hook boundary before the protected action ran",
1711 )
1712 .with_recovery_commands(vec!["heddle hook list".to_string()]),
1713 )));
1714 }
1715 Ok((pre_snapshot_ran, pre_snapshot_ran || pre_capture_ran))
1716}
1717
1718fn create_heddle_state(
1719 repo: &Repository,
1720 plan: &SavePlan,
1721 pre_snapshot_ran: bool,
1722) -> Result<CreatedState> {
1723 let mut post_hook_worktree_changes = None;
1724
1725 if pre_snapshot_ran && plan.supplied_tree.is_none() {
1726 let authoritative_options = WorktreeStatusOptions {
1730 fsmonitor: repo::FsMonitorSettings {
1731 mode: repo::FsMonitorMode::Off,
1732 },
1733 };
1734 post_hook_worktree_changes = Some(capture_worktree_status(repo, &authoritative_options)?);
1735 }
1736 let mut execution = if let Some(tree) = plan.supplied_tree.clone() {
1737 repo.snapshot_tree_with_attribution_profiled(
1738 tree,
1739 plan.intent.clone(),
1740 plan.confidence,
1741 plan.attribution.clone(),
1742 )?
1743 } else if let Some(status) =
1744 post_hook_worktree_changes.or_else(|| plan.known_worktree_changes.clone())
1745 {
1746 repo.snapshot_with_attribution_profiled_from_status(
1747 plan.intent.clone(),
1748 plan.confidence,
1749 plan.attribution.clone(),
1750 status,
1751 plan.require_worktree_change,
1752 )?
1753 } else if plan.require_worktree_change {
1754 repo.snapshot_with_attribution_profiled_if_changed(
1755 plan.intent.clone(),
1756 plan.confidence,
1757 plan.attribution.clone(),
1758 )?
1759 } else {
1760 repo.snapshot_with_attribution_profiled(
1761 plan.intent.clone(),
1762 plan.confidence,
1763 plan.attribution.clone(),
1764 )?
1765 };
1766
1767 let thread_metadata_start = Instant::now();
1768 let refresh = refresh_active_thread_metadata(repo, &execution.state, &execution.tree)?;
1769 let thread_metadata_ms = thread_metadata_start.elapsed().as_millis();
1770
1771 Ok(CreatedState {
1772 state: execution.state,
1773 profile: std::mem::take(&mut execution.profile),
1774 thread_metadata_ms,
1775 promotion_suggested: refresh.promotion_suggested,
1776 heavy_impact_paths: refresh.heavy_impact_paths,
1777 })
1778}
1779
1780fn run_post_snapshot_hooks(repo: &Repository, state: &State) -> Result<()> {
1781 let hook_manager = HookManager::new(repo);
1782 let hook_ctx = HookContext::new(repo);
1783 hook_manager.run(Hook::PostSnapshot, &hook_ctx)?;
1784 let post_capture_payload = serde_json::json!({
1785 "state_id": state.state_id.to_string_full(),
1786 });
1787 if let Err(err) = hook_manager.run_with_payload(
1788 Hook::PostSnapshot,
1789 &hook_ctx,
1790 &post_capture_payload,
1791 std::time::Duration::from_secs(5),
1792 ) {
1793 tracing::warn!(error = %err, "post_capture hook error swallowed");
1794 }
1795 Ok(())
1796}
1797
1798fn write_git_checkpoint(
1799 repo: &Repository,
1800 state: &State,
1801 summary: String,
1802 linearize_git_parent: bool,
1803) -> Result<GitCheckpointRecord> {
1804 let _lock = repo.locker().write()?;
1805 objects::fault_inject::maybe_fail_at("git_checkpoint_before_write_through")?;
1806 let mut bridge = GitProjection::new(repo);
1807 if linearize_git_parent {
1808 bridge.linearize_unmapped_tip_to_checkout();
1809 }
1810 let git_commit = match bridge
1811 .write_through_current_checkout_with_message(state.state_id, summary.clone())?
1812 {
1813 WriteThroughOutcome::Wrote(git_commit) => git_commit.to_string(),
1814 WriteThroughOutcome::Skipped(reason) => {
1815 return Err(anyhow!(HeddleError::recovery(
1816 RecoveryDetails::safety_refusal(
1817 "checkpoint_git_write_skipped",
1818 format!("Git checkpoint write-through was skipped: {reason}"),
1819 "Inspect `heddle verify`, resolve the skip reason, then retry `heddle land`.",
1820 format!("write-through skipped: {reason}"),
1821 "checkpoint would need to write the current Heddle state into the Git branch and index",
1822 "the current Heddle state was preserved; no Git checkpoint record was written",
1823 ),
1824 )));
1825 }
1826 };
1827 let intent = repo.pending_git_checkpoint_intent()?.ok_or_else(|| {
1828 anyhow!("Git checkpoint published without its durable finalization intent")
1829 })?;
1830 if intent.phase != repo::GitCheckpointIntentPhase::Published
1831 || intent.state_id != state.state_id.to_string_full()
1832 || intent.new_git_oid != git_commit
1833 {
1834 return Err(anyhow!(
1835 "published Git checkpoint does not match its durable finalization intent"
1836 ));
1837 }
1838 finalize_published_git_checkpoint(repo, &state.state_id, git_commit, summary, intent)
1839}
1840
1841pub fn recover_published_git_checkpoint(
1845 repo: &Repository,
1846 state_id: &StateId,
1847) -> Result<Option<GitCheckpointRecord>> {
1848 let _lock = repo.locker().write()?;
1849 let Some(mut intent) = repo.pending_git_checkpoint_intent()? else {
1850 return Ok(None);
1851 };
1852 if intent.state_id != state_id.to_string_full() {
1853 return Ok(None);
1854 }
1855 let current_branch = repo.git_overlay_current_branch()?;
1856 if current_branch.as_deref() != Some(intent.branch.as_str()) {
1857 return Err(anyhow!(
1858 "pending Git checkpoint targets branch '{}' but the checkout is on '{}'",
1859 intent.branch,
1860 current_branch.as_deref().unwrap_or("detached HEAD")
1861 ));
1862 }
1863 let current_oid = git_rev_parse_head(repo.root());
1864 if intent.phase == repo::GitCheckpointIntentPhase::Prepared {
1865 if current_oid == intent.previous_git_oid {
1866 return Ok(None);
1867 }
1868 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1869 return Err(anyhow!(
1870 "prepared Git checkpoint expected HEAD at {} or {}, found {}",
1871 intent.previous_git_oid.as_deref().unwrap_or("<unborn>"),
1872 intent.new_git_oid,
1873 current_oid.as_deref().unwrap_or("<unborn>")
1874 ));
1875 }
1876 let git_oid = intent.new_git_oid.clone();
1877 intent = repo.mark_git_checkpoint_published(state_id, &git_oid)?;
1878 }
1879 if intent.phase != repo::GitCheckpointIntentPhase::Published {
1880 return Ok(None);
1881 }
1882 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1883 return Err(anyhow!(
1884 "published Git checkpoint expected HEAD at {}, found {}",
1885 intent.new_git_oid,
1886 current_oid.as_deref().unwrap_or("<unborn>")
1887 ));
1888 }
1889 let git_commit = intent.new_git_oid.clone();
1890 let summary = intent.summary.clone();
1891 finalize_published_git_checkpoint(repo, state_id, git_commit, summary, intent).map(Some)
1892}
1893
1894fn finalize_published_git_checkpoint(
1895 repo: &Repository,
1896 state_id: &StateId,
1897 git_commit: String,
1898 summary: String,
1899 intent: repo::GitCheckpointIntent,
1900) -> Result<GitCheckpointRecord> {
1901 let record = repo.record_git_checkpoint(state_id, git_commit.clone(), summary)?;
1902 objects::fault_inject::maybe_panic_at("git_checkpoint_after_metadata_before_oplog");
1903 let transaction_id = format!(
1904 "git-checkpoint:v1:{}:{}",
1905 state_id.to_string_full(),
1906 git_commit
1907 );
1908 repo.oplog().record_batch_exactly_once(
1909 vec![
1910 OpRecord::GitCheckpoint {
1911 branch: intent.branch,
1912 state: *state_id,
1913 previous_git_oid: intent.previous_git_oid,
1914 new_git_oid: git_commit.clone(),
1915 },
1916 OpRecord::TransactionCommit {
1917 transaction_id: transaction_id.clone(),
1918 op_count: 1,
1919 },
1920 ],
1921 Some(&repo.op_scope()),
1922 &transaction_id,
1923 )?;
1924 objects::fault_inject::maybe_panic_at("git_checkpoint_after_oplog_before_finalize");
1925 repo.finish_git_checkpoint_intent(state_id, &git_commit)?;
1926 Ok(record)
1927}
1928
1929fn coalesce_snapshot_and_checkpoint(
1930 repo: &Repository,
1931 state_id: &StateId,
1932 git_commit: &str,
1933) -> Result<()> {
1934 let snapshot_batch = repo
1935 .oplog()
1936 .recent_batches_scoped(8, Some(&repo.op_scope()))?
1937 .into_iter()
1938 .find(|batch| {
1939 batch.entries.iter().any(|entry| {
1940 matches!(
1941 &entry.operation,
1942 OpRecord::Snapshot { new_state, .. } if new_state == state_id
1943 )
1944 })
1945 })
1946 .ok_or_else(|| anyhow!("capture succeeded but its oplog batch was not found"))?;
1947 let checkpoint_batch = repo
1948 .oplog()
1949 .recent_batches_scoped(8, Some(&repo.op_scope()))?
1950 .into_iter()
1951 .find(|batch| {
1952 batch.entries.iter().any(|entry| {
1953 matches!(
1954 &entry.operation,
1955 OpRecord::GitCheckpoint { new_git_oid, .. } if new_git_oid == git_commit
1956 )
1957 })
1958 })
1959 .ok_or_else(|| anyhow!("Git checkpoint succeeded but its oplog batch was not found"))?;
1960 repo.oplog()
1961 .coalesce_batches(snapshot_batch.id, checkpoint_batch.id)
1962 .context(
1963 "capture completed but failed to record its state and Git checkpoint as one undo batch",
1964 )?;
1965 Ok(())
1966}
1967
1968fn checkpoint_summary(plan: &SavePlan, state: &State) -> String {
1969 plan.intent
1970 .clone()
1971 .or_else(|| state.intent.clone())
1972 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short()))
1973}
1974
1975fn current_thread_name(repo: &Repository) -> String {
1976 match repo.head_ref() {
1977 Ok(Head::Attached { thread }) => thread.to_string(),
1978 _ => String::new(),
1979 }
1980}
1981
1982fn git_rev_parse_head(root: &std::path::Path) -> Option<String> {
1983 let git = SleyRepository::discover(root).ok()?;
1984 git.head().ok()?.oid.map(|id| id.to_string())
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989 use repo::RepositoryCapability;
1990 use tempfile::TempDir;
1991
1992 use super::*;
1993
1994 #[test]
1995 fn capture_interface_owns_mutation_and_returns_the_report_contract() {
1996 let temp = TempDir::new().expect("create temp repository");
1997 let repo = Repository::init_default(temp.path()).expect("initialize repository");
1998 std::fs::write(temp.path().join("tracked.txt"), "captured\n")
1999 .expect("write worktree change");
2000 let ctx = ExecutionContext::builder()
2001 .repo(repo)
2002 .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
2003 .build();
2004 let report = capture(
2005 &ctx,
2006 CaptureOptions {
2007 intent: "exercise the deep capture interface".into(),
2008 confidence: Some(0.9),
2009 force: false,
2010 agent: CaptureAgentOptions::default(),
2011 machine_contract_input: None,
2012 },
2013 )
2014 .expect("capture succeeds");
2015
2016 assert_eq!(report.output_kind, "capture");
2017 assert_eq!(report.captured_path_count, 1);
2018 assert_eq!(report.principal.name, "Ada");
2019 assert_eq!(report.principal.email, "ada@example.com");
2020 assert_eq!(report.principal_source, "user_config");
2021 assert_eq!(CaptureReport::CONTRACT.schema_name, "capture");
2022 assert_eq!(
2023 ctx.require_repo()
2024 .expect("repository")
2025 .head()
2026 .expect("read head")
2027 .expect("captured head")
2028 .short(),
2029 report.state_id
2030 );
2031
2032 let wire = serde_json::to_value(&report).expect("serialize report");
2033 assert_eq!(wire["output_kind"], "capture");
2034 assert!(wire.get("diagnostics").is_none());
2035 assert!(wire.get("captured_thread_targets_integration").is_none());
2036 }
2037
2038 #[test]
2039 fn manual_resolution_land_action_quotes_untrusted_thread_ids() {
2040 let temp = TempDir::new().expect("create temp repository");
2041 let repo = Repository::init_default(temp.path()).expect("initialize repository");
2042
2043 assert_eq!(
2044 manual_resolution_land_action(&repo, "bad;echo pwn", None),
2045 "heddle land --thread 'bad;echo pwn'"
2046 );
2047 assert_eq!(
2048 manual_resolution_land_action(&repo, "-danger", None),
2049 "heddle land --thread=-danger"
2050 );
2051 }
2052
2053 #[test]
2054 fn clean_overlay_refuses_before_requiring_identity() {
2055 let temp = TempDir::new().expect("create temp repository");
2056 SleyRepository::init(temp.path()).expect("initialize Git repository");
2057 let repo = Repository::init_git_overlay_sidecar(temp.path())
2058 .expect("initialize Git-overlay sidecar");
2059 let ctx = ExecutionContext::builder().repo(repo).build();
2060
2061 let error = capture(
2062 &ctx,
2063 CaptureOptions {
2064 intent: "nothing changed".into(),
2065 confidence: None,
2066 force: false,
2067 agent: CaptureAgentOptions::default(),
2068 machine_contract_input: None,
2069 },
2070 )
2071 .expect_err("clean overlay must refuse capture");
2072
2073 assert!(error.to_string().contains("nothing to capture"));
2074 }
2075
2076 #[test]
2077 fn capture_rejects_missing_intent_before_mutation() {
2078 let temp = TempDir::new().expect("create temp repository");
2079 let repo = Repository::init_default(temp.path()).expect("initialize repository");
2080 std::fs::write(temp.path().join("tracked.txt"), "uncaptured\n")
2081 .expect("write worktree change");
2082 let ctx = ExecutionContext::builder()
2083 .repo(repo)
2084 .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
2085 .build();
2086 let before = ctx.require_repo().expect("repository").head().unwrap();
2087
2088 let error = capture(
2089 &ctx,
2090 CaptureOptions {
2091 intent: " ".into(),
2092 confidence: None,
2093 force: false,
2094 agent: CaptureAgentOptions::default(),
2095 machine_contract_input: None,
2096 },
2097 )
2098 .expect_err("blank intent must be rejected by the operation interface");
2099
2100 assert!(
2101 error
2102 .to_string()
2103 .contains("refusing to capture without an intent")
2104 );
2105 assert_eq!(
2106 ctx.require_repo().expect("repository").head().unwrap(),
2107 before
2108 );
2109 }
2110
2111 #[test]
2112 fn attribution_cleaning_only_rejects_the_placeholder_token() {
2113 assert_eq!(clean_attribution_value(" unknown ".into()), None);
2114 assert_eq!(clean_attribution_value(" ".into()), None);
2115 assert_eq!(
2116 clean_attribution_value("unknown-model-v2".into()),
2117 Some("unknown-model-v2".into())
2118 );
2119 }
2120
2121 #[test]
2122 fn identity_freeze_waits_for_the_repository_write_lock() {
2123 use std::{sync::mpsc, time::Duration};
2124
2125 let temp = TempDir::new().expect("create temp repository");
2126 let repo = Repository::init_default(temp.path()).expect("initialize repository");
2127 let hold = repo.locker().write().expect("hold repository lock");
2128 let root = repo.root().to_path_buf();
2129 let (tx, rx) = mpsc::channel();
2130 let worker = std::thread::spawn(move || {
2131 let repo = Repository::open(root).expect("open worker repository");
2132 let patch = IdentityCursor {
2133 provider: Some("anthropic".into()),
2134 model: Some("opus".into()),
2135 ..IdentityCursor::default()
2136 };
2137 let frozen = freeze_identity_for_capture(&repo, &patch);
2138 let _ = tx.send(frozen.is_ok());
2139 });
2140
2141 assert!(
2142 rx.recv_timeout(Duration::from_millis(150)).is_err(),
2143 "identity mutation must wait for the repository lock"
2144 );
2145 drop(hold);
2146 assert!(
2147 rx.recv_timeout(Duration::from_secs(2))
2148 .expect("worker should finish after lock release")
2149 );
2150 worker.join().expect("join identity worker");
2151 }
2152
2153 #[test]
2154 fn reserved_capture_paths_consider_selected_additions_and_modifications_only() {
2155 let status = WorktreeStatus {
2156 added: vec![std::path::PathBuf::from("services/api/.env.local")],
2157 modified: Vec::new(),
2158 deleted: vec![std::path::PathBuf::from("old/.env")],
2159 };
2160 assert_eq!(
2161 reserved_capture_path(&status).as_deref(),
2162 Some("services/api/.env.local")
2163 );
2164
2165 let deletion_only = WorktreeStatus {
2166 deleted: vec![std::path::PathBuf::from(".env")],
2167 ..WorktreeStatus::default()
2168 };
2169 assert!(reserved_capture_path(&deletion_only).is_none());
2170 }
2171
2172 #[test]
2173 fn plan_creates_new_state_routing() {
2174 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2175 let capture = SavePlan::capture("wip", attr.clone());
2176 assert!(plan_creates_new_state(&capture, true));
2177 assert!(plan_creates_new_state(&capture, false));
2178
2179 let checkpoint = SavePlan::checkpoint(Some("cp".into()), attr.clone(), false);
2180 assert!(!plan_creates_new_state(&checkpoint, true));
2181 assert!(plan_creates_new_state(&checkpoint, false));
2182
2183 let staged =
2184 SavePlan::checkpoint(Some("cp".into()), attr, true).with_supplied_tree(Tree::new());
2185 assert!(plan_creates_new_state(&staged, true));
2186 }
2187
2188 #[test]
2189 fn plan_writes_git_checkpoint_respects_scope_and_capability() {
2190 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2191 let mut capture = SavePlan::capture("wip", attr);
2192 assert!(!plan_writes_git_checkpoint(
2193 &capture,
2194 RepositoryCapability::GitOverlay
2195 ));
2196 capture.git_scope = GitScope::WorktreeAll;
2197 assert!(plan_writes_git_checkpoint(
2198 &capture,
2199 RepositoryCapability::GitOverlay
2200 ));
2201 assert!(!plan_writes_git_checkpoint(
2202 &capture,
2203 RepositoryCapability::NativeHeddle
2204 ));
2205 }
2206
2207 #[test]
2208 fn save_plan_builders_set_expected_defaults() {
2209 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2210 let capture = SavePlan::capture("intent", attr.clone());
2211 assert_eq!(capture.verb, SaveVerb::Capture);
2212 assert_eq!(capture.git_scope, GitScope::None);
2213 assert!(!capture.coalesce_snapshot_and_checkpoint);
2214
2215 let staged = SavePlan::checkpoint(None, attr, true);
2216 assert_eq!(staged.git_scope, GitScope::Staged);
2217 assert!(!staged.require_clean_worktree);
2218 assert!(staged.reuse_current_state);
2219 }
2220
2221 #[test]
2222 fn tree_leaf_name_returns_the_final_component() {
2223 assert_eq!(tree_leaf_name("a/b/c.rs"), "c.rs");
2224 assert_eq!(tree_leaf_name("solo"), "solo");
2225 }
2226}