Skip to main content

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