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 &options.agent,
453 )?;
454 let harness_session_id = resolved_attribution
455 .attribution
456 .agent
457 .is_some()
458 .then(|| resolved_attribution.harness_session_id.clone())
459 .flatten();
460 let mut warnings = resolved_attribution.warnings;
461 let attribution_ms = attribution_started.elapsed().as_millis();
462
463 let git_overlay = repo.capability() == RepositoryCapability::GitOverlay;
464 let plan = SavePlan {
465 verb: SaveVerb::Capture,
466 intent: Some(options.intent),
467 confidence: options.confidence,
468 attribution: resolved_attribution.attribution,
469 git_scope: if git_overlay {
470 GitScope::WorktreeAll
471 } else {
472 GitScope::None
473 },
474 supplied_tree: None,
475 reuse_current_state,
476 require_clean_worktree: git_overlay,
477 require_worktree_change: repo.capability() == RepositoryCapability::NativeHeddle
482 && !complete_thread_resolution,
483 worktree_status_options: ctx.worktree_status_options(),
484 known_worktree_changes,
485 run_hooks: true,
486 coalesce_snapshot_and_checkpoint: git_overlay,
487 linearize_git_parent: git_overlay,
492 precomputed_worktree_status: Some(worktree_status),
493 machine_contract_input: options.machine_contract_input,
494 };
495 let execute_save_started = Instant::now();
496 let save = execute_save(repo, plan).map_err(map_capture_error)?;
497 let execute_save_ms = execute_save_started.elapsed().as_millis();
498 if let Some(session_id) = harness_session_id
499 && let Err(error) = crate::record_last_turn_capture(repo, &session_id, save.state_id)
500 {
501 warnings.push(format!(
502 "could not update the reconstructible last-turn anchor: {error}"
503 ));
504 }
505
506 let manual_resolution_action = if complete_thread_resolution {
507 complete_current_thread_manual_resolution(repo)?
508 } else {
509 None
510 };
511
512 let current_thread = current_thread(repo)?;
513 let captured_thread_targets_integration = current_thread
514 .as_ref()
515 .and_then(|thread| thread.target_thread.as_ref())
516 .is_some();
517 let task_assignment_id = active_task_assignment_id(repo, current_thread.as_ref())?;
518 let principal_source = resolved_attribution.principal_source;
519 warnings.extend(bulk_capture_warning(save.captured_path_count));
520
521 let mut recommended_action = non_empty_action(&save.verification.recommended_action);
522 let mut recommended_action_template = recommended_action
523 .as_deref()
524 .and_then(action_template)
525 .or_else(|| save.verification.recommended_action_template.clone());
526 if let Some(action) = manual_resolution_action {
527 recommended_action_template = action_template(&action);
528 recommended_action = Some(action);
529 }
530
531 let principal = CapturePrincipalReport {
532 name: save.principal.name_lossy().into_owned(),
533 email: save.principal.email_lossy().into_owned(),
534 };
535 let agent = save.agent.as_ref().map(|agent| CaptureAgentReport {
536 provider: agent.provider.clone(),
537 model: agent.model.clone(),
538 session_id: agent.session_id.clone(),
539 segment_id: agent.segment_id.clone(),
540 policy_id: agent.policy_id.clone(),
541 thought_level: agent.thought_level.clone(),
542 parent: agent.parent.clone(),
543 });
544 Ok(CaptureReport {
545 output_kind: "capture",
546 state_id: save.state_id.short(),
547 content_hash: save.content_hash.short(),
548 git_checkpoint: save.git_commit.clone(),
549 intent: save.intent.clone(),
550 confidence: save.confidence,
551 task_assignment_id,
552 principal,
553 principal_source,
554 agent,
555 promotion_suggested: save.promotion_suggested,
556 heavy_impact_paths: save.heavy_impact_paths.clone(),
557 captured_path_count: save.captured_path_count,
558 warnings,
559 signed: save.signed,
560 message: save.summary.clone(),
561 recommended_action,
562 recommended_action_template,
563 verification: save.verification.clone(),
564 captured_thread_targets_integration,
565 diagnostics: CaptureDiagnostics {
566 profile: CaptureProfile {
567 worktree_status_ms,
568 preflight_ms,
569 attribution_ms,
570 execute_save_ms,
571 },
572 snapshot_profile: save.snapshot_profile,
573 state_create_ms: save.state_create_ms,
574 captured_path_count_ms: save.captured_path_count_ms,
575 post_verification_ms: save.post_verification_ms,
576 thread_metadata_ms: save.thread_metadata_ms,
577 previous_state_ms: save.previous_state_ms,
578 previous_state_profile: save.previous_state_profile,
579 signature_lookup_ms: save.signature_lookup_ms,
580 },
581 })
582}
583
584fn resolve_capture_attribution(
590 repo: &Repository,
591 principal_fallback: Option<(&str, &str)>,
592 hosted_principal: Option<(&str, &str)>,
593 options: &CaptureAgentOptions,
594) -> Result<CaptureAttribution> {
595 let resolved_principal = crate::apply_hosted_principal_fallback(
596 crate::resolve_principal(repo, principal_fallback)?,
597 hosted_principal,
598 );
599 let principal_source = resolved_principal.source.unwrap_or("unknown").to_string();
600 let principal = resolved_principal.principal;
601 if crate::principal_lacks_accountable_identity(
602 &principal.name_lossy(),
603 &principal.email_lossy(),
604 ) {
605 return Err(capture_refusal(
606 "capture_identity_required",
607 "Refusing to capture: no accountable identity is configured",
608 "Set `HEDDLE_PRINCIPAL_NAME` and `HEDDLE_PRINCIPAL_EMAIL`, or run `heddle init --principal-name <name> --principal-email <email>`, then retry the capture.",
609 "Heddle would otherwise have to record Unknown <unknown@example.com> on the captured state",
610 "capture would create durable Heddle history without a real principal",
611 "Heddle refs, captured states, Git refs, index, and worktree files were left unchanged",
612 vec![
613 "heddle init --principal-name <name> --principal-email <email>".to_string(),
614 "heddle capture -m \"...\"".to_string(),
615 ],
616 ));
617 }
618
619 if options.no_agent {
620 return Ok(CaptureAttribution {
621 attribution: Attribution::human(principal),
622 principal_source,
623 harness_session_id: None,
624 warnings: Vec::new(),
625 });
626 }
627
628 let (frozen, warnings) = match freeze_identity_for_capture(repo, &options.identity_patch) {
633 Ok(frozen) => (frozen, Vec::new()),
634 Err(error) => (
635 FrozenCaptureIdentity::default(),
636 vec![format!(
637 "could not freeze agent identity for this capture; recorded human attribution: {error}"
638 )],
639 ),
640 };
641 let harness_session_id = frozen.session.clone();
642 let current_session = SessionManager::new(repo.root()).get_current_session()?;
643 let provider = options
644 .provider
645 .clone()
646 .or(frozen.provider.clone())
647 .and_then(clean_attribution_value);
648 let model = options
649 .model
650 .clone()
651 .or(frozen.model.clone())
652 .and_then(clean_attribution_value);
653 let session_policy = current_session
654 .as_ref()
655 .and_then(|session| session.current_segment())
656 .and_then(|segment| segment.policy_id.clone())
657 .and_then(clean_attribution_value);
658 let session_id = options
660 .session
661 .clone()
662 .or_else(|| current_session.as_ref().map(|session| session.id.clone()));
663 let segment_id = options
664 .segment
665 .clone()
666 .or(frozen.segment_id.clone())
667 .or_else(|| {
668 current_session
669 .as_ref()
670 .and_then(|session| session.current_segment_id.clone())
671 });
672 let policy = if options.no_policy {
673 None
674 } else {
675 options
676 .policy
677 .clone()
678 .or_else(|| options.environment_policy.clone())
679 .and_then(clean_attribution_value)
680 .or(session_policy)
681 .or_else(|| options.default_policy.clone())
682 .or_else(|| repo.config().policies.default_policy.clone())
683 };
684
685 let attribution = match (provider, model) {
686 (Some(provider), Some(model)) => {
687 let mut agent = Agent::new(provider, model);
688 if let (Some(session_id), Some(segment_id)) = (session_id, segment_id) {
689 agent = agent.with_session(session_id, segment_id);
690 }
691 if let Some(policy) = policy {
692 agent = agent.with_policy(policy);
693 }
694 if let Some(thought_level) = frozen.thought_level {
695 agent = agent.with_thought_level(thought_level);
696 }
697 if let Some(parent) = frozen.parent {
698 agent = agent.with_parent(parent);
699 }
700 Attribution::with_agent(principal, agent)
701 }
702 _ => Attribution::human(principal),
703 };
704 Ok(CaptureAttribution {
705 attribution,
706 principal_source,
707 harness_session_id,
708 warnings,
709 })
710}
711
712pub fn resolve_capture_author(
717 repo: &Repository,
718 principal_fallback: Option<(&str, &str)>,
719 options: &CaptureAgentOptions,
720) -> Result<Attribution> {
721 resolve_capture_attribution(repo, principal_fallback, None, options)
722 .map(|resolved| resolved.attribution)
723}
724
725#[derive(Clone, Debug, Default)]
726struct FrozenCaptureIdentity {
727 provider: Option<String>,
728 model: Option<String>,
729 thought_level: Option<String>,
730 session: Option<String>,
731 parent: Option<String>,
732 segment_id: Option<String>,
733}
734
735fn freeze_identity_for_capture(
736 repo: &Repository,
737 identity_patch: &IdentityCursor,
738) -> Result<FrozenCaptureIdentity> {
739 let _guard = repo.locker().write()?;
740 let mut cursor = read_identity_cursor(repo.root());
741 if !identity_patch.is_empty() {
742 cursor = stamp_identity_cursor(repo.root(), identity_patch)?;
743 }
744 let mut manager = SessionManager::new(repo.root());
745 let session = manager.get_current_session()?;
746 let segment_id = apply_capture_segment_policy(&mut manager, session.as_ref(), &cursor)?;
747 Ok(FrozenCaptureIdentity {
748 provider: cursor.provider,
749 model: cursor.model,
750 thought_level: cursor.thought_level,
751 session: cursor.session,
752 parent: cursor.parent,
753 segment_id,
754 })
755}
756
757fn apply_capture_segment_policy(
758 manager: &mut SessionManager,
759 session: Option<&objects::object::Session>,
760 cursor: &IdentityCursor,
761) -> Result<Option<String>> {
762 let Some(session) = session else {
763 return Ok(None);
764 };
765 let current = session.current_segment();
766 let rotation = cursor_segment_rotation(
767 current.map(|segment| segment.provider.as_str()),
768 current.map(|segment| segment.model.as_str()),
769 current.and_then(|segment| segment.thought_level.as_deref()),
770 cursor.provider.as_deref(),
771 cursor.model.as_deref(),
772 cursor.thought_level.as_deref(),
773 );
774 if rotation == SegmentRotation::Rotate {
775 let provider = cursor
776 .provider
777 .clone()
778 .or_else(|| current.map(|segment| segment.provider.clone()))
779 .unwrap_or_default();
780 let model = cursor
781 .model
782 .clone()
783 .or_else(|| current.map(|segment| segment.model.clone()))
784 .unwrap_or_default();
785 if published_field(Some(&provider)).is_some() && published_field(Some(&model)).is_some() {
786 let segment = manager.add_segment(&session.id, provider, model, None)?;
787 if let Some(thought_level) = cursor.thought_level.clone()
788 && let Some(mut updated) = manager.get_session(&session.id)?
789 {
790 if let Some(current) = updated.current_segment_mut() {
791 current.thought_level = Some(thought_level);
792 }
793 manager.save_session(&updated)?;
794 }
795 return Ok(Some(segment.id));
796 }
797 } else if rotation == SegmentRotation::Attach
798 && let Some(mut updated) = manager.get_session(&session.id)?
799 {
800 if let Some(segment) = updated.current_segment_mut() {
801 attach_published_segment_fields(
802 segment,
803 cursor.provider.as_deref(),
804 cursor.model.as_deref(),
805 cursor.thought_level.as_deref(),
806 );
807 }
808 manager.save_session(&updated)?;
809 }
810 Ok(session.current_segment_id.clone())
811}
812
813fn clean_attribution_value(value: String) -> Option<String> {
814 let trimmed = value.trim();
815 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
816 None
817 } else {
818 Some(value)
819 }
820}
821
822fn preflight_unimported_git_history(repo: &Repository, action: &str) -> Result<()> {
823 if repo.capability() != RepositoryCapability::GitOverlay {
824 return Ok(());
825 }
826 let Some(guidance) = repo.git_import_guidance()? else {
827 return Ok(());
828 };
829 if !import_guidance_includes_active_branch(&guidance) {
830 return Ok(());
831 }
832 let branches = preview_paths(&guidance.missing_branches);
833 let command = guidance.recommended_command;
834 Err(capture_refusal(
835 "git_history_needs_import",
836 format!("Refusing to {action}: Git history has not been imported into Heddle"),
837 format!("Run `{command}` before retrying `heddle {action}`."),
838 format!("Git branch(es) waiting for Heddle import: {branches}"),
839 format!(
840 "{action} would write new Heddle state before Heddle has adopted the existing Git history"
841 ),
842 "Git refs, Heddle refs, and worktree files were left unchanged",
843 vec![command],
844 ))
845}
846
847fn preflight_capture_mutation(
848 repo: &Repository,
849 worktree_status: &repo::Result<Option<WorktreeStatus>>,
850 machine_contract_input: Option<&MachineContractInput>,
851) -> Result<()> {
852 if repo.capability() != RepositoryCapability::GitOverlay {
853 return Ok(());
854 }
855 if repo.git_overlay_head_is_detached()? {
856 let primary = detached_head_primary_recovery(repo);
857 return Err(capture_refusal(
858 "git_head_detached",
859 "Refusing to capture: Git HEAD is detached",
860 format!("Run `{primary}` before retrying `heddle capture`."),
861 "Git HEAD points directly to a commit instead of an attached branch",
862 "capture would need to write a Git checkpoint through a branch and could reattach or advance the wrong ref",
863 "Git refs, Heddle refs, Git checkpoints, and worktree files were left unchanged",
864 vec![primary],
865 ));
866 }
867 if let Some(operation) = repo.operation_status()?
868 && matches!(operation.scope, OperationScope::Git)
869 {
870 return Err(capture_refusal(
871 "raw_git_operation_in_progress",
872 format!(
873 "Refusing to capture: an externally-started Git {} is in progress",
874 operation.kind
875 ),
876 format!(
877 "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`.",
878 operation.kind
879 ),
880 format!(
881 "Git {} is {}; Heddle cannot safely turn sequencer state into a saved change inside the no-git runtime",
882 operation.kind, operation.state
883 ),
884 "capture would capture worktree/index contents while Git still has unresolved sequencer metadata",
885 "Git refs, Git sequencer files, Heddle refs, and worktree files were left unchanged",
886 vec!["heddle verify".to_string()],
887 ));
888 }
889
890 let health = build_repository_verification_health_with_worktree_status(repo, worktree_status);
891 let trust = if let Some(input) = machine_contract_input {
892 build_repository_verification_state_with_worktree_status_and_machine_contract(
893 repo,
894 health,
895 worktree_status,
896 input,
897 )
898 } else {
899 build_repository_verification_state_with_worktree_status(repo, health, worktree_status)
900 };
901 if !checkpoint_trust_allows_ref_update(&trust)
902 && !checkpoint_can_close_integrated_remote_gap(repo, &trust)
903 {
904 let (primary, recovery_commands) = capture_blocked_recovery(repo, &trust);
905 return Err(capture_refusal(
906 "git_checkpoint_preflight_blocked",
907 "Refusing to capture: Git checkpoint preflight is blocked",
908 format!("Run `{primary}` before retrying `heddle capture`."),
909 format!(
910 "repository verification status is {}; remote drift is {}: {}",
911 trust.status, trust.remote_drift, trust.summary
912 ),
913 "capture would write Heddle state before the Git checkpoint ref update is known to be safe",
914 "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
915 recovery_commands,
916 ));
917 }
918 if trust.status != "needs_reconcile" || uncheckpointed_state_is_ahead_of_git(repo)? {
919 return Ok(());
920 }
921 let (primary, recovery_commands) = capture_blocked_recovery(repo, &trust);
922 Err(capture_refusal(
923 "repository_verification_blocked",
924 format!(
925 "Refusing to capture: repository verification is blocked ({})",
926 trust.status
927 ),
928 format!("Run `{primary}` before retrying `heddle capture`."),
929 format!(
930 "repository verification status is {}: {}",
931 trust.status, trust.summary
932 ),
933 "capture would write new Heddle or Git state while Git and Heddle disagree",
934 "Git refs, Heddle refs, Git checkpoint metadata, and worktree files were left unchanged",
935 recovery_commands,
936 ))
937}
938
939fn detached_head_primary_recovery(repo: &Repository) -> String {
940 if let Ok(Head::Attached { thread }) = repo.refs().read_head()
941 && !thread.trim().is_empty()
942 {
943 return if thread.starts_with('-') {
944 heddle_action(["thread", "switch", "--", thread.as_str()])
945 } else {
946 heddle_action(["thread", "switch", thread.as_str()])
947 };
948 }
949 if let Ok(Some(detached_commit)) = repo.git_overlay_detached_head_commit()
950 && let Ok(branch_tips) = repo.git_overlay_branch_tips()
951 && let Some(tip) = branch_tips
952 .iter()
953 .filter(|tip| tip.history_imported)
954 .find(|tip| tip.git_commit == detached_commit)
955 {
956 return heddle_action(["thread", "switch", tip.branch.as_str()]);
957 }
958 heddle_action(["status"])
959}
960
961fn capture_blocked_recovery(
962 repo: &Repository,
963 trust: &RepositoryVerificationState,
964) -> (String, Vec<String>) {
965 if let Some(recovery) = capture_remote_drift_recovery(repo) {
966 return recovery;
967 }
968 let primary =
969 non_empty_action(&trust.recommended_action).unwrap_or_else(|| "heddle verify".to_string());
970 let recovery_commands = if trust.recovery_commands.is_empty() {
971 vec![primary.clone()]
972 } else {
973 trust.recovery_commands.clone()
974 };
975 (primary, recovery_commands)
976}
977
978fn capture_remote_drift_recovery(repo: &Repository) -> Option<(String, Vec<String>)> {
979 let remote = repo.git_remote_tracking_status().ok().flatten()?;
980 let status = remote_tracking_status(&remote);
981 if !matches!(
982 status,
983 "remote_behind" | "remote_diverged" | "remote_contains_undone_checkpoint"
984 ) {
985 return None;
986 }
987 let recovery_commands = crate::status::remote_drift_recovery_commands(repo, &remote, status);
988 let primary = recovery_commands.first()?.clone();
989 Some((primary, recovery_commands))
990}
991
992fn checkpoint_trust_allows_ref_update(trust: &RepositoryVerificationState) -> bool {
993 let status_allows_checkpoint = matches!(
994 trust.status.as_str(),
995 "clean" | "dirty_worktree" | "needs_checkpoint" | "remote_ahead" | "remote_untracked"
996 );
997 let remote_allows_checkpoint = matches!(
998 trust.remote_drift.as_str(),
999 "clean" | "remote_ahead" | "remote_untracked"
1000 );
1001 status_allows_checkpoint && remote_allows_checkpoint
1002}
1003
1004fn checkpoint_can_close_integrated_remote_gap(
1005 repo: &Repository,
1006 trust: &RepositoryVerificationState,
1007) -> bool {
1008 if trust.status != "needs_checkpoint"
1009 || !matches!(
1010 trust.remote_drift.as_str(),
1011 "remote_behind" | "remote_diverged"
1012 )
1013 {
1014 return false;
1015 }
1016 let Some(remote) = repo.git_remote_tracking_status().ok().flatten() else {
1017 return false;
1018 };
1019 let upstream = remote.upstream.trim();
1020 if upstream.is_empty() {
1021 return false;
1022 }
1023 let Ok(Some(upstream_state)) = repo.refs().get_thread(&ThreadName::new(upstream)) else {
1024 return false;
1025 };
1026 let Ok(Some(current_state)) = repo.head() else {
1027 return false;
1028 };
1029 let mut graph = CommitGraphIndex::new(repo);
1030 graph
1031 .is_ancestor(&upstream_state, ¤t_state)
1032 .unwrap_or(false)
1033}
1034
1035fn uncheckpointed_state_is_ahead_of_git(repo: &Repository) -> Result<bool> {
1036 let Some(branch) = repo.git_overlay_current_branch()? else {
1037 return Ok(false);
1038 };
1039 let Some(current) = repo.current_state()? else {
1040 return Ok(false);
1041 };
1042 if repo
1043 .latest_git_checkpoint_for_state(¤t.state_id)?
1044 .is_some()
1045 {
1046 return Ok(false);
1047 }
1048 let Some(tip) = repo.git_overlay_branch_tip(&branch)? else {
1052 return Ok(true);
1053 };
1054 let Some(mapped) = tip.mapped_state else {
1055 return Ok(false);
1056 };
1057 if mapped == current.state_id {
1058 return Ok(false);
1059 }
1060 let mut graph = CommitGraphIndex::new(repo);
1061 if !graph
1062 .is_ancestor(&mapped, ¤t.state_id)
1063 .unwrap_or(false)
1064 || graph
1065 .is_ancestor(¤t.state_id, &mapped)
1066 .unwrap_or(false)
1067 {
1068 return Ok(false);
1069 }
1070 Ok(true)
1071}
1072
1073fn preflight_large_capture(
1074 force: bool,
1075 worktree_status: &repo::Result<Option<WorktreeStatus>>,
1076) -> Result<()> {
1077 if force {
1078 return Ok(());
1079 }
1080 let Ok(Some(status)) = worktree_status else {
1081 return Ok(());
1082 };
1083 let total = status.change_count();
1084 let delete_count = status.deleted.len();
1085 let add_count = status.added.len();
1086 if !crate::large_capture_requires_force(total, delete_count, add_count) {
1087 return Ok(());
1088 }
1089 let sample = status
1090 .deleted
1091 .iter()
1092 .chain(status.added.iter())
1093 .chain(status.modified.iter())
1094 .take(5)
1095 .map(|path| path.display().to_string())
1096 .collect::<Vec<_>>()
1097 .join(", ");
1098 let sample = if sample.is_empty() {
1099 "no sample paths available".to_string()
1100 } else {
1101 sample
1102 };
1103 Err(capture_refusal(
1104 "large_capture_requires_force",
1105 format!(
1106 "Large capture safety check: this would capture {total} changed paths ({delete_count} deletions, {add_count} additions)"
1107 ),
1108 "If this is intentional, rerun with `heddle capture --force -m \"...\"`.",
1109 format!("sample changed paths: {sample}"),
1110 "capture would preserve an unusually large Git-overlay worktree change without an explicit confirmation",
1111 "repository state, refs, metadata, and worktree files were left unchanged",
1112 vec!["heddle capture --force -m \"...\"".to_string()],
1113 ))
1114}
1115
1116fn merge_resolution_is_complete(repo: &Repository) -> Result<bool> {
1117 Ok(repo
1118 .merge_state_manager()
1119 .load()?
1120 .is_some_and(|merge_state| {
1121 merge_state
1122 .conflicts
1123 .iter()
1124 .all(|path| merge_state.resolved.contains(path))
1125 }))
1126}
1127
1128fn capture_worktree_status(
1134 repo: &Repository,
1135 options: &WorktreeStatusOptions,
1136) -> Result<WorktreeStatus> {
1137 if repo.current_state_for_worktree_status()?.is_none()
1138 && let Some(status) = repo.git_overlay_worktree_status()?
1139 {
1140 return Ok(status);
1141 }
1142 let tree = match repo.current_state_for_worktree_status()? {
1143 Some(state) => repo.require_tree_for_worktree_status(&state.tree)?,
1144 None => Tree::new(),
1145 };
1146 Ok(repo.compare_worktree_cached_with_options(&tree, options)?)
1147}
1148
1149pub fn complete_current_thread_manual_resolution(repo: &Repository) -> Result<Option<String>> {
1154 let Some(current_thread) = repo.current_lane()? else {
1155 return Ok(None);
1156 };
1157 let Some(current_state) = repo.head()? else {
1158 return Ok(None);
1159 };
1160 let Some(current_state_object) = repo.store().get_state(¤t_state)? else {
1161 return Ok(None);
1162 };
1163 let manager = ThreadManager::new(repo.heddle_dir());
1164 let Some(mut thread) = manager.find_by_thread(¤t_thread)? else {
1165 return Ok(None);
1166 };
1167 let Some(target_thread) = thread.target_thread.clone() else {
1168 return Ok(None);
1169 };
1170 let Some(target_state) = repo.refs().get_thread(&ThreadName::new(&target_thread))? else {
1171 return Ok(None);
1172 };
1173 let Some(target_state_object) = repo.store().get_state(&target_state)? else {
1174 return Ok(None);
1175 };
1176 let before = crate::capture_thread_update_before(repo, &manager, &thread)?;
1177
1178 thread.base_state = target_state.short();
1179 thread.base_root = target_state_object.tree.short();
1180 update_thread_state_from_state(&mut thread, ¤t_state_object);
1181 thread.state = ThreadState::Ready;
1182 thread.freshness = ThreadFreshness::Current;
1183 thread.integration_policy_result = ThreadIntegrationPolicy {
1184 status: Some("manual_resolved".to_string()),
1185 reason: Some("manual conflict resolution captured".to_string()),
1186 manual_resolution_state: Some(current_state.short()),
1187 conflicts_resolved_manually: true,
1188 };
1189 thread.updated_at = Utc::now();
1190 let thread_name = thread.thread.clone();
1191 let target = thread.target_thread.clone();
1192 crate::save_thread_update(repo, &manager, &thread, before, current_state)?;
1193
1194 Ok(Some(manual_resolution_land_action(
1195 repo,
1196 &thread_name,
1197 target.as_deref(),
1198 )))
1199}
1200
1201fn manual_resolution_land_action(
1202 repo: &Repository,
1203 thread_id: &str,
1204 target_thread: Option<&str>,
1205) -> String {
1206 let action = crate::status::next_action::land_local_command(thread_id);
1207 contextual_thread_action(repo, thread_id, target_thread, &action)
1208}
1209
1210fn current_thread(repo: &Repository) -> Result<Option<Thread>> {
1211 let manager = ThreadManager::new(repo.heddle_dir());
1212 if let Some(thread) = manager.find_by_execution_root(repo.root())? {
1213 return Ok(Some(thread));
1214 }
1215 let Head::Attached { thread } = repo.head_ref()? else {
1216 return Ok(None);
1217 };
1218 let current_state_id = repo.refs().get_thread(&thread)?;
1219 let current_state = current_state_id.map(|state| state.short());
1220 let base_root = current_state_id
1221 .and_then(|state| repo.store().get_state(&state).ok().flatten())
1222 .map(|state| state.tree.short())
1223 .unwrap_or_default();
1224 let thread = thread.to_string();
1225 Ok(Some(Thread {
1226 id: thread.clone(),
1227 thread,
1228 target_thread: None,
1229 parent_thread: None,
1230 mode: ThreadMode::Materialized,
1231 state: ThreadState::Active,
1232 base_state: current_state.clone().unwrap_or_default(),
1233 base_root,
1234 current_state,
1235 merged_state: None,
1236 task: None,
1237 execution_path: repo.root().to_path_buf(),
1238 materialized_path: None,
1239 changed_paths: Vec::new(),
1240 impact_categories: Vec::new(),
1241 heavy_impact_paths: Vec::new(),
1242 promotion_suggested: false,
1243 freshness: ThreadFreshness::Unknown,
1244 verification_summary: Default::default(),
1245 confidence_summary: Default::default(),
1246 integration_policy_result: Default::default(),
1247 created_at: Utc::now(),
1248 updated_at: Utc::now(),
1249 ephemeral: None,
1250 auto: false,
1251 shared_target_dir: None,
1252 }))
1253}
1254
1255fn active_task_assignment_id(repo: &Repository, thread: Option<&Thread>) -> Result<Option<String>> {
1256 let Some(thread) = thread else {
1257 return Ok(None);
1258 };
1259 let store = ActorPresenceStore::new(repo.heddle_dir());
1260 Ok(store
1261 .active_entries()?
1262 .into_iter()
1263 .filter(|entry| entry.thread == thread.thread || entry.thread == thread.id)
1264 .max_by_key(|entry| entry.started_at)
1265 .and_then(|entry| entry.task_assignment_id))
1266}
1267
1268fn bulk_capture_warning(captured_path_count: usize) -> Option<String> {
1269 (captured_path_count >= BULK_CAPTURE_WARNING_THRESHOLD).then(|| {
1270 format!(
1271 "captured {captured_path_count} paths in one operation; check root .gitignore and .heddleignore rules if build artifacts or tool state were included"
1272 )
1273 })
1274}
1275
1276fn reserved_capture_path(status: &WorktreeStatus) -> Option<String> {
1281 status
1282 .added
1283 .iter()
1284 .chain(&status.modified)
1285 .map(|path| path.to_string_lossy())
1286 .find(|path| env_store::is_reserved_materialization_path(path))
1287 .map(|path| path.into_owned())
1288}
1289
1290fn non_empty_action(action: &str) -> Option<String> {
1291 (!action.trim().is_empty()).then(|| action.to_string())
1292}
1293
1294fn map_capture_error(error: anyhow::Error) -> anyhow::Error {
1295 if error.chain().any(|cause| {
1296 cause
1297 .downcast_ref::<HeddleError>()
1298 .is_some_and(|error| matches!(error, HeddleError::NoChanges))
1299 }) {
1300 return capture_refusal(
1301 "nothing_to_capture",
1302 "nothing to capture: worktree has no changes eligible for Heddle capture",
1303 "Inspect the worktree with `heddle status`; make changes before running `heddle capture -m \"...\"`.",
1304 "the worktree has no modified, deleted, or untracked paths relative to the current Heddle state",
1305 "capture would not create a meaningful Heddle state",
1306 "repository state was left unchanged",
1307 vec!["heddle status".to_string()],
1308 );
1309 }
1310 if error.chain().any(|cause| {
1311 cause
1312 .downcast_ref::<std::io::Error>()
1313 .is_some_and(objects::fs_atomic::is_out_of_space)
1314 }) {
1315 return capture_refusal(
1316 "capture_out_of_space",
1317 format!("Capture aborted because the filesystem is out of space: {error:#}"),
1318 "Free disk space and re-run `heddle capture`. Your working tree changes are intact.",
1319 "the filesystem reported no remaining space while Heddle was writing captured objects",
1320 "retrying before freeing space may fail again or leave another incomplete object write",
1321 "the working tree was not modified; already-committed repository data remains behind atomic write boundaries",
1322 vec!["heddle capture -m \"...\"".to_string()],
1323 );
1324 }
1325 error
1326}
1327
1328#[allow(clippy::too_many_arguments)]
1329fn capture_refusal(
1330 kind: &'static str,
1331 error: impl Into<String>,
1332 hint: impl Into<String>,
1333 unsafe_condition: impl Into<String>,
1334 would_change: impl Into<String>,
1335 preserved: impl Into<String>,
1336 recovery_commands: Vec<String>,
1337) -> anyhow::Error {
1338 anyhow!(HeddleError::recovery(
1339 RecoveryDetails::safety_refusal(
1340 kind,
1341 error,
1342 hint,
1343 unsafe_condition,
1344 would_change,
1345 preserved,
1346 )
1347 .with_recovery_commands(recovery_commands),
1348 ))
1349}
1350
1351fn preview_paths(paths: &[String]) -> String {
1352 let shown = paths
1353 .iter()
1354 .take(12)
1355 .cloned()
1356 .collect::<Vec<_>>()
1357 .join(", ");
1358 let hidden = paths.len().saturating_sub(12);
1359 if hidden == 0 {
1360 shown
1361 } else {
1362 format!("{shown}, and {hidden} more")
1363 }
1364}
1365
1366pub fn execute_save(repo: &Repository, plan: SavePlan) -> Result<SaveReport> {
1372 if plan.git_scope != GitScope::None && repo.capability() != RepositoryCapability::GitOverlay {
1378 return Err(anyhow!(HeddleError::recovery(
1379 RecoveryDetails::safety_refusal(
1380 "native_checkpoint_unavailable",
1381 "Git checkpointing is only available in Git-overlay repositories",
1382 "Use `heddle capture -m \"...\"` to save Heddle state in a native checkout.",
1383 "this checkout is not a Git-overlay repository",
1384 "checkpoint would try to write a Git commit where no active Git store is bound",
1385 "repository state, refs, and worktree files were left unchanged",
1386 ),
1387 )));
1388 }
1389
1390 let previous_state_started = Instant::now();
1391 let (previous_state, previous_state_profile) =
1392 repo.current_state_for_worktree_status_profiled()?;
1393 let previous_state_ms = previous_state_started.elapsed().as_millis();
1394 let native_thread_name = match repo.head_ref()? {
1395 refs::Head::Attached { thread } => Some(thread.to_string()),
1396 refs::Head::Detached { .. } => None,
1397 };
1398 if let (Some(name), Some(previous)) = (native_thread_name.as_deref(), previous_state.as_ref())
1399 && repo.native_thread(name).is_err()
1400 {
1401 repo.create_native_thread(name, previous.state_id, None, "")?;
1402 }
1403 let actor = format!("cli:{}", std::process::id());
1404 let _checkout_writer = match native_thread_name.as_deref() {
1405 Some(name) => match repo.native_thread(name) {
1406 Ok(replica) => Some(repo.acquire_checkout_writer(replica.thread_id(), &actor)?),
1407 Err(_) => None,
1408 },
1409 None => match repo::thread_replication::checkout::ThreadCheckout::open(repo.root()) {
1410 Ok(checkout) => Some(repo.acquire_checkout_writer(checkout.binding.thread, &actor)?),
1411 Err(_) => None,
1412 },
1413 };
1414 let has_current = previous_state.is_some();
1415 let mut created_new_state = false;
1416 let mut snapshot_profile = SnapshotProfile::default();
1417 let mut thread_metadata_ms = 0u128;
1418 let mut promotion_suggested = false;
1419 let mut heavy_impact_paths = Vec::new();
1420 let mut snapshot_state_id: Option<StateId> = None;
1421 let mut captured_path_count = 0usize;
1422 let mut state_create_ms = 0u128;
1423 let mut captured_path_count_ms = 0u128;
1424
1425 let mut state = if plan_creates_new_state(&plan, has_current) {
1426 created_new_state = true;
1427 let state_create_started = Instant::now();
1428 let execution = create_heddle_state(repo, &plan)?;
1429 state_create_ms = state_create_started.elapsed().as_millis();
1430 snapshot_profile = execution.profile;
1431 thread_metadata_ms = execution.thread_metadata_ms;
1432 promotion_suggested = execution.promotion_suggested;
1433 heavy_impact_paths = execution.heavy_impact_paths;
1434 snapshot_state_id = Some(execution.state.state_id);
1435 let previous_tree = match previous_state.as_ref() {
1436 Some(state) => state.tree,
1437 None => repo.store().put_tree(&Tree::new())?,
1438 };
1439 let captured_path_count_started = Instant::now();
1440 captured_path_count = repo
1441 .diff_trees(&previous_tree, &execution.state.tree)?
1442 .len();
1443 captured_path_count_ms = captured_path_count_started.elapsed().as_millis();
1444 execution.state
1445 } else {
1446 repo.current_state()?
1447 .ok_or_else(|| anyhow!("no captured state found for save"))?
1448 };
1449
1450 let mut git_commit = None;
1451 let mut git_previous_commit = None;
1452 let mut git_checkpoint = None;
1453
1454 if plan_writes_git_checkpoint(&plan, repo.capability()) {
1455 if plan.require_clean_worktree {
1456 let tree = repo.require_tree(&state.tree)?;
1457 let status = repo.compare_worktree_cached_detailed_with_options(
1458 &tree,
1459 &plan.worktree_status_options,
1460 )?;
1461 if !status.is_clean() {
1462 return Err(anyhow!(HeddleError::recovery(
1463 RecoveryDetails::safety_refusal(
1464 "dirty_worktree",
1465 "Save worktree changes before checkpointing",
1466 "Save the work with `heddle capture -m \"...\"`.",
1467 "the current Heddle state was left unchanged; these paths have not been captured",
1468 "a Git checkpoint would omit dirty worktree paths",
1469 "the current Heddle state was left unchanged; these paths have not been captured",
1470 ),
1471 )));
1472 }
1473 }
1474
1475 if let Some(existing) = repo.latest_git_checkpoint_for_state(&state.state_id)?
1476 && repo.pending_git_checkpoint_intent()?.is_none()
1477 {
1478 git_commit = Some(existing.git_commit.clone());
1479 git_checkpoint = Some(existing);
1480 } else {
1481 let previous = repo
1482 .pending_git_checkpoint_intent()?
1483 .and_then(|intent| intent.previous_git_oid)
1484 .or_else(|| git_rev_parse_head(repo.root()));
1485 git_previous_commit = previous.clone();
1486 let summary = checkpoint_summary(&plan, &state);
1487 let record = write_git_checkpoint(repo, &state, summary, plan.linearize_git_parent)?;
1488 if plan.coalesce_snapshot_and_checkpoint
1489 && let Some(state_id) = snapshot_state_id.as_ref()
1490 {
1491 coalesce_snapshot_and_checkpoint(repo, state_id, &record.git_commit)?;
1492 }
1493 git_commit = Some(record.git_commit.clone());
1494 git_checkpoint = Some(record);
1495 }
1496 }
1497
1498 let captured_native_worktree = created_new_state
1502 && plan.supplied_tree.is_none()
1503 && repo.capability() == RepositoryCapability::NativeHeddle;
1504 let captured_worktree_status = Ok(Some(objects::worktree::WorktreeStatus::default()));
1505 let verification_started = Instant::now();
1506 let verification = if captured_native_worktree && git_checkpoint.is_none() {
1507 let health = build_repository_verification_health_with_worktree_status(
1508 repo,
1509 &captured_worktree_status,
1510 );
1511 if let Some(input) = &plan.machine_contract_input {
1512 build_repository_verification_state_with_worktree_status_and_machine_contract(
1513 repo,
1514 health,
1515 &captured_worktree_status,
1516 input,
1517 )
1518 } else {
1519 build_repository_verification_state_with_worktree_status(
1520 repo,
1521 health,
1522 &captured_worktree_status,
1523 )
1524 }
1525 } else if created_new_state || git_checkpoint.is_some() {
1526 if let Some(input) = &plan.machine_contract_input {
1527 build_repository_verification_state_with_machine_contract(repo, input)?
1528 } else {
1529 build_repository_verification_state(repo)?
1530 }
1531 } else if let Some(status) = &plan.precomputed_worktree_status {
1532 let health = build_repository_verification_health_with_worktree_status(repo, status);
1533 if let Some(input) = &plan.machine_contract_input {
1534 build_repository_verification_state_with_worktree_status_and_machine_contract(
1535 repo, health, status, input,
1536 )
1537 } else {
1538 build_repository_verification_state_with_worktree_status(repo, health, status)
1539 }
1540 } else {
1541 if let Some(input) = &plan.machine_contract_input {
1542 build_repository_verification_state_with_machine_contract(repo, input)?
1543 } else {
1544 build_repository_verification_state(repo)?
1545 }
1546 };
1547 let post_verification_ms = verification_started.elapsed().as_millis();
1548
1549 let summary = match plan.verb {
1550 SaveVerb::Capture => format!(
1551 "Captured state {} ({})",
1552 state.state_id.short(),
1553 state.hash().short()
1554 ),
1555 SaveVerb::Checkpoint => git_checkpoint
1556 .as_ref()
1557 .map(|r| r.summary.clone())
1558 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short())),
1559 };
1560
1561 if created_new_state && let Some(name) = native_thread_name.as_deref() {
1562 if repo.native_thread(name).is_err() {
1563 repo.create_native_thread(
1564 name,
1565 previous_state
1566 .as_ref()
1567 .map(|previous| previous.state_id)
1568 .unwrap_or(state.state_id),
1569 None,
1570 "",
1571 )?;
1572 }
1573 let genesis_base = repo.native_thread(name)?.genesis()?.base;
1574 if state.state_id != genesis_base && !state.parents.is_empty() {
1575 repo.record_native_capture(name, state.state_id)?;
1576 }
1577 }
1578 let signature_lookup_started = Instant::now();
1579 let signed = repo.get_state_signature(&state.id())?.is_some();
1580 let signature_lookup_ms = signature_lookup_started.elapsed().as_millis();
1581 Ok(SaveReport {
1582 verb: plan.verb,
1583 state_id: state.state_id,
1584 content_hash: state.hash(),
1585 intent: state.intent.clone(),
1586 confidence: state.confidence,
1587 signed,
1588 git_commit,
1589 git_previous_commit,
1590 summary,
1591 principal: state.attribution.principal.clone(),
1592 agent: state.attribution.agent.clone(),
1593 promotion_suggested,
1594 heavy_impact_paths,
1595 captured_path_count,
1596 verification,
1597 created_new_state,
1598 git_checkpoint,
1599 snapshot_profile,
1600 state_create_ms,
1601 captured_path_count_ms,
1602 post_verification_ms,
1603 thread_metadata_ms,
1604 previous_state_ms,
1605 previous_state_profile,
1606 signature_lookup_ms,
1607 })
1608}
1609
1610struct CreatedState {
1611 state: State,
1612 profile: SnapshotProfile,
1613 thread_metadata_ms: u128,
1614 promotion_suggested: bool,
1615 heavy_impact_paths: Vec<String>,
1616}
1617
1618fn create_heddle_state(repo: &Repository, plan: &SavePlan) -> Result<CreatedState> {
1619 let hook_manager = HookManager::new(repo);
1620 let hook_ctx = HookContext::new(repo);
1621 let mut post_hook_worktree_changes = None;
1622
1623 if plan.run_hooks {
1624 let pre_snapshot_ran = hook_manager.run(Hook::PreSnapshot, &hook_ctx)?;
1625 let pre_capture_payload = serde_json::json!({
1626 "thread": current_thread_name(repo),
1627 "intent": plan.intent.clone().unwrap_or_default(),
1628 });
1629 let pre_capture_response = hook_manager.run_with_payload(
1630 Hook::PreSnapshot,
1631 &hook_ctx,
1632 &pre_capture_payload,
1633 std::time::Duration::from_secs(5),
1634 )?;
1635 if let Some(resp) = pre_capture_response
1636 && !resp.abort.is_empty()
1637 {
1638 return Err(anyhow!(HeddleError::recovery(
1639 RecoveryDetails::safety_refusal(
1640 "hook_veto",
1641 format!("pre_capture hook vetoed: {}", resp.abort),
1642 "Inspect `pre_capture` with `heddle hook list`, update the hook policy or inputs, then retry.",
1643 format!("pre_capture hook vetoed capture: {}", resp.abort),
1644 "capture would continue after repository policy explicitly aborted the operation",
1645 "the operation stopped at the hook boundary before the protected action ran",
1646 )
1647 .with_recovery_commands(vec!["heddle hook list".to_string()]),
1648 )));
1649 }
1650 if pre_snapshot_ran && plan.supplied_tree.is_none() {
1651 let authoritative_options = WorktreeStatusOptions {
1655 fsmonitor: repo::FsMonitorSettings {
1656 mode: repo::FsMonitorMode::Off,
1657 },
1658 };
1659 post_hook_worktree_changes =
1660 Some(capture_worktree_status(repo, &authoritative_options)?);
1661 }
1662 }
1663 let mut execution = if let Some(tree) = plan.supplied_tree.clone() {
1664 repo.snapshot_tree_with_attribution_profiled(
1665 tree,
1666 plan.intent.clone(),
1667 plan.confidence,
1668 plan.attribution.clone(),
1669 )?
1670 } else if let Some(status) =
1671 post_hook_worktree_changes.or_else(|| plan.known_worktree_changes.clone())
1672 {
1673 repo.snapshot_with_attribution_profiled_from_status(
1674 plan.intent.clone(),
1675 plan.confidence,
1676 plan.attribution.clone(),
1677 status,
1678 plan.require_worktree_change,
1679 )?
1680 } else if plan.require_worktree_change {
1681 repo.snapshot_with_attribution_profiled_if_changed(
1682 plan.intent.clone(),
1683 plan.confidence,
1684 plan.attribution.clone(),
1685 )?
1686 } else {
1687 repo.snapshot_with_attribution_profiled(
1688 plan.intent.clone(),
1689 plan.confidence,
1690 plan.attribution.clone(),
1691 )?
1692 };
1693
1694 let thread_metadata_start = Instant::now();
1695 let refresh = refresh_active_thread_metadata(repo, &execution.state, &execution.tree)?;
1696 let thread_metadata_ms = thread_metadata_start.elapsed().as_millis();
1697
1698 if plan.run_hooks {
1699 hook_manager.run(Hook::PostSnapshot, &hook_ctx)?;
1700 let post_capture_payload = serde_json::json!({
1701 "state_id": execution.state.state_id.to_string_full(),
1702 });
1703 if let Err(err) = hook_manager.run_with_payload(
1704 Hook::PostSnapshot,
1705 &hook_ctx,
1706 &post_capture_payload,
1707 std::time::Duration::from_secs(5),
1708 ) {
1709 tracing::warn!(error = %err, "post_capture hook error swallowed");
1710 }
1711 }
1712
1713 Ok(CreatedState {
1714 state: execution.state,
1715 profile: std::mem::take(&mut execution.profile),
1716 thread_metadata_ms,
1717 promotion_suggested: refresh.promotion_suggested,
1718 heavy_impact_paths: refresh.heavy_impact_paths,
1719 })
1720}
1721
1722fn write_git_checkpoint(
1723 repo: &Repository,
1724 state: &State,
1725 summary: String,
1726 linearize_git_parent: bool,
1727) -> Result<GitCheckpointRecord> {
1728 let _lock = repo.locker().write()?;
1729 objects::fault_inject::maybe_fail_at("git_checkpoint_before_write_through")?;
1730 let mut bridge = GitProjection::new(repo);
1731 if linearize_git_parent {
1732 bridge.linearize_unmapped_tip_to_checkout();
1733 }
1734 let git_commit = match bridge
1735 .write_through_current_checkout_with_message(state.state_id, summary.clone())?
1736 {
1737 WriteThroughOutcome::Wrote(git_commit) => git_commit.to_string(),
1738 WriteThroughOutcome::Skipped(reason) => {
1739 return Err(anyhow!(HeddleError::recovery(
1740 RecoveryDetails::safety_refusal(
1741 "checkpoint_git_write_skipped",
1742 format!("Git checkpoint write-through was skipped: {reason}"),
1743 "Inspect `heddle verify`, resolve the skip reason, then retry `heddle land`.",
1744 format!("write-through skipped: {reason}"),
1745 "checkpoint would need to write the current Heddle state into the Git branch and index",
1746 "the current Heddle state was preserved; no Git checkpoint record was written",
1747 ),
1748 )));
1749 }
1750 };
1751 let intent = repo.pending_git_checkpoint_intent()?.ok_or_else(|| {
1752 anyhow!("Git checkpoint published without its durable finalization intent")
1753 })?;
1754 if intent.phase != repo::GitCheckpointIntentPhase::Published
1755 || intent.state_id != state.state_id.to_string_full()
1756 || intent.new_git_oid != git_commit
1757 {
1758 return Err(anyhow!(
1759 "published Git checkpoint does not match its durable finalization intent"
1760 ));
1761 }
1762 finalize_published_git_checkpoint(repo, &state.state_id, git_commit, summary, intent)
1763}
1764
1765pub fn recover_published_git_checkpoint(
1769 repo: &Repository,
1770 state_id: &StateId,
1771) -> Result<Option<GitCheckpointRecord>> {
1772 let _lock = repo.locker().write()?;
1773 let Some(mut intent) = repo.pending_git_checkpoint_intent()? else {
1774 return Ok(None);
1775 };
1776 if intent.state_id != state_id.to_string_full() {
1777 return Ok(None);
1778 }
1779 let current_branch = repo.git_overlay_current_branch()?;
1780 if current_branch.as_deref() != Some(intent.branch.as_str()) {
1781 return Err(anyhow!(
1782 "pending Git checkpoint targets branch '{}' but the checkout is on '{}'",
1783 intent.branch,
1784 current_branch.as_deref().unwrap_or("detached HEAD")
1785 ));
1786 }
1787 let current_oid = git_rev_parse_head(repo.root());
1788 if intent.phase == repo::GitCheckpointIntentPhase::Prepared {
1789 if current_oid == intent.previous_git_oid {
1790 return Ok(None);
1791 }
1792 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1793 return Err(anyhow!(
1794 "prepared Git checkpoint expected HEAD at {} or {}, found {}",
1795 intent.previous_git_oid.as_deref().unwrap_or("<unborn>"),
1796 intent.new_git_oid,
1797 current_oid.as_deref().unwrap_or("<unborn>")
1798 ));
1799 }
1800 let git_oid = intent.new_git_oid.clone();
1801 intent = repo.mark_git_checkpoint_published(state_id, &git_oid)?;
1802 }
1803 if intent.phase != repo::GitCheckpointIntentPhase::Published {
1804 return Ok(None);
1805 }
1806 if current_oid.as_deref() != Some(intent.new_git_oid.as_str()) {
1807 return Err(anyhow!(
1808 "published Git checkpoint expected HEAD at {}, found {}",
1809 intent.new_git_oid,
1810 current_oid.as_deref().unwrap_or("<unborn>")
1811 ));
1812 }
1813 let git_commit = intent.new_git_oid.clone();
1814 let summary = intent.summary.clone();
1815 finalize_published_git_checkpoint(repo, state_id, git_commit, summary, intent).map(Some)
1816}
1817
1818fn finalize_published_git_checkpoint(
1819 repo: &Repository,
1820 state_id: &StateId,
1821 git_commit: String,
1822 summary: String,
1823 intent: repo::GitCheckpointIntent,
1824) -> Result<GitCheckpointRecord> {
1825 let record = repo.record_git_checkpoint(state_id, git_commit.clone(), summary)?;
1826 objects::fault_inject::maybe_panic_at("git_checkpoint_after_metadata_before_oplog");
1827 let transaction_id = format!(
1828 "git-checkpoint:v1:{}:{}",
1829 state_id.to_string_full(),
1830 git_commit
1831 );
1832 repo.oplog().record_batch_exactly_once(
1833 vec![
1834 OpRecord::GitCheckpoint {
1835 branch: intent.branch,
1836 state: *state_id,
1837 previous_git_oid: intent.previous_git_oid,
1838 new_git_oid: git_commit.clone(),
1839 },
1840 OpRecord::TransactionCommit {
1841 transaction_id: transaction_id.clone(),
1842 op_count: 1,
1843 },
1844 ],
1845 Some(&repo.op_scope()),
1846 &transaction_id,
1847 )?;
1848 objects::fault_inject::maybe_panic_at("git_checkpoint_after_oplog_before_finalize");
1849 repo.finish_git_checkpoint_intent(state_id, &git_commit)?;
1850 Ok(record)
1851}
1852
1853fn coalesce_snapshot_and_checkpoint(
1854 repo: &Repository,
1855 state_id: &StateId,
1856 git_commit: &str,
1857) -> Result<()> {
1858 let snapshot_batch = repo
1859 .oplog()
1860 .recent_batches_scoped(8, Some(&repo.op_scope()))?
1861 .into_iter()
1862 .find(|batch| {
1863 batch.entries.iter().any(|entry| {
1864 matches!(
1865 &entry.operation,
1866 OpRecord::Snapshot { new_state, .. } if new_state == state_id
1867 )
1868 })
1869 })
1870 .ok_or_else(|| anyhow!("capture succeeded but its oplog batch was not found"))?;
1871 let checkpoint_batch = repo
1872 .oplog()
1873 .recent_batches_scoped(8, Some(&repo.op_scope()))?
1874 .into_iter()
1875 .find(|batch| {
1876 batch.entries.iter().any(|entry| {
1877 matches!(
1878 &entry.operation,
1879 OpRecord::GitCheckpoint { new_git_oid, .. } if new_git_oid == git_commit
1880 )
1881 })
1882 })
1883 .ok_or_else(|| anyhow!("Git checkpoint succeeded but its oplog batch was not found"))?;
1884 repo.oplog()
1885 .coalesce_batches(snapshot_batch.id, checkpoint_batch.id)
1886 .context(
1887 "capture completed but failed to record its state and Git checkpoint as one undo batch",
1888 )?;
1889 Ok(())
1890}
1891
1892fn checkpoint_summary(plan: &SavePlan, state: &State) -> String {
1893 plan.intent
1894 .clone()
1895 .or_else(|| state.intent.clone())
1896 .unwrap_or_else(|| format!("Checkpoint {}", state.state_id.short()))
1897}
1898
1899fn current_thread_name(repo: &Repository) -> String {
1900 match repo.head_ref() {
1901 Ok(Head::Attached { thread }) => thread.to_string(),
1902 _ => String::new(),
1903 }
1904}
1905
1906fn git_rev_parse_head(root: &std::path::Path) -> Option<String> {
1907 let git = SleyRepository::discover(root).ok()?;
1908 git.head().ok()?.oid.map(|id| id.to_string())
1909}
1910
1911#[cfg(test)]
1912mod tests {
1913 use repo::RepositoryCapability;
1914 use tempfile::TempDir;
1915
1916 use super::*;
1917
1918 #[test]
1919 fn capture_interface_owns_mutation_and_returns_the_report_contract() {
1920 let temp = TempDir::new().expect("create temp repository");
1921 let repo = Repository::init_default(temp.path()).expect("initialize repository");
1922 std::fs::write(temp.path().join("tracked.txt"), "captured\n")
1923 .expect("write worktree change");
1924 let ctx = ExecutionContext::builder()
1925 .repo(repo)
1926 .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
1927 .build();
1928 let report = capture(
1929 &ctx,
1930 CaptureOptions {
1931 intent: "exercise the deep capture interface".into(),
1932 confidence: Some(0.9),
1933 force: false,
1934 agent: CaptureAgentOptions::default(),
1935 machine_contract_input: None,
1936 },
1937 )
1938 .expect("capture succeeds");
1939
1940 assert_eq!(report.output_kind, "capture");
1941 assert_eq!(report.captured_path_count, 1);
1942 assert_eq!(report.principal.name, "Ada");
1943 assert_eq!(report.principal.email, "ada@example.com");
1944 assert_eq!(report.principal_source, "user_config");
1945 assert_eq!(CaptureReport::CONTRACT.schema_name, "capture");
1946 assert_eq!(
1947 ctx.require_repo()
1948 .expect("repository")
1949 .head()
1950 .expect("read head")
1951 .expect("captured head")
1952 .short(),
1953 report.state_id
1954 );
1955
1956 let wire = serde_json::to_value(&report).expect("serialize report");
1957 assert_eq!(wire["output_kind"], "capture");
1958 assert!(wire.get("diagnostics").is_none());
1959 assert!(wire.get("captured_thread_targets_integration").is_none());
1960 }
1961
1962 #[test]
1963 fn manual_resolution_land_action_quotes_untrusted_thread_ids() {
1964 let temp = TempDir::new().expect("create temp repository");
1965 let repo = Repository::init_default(temp.path()).expect("initialize repository");
1966
1967 assert_eq!(
1968 manual_resolution_land_action(&repo, "bad;echo pwn", None),
1969 "heddle land --thread 'bad;echo pwn'"
1970 );
1971 assert_eq!(
1972 manual_resolution_land_action(&repo, "-danger", None),
1973 "heddle land --thread=-danger"
1974 );
1975 }
1976
1977 #[test]
1978 fn clean_overlay_refuses_before_requiring_identity() {
1979 let temp = TempDir::new().expect("create temp repository");
1980 SleyRepository::init(temp.path()).expect("initialize Git repository");
1981 let repo = Repository::init_git_overlay_sidecar(temp.path())
1982 .expect("initialize Git-overlay sidecar");
1983 let ctx = ExecutionContext::builder().repo(repo).build();
1984
1985 let error = capture(
1986 &ctx,
1987 CaptureOptions {
1988 intent: "nothing changed".into(),
1989 confidence: None,
1990 force: false,
1991 agent: CaptureAgentOptions::default(),
1992 machine_contract_input: None,
1993 },
1994 )
1995 .expect_err("clean overlay must refuse capture");
1996
1997 assert!(error.to_string().contains("nothing to capture"));
1998 }
1999
2000 #[test]
2001 fn capture_rejects_missing_intent_before_mutation() {
2002 let temp = TempDir::new().expect("create temp repository");
2003 let repo = Repository::init_default(temp.path()).expect("initialize repository");
2004 std::fs::write(temp.path().join("tracked.txt"), "uncaptured\n")
2005 .expect("write worktree change");
2006 let ctx = ExecutionContext::builder()
2007 .repo(repo)
2008 .principal_fallback(Some(("Ada".into(), "ada@example.com".into())))
2009 .build();
2010 let before = ctx.require_repo().expect("repository").head().unwrap();
2011
2012 let error = capture(
2013 &ctx,
2014 CaptureOptions {
2015 intent: " ".into(),
2016 confidence: None,
2017 force: false,
2018 agent: CaptureAgentOptions::default(),
2019 machine_contract_input: None,
2020 },
2021 )
2022 .expect_err("blank intent must be rejected by the operation interface");
2023
2024 assert!(
2025 error
2026 .to_string()
2027 .contains("refusing to capture without an intent")
2028 );
2029 assert_eq!(
2030 ctx.require_repo().expect("repository").head().unwrap(),
2031 before
2032 );
2033 }
2034
2035 #[test]
2036 fn attribution_cleaning_only_rejects_the_placeholder_token() {
2037 assert_eq!(clean_attribution_value(" unknown ".into()), None);
2038 assert_eq!(clean_attribution_value(" ".into()), None);
2039 assert_eq!(
2040 clean_attribution_value("unknown-model-v2".into()),
2041 Some("unknown-model-v2".into())
2042 );
2043 }
2044
2045 #[test]
2046 fn identity_freeze_waits_for_the_repository_write_lock() {
2047 use std::{sync::mpsc, time::Duration};
2048
2049 let temp = TempDir::new().expect("create temp repository");
2050 let repo = Repository::init_default(temp.path()).expect("initialize repository");
2051 let hold = repo.locker().write().expect("hold repository lock");
2052 let root = repo.root().to_path_buf();
2053 let (tx, rx) = mpsc::channel();
2054 let worker = std::thread::spawn(move || {
2055 let repo = Repository::open(root).expect("open worker repository");
2056 let patch = IdentityCursor {
2057 provider: Some("anthropic".into()),
2058 model: Some("opus".into()),
2059 ..IdentityCursor::default()
2060 };
2061 let frozen = freeze_identity_for_capture(&repo, &patch);
2062 let _ = tx.send(frozen.is_ok());
2063 });
2064
2065 assert!(
2066 rx.recv_timeout(Duration::from_millis(150)).is_err(),
2067 "identity mutation must wait for the repository lock"
2068 );
2069 drop(hold);
2070 assert!(
2071 rx.recv_timeout(Duration::from_secs(2))
2072 .expect("worker should finish after lock release")
2073 );
2074 worker.join().expect("join identity worker");
2075 }
2076
2077 #[test]
2078 fn reserved_capture_paths_consider_selected_additions_and_modifications_only() {
2079 let status = WorktreeStatus {
2080 added: vec![std::path::PathBuf::from("services/api/.env.local")],
2081 modified: Vec::new(),
2082 deleted: vec![std::path::PathBuf::from("old/.env")],
2083 };
2084 assert_eq!(
2085 reserved_capture_path(&status).as_deref(),
2086 Some("services/api/.env.local")
2087 );
2088
2089 let deletion_only = WorktreeStatus {
2090 deleted: vec![std::path::PathBuf::from(".env")],
2091 ..WorktreeStatus::default()
2092 };
2093 assert!(reserved_capture_path(&deletion_only).is_none());
2094 }
2095
2096 #[test]
2097 fn plan_creates_new_state_routing() {
2098 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2099 let capture = SavePlan::capture("wip", attr.clone());
2100 assert!(plan_creates_new_state(&capture, true));
2101 assert!(plan_creates_new_state(&capture, false));
2102
2103 let checkpoint = SavePlan::checkpoint(Some("cp".into()), attr.clone(), false);
2104 assert!(!plan_creates_new_state(&checkpoint, true));
2105 assert!(plan_creates_new_state(&checkpoint, false));
2106
2107 let staged =
2108 SavePlan::checkpoint(Some("cp".into()), attr, true).with_supplied_tree(Tree::new());
2109 assert!(plan_creates_new_state(&staged, true));
2110 }
2111
2112 #[test]
2113 fn plan_writes_git_checkpoint_respects_scope_and_capability() {
2114 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2115 let mut capture = SavePlan::capture("wip", attr);
2116 assert!(!plan_writes_git_checkpoint(
2117 &capture,
2118 RepositoryCapability::GitOverlay
2119 ));
2120 capture.git_scope = GitScope::WorktreeAll;
2121 assert!(plan_writes_git_checkpoint(
2122 &capture,
2123 RepositoryCapability::GitOverlay
2124 ));
2125 assert!(!plan_writes_git_checkpoint(
2126 &capture,
2127 RepositoryCapability::NativeHeddle
2128 ));
2129 }
2130
2131 #[test]
2132 fn save_plan_builders_set_expected_defaults() {
2133 let attr = Attribution::human(Principal::new("Ada", "ada@example.com"));
2134 let capture = SavePlan::capture("intent", attr.clone());
2135 assert_eq!(capture.verb, SaveVerb::Capture);
2136 assert_eq!(capture.git_scope, GitScope::None);
2137 assert!(!capture.coalesce_snapshot_and_checkpoint);
2138
2139 let staged = SavePlan::checkpoint(None, attr, true);
2140 assert_eq!(staged.git_scope, GitScope::Staged);
2141 assert!(!staged.require_clean_worktree);
2142 assert!(staged.reuse_current_state);
2143 }
2144
2145 #[test]
2146 fn tree_leaf_name_returns_the_final_component() {
2147 assert_eq!(tree_leaf_name("a/b/c.rs"), "c.rs");
2148 assert_eq!(tree_leaf_name("solo"), "solo");
2149 }
2150}