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