Skip to main content

verbs/
status.rs

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