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