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