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