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