1pub mod next_action;
5pub mod verdict;
6
7use std::{
8 collections::{BTreeMap, BTreeSet},
9 fs,
10 path::{Path, PathBuf},
11 time::Instant,
12};
13
14use chrono::Utc;
15use objects::{
16 HeddleError,
17 error::Result,
18 object::{State, ThreadName, Tree},
19 worktree::{WorktreeStatus, build_worktree_ignore},
20};
21use refs::Head;
22use repo::{
23 ActorPresence, ActorPresenceStatus, ActorPresenceStore, AgentUsageSummary, CommitGraphIndex,
24 GitImportGuidance, GitOverlayBranchTip, GitOverlayOutOfBandCommits, GitRemoteTrackingStatus,
25 RepoConfig, Repository, RepositoryCapability, RepositoryOperationStatus, Thread,
26 ThreadFreshness, ThreadImpactCategory, ThreadManager, ThreadMode, ThreadState,
27 WorktreeCompareProfile, describe_thread_advice_with_initial, discover_heddle_root,
28 is_synthetic_root, refresh_thread_freshness,
29};
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use sley::{
34 Repository as SleyRepository, ShortStatusOptions, ShortStatusRow, StatusUntrackedMode,
35 StreamControl,
36};
37pub use verdict::{
38 StatusCombinedVerdict, combined_verdict_axes, coordination_axis_clean, coordination_label,
39 coordination_severity, health_severity, human_thread_health, resolve_coordination_with_trust,
40 status_combined_verdict,
41};
42
43use self::next_action::{
44 NextActionInput, canonical_git_import_ref_command, canonical_git_repair_ref_preview_command,
45 contextual_thread_action, effective_next_action, heddle_action, non_empty_action,
46 remote_tracking_status,
47};
48use crate::{
49 ActionTemplate, ExecutionContext, HeddleReport, MachineOutputKind, OutputDiscriminator,
50 ReportContract, RepositoryContextInfo, RepositoryVerificationState, VerificationCheck,
51 schema_for_report,
52 source_authority::{SourceAction, SourceAuthorityActions},
53 verify::{
54 MachineContractInput, action_template, action_templates,
55 build_plain_git_verification_probe_with_machine_contract,
56 build_repository_verification_state_with_worktree_status_and_machine_contract,
57 repository_mode_label, serialize_empty_action_as_null,
58 },
59};
60
61#[derive(Clone)]
62pub struct StatusOptions {
63 pub start_path: Option<PathBuf>,
64 pub detail: StatusDetail,
65 pub worktree_status_options: repo::WorktreeStatusOptions,
66 pub machine_contract_input: MachineContractInput,
67}
68
69impl StatusOptions {
70 pub fn new(detail: StatusDetail, worktree_status_options: repo::WorktreeStatusOptions) -> Self {
71 Self {
72 start_path: None,
73 detail,
74 worktree_status_options,
75 machine_contract_input: MachineContractInput::default(),
76 }
77 }
78
79 pub fn with_start_path(mut self, start_path: impl Into<PathBuf>) -> Self {
80 self.start_path = Some(start_path.into());
81 self
82 }
83
84 pub fn with_machine_contract_input(mut self, input: MachineContractInput) -> Self {
85 self.machine_contract_input = input;
86 self
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum StatusDetail {
92 ShortText,
93 CompactMachine,
94 DefaultText,
95 Full,
96}
97
98impl StatusDetail {
99 fn short_path(self) -> bool {
100 matches!(self, Self::ShortText | Self::CompactMachine)
101 }
102
103 fn needs_full_walk(self) -> bool {
104 matches!(self, Self::Full)
105 }
106
107 fn needs_remote_tracking(self) -> bool {
108 matches!(self, Self::ShortText | Self::Full)
109 }
110}
111
112#[derive(Debug, Clone, Serialize, JsonSchema)]
113#[schemars(rename = "StatusSchema")]
114pub struct StatusReport {
115 pub output_kind: &'static str,
116 pub repository_capability: String,
117 pub repository_label: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub repository_context: Option<RepositoryContextInfo>,
120 pub storage_model: String,
121 pub hosted_enabled: bool,
122 #[serde(skip)]
123 #[schemars(skip)]
124 pub validation_capability: RepositoryCapability,
125 #[schemars(with = "Option<serde_json::Value>")]
126 pub operation: Option<RepositoryOperationStatus>,
127 #[schemars(with = "Option<serde_json::Value>")]
128 pub remote_tracking: Option<GitRemoteTrackingStatus>,
129 #[serde(rename = "verification")]
130 pub trust: RepositoryVerificationState,
131 pub git_index: Option<GitIndexPlan>,
132 #[serde(skip)]
133 #[schemars(skip)]
134 pub import_guidance: Option<GitImportGuidanceReport>,
135 #[serde(skip)]
136 #[schemars(skip)]
137 pub verification_health: RepositoryVerificationHealth,
138 pub thread: Option<String>,
139 pub base_state: Option<String>,
140 pub base_root: Option<String>,
141 pub current_state: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub native_remote: Option<NativeRemoteStatus>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub path: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub execution_path: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub session_id: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub heddle_session_id: Option<String>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub actor: Option<ActorInfo>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub harness: Option<String>,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub thinking_level: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 #[schemars(with = "Option<serde_json::Value>")]
160 pub usage_summary: Option<AgentUsageSummary>,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub last_progress_at: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub report_flush_state: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub attach_reason: Option<String>,
167 #[schemars(with = "Option<String>")]
168 pub thread_mode: Option<ThreadMode>,
169 #[schemars(with = "Option<String>")]
170 pub thread_state: Option<ThreadState>,
171 #[schemars(with = "Option<String>")]
172 pub freshness: Option<ThreadFreshness>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub target_thread: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub parent_thread: Option<String>,
177 pub child_threads: Vec<String>,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub task: Option<String>,
180 pub promotion_suggested: bool,
181 #[schemars(with = "Vec<String>")]
182 pub impact_categories: Vec<ThreadImpactCategory>,
183 pub heavy_impact_paths: Vec<String>,
184 #[serde(skip)]
185 #[schemars(skip)]
186 pub changed_paths: Vec<String>,
187 pub changed_path_count: usize,
188 pub worktree_changed_path_count: usize,
189 pub thread_changed_path_count: usize,
190 pub blockers: Vec<String>,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub identity_notice: Option<String>,
193 #[serde(serialize_with = "serialize_empty_action_as_null")]
194 #[schemars(with = "Option<String>")]
195 pub recommended_action: String,
196 pub recommended_action_template: Option<ActionTemplate>,
197 pub recovery_commands: Vec<String>,
198 pub recovery_action_templates: Vec<ActionTemplate>,
199 pub thread_health: String,
200 pub coordination_status: CoordinationStatus,
201 #[serde(skip)]
202 #[schemars(skip)]
203 pub coordination_blocked_by_trust: bool,
204 pub is_isolated: bool,
205 pub parallel_threads: Vec<ParallelThreadInfo>,
206 pub state: Option<StateInfo>,
207 pub git_checkpoint: Option<GitCheckpointInfo>,
208 pub changes: ChangesInfo,
209 pub submodules: Vec<SubmoduleInfo>,
210 #[serde(default)]
211 pub materialized_threads: Vec<MaterializedThreadInfo>,
212 #[serde(skip)]
213 #[schemars(skip)]
214 pub profile: StatusProfile,
215}
216
217#[derive(Debug, Clone, Serialize, JsonSchema)]
218pub struct NativeRemoteStatus {
219 pub name: String,
220 pub head: String,
221 pub relation: &'static str,
222}
223
224fn native_remote_status(
225 repo: &Repository,
226 thread: Option<&str>,
227 local: Option<&State>,
228) -> Result<Option<NativeRemoteStatus>> {
229 if repo.capability() == RepositoryCapability::GitOverlay {
230 return Ok(None);
231 }
232 let (Some(remote), Some(thread), Some(local)) = (default_remote_name(repo), thread, local)
233 else {
234 return Ok(None);
235 };
236 let Some(head) = repo
237 .refs()
238 .get_remote_thread(&remote, &ThreadName::new(thread))?
239 else {
240 return Ok(None);
241 };
242 let relation = if local.state_id == head {
243 "up to date"
244 } else {
245 let mut graph = CommitGraphIndex::new(repo);
246 if graph
247 .is_ancestor(&head, &local.state_id)
248 .map_err(|error| HeddleError::InvalidObject(error.to_string()))?
249 {
250 "ahead"
251 } else if graph
252 .is_ancestor(&local.state_id, &head)
253 .map_err(|error| HeddleError::InvalidObject(error.to_string()))?
254 {
255 "behind"
256 } else {
257 "diverged"
258 }
259 };
260 Ok(Some(NativeRemoteStatus {
261 name: remote,
262 head: head.short(),
263 relation,
264 }))
265}
266
267impl StatusReport {
268 pub const CONTRACT: ReportContract = ReportContract {
269 schema_name: "status",
270 machine_output_kind: MachineOutputKind::JsonOrJsonLines,
271 output_discriminator: Some(OutputDiscriminator {
272 field: "output_kind",
273 value: "status",
274 }),
275 schema: status_report_schema,
276 };
277}
278
279impl HeddleReport for StatusReport {
280 const CONTRACT: ReportContract = StatusReport::CONTRACT;
281}
282
283fn status_report_schema() -> Value {
284 let mut schema = schema_for_report::<StatusReport>();
285 require_schema_field(&mut schema, "recommended_action");
286 replace_property_schema(
287 &mut schema,
288 "thread_mode",
289 serde_json::json!({
290 "anyOf": [
291 {
292 "type": "string",
293 "enum": ["materialized", "virtualized", "solid"]
294 },
295 { "type": "null" }
296 ]
297 }),
298 );
299 schema
300}
301
302fn require_schema_field(schema: &mut Value, field: &str) {
303 let Some(object) = schema.as_object_mut() else {
304 return;
305 };
306 let required = object
307 .entry("required".to_string())
308 .or_insert_with(|| serde_json::json!([]));
309 let Some(required) = required.as_array_mut() else {
310 return;
311 };
312 if !required
313 .iter()
314 .any(|candidate| candidate.as_str() == Some(field))
315 {
316 required.push(Value::String(field.to_string()));
317 }
318}
319
320fn replace_property_schema(schema: &mut Value, field: &str, replacement: Value) {
321 let Some(properties) = schema
322 .get_mut("properties")
323 .and_then(|properties| properties.as_object_mut())
324 else {
325 return;
326 };
327 properties.insert(field.to_string(), replacement);
328}
329
330#[derive(Debug, Clone, Default)]
331pub struct StatusProfile {
332 pub repo_open_ms: u128,
333 pub current_state_ms: u128,
334 pub operation_ms: u128,
335 pub remote_tracking_ms: u128,
336 pub import_hint_ms: u128,
337 pub git_overlay_status_ms: u128,
338 pub verification_ms: u128,
339 pub git_index_ms: u128,
340 pub worktree_status_ms: u128,
341 pub thread_summary_ms: u128,
342 pub parallel_threads_ms: u128,
343 pub late_state_ms: u128,
344 pub materialized_threads_ms: u128,
345 pub advice_ms: u128,
346 pub build_total_ms: u128,
347 pub worktree_profile: Option<WorktreeCompareProfile>,
348}
349
350#[derive(Debug, Clone, Serialize, JsonSchema)]
351pub struct RepositoryVerificationHealth {
352 pub status: String,
353 pub clean: bool,
354 pub summary: String,
355 pub recovery_commands: Vec<String>,
356 pub checks: Vec<RepositoryVerificationCheck>,
357}
358
359#[derive(Debug, Clone, Serialize, JsonSchema)]
360pub struct RepositoryVerificationCheck {
361 pub name: String,
362 pub status: String,
363 pub summary: String,
364 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
365 pub details: std::collections::BTreeMap<String, String>,
366}
367
368pub fn build_repository_verification_health_with_worktree_status(
369 repo: &Repository,
370 worktree_status: &Result<Option<WorktreeStatus>>,
371) -> RepositoryVerificationHealth {
372 let source_actions = SourceAuthorityActions::new(repo.source_authority());
373 if repo.capability() != RepositoryCapability::GitOverlay {
374 match repo.operation_status() {
382 Ok(Some(operation)) => {
383 return RepositoryVerificationHealth {
384 status: "operation_in_progress".to_string(),
385 clean: false,
386 summary: operation.message.clone(),
387 recovery_commands: vec![operation.next_action.clone()],
388 checks: vec![RepositoryVerificationCheck {
389 name: "operation".to_string(),
390 status: "operation_in_progress".to_string(),
391 summary: operation.message,
392 details: Default::default(),
393 }],
394 };
395 }
396 Ok(None) => {}
397 Err(error) => {
398 return degraded_health(
399 vec![RepositoryVerificationCheck {
400 name: "operation".to_string(),
401 status: "degraded".to_string(),
402 summary: error.to_string(),
403 details: Default::default(),
404 }],
405 "Could not inspect in-progress operations",
406 );
407 }
408 }
409 let computed_native_status;
417 let effective_status: &Result<Option<WorktreeStatus>> = match worktree_status {
418 Ok(Some(_)) | Err(_) => worktree_status,
419 Ok(None) => {
420 computed_native_status = native_worktree_status(repo);
421 &computed_native_status
422 }
423 };
424 return match effective_status {
425 Ok(Some(status)) if !status.is_clean() => {
426 let changed = status.modified.len() + status.added.len() + status.deleted.len();
427 let summary = format!(
428 "{changed} Heddle worktree path(s) are not captured in the current state"
429 );
430 RepositoryVerificationHealth {
431 status: "uncaptured".to_string(),
432 clean: false,
433 summary: summary.clone(),
434 recovery_commands: vec![source_actions.display(SourceAction::Capture)],
435 checks: vec![RepositoryVerificationCheck {
436 name: "heddle_worktree".to_string(),
437 status: "uncaptured".to_string(),
438 summary,
439 details: dirty_details(status),
440 }],
441 }
442 }
443 Ok(_) => clean_health(
444 "Heddle-native repository is verified in non-overlay mode",
445 vec![RepositoryVerificationCheck {
446 name: "heddle_worktree".to_string(),
447 status: "clean".to_string(),
448 summary: "Heddle worktree matches the current state".to_string(),
449 details: Default::default(),
450 }],
451 ),
452 Err(error) => degraded_health(
453 vec![RepositoryVerificationCheck {
454 name: "heddle_worktree".to_string(),
455 status: "degraded".to_string(),
456 summary: error.to_string(),
457 details: Default::default(),
458 }],
459 "Could not inspect Heddle worktree status",
460 ),
461 };
462 }
463 if repo.root().join(".heddle/objectstore").is_file() && !repo.root().join(".git").exists() {
464 return clean_health(
465 "Heddle-managed isolated checkout; Git verification belongs to the parent checkout",
466 vec![RepositoryVerificationCheck {
467 name: "worktree".to_string(),
468 status: "clean".to_string(),
469 summary: "No .git directory is present in this isolated checkout".to_string(),
470 details: BTreeMap::new(),
471 }],
472 );
473 }
474
475 let mut checks = Vec::new();
476 match repo.operation_status() {
477 Ok(Some(operation)) => {
478 checks.push(RepositoryVerificationCheck {
479 name: "operation".to_string(),
480 status: "operation_in_progress".to_string(),
481 summary: operation.message.clone(),
482 details: Default::default(),
483 });
484 return RepositoryVerificationHealth {
485 status: "operation_in_progress".to_string(),
486 clean: false,
487 summary: operation.message,
488 recovery_commands: vec![operation.next_action],
489 checks,
490 };
491 }
492 Ok(None) => checks.push(RepositoryVerificationCheck {
493 name: "operation".to_string(),
494 status: "clean".to_string(),
495 summary: "no Git or Heddle operation in progress".to_string(),
496 details: Default::default(),
497 }),
498 Err(error) => {
499 checks.push(RepositoryVerificationCheck {
500 name: "operation".to_string(),
501 status: "degraded".to_string(),
502 summary: error.to_string(),
503 details: Default::default(),
504 });
505 return degraded_health(checks, "Could not inspect in-progress operations");
506 }
507 }
508
509 match repo.git_overlay_head_is_detached() {
510 Ok(true) => {
511 let mut details = BTreeMap::new();
512 if let Ok(Some(commit)) = repo.git_overlay_detached_head_commit() {
513 details.insert("git_commit".to_string(), commit);
514 }
515 checks.push(RepositoryVerificationCheck {
516 name: "head_mapping".to_string(),
517 status: "detached_head".to_string(),
518 summary: "Git HEAD is detached; attach a branch before mutating this Git overlay"
519 .to_string(),
520 details,
521 });
522 return RepositoryVerificationHealth {
523 status: "detached_head".to_string(),
524 clean: false,
525 summary: "Git HEAD is detached; attach a branch before mutating this Git overlay"
526 .to_string(),
527 recovery_commands: detached_head_recovery_commands(repo),
528 checks,
529 };
530 }
531 Ok(false) => {}
532 Err(error) => {
533 checks.push(RepositoryVerificationCheck {
534 name: "head_mapping".to_string(),
535 status: "degraded".to_string(),
536 summary: error.to_string(),
537 details: Default::default(),
538 });
539 return degraded_health(checks, "Could not inspect Git HEAD state");
540 }
541 }
542
543 let import_hint = match repo.git_import_guidance() {
544 Ok(hint) => hint,
545 Err(error) => {
546 checks.push(RepositoryVerificationCheck {
547 name: "import".to_string(),
548 status: "degraded".to_string(),
549 summary: error.to_string(),
550 details: BTreeMap::new(),
551 });
552 return degraded_health(checks, "Could not inspect Git import state");
553 }
554 };
555
556 match current_branch_tip(repo) {
557 Ok(Some(tip))
558 if !tip.history_imported
559 && repo
560 .current_state_for_worktree_status()
561 .ok()
562 .flatten()
563 .is_some()
564 && import_hint
565 .as_ref()
566 .is_some_and(import_guidance_includes_active_branch) =>
567 {
568 let out_of_band = repo
569 .git_overlay_out_of_band_commits(&tip.git_commit)
570 .ok()
571 .flatten();
572 let out_of_band_clause = out_of_band_commit_clause(out_of_band.as_ref());
573 let mut details = BTreeMap::new();
574 details.insert("git_branch".to_string(), tip.branch.clone());
575 details.insert("git_commit".to_string(), tip.git_commit.clone());
576 if let Some(out_of_band) = &out_of_band {
577 details.insert(
578 "out_of_band_commit_count".to_string(),
579 out_of_band.count.to_string(),
580 );
581 if out_of_band.truncated {
582 details.insert(
583 "out_of_band_commit_count_truncated".to_string(),
584 "true".to_string(),
585 );
586 }
587 }
588 checks.push(RepositoryVerificationCheck {
589 name: "head_mapping".to_string(),
590 status: "git_branch_advanced".to_string(),
591 summary: format!(
592 "Git branch '{}' advanced to commit {} outside Heddle{}",
593 tip.branch, tip.git_commit, out_of_band_clause
594 ),
595 details,
596 });
597 if let Some(hint) = &import_hint
598 && import_guidance_includes_active_branch(hint)
599 {
600 checks.push(RepositoryVerificationCheck {
601 name: "import".to_string(),
602 status: "needs_import".to_string(),
603 summary: format!(
604 "{} Git branch tip(s) still need Heddle import",
605 hint.missing_branch_count
606 ),
607 details: BTreeMap::new(),
608 });
609 }
610 return RepositoryVerificationHealth {
611 status: "git_branch_advanced".to_string(),
612 clean: false,
613 summary: format!(
614 "Git branch '{}' advanced outside Heddle{}; import the new Git tip to restore the mapping",
615 tip.branch, out_of_band_clause
616 ),
617 recovery_commands: vec![canonical_git_import_ref_command(&tip.branch)],
618 checks,
619 };
620 }
621 Ok(Some(tip)) if !tip.history_imported => checks.push(RepositoryVerificationCheck {
622 name: "head_mapping".to_string(),
623 status: "git_backed".to_string(),
624 summary: format!(
625 "Git branch '{}' resolves directly to Git commit {}",
626 tip.branch,
627 short_oid(&tip.git_commit)
628 ),
629 details: BTreeMap::from([
630 ("git_branch".to_string(), tip.branch),
631 ("git_commit".to_string(), tip.git_commit),
632 ]),
633 }),
634 Ok(Some(tip)) => checks.push(RepositoryVerificationCheck {
635 name: "head_mapping".to_string(),
636 status: "clean".to_string(),
637 summary: format!("Git branch '{}' maps to imported Heddle state", tip.branch),
638 details: BTreeMap::new(),
639 }),
640 Ok(None) => checks.push(RepositoryVerificationCheck {
641 name: "head_mapping".to_string(),
642 status: "clean".to_string(),
643 summary: "No attached Git branch to map".to_string(),
644 details: BTreeMap::new(),
645 }),
646 Err(error) => {
647 checks.push(RepositoryVerificationCheck {
648 name: "head_mapping".to_string(),
649 status: "degraded".to_string(),
650 summary: error.to_string(),
651 details: BTreeMap::new(),
652 });
653 return degraded_health(checks, "Could not inspect Git/Heddle branch mapping");
654 }
655 }
656
657 match import_hint {
658 Some(hint) if import_guidance_includes_active_branch(&hint) => {
659 return needs_import(checks, hint);
660 }
661 Some(hint) => checks.push(RepositoryVerificationCheck {
662 name: "import".to_string(),
663 status: "available".to_string(),
664 summary: format!(
665 "{} other Git branch tip(s) are available to import",
666 hint.missing_branch_count
667 ),
668 details: BTreeMap::new(),
669 }),
670 None => checks.push(RepositoryVerificationCheck {
671 name: "import".to_string(),
672 status: "clean".to_string(),
673 summary: "Git refs are read directly from Git storage".to_string(),
674 details: BTreeMap::new(),
675 }),
676 }
677
678 match worktree_status {
679 Ok(Some(status)) if !status.is_clean() => {
680 let changed = status.modified.len() + status.added.len() + status.deleted.len();
681 checks.push(RepositoryVerificationCheck {
682 name: "worktree".to_string(),
683 status: if heddle_worktree_is_clean(repo) {
684 "needs_checkpoint".to_string()
685 } else {
686 "dirty_worktree".to_string()
687 },
688 summary: if heddle_worktree_is_clean(repo) {
689 format!(
690 "{changed} Git worktree path(s) are captured in Heddle but not checkpointed to Git"
691 )
692 } else {
693 format!("{changed} Git worktree path(s) have uncommitted changes")
694 },
695 details: dirty_details(status),
696 });
697 if heddle_worktree_is_clean(repo) {
698 return RepositoryVerificationHealth {
699 status: "needs_checkpoint".to_string(),
700 clean: false,
701 summary: format!(
702 "{changed} Git worktree path(s) are captured in Heddle but not checkpointed to Git"
703 ),
704 recovery_commands: vec![source_actions.display(SourceAction::Capture)],
705 checks,
706 };
707 }
708 RepositoryVerificationHealth {
709 status: "dirty_worktree".to_string(),
710 clean: false,
711 summary: format!("{changed} Git worktree path(s) have uncommitted changes"),
712 recovery_commands: vec![source_actions.display(SourceAction::Capture)],
713 checks,
714 }
715 }
716 Ok(_) => {
717 checks.push(RepositoryVerificationCheck {
718 name: "worktree".to_string(),
719 status: "clean".to_string(),
720 summary: "Git worktree is clean".to_string(),
721 details: Default::default(),
722 });
723 match clean_git_branch_reconcile_check(repo) {
724 Ok(Some(check)) => {
725 let status = check.status.clone();
726 let summary = check.summary.clone();
727 let ref_name = check
728 .details
729 .get("git_branch")
730 .cloned()
731 .unwrap_or_else(|| "<branch>".to_string());
732 let recovery = if status == "needs_checkpoint" {
733 source_actions.display(SourceAction::Capture)
734 } else {
735 canonical_git_repair_ref_preview_command(None, &ref_name)
736 };
737 checks.push(check);
738 return RepositoryVerificationHealth {
739 status,
740 clean: false,
741 summary,
742 recovery_commands: vec![recovery],
743 checks,
744 };
745 }
746 Ok(None) => {}
747 Err(error) => {
748 checks.push(RepositoryVerificationCheck {
749 name: "head_mapping".to_string(),
750 status: "degraded".to_string(),
751 summary: error.to_string(),
752 details: BTreeMap::new(),
753 });
754 return degraded_health(
755 checks,
756 "Could not inspect Git/Heddle branch agreement",
757 );
758 }
759 }
760 if !head_mapping_is_git_backed(&checks)
761 && let Ok(Some(state)) = repo.current_state_for_worktree_status()
762 && let Ok(tree) = repo.require_tree_for_worktree_status(&state.tree)
763 && let Ok(status) = repo.compare_worktree_cached_with_options(
764 &tree,
765 &core_worktree_status_options(repo),
766 )
767 && !status.is_clean()
768 {
769 let changed = status.modified.len() + status.added.len() + status.deleted.len();
770 checks.push(RepositoryVerificationCheck {
771 name: "heddle_worktree".to_string(),
772 status: "dirty_worktree".to_string(),
773 summary: format!(
774 "{changed} Heddle worktree path(s) differ from the current state"
775 ),
776 details: dirty_details(&status),
777 });
778 return RepositoryVerificationHealth {
779 status: "dirty_worktree".to_string(),
780 clean: false,
781 summary: format!(
782 "{changed} Heddle worktree path(s) differ from the current state"
783 ),
784 recovery_commands: vec![source_actions.display(SourceAction::Capture)],
785 checks,
786 };
787 }
788 match tag_mapping_check(repo) {
789 Ok(Some(check)) => {
790 let summary = check.summary.clone();
791 let recovery_commands = tag_mapping_recovery_commands(&check);
792 checks.push(check);
793 return RepositoryVerificationHealth {
794 status: "tag_marker_mismatch".to_string(),
795 clean: false,
796 summary,
797 recovery_commands,
798 checks,
799 };
800 }
801 Ok(None) => checks.push(RepositoryVerificationCheck {
802 name: "tag_mapping".to_string(),
803 status: "clean".to_string(),
804 summary: "Git tags visible to this checkout map to Heddle markers".to_string(),
805 details: Default::default(),
806 }),
807 Err(error) => {
808 checks.push(RepositoryVerificationCheck {
809 name: "tag_mapping".to_string(),
810 status: "degraded".to_string(),
811 summary: error.to_string(),
812 details: Default::default(),
813 });
814 return degraded_health(checks, "Could not inspect Git tag mapping");
815 }
816 }
817 match stale_integration_metadata_check(repo) {
818 Ok(Some(check)) => {
819 let summary = check.summary.clone();
820 checks.push(check);
821 return RepositoryVerificationHealth {
822 status: "stale_integration_metadata".to_string(),
823 clean: false,
824 summary,
825 recovery_commands: vec!["heddle thread list".to_string()],
826 checks,
827 };
828 }
829 Ok(None) => checks.push(RepositoryVerificationCheck {
830 name: "thread_integration_metadata".to_string(),
831 status: "clean".to_string(),
832 summary: "merged thread metadata agrees with target history".to_string(),
833 details: BTreeMap::new(),
834 }),
835 Err(error) => {
836 checks.push(RepositoryVerificationCheck {
837 name: "thread_integration_metadata".to_string(),
838 status: "degraded".to_string(),
839 summary: error.to_string(),
840 details: BTreeMap::new(),
841 });
842 return degraded_health(
843 checks,
844 "Could not inspect thread integration metadata",
845 );
846 }
847 }
848 match repo.git_remote_tracking_status() {
849 Ok(Some(remote)) => remote_drift_health(repo, checks, remote),
850 Ok(None) => {
851 checks.push(RepositoryVerificationCheck {
852 name: "remote_tracking".to_string(),
853 status: "clean".to_string(),
854 summary: "No Git upstream drift detected".to_string(),
855 details: Default::default(),
856 });
857 clean_health("Git overlay and Heddle agree", checks)
858 }
859 Err(error) => {
860 checks.push(RepositoryVerificationCheck {
861 name: "remote_tracking".to_string(),
862 status: "degraded".to_string(),
863 summary: error.to_string(),
864 details: Default::default(),
865 });
866 degraded_health(checks, "Could not inspect Git upstream drift")
867 }
868 }
869 }
870 Err(error) => {
871 checks.push(RepositoryVerificationCheck {
872 name: "worktree".to_string(),
873 status: "degraded".to_string(),
874 summary: error.to_string(),
875 details: Default::default(),
876 });
877 degraded_health(checks, "Could not inspect Git overlay worktree")
878 }
879 }
880}
881
882fn needs_import(
883 mut checks: Vec<RepositoryVerificationCheck>,
884 hint: GitImportGuidance,
885) -> RepositoryVerificationHealth {
886 checks.push(RepositoryVerificationCheck {
887 name: "import".to_string(),
888 status: "needs_import".to_string(),
889 summary: format!(
890 "{} Git branch tip(s) still need Heddle import",
891 hint.missing_branch_count
892 ),
893 details: BTreeMap::new(),
894 });
895 RepositoryVerificationHealth {
896 status: "needs_import".to_string(),
897 clean: false,
898 summary: format!(
899 "{} Git branch tip(s) still need Heddle import",
900 hint.missing_branch_count
901 ),
902 recovery_commands: vec![hint.recommended_command],
903 checks,
904 }
905}
906
907fn tag_mapping_check(repo: &Repository) -> anyhow::Result<Option<RepositoryVerificationCheck>> {
908 let mut mismatched = Vec::new();
909 for tip in repo.git_overlay_tag_tips()? {
910 let marker = repo
911 .refs()
912 .get_marker(&objects::object::MarkerName::new(&tip.tag))?;
913 match (marker, tip.mapped_state) {
914 (Some(existing), Some(mapped)) if existing == mapped => {}
915 (Some(existing), Some(mapped)) => mismatched.push(format!(
916 "{} (marker {}; Git tag {})",
917 tip.tag,
918 existing.short(),
919 mapped.short()
920 )),
921 (Some(_), None) | (None, _) => {}
922 }
923 }
924 if mismatched.is_empty() {
925 return Ok(None);
926 }
927 let mut details = BTreeMap::new();
928 details.insert(
929 "mismatched_tag_count".to_string(),
930 mismatched.len().to_string(),
931 );
932 details.insert("mismatched_tags".to_string(), mismatched.join(", "));
933 Ok(Some(RepositoryVerificationCheck {
934 name: "tag_mapping".to_string(),
935 status: "tag_marker_mismatch".to_string(),
936 summary: format!(
937 "{} Git tag marker(s) disagree with Heddle markers: {}",
938 mismatched.len(),
939 mismatched.join(", ")
940 ),
941 details,
942 }))
943}
944
945fn tag_mapping_recovery_commands(check: &RepositoryVerificationCheck) -> Vec<String> {
946 let tags = check
947 .details
948 .get("mismatched_tags")
949 .map(|tags| {
950 tags.split(',')
951 .filter_map(|tag| tag.split_whitespace().next())
952 .filter(|tag| !tag.is_empty())
953 .map(ToString::to_string)
954 .collect::<Vec<_>>()
955 })
956 .unwrap_or_default();
957 if tags.len() == 1 {
958 vec![canonical_git_import_ref_command(&tags[0])]
959 } else {
960 vec!["heddle bridge git import".to_string()]
961 }
962}
963
964fn short_oid(oid: &str) -> &str {
965 oid.get(..12).unwrap_or(oid)
966}
967
968fn current_branch_tip(repo: &Repository) -> anyhow::Result<Option<GitOverlayBranchTip>> {
969 let Some(branch) = repo.git_overlay_current_branch()? else {
970 return Ok(None);
971 };
972 repo.git_overlay_branch_tip(&branch).map_err(Into::into)
973}
974
975fn detached_head_recovery_commands(repo: &Repository) -> Vec<String> {
976 vec![detached_head_primary_recovery(repo)]
977}
978
979fn detached_head_primary_recovery(repo: &Repository) -> String {
980 match repo.refs().read_head() {
981 Ok(Head::Attached { thread }) if !thread.trim().is_empty() => {
982 return if thread.starts_with('-') {
983 heddle_action(["thread", "switch", "--", thread.as_str()])
984 } else {
985 heddle_action(["thread", "switch", thread.as_str()])
986 };
987 }
988 _ => {}
989 }
990 if let Ok(Some(detached_commit)) = repo.git_overlay_detached_head_commit()
991 && let Ok(branch_tips) = repo.git_overlay_branch_tips()
992 && let Some(tip) = branch_tips
993 .iter()
994 .filter(|tip| tip.history_imported)
995 .find(|tip| tip.git_commit == detached_commit)
996 {
997 return heddle_action(["thread", "switch", tip.branch.as_str()]);
998 }
999 "heddle thread switch <branch>".to_string()
1000}
1001
1002fn branch_tip_needs_reconcile(repo: &Repository, tip: &GitOverlayBranchTip) -> bool {
1003 let Some(mapped) = tip.mapped_state else {
1004 return false;
1005 };
1006 let Ok(Some(current)) = thread_tip_for_branch(repo, &tip.branch) else {
1007 return false;
1008 };
1009 mapped != current
1010}
1011
1012fn clean_git_branch_reconcile_check(
1013 repo: &Repository,
1014) -> anyhow::Result<Option<RepositoryVerificationCheck>> {
1015 let Some(tip) = current_branch_tip(repo)? else {
1016 return Ok(None);
1017 };
1018 if !tip.history_imported || !branch_tip_needs_reconcile(repo, &tip) {
1019 return Ok(None);
1020 }
1021 let Some(current_change) = thread_tip_for_branch(repo, &tip.branch)? else {
1022 return Ok(None);
1023 };
1024 let Some(mapped) = tip.mapped_state else {
1025 return Ok(None);
1026 };
1027 let relation = mapped_change_relation(repo, &mapped, ¤t_change);
1028 if relation == "git_behind_heddle"
1029 && repo
1030 .latest_git_checkpoint_for_state(¤t_change)?
1031 .is_none()
1032 && heddle_worktree_is_clean(repo)
1033 {
1034 let mut details = dirty_details(&WorktreeStatus::default());
1035 details.insert("git_branch".to_string(), tip.branch.clone());
1036 details.insert("git_commit".to_string(), tip.git_commit.clone());
1037 details.insert("git_mapped_state".to_string(), mapped.to_string());
1038 details.insert(
1039 "heddle_thread_state".to_string(),
1040 current_change.to_string(),
1041 );
1042 details.insert("relation".to_string(), relation.to_string());
1043 return Ok(Some(RepositoryVerificationCheck {
1044 name: "worktree".to_string(),
1045 status: "needs_checkpoint".to_string(),
1046 summary: format!(
1047 "Heddle state {} is captured but not checkpointed to Git",
1048 current_change.short()
1049 ),
1050 details,
1051 }));
1052 }
1053 let mut details = BTreeMap::new();
1054 details.insert("git_branch".to_string(), tip.branch.clone());
1055 details.insert("git_commit".to_string(), tip.git_commit.clone());
1056 details.insert("git_mapped_state".to_string(), mapped.to_string());
1057 details.insert(
1058 "heddle_thread_state".to_string(),
1059 current_change.to_string(),
1060 );
1061 details.insert("relation".to_string(), relation.to_string());
1062 Ok(Some(RepositoryVerificationCheck {
1063 name: "head_mapping".to_string(),
1064 status: "needs_reconcile".to_string(),
1065 summary: format!(
1066 "Git branch '{}' points at {}, but Heddle thread state is {}; preview the Git/Heddle mapping before saving new work",
1067 tip.branch,
1068 mapped.short(),
1069 current_change.short()
1070 ),
1071 details,
1072 }))
1073}
1074
1075fn thread_tip_for_branch(
1076 repo: &Repository,
1077 branch: &str,
1078) -> Result<Option<objects::object::StateId>> {
1079 repo.refs().get_thread(&ThreadName::new(branch))
1080}
1081
1082fn mapped_change_relation(
1083 repo: &Repository,
1084 git_mapped: &objects::object::StateId,
1085 heddle_current: &objects::object::StateId,
1086) -> &'static str {
1087 let mut graph = CommitGraphIndex::new(repo);
1088 let git_is_ancestor = graph
1089 .is_ancestor(git_mapped, heddle_current)
1090 .unwrap_or(false);
1091 let heddle_is_ancestor = graph
1092 .is_ancestor(heddle_current, git_mapped)
1093 .unwrap_or(false);
1094 match (git_is_ancestor, heddle_is_ancestor) {
1095 (true, false) => "git_behind_heddle",
1096 (false, true) => "git_ahead_of_heddle",
1097 (true, true) => "same",
1098 (false, false) => "diverged",
1099 }
1100}
1101
1102fn head_mapping_is_git_backed(checks: &[RepositoryVerificationCheck]) -> bool {
1103 checks
1104 .iter()
1105 .any(|check| check.name == "head_mapping" && check.status == "git_backed")
1106}
1107
1108fn stale_integration_metadata_check(
1109 repo: &Repository,
1110) -> anyhow::Result<Option<RepositoryVerificationCheck>> {
1111 let manager = ThreadManager::new(repo.heddle_dir());
1112 let mut stale = Vec::new();
1113 let mut graph = CommitGraphIndex::new(repo);
1114
1115 for thread in manager.list()? {
1116 if thread.state != ThreadState::Merged {
1117 continue;
1118 }
1119 let Some(target_thread) = thread.target_thread.as_deref() else {
1120 continue;
1121 };
1122 let Some(target_tip) = repo.refs().get_thread(&ThreadName::new(target_thread))? else {
1123 continue;
1124 };
1125 let candidate = thread
1126 .current_state
1127 .as_deref()
1128 .or(thread.merged_state.as_deref())
1129 .and_then(|state| repo.resolve_state(state).ok().flatten())
1130 .or_else(|| {
1131 repo.refs()
1132 .get_thread(&ThreadName::new(&thread.thread))
1133 .ok()
1134 .flatten()
1135 });
1136 let Some(candidate) = candidate else {
1137 continue;
1138 };
1139 if !graph.is_ancestor(&candidate, &target_tip).unwrap_or(false) {
1140 stale.push(format!(
1141 "{} claims merged into {} at {}, but target is {}",
1142 thread.thread,
1143 target_thread,
1144 candidate.short(),
1145 target_tip.short()
1146 ));
1147 }
1148 }
1149
1150 if stale.is_empty() {
1151 return Ok(None);
1152 }
1153
1154 let mut details = BTreeMap::new();
1155 details.insert("stale_thread_count".to_string(), stale.len().to_string());
1156 details.insert("stale_threads".to_string(), stale.join("; "));
1157 Ok(Some(RepositoryVerificationCheck {
1158 name: "thread_integration_metadata".to_string(),
1159 status: "stale_integration_metadata".to_string(),
1160 summary: format!(
1161 "{} merged thread record(s) are no longer contained in their target history",
1162 stale.len()
1163 ),
1164 details,
1165 }))
1166}
1167
1168fn out_of_band_commit_clause(out_of_band: Option<&GitOverlayOutOfBandCommits>) -> String {
1169 match out_of_band {
1170 Some(out_of_band) if out_of_band.truncated => {
1171 format!(" ({}+ out-of-band git commits detected)", out_of_band.count)
1172 }
1173 Some(out_of_band) if out_of_band.count == 1 => {
1174 " (1 out-of-band git commit detected)".to_string()
1175 }
1176 Some(out_of_band) => format!(" ({} out-of-band git commits detected)", out_of_band.count),
1177 None => String::new(),
1178 }
1179}
1180
1181fn core_worktree_status_options(repo: &Repository) -> repo::WorktreeStatusOptions {
1182 repo::WorktreeStatusOptions {
1183 fsmonitor: repo.config().worktree.fsmonitor.into(),
1184 }
1185}
1186
1187fn native_worktree_status(repo: &Repository) -> Result<Option<WorktreeStatus>> {
1192 let Some(state) = repo.current_state_for_worktree_status()? else {
1193 return Ok(Some(WorktreeStatus::default()));
1194 };
1195 let tree = repo.require_tree_for_worktree_status(&state.tree)?;
1196 repo.compare_worktree_cached_with_options(&tree, &core_worktree_status_options(repo))
1197 .map(Some)
1198}
1199
1200pub fn default_remote_name(repo: &Repository) -> Option<String> {
1201 crate::remote::resolved_default_remote_name(repo)
1202 .ok()
1203 .flatten()
1204}
1205
1206pub(crate) fn git_default_remote_name_from_repo(repo: &SleyRepository) -> Option<String> {
1207 let remotes = repo.remote_names().ok()?;
1208 remotes
1209 .iter()
1210 .find(|name| name.as_str() == "origin")
1211 .cloned()
1212 .or_else(|| (remotes.len() == 1).then(|| remotes[0].clone()))
1213}
1214
1215fn heddle_worktree_is_clean(repo: &Repository) -> bool {
1216 let Ok(Some(state)) = repo.current_state_for_worktree_status() else {
1217 return false;
1218 };
1219 let Ok(tree) = repo.require_tree_for_worktree_status(&state.tree) else {
1220 return false;
1221 };
1222 repo.compare_worktree_cached_with_options(&tree, &core_worktree_status_options(repo))
1223 .map(|status| status.is_clean())
1224 .unwrap_or(false)
1225}
1226
1227fn remote_drift_health(
1228 repo: &Repository,
1229 mut checks: Vec<RepositoryVerificationCheck>,
1230 remote: GitRemoteTrackingStatus,
1231) -> RepositoryVerificationHealth {
1232 let status = remote_tracking_status(&remote);
1233 let mut details = BTreeMap::new();
1234 details.insert("branch".to_string(), remote.branch.clone());
1235 details.insert("upstream".to_string(), remote.upstream.clone());
1236 details.insert("ahead".to_string(), remote.ahead.to_string());
1237 details.insert("behind".to_string(), remote.behind.to_string());
1238 if let Some(local_oid) = &remote.local_oid {
1239 details.insert("local_oid".to_string(), local_oid.clone());
1240 }
1241 if let Some(upstream_oid) = &remote.upstream_oid {
1242 details.insert("upstream_oid".to_string(), upstream_oid.clone());
1243 }
1244 checks.push(RepositoryVerificationCheck {
1245 name: "remote_tracking".to_string(),
1246 status: status.to_string(),
1247 summary: remote.message.clone(),
1248 details,
1249 });
1250 let recovery_commands = remote_drift_recovery_commands(repo, &remote, status);
1251 if matches!(status, "clean" | "remote_ahead" | "remote_untracked") {
1252 return RepositoryVerificationHealth {
1253 status: "clean".to_string(),
1254 clean: true,
1255 summary: "Git overlay verified".to_string(),
1256 recovery_commands: Vec::new(),
1257 checks,
1258 };
1259 }
1260 RepositoryVerificationHealth {
1261 status: status.to_string(),
1262 clean: false,
1263 summary: remote.message,
1264 recovery_commands,
1265 checks,
1266 }
1267}
1268
1269pub(crate) fn remote_drift_recovery_commands(
1270 repo: &Repository,
1271 remote: &GitRemoteTrackingStatus,
1272 status: &str,
1273) -> Vec<String> {
1274 match status {
1275 "remote_behind" => vec!["heddle pull".to_string()],
1276 "remote_diverged" => {
1277 let upstream = remote.upstream.trim();
1278 if upstream.is_empty() {
1279 return vec!["heddle pull".to_string()];
1280 }
1281 let import = canonical_git_import_ref_command(upstream);
1282 let reconcile = canonical_git_repair_ref_preview_command(None, upstream);
1283 if upstream_thread_matches_current_git_tip(repo, upstream) {
1284 vec![reconcile]
1285 } else {
1286 vec![import, reconcile]
1287 }
1288 }
1289 "remote_contains_undone_checkpoint" => {
1290 vec![
1291 "heddle push --force-with-lease".to_string(),
1292 "heddle undo --redo".to_string(),
1293 ]
1294 }
1295 _ => crate::status::next_action::remote_tracking_next_action_for(
1296 remote,
1297 repo.source_authority(),
1298 )
1299 .into_iter()
1300 .collect(),
1301 }
1302}
1303
1304fn upstream_thread_matches_current_git_tip(repo: &Repository, upstream: &str) -> bool {
1305 let Some(thread_tip) = repo
1306 .refs()
1307 .get_thread(&ThreadName::new(upstream))
1308 .ok()
1309 .flatten()
1310 else {
1311 return false;
1312 };
1313 repo.git_overlay_mapped_state_for_branch(upstream)
1314 .or(Ok(None))
1315 .and_then(|mapped| {
1316 if mapped.is_some() {
1317 Ok(mapped)
1318 } else {
1319 repo.git_overlay_mapped_state_for_remote_tracking_ref(upstream)
1320 }
1321 })
1322 .ok()
1323 .flatten()
1324 .is_some_and(|mapped_tip| mapped_tip == thread_tip)
1325}
1326
1327fn clean_health(
1328 summary: impl Into<String>,
1329 checks: Vec<RepositoryVerificationCheck>,
1330) -> RepositoryVerificationHealth {
1331 RepositoryVerificationHealth {
1332 status: "clean".to_string(),
1333 clean: true,
1334 summary: summary.into(),
1335 recovery_commands: Vec::new(),
1336 checks,
1337 }
1338}
1339
1340fn degraded_health(
1341 checks: Vec<RepositoryVerificationCheck>,
1342 summary: &str,
1343) -> RepositoryVerificationHealth {
1344 RepositoryVerificationHealth {
1345 status: "degraded".to_string(),
1346 clean: false,
1347 summary: summary.to_string(),
1348 recovery_commands: vec!["heddle doctor".to_string()],
1349 checks,
1350 }
1351}
1352
1353fn dirty_details(status: &WorktreeStatus) -> std::collections::BTreeMap<String, String> {
1354 let mut details = std::collections::BTreeMap::new();
1355 let count = status.modified.len() + status.added.len() + status.deleted.len();
1356 details.insert("dirty_path_count".to_string(), count.to_string());
1357 let mut paths = status
1358 .modified
1359 .iter()
1360 .chain(status.added.iter())
1361 .chain(status.deleted.iter())
1362 .map(|path| path.display().to_string())
1363 .collect::<Vec<_>>();
1364 paths.sort();
1365 if !paths.is_empty() {
1366 details.insert("dirty_paths".to_string(), paths.join(", "));
1367 }
1368 details
1369}
1370
1371fn import_guidance_includes_active_branch(hint: &GitImportGuidance) -> bool {
1372 hint.missing_branches
1373 .iter()
1374 .any(|branch| branch == &hint.current_branch)
1375}
1376
1377#[derive(Debug, Clone, Serialize, JsonSchema)]
1378pub struct GitImportGuidanceReport {
1379 pub current_branch: String,
1380 pub missing_branch_count: usize,
1381 pub missing_branches: Vec<String>,
1382 pub recommended_command: String,
1383}
1384
1385impl From<GitImportGuidance> for GitImportGuidanceReport {
1386 fn from(hint: GitImportGuidance) -> Self {
1387 Self {
1388 current_branch: hint.current_branch,
1389 missing_branch_count: hint.missing_branch_count,
1390 missing_branches: hint.missing_branches,
1391 recommended_command: hint.recommended_command,
1392 }
1393 }
1394}
1395
1396#[derive(Debug, Clone, Serialize, JsonSchema)]
1397pub struct GitIndexPlan {
1398 pub commit_mode: &'static str,
1399 pub has_staged_changes: bool,
1400 pub staged_paths: Vec<String>,
1401 pub unstaged_paths: Vec<String>,
1402 pub untracked_paths: Vec<String>,
1403 pub will_commit: Vec<String>,
1404 pub preserved_after_commit: Vec<String>,
1405}
1406
1407#[derive(Default)]
1408struct GitIndexIntent {
1409 staged_paths: Vec<String>,
1410 extra_paths: Vec<String>,
1411}
1412
1413impl GitIndexPlan {
1414 fn from_intent(intent: &GitIndexIntent) -> Self {
1415 let (unstaged_paths, untracked_paths) = split_extra_paths(&intent.extra_paths);
1416 let has_staged_changes = !intent.staged_paths.is_empty();
1417 let mut will_commit = Vec::new();
1418 if has_staged_changes {
1419 will_commit.extend(intent.staged_paths.iter().cloned());
1420 } else {
1421 will_commit.extend(unstaged_paths.iter().cloned());
1422 will_commit.extend(untracked_paths.iter().cloned());
1423 }
1424 let preserved_after_commit = if has_staged_changes {
1425 intent.extra_paths.clone()
1426 } else {
1427 Vec::new()
1428 };
1429 Self {
1430 commit_mode: if has_staged_changes {
1431 "staged_index"
1432 } else {
1433 "worktree"
1434 },
1435 has_staged_changes,
1436 staged_paths: intent.staged_paths.clone(),
1437 unstaged_paths,
1438 untracked_paths,
1439 will_commit,
1440 preserved_after_commit,
1441 }
1442 }
1443}
1444
1445const GIT_MODE_COMMIT: u32 = 0o160000;
1446
1447pub fn git_index_plan_for_repo(repo: &Repository) -> Result<Option<GitIndexPlan>> {
1448 let Some(status) = repo.git_overlay_short_status()? else {
1449 return Ok(None);
1450 };
1451 Ok(git_index_plan_from_short_status(&status))
1452}
1453
1454fn git_index_plan_from_short_status(status: &repo::GitOverlayShortStatus) -> Option<GitIndexPlan> {
1455 status.index_plan_applicable.then(|| {
1456 GitIndexPlan::from_intent(&GitIndexIntent {
1457 staged_paths: status.index_staged_paths.clone(),
1458 extra_paths: status.index_extra_paths.clone(),
1459 })
1460 })
1461}
1462
1463fn load_git_overlay_status_and_index_plan(
1464 repo: &Repository,
1465) -> (Result<Option<WorktreeStatus>>, Option<GitIndexPlan>) {
1466 match repo.git_overlay_short_status() {
1467 Ok(Some(status)) => {
1468 let index = git_index_plan_from_short_status(&status);
1469 (Ok(Some(status.worktree)), index)
1470 }
1471 Ok(None) => (Ok(None), None),
1472 Err(error) => (Err(error), None),
1473 }
1474}
1475
1476pub fn git_index_plan_for_root(root: &Path) -> Result<Option<GitIndexPlan>> {
1479 let git = match SleyRepository::discover(root) {
1480 Ok(git) => git,
1481 Err(_) => return Ok(None),
1482 };
1483 if !git_worktree_matches_root(&git, root) {
1484 return Ok(None);
1485 }
1486 let ignore_patterns = git_ignore_patterns_for_root(root, &git)?;
1487 Ok(Some(GitIndexPlan::from_intent(
1488 &git_index_intent_for_root_with_ignore_and_repo(root, &ignore_patterns, &git)?,
1489 )))
1490}
1491
1492fn git_ignore_patterns_for_root(root: &Path, git: &SleyRepository) -> Result<Vec<String>> {
1493 let mut patterns = Vec::new();
1494 append_ignore_file_patterns(&mut patterns, &root.join(".gitignore"))?;
1495 append_ignore_file_patterns(&mut patterns, &git.git_dir().join("info").join("exclude"))?;
1496 Ok(patterns)
1497}
1498
1499fn append_ignore_file_patterns(patterns: &mut Vec<String>, path: &Path) -> Result<()> {
1500 if !path.exists() {
1501 return Ok(());
1502 }
1503 let contents = fs::read_to_string(path).map_err(|err| {
1504 HeddleError::Config(format!(
1505 "failed to read ignore file {}: {err}",
1506 path.display()
1507 ))
1508 })?;
1509 for line in contents.lines() {
1510 let trimmed = line.trim();
1511 if trimmed.is_empty() || trimmed.starts_with('#') {
1512 continue;
1513 }
1514 if !patterns.iter().any(|pattern| pattern == trimmed) {
1515 patterns.push(trimmed.to_string());
1516 }
1517 }
1518 Ok(())
1519}
1520
1521fn git_worktree_matches_root(git: &SleyRepository, root: &Path) -> bool {
1522 git.workdir()
1523 .is_some_and(|workdir| paths_equal(&workdir, root))
1524}
1525
1526fn split_extra_paths(extra_paths: &[String]) -> (Vec<String>, Vec<String>) {
1527 let mut unstaged_paths = Vec::new();
1528 let mut untracked_paths = Vec::new();
1529 for path in extra_paths {
1530 if let Some(path) = path.strip_prefix("unstaged: ") {
1531 unstaged_paths.push(path.to_string());
1532 } else if let Some(path) = path.strip_prefix("untracked: ") {
1533 untracked_paths.push(path.to_string());
1534 }
1535 }
1536 (unstaged_paths, untracked_paths)
1537}
1538
1539fn git_index_intent_for_root_with_ignore_and_repo(
1540 root: &Path,
1541 ignore_patterns: &[String],
1542 git: &SleyRepository,
1543) -> Result<GitIndexIntent> {
1544 let ignore_matcher = build_worktree_ignore(ignore_patterns);
1545 let mut intent = GitIndexIntent::default();
1546 git.stream_short_status_with_options(
1547 ShortStatusOptions {
1548 untracked_mode: StatusUntrackedMode::All,
1549 ..ShortStatusOptions::default()
1550 },
1551 |entry| {
1552 append_status_row_to_index_intent(&mut intent, &ignore_matcher, entry);
1553 Ok(StreamControl::Continue)
1554 },
1555 )
1556 .map_err(|err| {
1557 HeddleError::Config(format!(
1558 "failed to inspect Git status before commit at {}: {err}",
1559 root.display()
1560 ))
1561 })?;
1562 Ok(intent)
1563}
1564
1565fn append_status_row_to_index_intent(
1566 intent: &mut GitIndexIntent,
1567 ignore_matcher: &objects::worktree::WorktreeIgnoreMatcher,
1568 entry: ShortStatusRow<'_>,
1569) {
1570 let path = String::from_utf8_lossy(entry.path).into_owned();
1571 if path.is_empty() {
1572 return;
1573 }
1574 if entry.index == b'?' && entry.worktree == b'?' {
1575 if !ignore_matcher.is_ignored(Path::new(&path)) {
1576 intent.extra_paths.push(format!("untracked: {path}"));
1577 }
1578 return;
1579 }
1580 if entry.index != b' ' && entry.index != b'!' {
1581 intent.staged_paths.push(path.clone());
1582 }
1583 if entry.worktree != b' '
1584 && entry.worktree != b'!'
1585 && !status_row_is_gitlink_worktree_only(entry)
1586 {
1587 intent.extra_paths.push(format!("unstaged: {path}"));
1588 }
1589}
1590
1591fn status_row_is_gitlink_worktree_only(entry: ShortStatusRow<'_>) -> bool {
1592 entry.index == b' '
1593 && (entry.index_mode == Some(GIT_MODE_COMMIT)
1594 || entry.head_mode == Some(GIT_MODE_COMMIT)
1595 || entry.worktree_mode == Some(GIT_MODE_COMMIT))
1596}
1597
1598#[derive(Debug, Clone, Serialize, JsonSchema)]
1599pub struct MaterializedThreadInfo {
1600 pub name: String,
1601 pub state_id: String,
1602 pub tree_hash_short: String,
1603 pub file_count: usize,
1604 pub stale: bool,
1605}
1606
1607#[derive(Debug, Clone, Serialize, JsonSchema)]
1608pub struct ActorInfo {
1609 #[serde(skip_serializing_if = "Option::is_none")]
1610 pub provider: Option<String>,
1611 #[serde(skip_serializing_if = "Option::is_none")]
1612 pub model: Option<String>,
1613}
1614
1615#[derive(Debug, Clone, Serialize, JsonSchema)]
1616pub struct ParallelThreadInfo {
1617 pub name: String,
1618 pub coordination_status: CoordinationStatus,
1619 pub current_state: Option<String>,
1620}
1621
1622#[derive(Debug, Clone, Serialize, JsonSchema)]
1623pub struct StateInfo {
1624 pub state_id: String,
1625 pub content_hash: String,
1626 pub intent: Option<String>,
1627}
1628
1629#[derive(Debug, Clone, Serialize, JsonSchema)]
1630pub struct GitCheckpointInfo {
1631 pub git_commit: String,
1632 pub committed_at: String,
1633}
1634
1635#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
1636pub struct SubmoduleInfo {
1637 pub path: String,
1638 pub commit: String,
1639}
1640
1641fn collect_status_submodules(
1642 repo: &Repository,
1643 state: Option<&State>,
1644) -> Result<Vec<SubmoduleInfo>> {
1645 let mut submodules = Vec::new();
1646 if let Some(state) = state
1647 && !is_synthetic_root(state)
1648 {
1649 let tree = repo.require_tree_for_worktree_status(&state.tree)?;
1650 if let Some(cached) = repo.cached_gitlinks_for_tree(&tree) {
1651 return Ok(cached
1652 .into_iter()
1653 .map(|(path, commit)| SubmoduleInfo { path, commit })
1654 .collect());
1655 }
1656 collect_tree_submodules(repo, &tree, "", &mut submodules)?;
1657 } else if let Some(git) = repo.git_overlay_sley_repository()? {
1658 let head = git.head_state().map_err(|error| {
1659 HeddleError::Config(format!(
1660 "read Git HEAD while collecting submodules: {error}"
1661 ))
1662 })?;
1663 if let Some(commit_oid) = head.oid() {
1664 let commit = git.read_commit(&commit_oid).map_err(|error| {
1665 HeddleError::Config(format!(
1666 "read Git commit {commit_oid} while collecting submodules: {error}"
1667 ))
1668 })?;
1669 collect_git_tree_submodules(&git, commit.tree, "", &mut submodules)?;
1670 }
1671 }
1672 submodules.sort_by(|left, right| left.path.cmp(&right.path));
1673 Ok(submodules)
1674}
1675
1676fn collect_tree_submodules(
1677 repo: &Repository,
1678 tree: &Tree,
1679 prefix: &str,
1680 submodules: &mut Vec<SubmoduleInfo>,
1681) -> Result<()> {
1682 for entry in tree.entries() {
1683 let path = format!("{prefix}{}", entry.name());
1684 if let Some(target) = entry.gitlink_target() {
1685 submodules.push(SubmoduleInfo {
1686 path,
1687 commit: target.to_string(),
1688 });
1689 } else if let Some(hash) = entry.tree_hash() {
1690 let subtree = repo.require_tree(&hash)?;
1691 collect_tree_submodules(repo, &subtree, &format!("{path}/"), submodules)?;
1692 }
1693 }
1694 Ok(())
1695}
1696
1697fn collect_git_tree_submodules(
1698 git: &SleyRepository,
1699 tree_oid: sley::ObjectId,
1700 prefix: &str,
1701 submodules: &mut Vec<SubmoduleInfo>,
1702) -> Result<()> {
1703 let tree = git.read_tree(&tree_oid).map_err(|error| {
1704 HeddleError::Config(format!(
1705 "read Git tree {tree_oid} while collecting submodules: {error}"
1706 ))
1707 })?;
1708 for entry in tree.entries {
1709 if !matches!(entry.mode, 0o040000 | 0o160000) {
1710 continue;
1711 }
1712 let Ok(name) = String::from_utf8(entry.name.as_bytes().to_vec()) else {
1713 continue;
1714 };
1715 let path = format!("{prefix}{name}");
1716 match entry.mode {
1717 0o040000 => {
1718 collect_git_tree_submodules(git, entry.oid, &format!("{path}/"), submodules)?;
1719 }
1720 0o160000 => submodules.push(SubmoduleInfo {
1721 path,
1722 commit: entry.oid.to_string(),
1723 }),
1724 _ => {}
1725 }
1726 }
1727 Ok(())
1728}
1729
1730#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
1731pub struct ChangesInfo {
1732 pub modified: Vec<String>,
1733 pub added: Vec<String>,
1734 pub deleted: Vec<String>,
1735}
1736
1737impl ChangesInfo {
1738 pub fn is_empty(&self) -> bool {
1739 self.modified.is_empty() && self.added.is_empty() && self.deleted.is_empty()
1740 }
1741}
1742
1743#[derive(Debug, Clone, Copy, Serialize, JsonSchema, PartialEq, Eq)]
1744#[serde(rename_all = "kebab-case")]
1745pub enum CoordinationStatus {
1746 Clean,
1747 Ahead,
1748 Diverged,
1749 Blocked,
1750 MergeReady,
1751}
1752
1753impl std::fmt::Display for CoordinationStatus {
1754 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1755 match self {
1756 Self::Clean => write!(f, "clean"),
1757 Self::Ahead => write!(f, "ahead"),
1758 Self::Diverged => write!(f, "diverged"),
1759 Self::Blocked => write!(f, "blocked"),
1760 Self::MergeReady => write!(f, "merge-ready"),
1761 }
1762 }
1763}
1764
1765#[derive(Debug, Clone)]
1766pub struct StatusThreadSummary {
1767 pub name: String,
1768 pub base_state: Option<String>,
1769 pub base_root: Option<String>,
1770 pub current_state: Option<String>,
1771 pub path: Option<String>,
1772 pub execution_path: Option<String>,
1773 pub session_id: Option<String>,
1774 pub heddle_session_id: Option<String>,
1775 pub actor: Option<ActorInfo>,
1776 pub harness: Option<String>,
1777 pub thinking_level: Option<String>,
1778 pub usage_summary: Option<AgentUsageSummary>,
1779 pub last_progress_at: Option<String>,
1780 pub report_flush_state: Option<String>,
1781 pub attach_reason: Option<String>,
1782 pub thread_mode: Option<ThreadMode>,
1783 pub thread_state: Option<ThreadState>,
1784 pub freshness: Option<ThreadFreshness>,
1785 pub target_thread: Option<String>,
1786 pub parent_thread: Option<String>,
1787 pub child_threads: Vec<String>,
1788 pub task: Option<String>,
1789 pub promotion_suggested: bool,
1790 pub impact_categories: Vec<ThreadImpactCategory>,
1791 pub heavy_impact_paths: Vec<String>,
1792 pub changed_paths: Vec<String>,
1793 pub verification_summary: repo::ThreadVerificationSummary,
1794 pub confidence_summary: repo::ThreadConfidenceSummary,
1795 pub integration_policy_result: repo::ThreadIntegrationPolicy,
1796 pub coordination_status: CoordinationStatus,
1797 pub is_current: bool,
1798 pub is_isolated: bool,
1799}
1800
1801pub fn collect_thread_summaries(repo: &Repository) -> Result<Vec<StatusThreadSummary>> {
1802 let thread_refs = repo.refs().list_threads()?;
1803 let current = repo.current_lane()?;
1804 let manager = ThreadManager::new(repo.heddle_dir());
1805 let mut names: BTreeSet<String> = thread_refs.iter().map(ToString::to_string).collect();
1806 names.extend(current.iter().cloned());
1807 names.extend(manager.list()?.into_iter().map(|thread| thread.thread));
1808
1809 let registry_entries = ActorPresenceStore::new(repo.heddle_dir()).list()?;
1813
1814 let mut summaries = Vec::new();
1815 for name in names {
1816 if let Some(summary) = find_thread_summary_with_agents(repo, &name, ®istry_entries)? {
1817 summaries.push(summary);
1818 }
1819 }
1820 let mut children_by_parent = std::collections::BTreeMap::<String, Vec<String>>::new();
1821 for summary in &summaries {
1822 if let Some(parent) = &summary.parent_thread {
1823 children_by_parent
1824 .entry(parent.clone())
1825 .or_default()
1826 .push(summary.name.clone());
1827 }
1828 }
1829 for summary in &mut summaries {
1830 summary.child_threads = children_by_parent
1831 .remove(&summary.name)
1832 .map(|mut children| {
1833 children.sort();
1834 children
1835 })
1836 .unwrap_or_default();
1837 }
1838 summaries.sort_by(|a, b| a.name.cmp(&b.name));
1839 Ok(summaries)
1840}
1841
1842pub fn find_thread_summary_single(
1843 repo: &Repository,
1844 name: &str,
1845) -> Result<Option<StatusThreadSummary>> {
1846 let registry_entries = ActorPresenceStore::new(repo.heddle_dir()).list()?;
1847 find_thread_summary_with_agents(repo, name, ®istry_entries)
1848}
1849
1850fn find_thread_summary_with_agents(
1851 repo: &Repository,
1852 name: &str,
1853 registry_entries: &[ActorPresence],
1854) -> Result<Option<StatusThreadSummary>> {
1855 let current = repo.current_lane()?;
1856 let is_current = current.as_deref() == Some(name);
1857 let manager = ThreadManager::new(repo.heddle_dir());
1858 let thread = manager.find_by_thread(name)?;
1859 let ref_state = repo.refs().get_thread(&ThreadName::new(name))?;
1860 if thread.is_none()
1861 && ref_state.is_none()
1862 && !(is_current && repo.capability() == RepositoryCapability::GitOverlay)
1863 {
1864 return Ok(None);
1865 }
1866 let mut thread =
1867 thread.unwrap_or_else(|| synthetic_thread(repo, name, ref_state.map(|id| id.short())));
1868 let _ = refresh_thread_freshness(repo, &mut thread);
1869 let entries: Vec<&ActorPresence> = registry_entries
1870 .iter()
1871 .filter(|entry| entry.thread == name)
1872 .collect();
1873 Ok(Some(thread_summary_from_thread(
1874 repo,
1875 thread,
1876 is_current,
1877 primary_agent_entry_refs(&entries),
1878 )))
1879}
1880
1881fn synthetic_thread(repo: &Repository, name: &str, current_state: Option<String>) -> Thread {
1882 Thread {
1883 id: name.to_string(),
1884 thread: name.to_string(),
1885 target_thread: None,
1886 parent_thread: None,
1887 mode: ThreadMode::Materialized,
1888 state: ThreadState::Active,
1889 base_state: current_state.clone().unwrap_or_default(),
1890 base_root: String::new(),
1891 current_state,
1892 merged_state: None,
1893 task: None,
1894 execution_path: repo.root().to_path_buf(),
1895 materialized_path: None,
1896 changed_paths: Vec::new(),
1897 impact_categories: Vec::new(),
1898 heavy_impact_paths: Vec::new(),
1899 promotion_suggested: false,
1900 freshness: ThreadFreshness::Unknown,
1901 verification_summary: Default::default(),
1902 confidence_summary: Default::default(),
1903 integration_policy_result: Default::default(),
1904 created_at: Utc::now(),
1905 updated_at: Utc::now(),
1906 ephemeral: None,
1907 auto: false,
1908 shared_target_dir: None,
1909 }
1910}
1911
1912fn thread_summary_from_thread(
1913 repo: &Repository,
1914 thread: Thread,
1915 is_current: bool,
1916 primary: Option<&ActorPresence>,
1917) -> StatusThreadSummary {
1918 let thread_state = thread.state;
1919 let coordination_status = coordination_status_for_thread_state(&thread_state);
1920 let path = thread
1921 .materialized_path
1922 .as_ref()
1923 .map(|path| path.display().to_string())
1924 .or_else(|| {
1925 primary
1926 .and_then(|entry| entry.path.as_ref())
1927 .map(|path| path.display().to_string())
1928 });
1929 let execution_path =
1930 if thread.execution_path.as_os_str().is_empty() || thread.execution_path == repo.root() {
1931 None
1935 } else {
1936 Some(thread.execution_path.display().to_string())
1937 };
1938 let git_backed_tip = is_current
1939 && repo.capability() == RepositoryCapability::GitOverlay
1940 && thread.current_state.is_none();
1941 StatusThreadSummary {
1942 name: thread.thread,
1943 base_state: non_empty(thread.base_state),
1944 base_root: non_empty(thread.base_root),
1945 current_state: thread.current_state,
1946 path,
1947 execution_path,
1948 session_id: primary.map(|entry| entry.session_id.clone()),
1949 heddle_session_id: primary.and_then(|entry| entry.heddle_session_id.clone()),
1950 actor: primary.and_then(|entry| match (&entry.provider, &entry.model) {
1951 (None, None) => None,
1952 (provider, model) => Some(ActorInfo {
1953 provider: provider.clone(),
1954 model: model.clone(),
1955 }),
1956 }),
1957 harness: primary.and_then(|entry| entry.harness.clone()),
1958 thinking_level: primary.and_then(|entry| entry.thinking_level.clone()),
1959 usage_summary: primary.map(|entry| entry.usage_summary.clone()),
1960 last_progress_at: primary
1961 .and_then(|entry| entry.last_progress_at)
1962 .map(|time| time.to_rfc3339()),
1963 report_flush_state: primary.and_then(|entry| entry.report_flush_state.clone()),
1964 attach_reason: primary
1965 .and_then(|entry| entry.attach_reason.clone())
1966 .or_else(|| git_backed_tip.then(|| "using Git-backed branch tip".to_string())),
1967 thread_mode: Some(thread.mode),
1968 thread_state: Some(thread_state),
1969 freshness: Some(thread.freshness),
1970 target_thread: thread.target_thread,
1971 parent_thread: thread.parent_thread,
1972 child_threads: Vec::new(),
1973 task: thread.task,
1974 promotion_suggested: thread.promotion_suggested,
1975 impact_categories: thread.impact_categories,
1976 heavy_impact_paths: thread.heavy_impact_paths,
1977 changed_paths: thread.changed_paths,
1978 verification_summary: thread.verification_summary,
1979 confidence_summary: thread.confidence_summary,
1980 integration_policy_result: thread.integration_policy_result,
1981 coordination_status,
1982 is_current,
1983 is_isolated: thread.materialized_path.is_some(),
1984 }
1985}
1986
1987fn primary_agent_entry_refs<'a>(entries: &[&'a ActorPresence]) -> Option<&'a ActorPresence> {
1988 entries
1989 .iter()
1990 .copied()
1991 .filter(|entry| entry.status == ActorPresenceStatus::Active)
1992 .max_by_key(|entry| entry.started_at)
1993 .or_else(|| entries.iter().copied().max_by_key(|entry| entry.started_at))
1994}
1995
1996fn non_empty(value: String) -> Option<String> {
1997 (!value.is_empty()).then_some(value)
1998}
1999
2000fn coordination_status_for_thread_state(state: &ThreadState) -> CoordinationStatus {
2001 match state {
2002 ThreadState::Blocked => CoordinationStatus::Blocked,
2003 ThreadState::Ready => CoordinationStatus::MergeReady,
2004 ThreadState::Merged | ThreadState::Abandoned => CoordinationStatus::Clean,
2005 ThreadState::Active | ThreadState::Draft | ThreadState::Promoted => {
2006 CoordinationStatus::Clean
2007 }
2008 }
2009}
2010
2011#[derive(Debug, Clone, Serialize, JsonSchema)]
2012pub struct FastShortStatusReport {
2013 pub subject: String,
2014 pub health: String,
2015 pub changes: ChangesInfo,
2016 #[serde(skip)]
2017 #[schemars(skip)]
2018 pub profile: FastShortStatusProfile,
2019}
2020
2021#[derive(Debug, Clone, Copy, Default)]
2022pub struct FastShortStatusProfile {
2023 pub git_discover_ms: u128,
2024 pub config_ms: u128,
2025 pub sley_status_ms: u128,
2026 pub branch_ms: u128,
2027 pub remote_ms: u128,
2028 pub total_ms: u128,
2029}
2030
2031#[derive(Debug, Clone, Serialize, JsonSchema)]
2037pub struct PlainGitStatusReport {
2038 pub output_kind: &'static str,
2039 pub repository_capability: String,
2040 pub repository_label: String,
2041 pub storage_model: String,
2042 pub heddle_initialized: bool,
2043 pub git_branch: Option<String>,
2044 pub path: String,
2045 #[serde(rename = "verification")]
2046 pub trust: RepositoryVerificationState,
2047 #[serde(serialize_with = "serialize_empty_action_as_null")]
2048 #[schemars(with = "Option<String>")]
2049 pub recommended_action: String,
2050 pub recommended_action_template: Option<ActionTemplate>,
2051 pub recovery_commands: Vec<String>,
2052 pub recovery_action_templates: Vec<ActionTemplate>,
2053 pub thread_health: String,
2054 pub changed_path_count: usize,
2055 pub changes: ChangesInfo,
2056 pub git_index: Option<GitIndexPlan>,
2057}
2058
2059pub fn plain_git_status_report(
2063 start: &Path,
2064 machine_contract_input: &MachineContractInput,
2065) -> Result<Option<PlainGitStatusReport>> {
2066 let Some(probe) =
2067 build_plain_git_verification_probe_with_machine_contract(start, machine_contract_input)?
2068 else {
2069 return Ok(None);
2070 };
2071 let changes = changes_from_worktree_status(&probe.changes);
2072 let changed_path_count = probe.changes.change_count();
2073 let trust = probe.trust;
2074 let git_index = git_index_plan_for_root(&probe.root)?;
2075 Ok(Some(PlainGitStatusReport {
2076 output_kind: "status",
2077 repository_capability: "plain-git".to_string(),
2078 repository_label: repository_mode_label("plain-git", "git-only"),
2079 storage_model: "git-only".to_string(),
2080 heddle_initialized: false,
2081 git_branch: probe.git_branch,
2082 path: probe.root.display().to_string(),
2083 recommended_action: trust.recommended_action.clone(),
2084 recommended_action_template: trust.recommended_action_template.clone(),
2085 recovery_commands: trust.recovery_commands.clone(),
2086 recovery_action_templates: trust.recovery_action_templates.clone(),
2087 thread_health: trust.status.clone(),
2088 changed_path_count,
2089 changes,
2090 git_index,
2091 trust,
2092 }))
2093}
2094
2095pub fn status(ctx: &ExecutionContext, opts: StatusOptions) -> Result<StatusReport> {
2096 let fallback;
2097 let start = if let Some(start) = opts.start_path.as_deref() {
2098 start
2099 } else if let Some(start) = ctx.start_path() {
2100 start
2101 } else {
2102 fallback = std::env::current_dir().map_err(HeddleError::Io)?;
2103 fallback.as_path()
2104 };
2105
2106 let opened;
2111 let (repo, repo_open_ms) = if let Some(repo) = ctx.repo() {
2112 (repo, 0)
2113 } else {
2114 let repo_open_start = Instant::now();
2115 opened = Repository::open(start)?;
2116 (&opened, repo_open_start.elapsed().as_millis())
2117 };
2118 let body_start = Instant::now();
2119
2120 let current_state_start = Instant::now();
2121 let current_state = repo.current_state_for_worktree_status()?;
2122 let current_state_ms = current_state_start.elapsed().as_millis();
2123
2124 let operation_start = Instant::now();
2125 let operation = repo.operation_status()?;
2126 let operation_ms = operation_start.elapsed().as_millis();
2127
2128 let remote_tracking_start = Instant::now();
2129 let remote_tracking = if opts.detail.needs_remote_tracking() {
2130 repo.git_remote_tracking_status().unwrap_or(None)
2131 } else {
2132 None
2133 };
2134 let remote_tracking_ms = remote_tracking_start.elapsed().as_millis();
2135
2136 let import_hint_start = Instant::now();
2137 let import_hint = if opts.detail.short_path() {
2138 None
2139 } else {
2140 repo.git_import_guidance().unwrap_or(None)
2141 };
2142 let import_hint_ms = import_hint_start.elapsed().as_millis();
2143
2144 let git_overlay_status_start = Instant::now();
2145 let (git_worktree_status_result, git_index) = load_git_overlay_status_and_index_plan(repo);
2146 let git_overlay_status_ms = git_overlay_status_start.elapsed().as_millis();
2147
2148 let native_worktree_status_start = Instant::now();
2149 let (worktree_status_result, native_worktree_profile) =
2150 if repo.capability() == RepositoryCapability::GitOverlay {
2151 (git_worktree_status_result, None)
2152 } else {
2153 match current_state.as_ref() {
2154 Some(state) => {
2155 match repo
2156 .require_tree_for_worktree_status(&state.tree)
2157 .and_then(|tree| {
2158 repo.compare_worktree_cached_profiled_with_options(
2159 &tree,
2160 &opts.worktree_status_options,
2161 )
2162 }) {
2163 Ok((status, profile)) => (Ok(Some(status)), Some(profile)),
2164 Err(error) => (Err(error), None),
2165 }
2166 }
2167 None => (Ok(Some(WorktreeStatus::default())), None),
2168 }
2169 };
2170 let native_worktree_status_ms = native_worktree_status_start.elapsed().as_millis();
2171
2172 let verification_start = Instant::now();
2173 let verification_health =
2174 build_repository_verification_health_with_worktree_status(repo, &worktree_status_result);
2175 let trust = build_repository_verification_state_with_worktree_status_and_machine_contract(
2176 repo,
2177 verification_health.clone(),
2178 &worktree_status_result,
2179 &opts.machine_contract_input,
2180 );
2181 let verification_ms = verification_start.elapsed().as_millis();
2182 let remote_tracking =
2183 remote_tracking.map(|remote| remote_tracking_with_verification_action(remote, &trust));
2184
2185 let worktree_status = worktree_status_result.unwrap_or(None);
2186
2187 let git_index_ms = 0;
2188
2189 let identity_notice = first_capture_identity_notice(ctx, repo, current_state.as_ref())?;
2190 let git_clean_mapping_blocker = matches!(
2191 trust.status.as_str(),
2192 "needs_import" | "needs_reconcile" | "git_branch_advanced"
2193 ) && worktree_status
2194 .as_ref()
2195 .is_some_and(WorktreeStatus::is_clean);
2196 let git_backed_mapping = trust.mapping_state == "git_backed";
2197
2198 let worktree_status_start = Instant::now();
2199 let (changes, worktree_profile) = if git_clean_mapping_blocker {
2200 (ChangesInfo::default(), None)
2201 } else if let Some(profile) = native_worktree_profile {
2202 (
2203 worktree_status
2204 .as_ref()
2205 .map(changes_from_worktree_status)
2206 .unwrap_or_default(),
2207 Some(profile),
2208 )
2209 } else if let Some(status) = worktree_status.as_ref()
2210 && !status.is_clean()
2211 && trust.status != "needs_checkpoint"
2212 {
2213 (changes_from_worktree_status(status), None)
2214 } else if git_backed_mapping {
2215 (
2216 worktree_status
2217 .as_ref()
2218 .map(changes_from_worktree_status)
2219 .unwrap_or_default(),
2220 None,
2221 )
2222 } else if let Some(ref state) = current_state {
2223 let tree = repo.require_tree_for_worktree_status(&state.tree)?;
2224 let (status, profile) = repo
2225 .compare_worktree_cached_profiled_with_options(&tree, &opts.worktree_status_options)?;
2226 (changes_from_worktree_status(&status), Some(profile))
2227 } else if let Some(status) = worktree_status {
2228 (changes_from_worktree_status(&status), None)
2229 } else {
2230 let tree = objects::object::Tree::new();
2231 let (status, profile) = repo
2232 .compare_worktree_cached_profiled_with_options(&tree, &opts.worktree_status_options)?;
2233 let mut changes = changes_from_worktree_status(&status);
2234 changes.modified.clear();
2235 changes.deleted.clear();
2236 (changes, Some(profile))
2237 };
2238 let worktree_status_ms =
2239 native_worktree_status_ms + worktree_status_start.elapsed().as_millis();
2240
2241 if opts.detail.short_path() {
2242 let mut report = build_short_path_report(ShortPathInputs {
2243 repo,
2244 current_state: current_state.as_ref(),
2245 operation,
2246 remote_tracking,
2247 verification_health,
2248 trust,
2249 import_hint,
2250 git_index,
2251 identity_notice,
2252 changes,
2253 profile: StatusProfile {
2254 repo_open_ms,
2255 current_state_ms,
2256 operation_ms,
2257 remote_tracking_ms,
2258 import_hint_ms,
2259 git_overlay_status_ms,
2260 verification_ms,
2261 git_index_ms,
2262 worktree_status_ms,
2263 build_total_ms: body_start.elapsed().as_millis(),
2264 worktree_profile,
2265 ..StatusProfile::default()
2266 },
2267 });
2268 apply_pending_land_recovery(repo, &mut report)?;
2269 return Ok(report);
2270 }
2271 let submodules = collect_status_submodules(repo, current_state.as_ref())?;
2272
2273 let thread_summary_start = Instant::now();
2274 let track_name = repo.current_lane()?;
2275 let full_thread_summaries = if opts.detail.needs_full_walk() {
2276 Some(collect_thread_summaries(repo)?)
2277 } else {
2278 None
2279 };
2280 let thread_summary = match (track_name.as_deref(), full_thread_summaries.as_ref()) {
2281 (Some(thread), Some(summaries)) => summaries
2282 .iter()
2283 .find(|summary| summary.name == thread)
2284 .cloned(),
2285 (Some(thread), None) => find_thread_summary_single(repo, thread)?,
2286 (None, _) => None,
2287 };
2288 let thread_summary_ms = thread_summary_start.elapsed().as_millis();
2289
2290 let parallel_threads_start = Instant::now();
2291 let parallel_threads = if let Some(summaries) = full_thread_summaries {
2292 summaries
2293 .into_iter()
2294 .filter(|thread| !thread.is_current)
2295 .filter(|thread| {
2296 matches!(
2297 thread.coordination_status,
2298 CoordinationStatus::Ahead
2299 | CoordinationStatus::Blocked
2300 | CoordinationStatus::Diverged
2301 | CoordinationStatus::MergeReady
2302 )
2303 })
2304 .collect::<Vec<_>>()
2305 } else {
2306 Vec::new()
2307 };
2308 let parallel_threads_ms = parallel_threads_start.elapsed().as_millis();
2309
2310 let late_state_start = Instant::now();
2311 let state_info = current_state.as_ref().map(|s| StateInfo {
2312 state_id: s.state_id.short(),
2313 content_hash: s.compute_hash().short(),
2314 intent: s.intent.clone(),
2315 });
2316 let current_state_short = current_state.as_ref().map(|state| state.state_id.short());
2317 let native_remote = native_remote_status(repo, track_name.as_deref(), current_state.as_ref())?;
2318 let git_checkpoint = if trust.status == "needs_checkpoint" {
2319 None
2320 } else {
2321 current_state
2322 .as_ref()
2323 .and_then(|state| {
2324 repo.latest_git_checkpoint_for_state(&state.state_id)
2325 .ok()
2326 .flatten()
2327 })
2328 .map(|record| GitCheckpointInfo {
2329 git_commit: record.git_commit,
2330 committed_at: record.committed_at,
2331 })
2332 };
2333
2334 let materialized_start = Instant::now();
2335 let materialized_threads = assess_materialized_threads(repo);
2336 let materialized_ms = materialized_start.elapsed().as_millis();
2337 let target_thread = thread_summary
2338 .as_ref()
2339 .and_then(|thread| thread.target_thread.clone());
2340 let parent_thread = thread_summary
2341 .as_ref()
2342 .and_then(|thread| thread.parent_thread.clone());
2343 let presentation =
2344 crate::repository_presentation(repo, target_thread.as_deref(), parent_thread.as_deref());
2345
2346 let output = StatusReport {
2347 output_kind: "status",
2348 repository_capability: repo.capability_label().to_string(),
2349 repository_label: presentation.label,
2350 repository_context: presentation.context,
2351 storage_model: repo.storage_model_label().to_string(),
2352 hosted_enabled: repo.hosted_enabled(),
2353 validation_capability: repo.capability(),
2354 import_guidance: import_hint.clone().map(Into::into),
2355 verification_health: verification_health.clone(),
2356 trust: trust.clone(),
2357 operation,
2358 remote_tracking,
2359 git_index,
2360 thread: track_name.clone(),
2361 base_state: thread_summary
2362 .as_ref()
2363 .and_then(|thread| thread.base_state.clone())
2364 .or_else(|| current_state_short.clone()),
2365 base_root: thread_summary
2366 .as_ref()
2367 .and_then(|thread| thread.base_root.clone()),
2368 current_state: current_state_short.clone(),
2369 native_remote,
2370 path: thread_summary
2371 .as_ref()
2372 .and_then(|thread| thread.path.clone()),
2373 execution_path: thread_summary
2374 .as_ref()
2375 .and_then(|thread| thread.execution_path.clone()),
2376 session_id: thread_summary
2377 .as_ref()
2378 .and_then(|thread| thread.session_id.clone()),
2379 heddle_session_id: thread_summary
2380 .as_ref()
2381 .and_then(|thread| thread.heddle_session_id.clone()),
2382 actor: thread_summary
2383 .as_ref()
2384 .and_then(|thread| thread.actor.clone()),
2385 harness: thread_summary
2386 .as_ref()
2387 .and_then(|thread| thread.harness.clone()),
2388 thinking_level: thread_summary
2389 .as_ref()
2390 .and_then(|thread| thread.thinking_level.clone()),
2391 usage_summary: thread_summary
2392 .as_ref()
2393 .and_then(|thread| thread.usage_summary.clone()),
2394 last_progress_at: thread_summary
2395 .as_ref()
2396 .and_then(|thread| thread.last_progress_at.clone()),
2397 report_flush_state: thread_summary
2398 .as_ref()
2399 .and_then(|thread| thread.report_flush_state.clone()),
2400 attach_reason: thread_summary
2401 .as_ref()
2402 .and_then(|thread| thread.attach_reason.clone()),
2403 thread_mode: thread_summary
2404 .as_ref()
2405 .and_then(|thread| thread.thread_mode.clone()),
2406 thread_state: thread_summary
2407 .as_ref()
2408 .and_then(|thread| thread.thread_state.clone()),
2409 freshness: thread_summary
2410 .as_ref()
2411 .and_then(|thread| thread.freshness.clone()),
2412 target_thread,
2413 parent_thread,
2414 child_threads: thread_summary
2415 .as_ref()
2416 .map(|thread| thread.child_threads.clone())
2417 .unwrap_or_default(),
2418 task: thread_summary
2419 .as_ref()
2420 .and_then(|thread| thread.task.clone()),
2421 promotion_suggested: thread_summary
2422 .as_ref()
2423 .map(|thread| thread.promotion_suggested)
2424 .unwrap_or(false),
2425 impact_categories: thread_summary
2426 .as_ref()
2427 .map(|thread| thread.impact_categories.clone())
2428 .unwrap_or_default(),
2429 heavy_impact_paths: thread_summary
2430 .as_ref()
2431 .map(|thread| thread.heavy_impact_paths.clone())
2432 .unwrap_or_default(),
2433 changed_paths: Vec::new(),
2434 changed_path_count: thread_summary
2435 .as_ref()
2436 .filter(|thread| thread.target_thread.is_some())
2437 .map(|thread| thread.changed_paths.len())
2438 .unwrap_or_default(),
2439 worktree_changed_path_count: changes_path_count(&changes),
2440 thread_changed_path_count: captured_thread_path_count(thread_summary.as_ref(), &changes),
2441 blockers: Vec::new(),
2442 identity_notice,
2443 recommended_action: String::new(),
2444 recommended_action_template: None,
2445 recovery_commands: trust.recovery_commands.clone(),
2446 recovery_action_templates: trust.recovery_action_templates.clone(),
2447 thread_health: "clean".to_string(),
2448 coordination_status: thread_summary
2449 .as_ref()
2450 .map(|thread| thread.coordination_status)
2451 .unwrap_or(CoordinationStatus::Clean),
2452 coordination_blocked_by_trust: false,
2453 is_isolated: thread_summary
2454 .as_ref()
2455 .map(|thread| thread.is_isolated)
2456 .unwrap_or(false),
2457 parallel_threads: parallel_threads
2458 .into_iter()
2459 .map(|thread| ParallelThreadInfo {
2460 name: thread.name,
2461 coordination_status: thread.coordination_status,
2462 current_state: thread.current_state,
2463 })
2464 .collect(),
2465 state: state_info,
2466 git_checkpoint,
2467 changes,
2468 submodules,
2469 materialized_threads,
2470 profile: StatusProfile::default(),
2471 };
2472 let late_state_ms = late_state_start.elapsed().as_millis();
2473 let advice_start = Instant::now();
2474 let mut output = apply_status_advice(
2475 repo,
2476 output,
2477 current_state.as_ref(),
2478 &thread_summary,
2479 import_hint,
2480 git_backed_mapping,
2481 );
2482 output.profile = StatusProfile {
2483 repo_open_ms,
2484 current_state_ms,
2485 operation_ms,
2486 remote_tracking_ms,
2487 import_hint_ms,
2488 git_overlay_status_ms,
2489 verification_ms,
2490 git_index_ms,
2491 worktree_status_ms,
2492 thread_summary_ms,
2493 parallel_threads_ms,
2494 late_state_ms,
2495 materialized_threads_ms: materialized_ms,
2496 advice_ms: advice_start.elapsed().as_millis(),
2497 build_total_ms: body_start.elapsed().as_millis(),
2498 worktree_profile,
2499 };
2500 apply_pending_land_recovery(repo, &mut output)?;
2501 Ok(output)
2502}
2503
2504const INCOMPLETE_LAND_MARKER: &str = "incomplete-land.json";
2505
2506#[derive(Deserialize)]
2507struct IncompleteLandStatusMarker {
2508 thread_id: String,
2509 merge_state: serde_json::Value,
2514 collapse_state: serde_json::Value,
2515}
2516
2517fn apply_pending_land_recovery(repo: &Repository, report: &mut StatusReport) -> Result<()> {
2521 let path = repo.heddle_dir().join(INCOMPLETE_LAND_MARKER);
2522 let raw = match fs::read_to_string(&path) {
2523 Ok(raw) => raw,
2524 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2525 Err(error) => {
2526 return Err(HeddleError::Config(format!(
2527 "failed to read incomplete-land marker {}: {error}",
2528 path.display()
2529 )));
2530 }
2531 };
2532 let marker: IncompleteLandStatusMarker = serde_json::from_str(&raw).map_err(|error| {
2533 HeddleError::Config(format!(
2534 "failed to parse incomplete-land marker {}: {error}",
2535 path.display()
2536 ))
2537 })?;
2538 for (name, value) in [
2539 ("merge_state", &marker.merge_state),
2540 ("collapse_state", &marker.collapse_state),
2541 ] {
2542 if !value.is_null() && !value.is_string() {
2543 return Err(HeddleError::Config(format!(
2544 "failed to parse incomplete-land marker {}: {name} must be a string or null",
2545 path.display()
2546 )));
2547 }
2548 }
2549 let thread = marker.thread_id;
2550 let action = heddle_action(["land", "--thread", thread.as_str()]);
2551 report.blockers.push(format!(
2552 "land of '{thread}' has durable recovery work pending"
2553 ));
2554 if !report.recovery_commands.contains(&action) {
2555 report.recovery_commands.push(action.clone());
2556 }
2557 report.recovery_action_templates = action_templates(&report.recovery_commands);
2558 report.coordination_status = CoordinationStatus::Blocked;
2559 if report.recommended_action.is_empty() {
2560 report.recommended_action = action.clone();
2561 report.recommended_action_template = action_template(&action);
2562 }
2563 Ok(())
2564}
2565
2566struct ShortPathInputs<'a> {
2567 repo: &'a Repository,
2568 current_state: Option<&'a State>,
2569 operation: Option<RepositoryOperationStatus>,
2570 remote_tracking: Option<GitRemoteTrackingStatus>,
2571 verification_health: RepositoryVerificationHealth,
2572 trust: RepositoryVerificationState,
2573 import_hint: Option<GitImportGuidance>,
2574 git_index: Option<GitIndexPlan>,
2575 identity_notice: Option<String>,
2576 changes: ChangesInfo,
2577 profile: StatusProfile,
2578}
2579
2580fn build_short_path_report(input: ShortPathInputs<'_>) -> StatusReport {
2581 let recommended_action = effective_next_action(
2582 NextActionInput::default(
2583 input.operation.as_ref(),
2584 input.remote_tracking.as_ref(),
2585 None,
2586 None,
2587 )
2588 .with_source_authority(input.repo.source_authority())
2589 .with_verification(&input.trust),
2590 );
2591 let worktree_clean = input.changes.is_empty();
2592 let recommended_action =
2593 first_save_recommendation(input.repo, input.current_state, worktree_clean)
2594 .unwrap_or(recommended_action);
2595 let presentation = crate::repository_presentation(input.repo, None, None);
2596 let recommended_action_template = action_template(&recommended_action);
2597 let thread = input.repo.current_lane().ok().flatten();
2600 let native_remote = native_remote_status(input.repo, thread.as_deref(), input.current_state)
2601 .ok()
2602 .flatten();
2603 StatusReport {
2604 output_kind: "status",
2605 repository_capability: input.repo.capability_label().to_string(),
2606 repository_label: presentation.label,
2607 repository_context: presentation.context,
2608 storage_model: input.repo.storage_model_label().to_string(),
2609 hosted_enabled: input.repo.hosted_enabled(),
2610 validation_capability: input.repo.capability(),
2611 import_guidance: input.import_hint.map(Into::into),
2612 verification_health: input.verification_health,
2613 trust: input.trust.clone(),
2614 operation: input.operation,
2615 remote_tracking: input.remote_tracking,
2616 git_index: input.git_index,
2617 thread,
2618 base_state: None,
2619 base_root: None,
2620 current_state: input.current_state.map(|state| state.state_id.short()),
2621 native_remote,
2622 path: None,
2623 execution_path: None,
2624 session_id: None,
2625 heddle_session_id: None,
2626 actor: None,
2627 harness: None,
2628 thinking_level: None,
2629 usage_summary: None,
2630 last_progress_at: None,
2631 report_flush_state: None,
2632 attach_reason: None,
2633 thread_mode: None,
2634 thread_state: None,
2635 freshness: None,
2636 target_thread: None,
2637 parent_thread: None,
2638 child_threads: Vec::new(),
2639 task: None,
2640 promotion_suggested: false,
2641 impact_categories: Vec::new(),
2642 heavy_impact_paths: Vec::new(),
2643 changed_paths: changes_paths(&input.changes).into_iter().collect(),
2644 changed_path_count: changes_path_count(&input.changes),
2645 worktree_changed_path_count: changes_path_count(&input.changes),
2646 thread_changed_path_count: 0,
2647 blockers: if input.trust.verified {
2648 Vec::new()
2649 } else {
2650 input
2651 .trust
2652 .checks
2653 .iter()
2654 .filter(|check| {
2655 !check.clean
2656 && check.status != "not_checked"
2657 && !check
2658 .summary
2659 .contains("checked after the primary verification blocker")
2660 })
2661 .map(|check| format!("{}: {}", check.name, check.summary))
2662 .collect()
2663 },
2664 identity_notice: input.identity_notice,
2665 recommended_action_template,
2666 recommended_action,
2667 recovery_commands: input.trust.recovery_commands.clone(),
2668 recovery_action_templates: input.trust.recovery_action_templates.clone(),
2669 thread_health: input.trust.status.clone(),
2670 coordination_status: if input.trust.verified {
2671 CoordinationStatus::Clean
2672 } else {
2673 CoordinationStatus::Blocked
2674 },
2675 coordination_blocked_by_trust: !input.trust.verified,
2676 is_isolated: false,
2677 parallel_threads: Vec::new(),
2678 state: None,
2679 git_checkpoint: None,
2680 changes: input.changes,
2681 submodules: Vec::new(),
2682 materialized_threads: assess_materialized_threads(input.repo),
2683 profile: input.profile,
2684 }
2685}
2686
2687fn apply_status_advice(
2688 repo: &Repository,
2689 output: StatusReport,
2690 current_state: Option<&State>,
2691 thread_summary: &Option<StatusThreadSummary>,
2692 import_hint: Option<GitImportGuidance>,
2693 git_backed_mapping: bool,
2694) -> StatusReport {
2695 let has_changes = !output.changes.is_empty();
2696 let checkpointed_clean = output.git_checkpoint.is_some() && !has_changes;
2697 let thread_stub = output.thread.as_ref().map(|thread| Thread {
2698 id: thread.clone(),
2699 thread: thread.clone(),
2700 target_thread: output.target_thread.clone(),
2701 parent_thread: thread_summary
2702 .as_ref()
2703 .and_then(|thread| thread.parent_thread.clone()),
2704 mode: output
2705 .thread_mode
2706 .clone()
2707 .unwrap_or(ThreadMode::Materialized),
2708 state: output.thread_state.clone().unwrap_or(ThreadState::Active),
2709 base_state: output.base_state.clone().unwrap_or_default(),
2710 base_root: output.base_root.clone().unwrap_or_default(),
2711 current_state: output.current_state.clone(),
2712 merged_state: None,
2713 task: output.task.clone(),
2714 execution_path: output
2715 .execution_path
2716 .as_ref()
2717 .map(PathBuf::from)
2718 .unwrap_or_else(|| repo.root().to_path_buf()),
2719 materialized_path: output.path.as_ref().map(PathBuf::from),
2720 changed_paths: thread_summary
2721 .as_ref()
2722 .map(|thread| thread.changed_paths.clone())
2723 .unwrap_or_default(),
2724 impact_categories: output.impact_categories.clone(),
2725 heavy_impact_paths: output.heavy_impact_paths.clone(),
2726 promotion_suggested: output.promotion_suggested && !checkpointed_clean,
2727 freshness: match output.freshness.clone().unwrap_or(ThreadFreshness::Unknown) {
2728 ThreadFreshness::Unknown if checkpointed_clean => ThreadFreshness::Current,
2729 freshness => freshness,
2730 },
2731 verification_summary: thread_summary
2732 .as_ref()
2733 .map(|thread| thread.verification_summary.clone())
2734 .unwrap_or_default(),
2735 confidence_summary: thread_summary
2736 .as_ref()
2737 .map(|thread| thread.confidence_summary.clone())
2738 .unwrap_or_default(),
2739 integration_policy_result: thread_summary
2740 .as_ref()
2741 .map(|thread| thread.integration_policy_result.clone())
2742 .unwrap_or_default(),
2743 created_at: chrono::Utc::now(),
2744 updated_at: chrono::Utc::now(),
2745 ephemeral: None,
2746 auto: false,
2747 shared_target_dir: None,
2748 });
2749 let initial_state = current_state.map(is_synthetic_root).unwrap_or(true);
2750 let advice = thread_stub.as_ref().map(|thread| {
2751 describe_thread_advice_with_initial(thread, has_changes, 0, false, initial_state)
2752 });
2753 let mut trust = output.trust.clone();
2754 if let Some(operation) = output.operation.as_ref()
2755 && trust.recommended_action != operation.next_action
2756 {
2757 override_trust_recommended_action(&mut trust, operation.next_action.clone());
2758 }
2759 if has_changes
2760 && output.validation_capability != RepositoryCapability::GitOverlay
2761 && output.operation.is_none()
2762 && trust.verified
2763 {
2764 let dirty_paths = changes_paths(&output.changes)
2765 .into_iter()
2766 .collect::<Vec<_>>();
2767 let dirty_summary = format!(
2768 "{} Heddle worktree path(s) are not captured in the current state",
2769 dirty_paths.len()
2770 );
2771 trust.verified = false;
2772 trust.status = "uncaptured".to_string();
2773 trust.worktree_dirty = true;
2774 trust.worktree_state = "dirty".to_string();
2775 trust.summary = dirty_summary.clone();
2776 trust.recommended_action = "heddle capture -m \"...\"".to_string();
2777 trust.recommended_action_template = action_template(&trust.recommended_action);
2778 trust.recovery_commands = vec![trust.recommended_action.clone()];
2779 trust.recovery_action_templates = action_templates(&trust.recovery_commands);
2780 let mut details = BTreeMap::new();
2781 details.insert(
2782 "dirty_path_count".to_string(),
2783 dirty_paths.len().to_string(),
2784 );
2785 if !dirty_paths.is_empty() {
2786 details.insert("dirty_paths".to_string(), dirty_paths.join(", "));
2787 }
2788 let worktree_check = VerificationCheck {
2789 name: "Worktree".to_string(),
2790 status: "uncaptured".to_string(),
2791 clean: false,
2792 summary: dirty_summary,
2793 recommended_action: Some(trust.recommended_action.clone()),
2794 recommended_action_template: trust.recommended_action_template.clone(),
2795 recovery_commands: trust.recovery_commands.clone(),
2796 recovery_action_templates: trust.recovery_action_templates.clone(),
2797 details,
2798 };
2799 if let Some(check) = trust
2800 .checks
2801 .iter_mut()
2802 .find(|check| check.name == "Worktree")
2803 {
2804 *check = worktree_check;
2805 } else {
2806 trust.checks.insert(0, worktree_check);
2807 }
2808 }
2809 if trust.status != "needs_checkpoint"
2810 && let Some(thread) = output.thread.as_deref()
2811 && !trust.recommended_action.is_empty()
2812 {
2813 let contextual = contextual_thread_action(
2814 repo,
2815 thread,
2816 output.target_thread.as_deref(),
2817 &trust.recommended_action,
2818 );
2819 if contextual != trust.recommended_action {
2820 override_trust_recommended_action(&mut trust, contextual);
2821 }
2822 }
2823 let thread_health = advice.as_ref().map(|advice| advice.thread_health.as_str());
2824 let thread_action = advice
2825 .as_ref()
2826 .map(|advice| advice.recommended_action.as_str());
2827 let fallback = if trust.status == "needs_checkpoint" {
2828 non_empty_action(Some(trust.recommended_action.as_str()))
2829 } else {
2830 non_empty_action(thread_action)
2831 .or_else(|| non_empty_action(Some(trust.recommended_action.as_str())))
2832 };
2833 let recommended_action = effective_next_action(
2834 NextActionInput::default(
2835 output.operation.as_ref(),
2836 output.remote_tracking.as_ref(),
2837 import_hint.as_ref(),
2838 fallback,
2839 )
2840 .with_source_authority(repo.source_authority())
2841 .current_thread(thread_health)
2842 .with_verification(&trust),
2843 );
2844 let recommended_action = if trust.status != "needs_checkpoint"
2845 && let Some(thread) = output.thread.as_deref()
2846 {
2847 contextual_thread_action(
2848 repo,
2849 thread,
2850 output.target_thread.as_deref(),
2851 &recommended_action,
2852 )
2853 } else {
2854 recommended_action
2855 };
2856 if trust.verified
2857 && !recommended_action.is_empty()
2858 && trust.recommended_action != recommended_action
2859 {
2860 override_trust_recommended_action(&mut trust, recommended_action.clone());
2861 }
2862 let recommended_action =
2863 if git_backed_mapping && trust.status != "needs_checkpoint" && output.operation.is_none() {
2864 if has_changes {
2865 "heddle capture -m \"...\"".to_string()
2866 } else {
2867 String::new()
2868 }
2869 } else {
2870 if output.operation.is_some() {
2871 recommended_action
2872 } else {
2873 first_save_recommendation(repo, current_state, !has_changes)
2874 .unwrap_or(recommended_action)
2875 }
2876 };
2877 let thread_health = if trust.verified {
2878 if git_backed_mapping {
2879 if has_changes {
2880 "dirty_worktree".to_string()
2881 } else {
2882 "clean".to_string()
2883 }
2884 } else {
2885 advice
2886 .as_ref()
2887 .map(|advice| advice.thread_health.clone())
2888 .unwrap_or_else(|| "clean".to_string())
2889 }
2890 } else {
2891 trust.status.clone()
2892 };
2893 let needs_checkpoint = trust.status == "needs_checkpoint";
2894 let mut trust_blockers = trust
2895 .checks
2896 .iter()
2897 .filter(|check| {
2898 !check.clean
2899 && check.status != "not_checked"
2900 && (check.name != "Clone" || check.status != "blocked")
2901 && !check
2902 .summary
2903 .contains("checked after the primary verification blocker")
2904 })
2905 .map(|check| {
2906 let name = if output.validation_capability != RepositoryCapability::GitOverlay
2907 && check.name == "Worktree"
2908 && check.status == "uncaptured"
2909 {
2910 "Verification"
2911 } else {
2912 check.name.as_str()
2913 };
2914 format!("{name}: {}", check.summary)
2915 })
2916 .collect::<Vec<_>>();
2917 let blocked_by_trust = !trust.verified;
2918 if blocked_by_trust && trust_blockers.is_empty() && !trust.summary.trim().is_empty() {
2919 trust_blockers.push(format!("Verification: {}", trust.summary));
2920 }
2921 let display_thread_summary = (!git_backed_mapping)
2922 .then_some(thread_summary.as_ref())
2923 .flatten();
2924 let worktree_changed_path_count = changes_path_count(&output.changes);
2925 let thread_changed_path_count =
2926 captured_thread_path_count(display_thread_summary, &output.changes);
2927 let (coordination_status, coordination_blocked_by_trust) = resolve_coordination_with_trust(
2928 output.coordination_status,
2929 blocked_by_trust,
2930 needs_checkpoint,
2931 );
2932 let recommended_action_template = action_template(&recommended_action);
2933 StatusReport {
2934 blockers: if blocked_by_trust {
2935 trust_blockers
2936 } else {
2937 advice
2938 .as_ref()
2939 .map(|advice| advice.blockers.clone())
2940 .unwrap_or_default()
2941 },
2942 identity_notice: output.identity_notice,
2943 recommended_action: recommended_action.clone(),
2944 recommended_action_template,
2945 recovery_commands: trust.recovery_commands.clone(),
2946 recovery_action_templates: trust.recovery_action_templates.clone(),
2947 thread_health,
2948 coordination_status,
2949 coordination_blocked_by_trust,
2950 thread_state: output.thread_state,
2951 changed_paths: changed_paths(display_thread_summary, &output.changes),
2952 changed_path_count: if trust.verified {
2953 changed_path_count(display_thread_summary, &output.changes)
2954 } else {
2955 changes_path_count(&output.changes)
2956 },
2957 worktree_changed_path_count,
2958 thread_changed_path_count,
2959 trust,
2960 ..output
2961 }
2962}
2963
2964fn override_trust_recommended_action(trust: &mut RepositoryVerificationState, action: String) {
2965 let template = action_template(&action);
2966 trust.recommended_action = action.clone();
2967 trust.recommended_action_template = template.clone();
2968 if let Some(check) = trust
2969 .checks
2970 .iter_mut()
2971 .find(|check| check.name == "Workflow")
2972 {
2973 check.recommended_action = Some(action);
2974 check.recommended_action_template = template;
2975 }
2976}
2977
2978fn paths_equal(left: &Path, right: &Path) -> bool {
2979 let left = left.canonicalize();
2980 let right = right.canonicalize();
2981 match (left, right) {
2982 (Ok(left), Ok(right)) => left == right,
2983 _ => false,
2984 }
2985}
2986
2987fn first_capture_identity_notice(
2988 ctx: &ExecutionContext,
2989 repo: &Repository,
2990 current_state: Option<&State>,
2991) -> Result<Option<String>> {
2992 if !current_state.map(is_synthetic_root).unwrap_or(true) {
2993 return Ok(None);
2994 }
2995 let resolved = crate::resolve_principal_from_context(repo, ctx)?;
2996 let Some(principal) = resolved.principal else {
2997 return Ok(Some(
2998 "no principal configured; the first capture will refuse until you set HEDDLE_PRINCIPAL_NAME and HEDDLE_PRINCIPAL_EMAIL or run `heddle init --principal-name <name> --principal-email <email>`.".to_string(),
2999 ));
3000 };
3001 let source = resolved
3002 .source
3003 .map(crate::principal_source_display)
3004 .map(|source| format!(" from {source}"))
3005 .unwrap_or_default();
3006 Ok(Some(format!("{principal}{source}")))
3007}
3008
3009pub fn large_capture_requires_force(
3013 total_changes: usize,
3014 delete_count: usize,
3015 add_count: usize,
3016) -> bool {
3017 total_changes > 100 || delete_count > 25 || add_count > 100
3018}
3019
3020pub fn fast_short_status_report(start: &Path) -> Result<Option<FastShortStatusReport>> {
3021 let total_start = Instant::now();
3022 let discover_start = Instant::now();
3023 if discover_heddle_root(start).is_some() {
3024 return Ok(None);
3025 }
3026 let git = match SleyRepository::open_from_environment(start) {
3027 Ok(git) => git,
3028 Err(_) => return Ok(None),
3029 };
3030 let Some(workdir) = git.workdir() else {
3031 return Ok(None);
3032 };
3033 let git_discover_ms = discover_start.elapsed().as_millis();
3034
3035 let config_start = Instant::now();
3036 let repo_kind = fast_short_repo_kind(&workdir)?;
3037 if matches!(repo_kind, FastShortRepoKind::Fallback) {
3038 return Ok(None);
3039 }
3040 let config_ms = config_start.elapsed().as_millis();
3041
3042 let status_start = Instant::now();
3043 let changes = fast_sley_changes(&git)?;
3044 let sley_status_ms = status_start.elapsed().as_millis();
3045
3046 let branch_start = Instant::now();
3047 let branch = fast_git_branch(&git)?;
3048 let subject = branch.as_deref().unwrap_or("detached").to_string();
3049 let branch_ms = branch_start.elapsed().as_millis();
3050
3051 let remote_start = Instant::now();
3052 let remote_health = match repo_kind {
3053 FastShortRepoKind::PlainGit | FastShortRepoKind::Fallback => None,
3054 FastShortRepoKind::GitOverlay => branch
3055 .as_deref()
3056 .map(|branch| fast_remote_health(&git, branch))
3057 .transpose()?
3058 .flatten(),
3059 };
3060 let remote_ms = remote_start.elapsed().as_millis();
3061 let health = if changes.is_empty() {
3062 match repo_kind {
3063 FastShortRepoKind::PlainGit => "setup needed".to_string(),
3064 FastShortRepoKind::GitOverlay | FastShortRepoKind::Fallback => {
3065 remote_health.unwrap_or("clean").to_string()
3066 }
3067 }
3068 } else {
3069 String::new()
3070 };
3071 Ok(Some(FastShortStatusReport {
3072 subject,
3073 health,
3074 changes,
3075 profile: FastShortStatusProfile {
3076 git_discover_ms,
3077 config_ms,
3078 sley_status_ms,
3079 branch_ms,
3080 remote_ms,
3081 total_ms: total_start.elapsed().as_millis(),
3082 },
3083 }))
3084}
3085
3086enum FastShortRepoKind {
3087 PlainGit,
3088 GitOverlay,
3089 Fallback,
3090}
3091
3092fn fast_short_repo_kind(workdir: &Path) -> Result<FastShortRepoKind> {
3093 let heddle_dir = workdir.join(".heddle");
3094 if !heddle_dir.exists() {
3095 return Ok(FastShortRepoKind::PlainGit);
3096 }
3097 if heddle_dir.join("objectstore").is_file() {
3098 return Ok(FastShortRepoKind::Fallback);
3099 }
3100 let config_path = heddle_dir.join("config.toml");
3101 if !config_path.is_file() {
3102 return Ok(FastShortRepoKind::Fallback);
3103 }
3104 let config = RepoConfig::load_for_repository(&config_path)?;
3105 Ok(match config.repository.source_authority {
3106 repo::RepositorySourceAuthority::GitOverlay => FastShortRepoKind::GitOverlay,
3107 repo::RepositorySourceAuthority::Native => FastShortRepoKind::Fallback,
3108 })
3109}
3110
3111fn fast_sley_changes(git: &SleyRepository) -> Result<ChangesInfo> {
3112 let mut changes = ChangesInfo::default();
3113 git.stream_short_status_with_options(
3114 ShortStatusOptions {
3115 untracked_mode: StatusUntrackedMode::All,
3116 ..ShortStatusOptions::default()
3117 },
3118 |entry| {
3119 append_fast_status_row(&mut changes, entry);
3120 Ok(StreamControl::Continue)
3121 },
3122 )
3123 .map_err(sley_error)?;
3124 Ok(changes)
3125}
3126
3127fn append_fast_status_row(changes: &mut ChangesInfo, entry: ShortStatusRow<'_>) {
3128 let path = String::from_utf8_lossy(entry.path).into_owned();
3129 if path.is_empty() || ignored_git_overlay_status_path(&path) {
3130 return;
3131 }
3132 if entry.index == b'?' && entry.worktree == b'?' {
3133 changes.added.push(path);
3134 } else if entry.index == b'D' || entry.worktree == b'D' {
3135 changes.deleted.push(path);
3136 } else if entry.index == b'A'
3137 || entry.index == b'R'
3138 || entry.index == b'C'
3139 || entry.head_oid.is_none()
3140 {
3141 changes.added.push(path);
3142 } else {
3143 changes.modified.push(path);
3144 }
3145}
3146
3147fn ignored_git_overlay_status_path(path: &str) -> bool {
3148 path == ".heddle" || path.starts_with(".heddle/")
3149}
3150
3151fn fast_git_branch(git: &SleyRepository) -> Result<Option<String>> {
3152 Ok(git
3153 .head()
3154 .ok()
3155 .and_then(|head| head.branch_name().map(str::to_string)))
3156}
3157
3158fn fast_remote_health(git: &SleyRepository, branch: &str) -> Result<Option<&'static str>> {
3159 let Some(head) = git.head().ok().and_then(|head| head.oid) else {
3160 return Ok(None);
3161 };
3162 if git
3163 .reference_exists(&format!("refs/heads/{branch}"))
3164 .map_err(sley_error)?
3165 && let Some(tracking_ref) = fast_configured_tracking_ref(git, branch)?
3166 && let Some(upstream) = fast_rev_parse(git, &tracking_ref)
3167 {
3168 return fast_remote_health_for_pair(git, head, upstream);
3169 }
3170
3171 let remotes = git.remote_names().map_err(sley_error)?;
3172 for remote in &remotes {
3173 if remote.trim().is_empty() {
3174 continue;
3175 }
3176 let remote_ref = format!("refs/remotes/{remote}/{branch}");
3177 let Some(upstream) = fast_rev_parse(git, &remote_ref) else {
3178 continue;
3179 };
3180 if upstream == head {
3181 return Ok(None);
3182 }
3183 return fast_remote_health_for_pair(git, head, upstream);
3184 }
3185
3186 if remotes.is_empty() {
3187 Ok(None)
3188 } else {
3189 Ok(Some("ready to push"))
3190 }
3191}
3192
3193fn fast_configured_tracking_ref(git: &SleyRepository, branch: &str) -> Result<Option<String>> {
3194 let config = git.config_snapshot().map_err(sley_error)?;
3195 let Some(remote) = config.get("branch", Some(branch), "remote") else {
3196 return Ok(None);
3197 };
3198 let Some(merge) = config.get("branch", Some(branch), "merge") else {
3199 return Ok(None);
3200 };
3201 if remote == "." {
3202 return Ok(Some(merge.to_string()));
3203 }
3204 let Some(short) = merge.strip_prefix("refs/heads/") else {
3205 return Ok(None);
3206 };
3207 Ok(Some(format!("refs/remotes/{remote}/{short}")))
3208}
3209
3210fn fast_rev_parse(git: &SleyRepository, rev: &str) -> Option<sley::ObjectId> {
3211 git.rev_parse(rev).ok()
3212}
3213
3214fn fast_remote_health_for_pair(
3215 git: &SleyRepository,
3216 head: sley::ObjectId,
3217 upstream: sley::ObjectId,
3218) -> Result<Option<&'static str>> {
3219 if head == upstream {
3220 return Ok(None);
3221 }
3222 let (ahead, behind) = git
3223 .rev_graph()
3224 .ahead_behind(head, upstream)
3225 .map_err(sley_error)?;
3226 Ok(match (ahead, behind) {
3227 (0, 0) => None,
3228 (_, 0) => Some("ready to push"),
3229 (0, _) => Some("behind upstream"),
3230 _ => Some("remote_diverged"),
3231 })
3232}
3233
3234fn sley_error(err: sley::GitError) -> HeddleError {
3235 HeddleError::Config(err.to_string())
3236}
3237
3238pub fn assess_materialized_threads(repo: &Repository) -> Vec<MaterializedThreadInfo> {
3239 let summaries = match repo::thread_manifest::list_thread_manifests(repo.heddle_dir()) {
3240 Ok(s) => s,
3241 Err(_) => return Vec::new(),
3242 };
3243 summaries
3244 .into_iter()
3245 .map(|summary| {
3246 let stale = match repo.refs().get_thread(&ThreadName::new(&summary.thread)) {
3247 Ok(Some(head)) => head != summary.state_id,
3248 _ => false,
3249 };
3250 let tree_hash = summary.tree_hash.to_string();
3251 MaterializedThreadInfo {
3252 name: summary.thread,
3253 state_id: summary.state_id.short(),
3254 tree_hash_short: tree_hash[..std::cmp::min(12, tree_hash.len())].to_string(),
3255 file_count: summary.file_count,
3256 stale,
3257 }
3258 })
3259 .collect()
3260}
3261
3262pub fn changes_from_worktree_status(status: &WorktreeStatus) -> ChangesInfo {
3263 ChangesInfo {
3264 modified: status
3265 .modified
3266 .iter()
3267 .map(|p| p.display().to_string())
3268 .collect(),
3269 added: status
3270 .added
3271 .iter()
3272 .map(|p| p.display().to_string())
3273 .collect(),
3274 deleted: status
3275 .deleted
3276 .iter()
3277 .map(|p| p.display().to_string())
3278 .collect(),
3279 }
3280}
3281
3282pub fn changes_path_count(changes: &ChangesInfo) -> usize {
3283 changes_paths(changes).len()
3284}
3285
3286pub fn changes_paths(changes: &ChangesInfo) -> BTreeSet<String> {
3287 let mut paths = BTreeSet::new();
3288 paths.extend(changes.modified.iter().cloned());
3289 paths.extend(changes.added.iter().cloned());
3290 paths.extend(changes.deleted.iter().cloned());
3291 paths
3292}
3293
3294fn changed_path_count(thread: Option<&StatusThreadSummary>, changes: &ChangesInfo) -> usize {
3295 let mut paths = BTreeSet::new();
3296 if let Some(thread) = thread.filter(|thread| thread.target_thread.is_some()) {
3300 paths.extend(thread.changed_paths.iter().cloned());
3301 }
3302 paths.extend(changes.modified.iter().cloned());
3303 paths.extend(changes.added.iter().cloned());
3304 paths.extend(changes.deleted.iter().cloned());
3305 paths.len()
3306}
3307
3308fn changed_paths(thread: Option<&StatusThreadSummary>, changes: &ChangesInfo) -> Vec<String> {
3309 let mut paths = BTreeSet::new();
3310 if let Some(thread) = thread.filter(|thread| thread.target_thread.is_some()) {
3311 paths.extend(thread.changed_paths.iter().cloned());
3312 }
3313 paths.extend(changes.modified.iter().cloned());
3314 paths.extend(changes.added.iter().cloned());
3315 paths.extend(changes.deleted.iter().cloned());
3316 paths.into_iter().collect()
3317}
3318
3319fn captured_thread_path_count(
3320 thread: Option<&StatusThreadSummary>,
3321 changes: &ChangesInfo,
3322) -> usize {
3323 let Some(thread) = thread.filter(|thread| thread.target_thread.is_some()) else {
3324 return 0;
3325 };
3326 let dirty_paths = changes_paths(changes);
3327 thread
3328 .changed_paths
3329 .iter()
3330 .filter(|path| !dirty_paths.contains(*path))
3331 .count()
3332}
3333
3334fn first_save_recommendation(
3335 repo: &Repository,
3336 current_state: Option<&State>,
3337 worktree_clean: bool,
3338) -> Option<String> {
3339 if !worktree_clean || repo.capability() != RepositoryCapability::NativeHeddle {
3340 return None;
3341 }
3342 let empty_log = current_state.map(is_synthetic_root).unwrap_or(true);
3343 empty_log.then(|| "heddle capture -m \"...\"".to_string())
3344}
3345
3346fn remote_tracking_with_verification_action(
3347 mut remote: GitRemoteTrackingStatus,
3348 trust: &RepositoryVerificationState,
3349) -> GitRemoteTrackingStatus {
3350 let remote_status = remote_tracking_status(&remote);
3351 if trust.status == remote_status && !trust.recommended_action.trim().is_empty() {
3352 remote.next_action = trust.recommended_action.clone();
3353 }
3354 remote
3355}
3356
3357#[cfg(test)]
3358mod tests {
3359 use super::*;
3360
3361 fn slow_path_bucket(row: &ShortStatusRow<'_>) -> &'static str {
3362 if row.index == b'?' && row.worktree == b'?' {
3363 "added"
3364 } else if row.index == b'D' || row.worktree == b'D' {
3365 "deleted"
3366 } else if row.index == b'A'
3367 || row.index == b'R'
3368 || row.index == b'C'
3369 || row.head_oid.is_none()
3370 {
3371 "added"
3372 } else {
3373 "modified"
3374 }
3375 }
3376
3377 fn fast_path_bucket(row: ShortStatusRow<'_>) -> &'static str {
3378 let mut changes = ChangesInfo::default();
3379 append_fast_status_row(&mut changes, row);
3380 match (
3381 changes.added.len(),
3382 changes.deleted.len(),
3383 changes.modified.len(),
3384 ) {
3385 (1, 0, 0) => "added",
3386 (0, 1, 0) => "deleted",
3387 (0, 0, 1) => "modified",
3388 other => panic!("fast path produced unexpected bucket counts: {other:?}"),
3389 }
3390 }
3391
3392 fn status_row<'a>(
3393 index: u8,
3394 worktree: u8,
3395 path: &'a [u8],
3396 in_head: bool,
3397 ) -> ShortStatusRow<'a> {
3398 ShortStatusRow {
3399 index,
3400 worktree,
3401 path,
3402 head_mode: None,
3403 index_mode: None,
3404 worktree_mode: None,
3405 head_oid: in_head.then(|| sley::ObjectId::null(sley::ObjectFormat::Sha1)),
3406 index_oid: None,
3407 submodule: None,
3408 }
3409 }
3410
3411 #[test]
3412 fn fast_short_status_agrees_with_slow_path_on_ad_rename_copy() {
3413 let cases: &[(u8, u8, bool, &str)] = &[
3414 (b'A', b'D', false, "AD: staged-add then worktree-deleted"),
3415 (b'R', b' ', true, "R: renamed"),
3416 (b'C', b' ', true, "C: copied"),
3417 (b'A', b' ', false, "A: staged add"),
3418 (b'M', b' ', true, "M: modified"),
3419 (b' ', b'M', true, "worktree-modified"),
3420 (b'D', b' ', true, "D: staged delete"),
3421 (b' ', b'D', true, "worktree delete"),
3422 (b'?', b'?', false, "untracked"),
3423 ];
3424 for &(index, worktree, in_head, label) in cases {
3425 let path = label.as_bytes();
3426 let fast = fast_path_bucket(status_row(index, worktree, path, in_head));
3427 let slow = slow_path_bucket(&status_row(index, worktree, path, in_head));
3428 assert_eq!(
3429 fast, slow,
3430 "fast and slow short-status classification disagree for {label}",
3431 );
3432 }
3433 }
3434
3435 #[test]
3436 fn status_uses_injected_repo_without_reopening_start_path() {
3437 let temp = tempfile::tempdir().expect("temp repo");
3438 repo::Repository::init_default(temp.path()).expect("init repo");
3439 let repo = Repository::open(temp.path()).expect("open repo");
3440 let bogus = temp.path().join("not-a-repo-start");
3442 let ctx = ExecutionContext::builder()
3443 .start_path(&bogus)
3444 .repo(repo)
3445 .build();
3446
3447 let report = status(
3448 &ctx,
3449 StatusOptions::new(
3450 StatusDetail::ShortText,
3451 repo::WorktreeStatusOptions::default(),
3452 )
3453 .with_start_path(&bogus),
3454 )
3455 .expect("status with injected repo must not re-open start_path");
3456
3457 assert_eq!(report.output_kind, "status");
3458 assert_eq!(
3459 report.profile.repo_open_ms, 0,
3460 "injected repo must report zero facade open cost"
3461 );
3462 assert!(!report.trust.status.is_empty());
3463 }
3464
3465 #[test]
3466 fn single_short_status_stream_builds_worktree_and_index_plan() {
3467 let temp = tempfile::tempdir().expect("temp");
3468 let root = temp.path();
3469 std::process::Command::new("git")
3470 .args(["init"])
3471 .current_dir(root)
3472 .output()
3473 .expect("git init");
3474 std::fs::write(root.join("tracked.txt"), "v1\n").unwrap();
3475 std::process::Command::new("git")
3476 .args(["add", "tracked.txt"])
3477 .current_dir(root)
3478 .output()
3479 .expect("git add");
3480 std::process::Command::new("git")
3481 .args([
3482 "-c",
3483 "user.email=t@example.com",
3484 "-c",
3485 "user.name=t",
3486 "commit",
3487 "-m",
3488 "init",
3489 ])
3490 .current_dir(root)
3491 .output()
3492 .expect("git commit");
3493 repo::Repository::init_git_overlay_sidecar(root).expect("heddle Git Overlay init");
3494 let repo = repo::Repository::open(root).expect("open");
3495 assert_eq!(
3496 repo.capability(),
3497 repo::RepositoryCapability::GitOverlay,
3498 "Git fixture must open as a Git Overlay repository"
3499 );
3500 std::fs::write(root.join("tracked.txt"), "v2\n").unwrap();
3501 std::fs::write(root.join("untracked.txt"), "u\n").unwrap();
3502 std::process::Command::new("git")
3503 .args(["add", "untracked.txt"])
3504 .current_dir(root)
3505 .output()
3506 .expect("stage untracked");
3507 std::fs::write(root.join("untracked.txt"), "u2\n").unwrap();
3508
3509 let snapshot = repo
3510 .git_overlay_short_status()
3511 .expect("short status")
3512 .expect("overlay short status");
3513 assert!(snapshot.index_plan_applicable);
3514 assert!(!snapshot.worktree.is_clean());
3515 assert!(!snapshot.index_staged_paths.is_empty() || !snapshot.index_extra_paths.is_empty());
3516
3517 let (worktree, plan) = super::load_git_overlay_status_and_index_plan(&repo);
3518 let worktree = worktree.expect("worktree ok").expect("some status");
3519 assert_eq!(worktree.modified.len(), snapshot.worktree.modified.len());
3520 assert_eq!(worktree.added.len(), snapshot.worktree.added.len());
3521 assert_eq!(worktree.deleted.len(), snapshot.worktree.deleted.len());
3522 assert!(plan.is_some());
3523 }
3524
3525 #[test]
3526 fn status_default_core_path_produces_complete_embedder_report() {
3527 let temp = tempfile::tempdir().expect("temp repo");
3528 repo::Repository::init_default(temp.path()).expect("init repo");
3529 let ctx = ExecutionContext::builder().start_path(temp.path()).build();
3530
3531 let report = status(
3532 &ctx,
3533 StatusOptions::new(
3534 StatusDetail::DefaultText,
3535 repo::WorktreeStatusOptions::default(),
3536 )
3537 .with_start_path(temp.path()),
3538 )
3539 .expect("core status");
3540
3541 assert_eq!(report.output_kind, "status");
3542 assert!(!report.repository_label.is_empty());
3543 assert!(!report.verification_health.status.is_empty());
3544 assert!(!report.trust.status.is_empty());
3545 assert_eq!(report.trust.machine_contract, "not_checked");
3546 assert_eq!(report.trust.machine_contract_coverage.status, "not_checked");
3547 assert!(
3548 report
3549 .trust
3550 .checks
3551 .iter()
3552 .any(|check| check.name == "Machine contract" && check.status == "not_checked")
3553 );
3554 }
3555
3556 #[test]
3557 fn status_interface_reports_durable_land_recovery_without_cli_augmentation() {
3558 let temp = tempfile::tempdir().expect("temp repo");
3559 repo::Repository::init_default(temp.path()).expect("init repo");
3560 let repo = Repository::open(temp.path()).expect("open repo");
3561 fs::write(
3562 repo.heddle_dir().join(INCOMPLETE_LAND_MARKER),
3563 serde_json::json!({
3564 "thread_id": "agent/recovery",
3565 "merge_state": null,
3566 "collapse_state": null
3567 })
3568 .to_string(),
3569 )
3570 .expect("write incomplete-land marker");
3571 let ctx = ExecutionContext::builder().repo(repo).build();
3572
3573 let report = status(
3574 &ctx,
3575 StatusOptions::new(
3576 StatusDetail::DefaultText,
3577 repo::WorktreeStatusOptions::default(),
3578 ),
3579 )
3580 .expect("status report");
3581
3582 assert_eq!(report.coordination_status, CoordinationStatus::Blocked);
3583 assert!(
3584 report
3585 .blockers
3586 .iter()
3587 .any(|blocker| blocker.contains("agent/recovery"))
3588 );
3589 assert!(
3590 report
3591 .recovery_commands
3592 .iter()
3593 .any(|command| command == "heddle land --thread agent/recovery")
3594 );
3595 assert!(
3596 report
3597 .recovery_action_templates
3598 .iter()
3599 .any(|template| { template.action == "heddle land --thread agent/recovery" })
3600 );
3601 }
3602
3603 #[test]
3604 fn status_interface_rejects_truncated_land_recovery_marker() {
3605 let temp = tempfile::tempdir().expect("temp repo");
3606 repo::Repository::init_default(temp.path()).expect("init repo");
3607 let repo = Repository::open(temp.path()).expect("open repo");
3608 fs::write(
3609 repo.heddle_dir().join(INCOMPLETE_LAND_MARKER),
3610 serde_json::json!({ "thread_id": "agent/recovery" }).to_string(),
3611 )
3612 .expect("write incomplete-land marker");
3613 let ctx = ExecutionContext::builder().repo(repo).build();
3614
3615 let error = status(
3616 &ctx,
3617 StatusOptions::new(
3618 StatusDetail::DefaultText,
3619 repo::WorktreeStatusOptions::default(),
3620 ),
3621 )
3622 .expect_err("truncated recovery marker must fail closed");
3623
3624 assert!(
3625 error
3626 .to_string()
3627 .contains("failed to parse incomplete-land marker")
3628 );
3629 }
3630
3631 #[test]
3632 fn verify_default_core_path_produces_complete_embedder_report() {
3633 let temp = tempfile::tempdir().expect("temp repo");
3634 repo::Repository::init_default(temp.path()).expect("init repo");
3635 let ctx = ExecutionContext::builder().start_path(temp.path()).build();
3636
3637 let report = crate::verify::verify(
3638 &ctx,
3639 crate::verify::VerifyOptions::new().with_start_path(temp.path()),
3640 )
3641 .expect("core verify");
3642
3643 assert_eq!(report.output_kind, "verify");
3644 assert!(!report.repository_label.is_empty());
3645 assert!(report.trust.heddle_initialized);
3646 assert!(!report.trust.status.is_empty());
3647 assert_eq!(report.trust.machine_contract, "not_checked");
3648 assert_eq!(report.trust.machine_contract_coverage.status, "not_checked");
3649 assert!(
3650 report
3651 .trust
3652 .checks
3653 .iter()
3654 .any(|check| check.name == "Machine contract" && check.status == "not_checked")
3655 );
3656 }
3657
3658 #[test]
3662 fn plain_git_status_serializes_empty_recommended_action_as_null() {
3663 let trust = RepositoryVerificationState {
3664 verified: true,
3665 status: "verified".to_string(),
3666 repository_mode: "plain-git".to_string(),
3667 heddle_initialized: false,
3668 git_branch: Some("main".to_string()),
3669 heddle_thread: None,
3670 worktree_dirty: false,
3671 worktree_state: "clean".to_string(),
3672 import_state: "not_applicable".to_string(),
3673 mapping_state: "not_applicable".to_string(),
3674 remote_drift: "clean".to_string(),
3675 active_operation: None,
3676 default_remote: None,
3677 clone_verification: "not_applicable".to_string(),
3678 machine_contract: "not_checked".to_string(),
3679 machine_contract_coverage: MachineContractInput::default().coverage,
3680 workflow_status: "clean".to_string(),
3681 workflow_summary: "no ready threads are waiting to land".to_string(),
3682 summary: "plain Git repository".to_string(),
3683 recommended_action: String::new(),
3684 recommended_action_template: None,
3685 recovery_commands: Vec::new(),
3686 recovery_action_templates: Vec::new(),
3687 checks: Vec::new(),
3688 };
3689 let output = PlainGitStatusReport {
3690 output_kind: "status",
3691 repository_capability: "plain-git".to_string(),
3692 repository_label: repository_mode_label("plain-git", "git-only"),
3693 storage_model: "git-only".to_string(),
3694 heddle_initialized: false,
3695 git_branch: Some("main".to_string()),
3696 path: "/tmp/repo".to_string(),
3697 recommended_action: trust.recommended_action.clone(),
3698 recommended_action_template: trust.recommended_action_template.clone(),
3699 recovery_commands: trust.recovery_commands.clone(),
3700 recovery_action_templates: trust.recovery_action_templates.clone(),
3701 thread_health: trust.status.clone(),
3702 changed_path_count: 0,
3703 changes: ChangesInfo::default(),
3704 git_index: None,
3705 trust,
3706 };
3707
3708 let value = serde_json::to_value(&output).unwrap();
3709 assert!(value["recommended_action"].is_null());
3710 assert!(value["verification"]["recommended_action"].is_null());
3711 }
3712
3713 #[test]
3714 fn plain_git_status_report_assembles_for_git_only_worktree() {
3715 let temp = tempfile::tempdir().expect("temp dir");
3716 let root = temp.path();
3717 SleyRepository::init(root).expect("init plain git repository");
3718 fs::write(root.join("README"), "hello\n").expect("write file");
3719
3720 let report = plain_git_status_report(root, &MachineContractInput::default())
3721 .expect("plain git status")
3722 .expect("probe present");
3723 assert_eq!(report.output_kind, "status");
3724 assert_eq!(report.repository_capability, "plain-git");
3725 assert_eq!(report.storage_model, "git-only");
3726 assert!(!report.heddle_initialized);
3727 assert!(!report.repository_label.is_empty());
3728 assert!(!report.trust.status.is_empty());
3729 assert!(report.changed_path_count > 0 || !report.changes.is_empty());
3730 }
3731
3732 #[test]
3733 fn plain_git_status_report_skips_heddle_repos() {
3734 let temp = tempfile::tempdir().expect("temp repo");
3735 repo::Repository::init_default(temp.path()).expect("init repo");
3736 let report = plain_git_status_report(temp.path(), &MachineContractInput::default())
3737 .expect("plain git status");
3738 assert!(report.is_none());
3739 }
3740}