Skip to main content

verbs/
status.rs

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