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