Skip to main content

cli/cli/commands/
log.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Log command.
3
4use std::{
5    collections::BTreeMap,
6    fs,
7    io::{self, BufRead},
8    path::{Path, PathBuf},
9};
10
11use anyhow::{Context, Result, anyhow};
12use heddle_core::{
13    parse_reflog_line, short_oid, status::next_action::canonical_adopt_ref_command,
14    summarize_paths, timeline_branch_reason as core_timeline_branch_reason,
15    timeline_cursor_reason as core_timeline_cursor_reason, timeline_label as core_timeline_label,
16    timeline_recovery_status as core_timeline_recovery_status,
17    timeline_tool_status as core_timeline_tool_status, yes_no,
18};
19use objects::object::{
20    Agent, ChangeId, State, TimelineBranchReason, TimelineCursorMoveReason, TimelineLabel,
21    TimelineToolCallStatus,
22};
23use repo::{
24    ChangedPathFilters, HistoryQuery, Repository, TimelineNavigationRecoveryStatus,
25    TimelineNavigationSnapshot, TimelineNavigationStep, TimelineStore, format_confidence,
26    is_synthetic_root,
27};
28use serde::Serialize;
29
30use super::{
31    action_line::{format_next_step_dim, print_next_step},
32    advice::RecoveryAdvice,
33    expand::{CollapseAnnotation, collapse_annotations_for_states},
34    history_target::resolve_state_id,
35    snapshot::ensure_current_state,
36    verification_health::{PlainGitVerificationProbe, build_plain_git_verification_probe},
37};
38use crate::{
39    cli::{Cli, should_output_json, style},
40    config::UserConfig,
41};
42
43#[derive(Clone, Debug)]
44pub struct LogCommandOptions {
45    pub state: Option<String>,
46    pub limit: usize,
47    pub all: bool,
48    pub graph: bool,
49    pub oneline: bool,
50    pub reflog: bool,
51    pub timeline: bool,
52    pub thread: String,
53    pub agent: Option<String>,
54    pub paths: Vec<String>,
55    /// Lower bound for the walk. Resolved through
56    /// `resolve_state_id` so it accepts marker names, short
57    /// state IDs, or any other spec the state resolver supports. Walk
58    /// stops as soon as we encounter this state — the bound itself is
59    /// excluded from the output (matches `git log --since`'s
60    /// half-open semantics for time bounds).
61    pub since: Option<String>,
62}
63
64#[derive(Serialize)]
65struct LogOutput {
66    output_kind: &'static str,
67    status: &'static str,
68    repository_capability: String,
69    storage_model: String,
70    states: Vec<StateEntry>,
71    /// Carried for the human-readable renderer only. Not part of the
72    /// JSON contract: import-hint information is exposed via
73    /// `heddle status --output json` instead.
74    #[serde(skip)]
75    import_guidance: Option<LogImportGuidanceOutput>,
76}
77
78#[derive(Serialize)]
79struct LogImportGuidanceOutput {
80    current_branch: String,
81    missing_branch_count: usize,
82    missing_branches: Vec<String>,
83    recommended_command: String,
84}
85
86#[derive(Serialize)]
87struct StateEntry {
88    change_id: String,
89    content_hash: String,
90    intent: Option<String>,
91    principal: String,
92    /// Raw principal name + email so we can render a styled
93    /// `name <email>` pair (bold/dim) without re-parsing the
94    /// pre-formatted `principal` string. Skipped from JSON
95    /// serialization to keep the wire format unchanged — only the
96    /// human-readable renderer reads them.
97    #[serde(skip)]
98    principal_name: String,
99    #[serde(skip)]
100    principal_email: String,
101    agent: Option<String>,
102    confidence: Option<f32>,
103    created_at: String,
104    parents: Vec<String>,
105    git_checkpoint: Option<String>,
106    collapsed: Option<CollapsedEntry>,
107}
108
109#[derive(Serialize)]
110struct CollapsedEntry {
111    expandable: bool,
112    source_count: usize,
113}
114
115#[derive(Serialize)]
116struct ReflogOutput {
117    output_kind: &'static str,
118    status: &'static str,
119    repository_capability: String,
120    storage_model: String,
121    entries: Vec<ReflogEntry>,
122}
123
124#[derive(Serialize)]
125struct TimelineLogOutput {
126    output_kind: &'static str,
127    status: &'static str,
128    repository_capability: String,
129    storage_model: String,
130    thread: String,
131    cursor: TimelineCursorOutput,
132    branches: Vec<TimelineBranchOutput>,
133    steps: Vec<TimelineStepOutput>,
134    active_branch_path: Vec<String>,
135    actions: TimelineActionsOutput,
136    recovery: Option<TimelineRecoveryOutput>,
137}
138
139#[derive(Serialize)]
140struct TimelineCursorOutput {
141    branch_id: Option<String>,
142    step_id: Option<String>,
143    state: Option<String>,
144    state_full: Option<String>,
145}
146
147#[derive(Serialize)]
148struct TimelineBranchOutput {
149    branch_id: String,
150    parent_branch_id: Option<String>,
151    forked_from_step_id: Option<String>,
152    forked_from_state: Option<String>,
153    reason: Option<String>,
154    created_at_ms: Option<i64>,
155    step_ids: Vec<String>,
156    is_active: bool,
157    is_on_active_path: bool,
158}
159
160#[derive(Serialize)]
161struct TimelineStepOutput {
162    step_id: String,
163    branch_id: String,
164    parent_step_id: Option<String>,
165    native: Option<TimelineNativeOutput>,
166    tool_name: Option<String>,
167    status: Option<String>,
168    changed: Option<bool>,
169    touched_paths: Vec<String>,
170    labels: Vec<String>,
171    before_state: Option<String>,
172    after_state: Option<String>,
173    capture_state: Option<String>,
174    cursor_state: Option<String>,
175    cursor_state_full: Option<String>,
176    payload_summary: Option<String>,
177    payload_hash: Option<String>,
178    capture_oplog_batch_id: Option<u64>,
179    started_at_ms: Option<i64>,
180    finished_at_ms: Option<i64>,
181    operation_ids: Vec<String>,
182    is_current: bool,
183    is_on_active_branch_path: bool,
184    can_seek: bool,
185    can_fork: bool,
186    can_reset: bool,
187    can_materialize: bool,
188    has_boundary_warning: bool,
189}
190
191#[derive(Serialize)]
192struct TimelineNativeOutput {
193    harness: String,
194    session_id: Option<String>,
195    message_id: Option<String>,
196    tool_call_id: String,
197}
198
199#[derive(Serialize)]
200struct TimelineActionsOutput {
201    can_undo: bool,
202    can_redo: bool,
203}
204
205#[derive(Serialize)]
206struct TimelineRecoveryOutput {
207    status: String,
208    branch_id: String,
209    from_step_id: Option<String>,
210    to_step_id: Option<String>,
211    from_state: String,
212    to_state: String,
213    reason: String,
214    moved_at_ms: i64,
215    checkout_state: Option<String>,
216}
217
218#[derive(Clone, Debug, Serialize)]
219struct ReflogEntry {
220    source: String,
221    reference: String,
222    old_oid: String,
223    new_oid: String,
224    actor: String,
225    timestamp: Option<String>,
226    message: String,
227}
228
229impl From<heddle_core::ReflogLine> for ReflogEntry {
230    fn from(line: heddle_core::ReflogLine) -> Self {
231        Self {
232            source: line.source,
233            reference: line.reference,
234            old_oid: line.old_oid,
235            new_oid: line.new_oid,
236            actor: line.actor,
237            timestamp: line.timestamp,
238            message: line.message,
239        }
240    }
241}
242
243impl From<&State> for StateEntry {
244    fn from(state: &State) -> Self {
245        Self {
246            change_id: state.change_id.short(),
247            content_hash: state.compute_hash().short(),
248            intent: state.intent.clone(),
249            principal: state.attribution.principal.to_string(),
250            principal_name: state.attribution.principal.name.clone(),
251            principal_email: state.attribution.principal.email.clone(),
252            agent: state.attribution.agent.as_ref().map(Agent::to_string),
253            confidence: state.confidence,
254            created_at: state.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
255            parents: state.parents.iter().map(ChangeId::short).collect(),
256            git_checkpoint: None,
257            collapsed: None,
258        }
259    }
260}
261
262pub async fn cmd_log(cli: &Cli, options: LogCommandOptions) -> Result<()> {
263    let cwd = std::env::current_dir()?;
264    let start = cli.repo.as_ref().unwrap_or(&cwd);
265    if !options.timeline
266        && !options.reflog
267        && options.state.is_none()
268        && options.since.is_none()
269        && options.paths.is_empty()
270        && let Some(probe) = build_plain_git_verification_probe(start)?
271    {
272        return render_plain_git_log(cli, &probe, options.oneline);
273    }
274
275    let repo = Repository::open(start)?;
276
277    if options.timeline && options.reflog {
278        return Err(anyhow!(RecoveryAdvice::invalid_usage(
279            "log_timeline_reflog_conflict",
280            "--timeline cannot be combined with --reflog",
281            "Choose either `heddle log --timeline` for agent timeline state or `heddle log --reflog` for ref movement history.",
282            "heddle log --timeline",
283        )));
284    }
285
286    if options.timeline {
287        return cmd_log_timeline(cli, &repo, &options.thread, options.oneline);
288    }
289
290    if options.reflog {
291        return cmd_log_reflog(cli, &repo, options.limit, options.oneline);
292    }
293
294    // Get starting state
295    let start_id = if let Some(ref spec) = options.state {
296        if matches!(spec.as_str(), "HEAD" | "@") && repo.current_state()?.is_none() {
297            ensure_current_state(
298                &repo,
299                &UserConfig::load_default()?,
300                Some("Bootstrap git-overlay before viewing log".to_string()),
301            )?;
302        }
303        Some(resolve_state_id(&repo, spec)?)
304    } else {
305        Some(ensure_current_state(
306            &repo,
307            &UserConfig::load_default()?,
308            Some("Bootstrap git-overlay before viewing log".to_string()),
309        )?)
310    };
311
312    // Resolve the `--since` bound (if any) and pass it down as the
313    // walker's `stop_at`. The bound is honored *before* `--agent` /
314    // `--path` filters, so a bound state that itself doesn't match the
315    // active filter still terminates the walk correctly. (Previously
316    // we applied the bound *after* filtering, which silently leaked
317    // matches older than the bound when the bound state was filtered
318    // out.) The bound is exclusive — matches git's `--since` shape.
319    let since_id = if let Some(ref spec) = options.since {
320        Some(resolve_state_id(&repo, spec)?)
321    } else {
322        None
323    };
324
325    let changed_paths = ChangedPathFilters::try_from_paths(options.paths)?;
326    let query = HistoryQuery::new(start_id)
327        .with_limit(options.limit)
328        .with_agent_filter(options.agent)
329        .with_changed_paths(changed_paths)
330        .with_stop_at(since_id);
331    let states = repo.query_history(&query)?;
332
333    // Hide the synthetic genesis root (`heddle init` seed). It carries
334    // a stable system attribution rather than the user's principal —
335    // see `repo::seed_default_thread`. Surfacing it in user-facing log
336    // output would always show the seed state above the user's first
337    // real snapshot and report a "Heddle <init@heddle>" or (in older
338    // repos) "Unknown" principal that the user never authored.
339    let visible_states = states
340        .iter()
341        .filter(|state| !is_synthetic_root(state))
342        .collect::<Vec<_>>();
343    let collapsed_annotations = collapse_annotations_for_states(
344        &repo,
345        visible_states.iter().map(|state| &state.change_id),
346    )?;
347
348    let output = LogOutput {
349        output_kind: "log",
350        status: "completed",
351        repository_capability: repo.capability_label().to_string(),
352        storage_model: repo.storage_model_label().to_string(),
353        import_guidance: repo
354            .git_import_guidance()?
355            .map(|hint| LogImportGuidanceOutput {
356                current_branch: hint.current_branch,
357                missing_branch_count: hint.missing_branch_count,
358                missing_branches: hint.missing_branches,
359                recommended_command: hint.recommended_command,
360            }),
361        states: visible_states
362            .into_iter()
363            .map(|state| {
364                let mut entry = StateEntry::from(state);
365                entry.git_checkpoint = repo
366                    .latest_git_checkpoint_for_change(&state.change_id)
367                    .ok()
368                    .flatten()
369                    .map(|record| record.git_commit);
370                entry.collapsed = collapsed_annotations
371                    .get(&state.change_id)
372                    .copied()
373                    .map(CollapsedEntry::from);
374                entry
375            })
376            .collect(),
377    };
378
379    let as_json = should_output_json(cli, Some(repo.config()));
380    if as_json {
381        println!("{}", serde_json::to_string(&output)?);
382    } else {
383        // Render to stdout via the writer-based helpers so the same
384        // formatting logic is unit-testable against a `Vec<u8>` —
385        // see `tests::print_oneline_no_ansi_when_disabled`.
386        let stdout = std::io::stdout();
387        let mut handle = stdout.lock();
388        if options.oneline {
389            let _ = write_oneline(&mut handle, &output, cli.verbose > 0);
390        } else {
391            let _ = write_full(&mut handle, &output, cli.verbose > 0);
392        }
393    }
394
395    // Discoverability tip after a successful log: nudge toward
396    // `heddle query` for filtered history. Once per session per repo.
397    crate::cli::tips::maybe_emit(
398        repo.root(),
399        Some(repo.config()),
400        crate::cli::tips::Tip::QueryFromLog,
401        as_json,
402        cli.quiet,
403    );
404
405    Ok(())
406}
407
408fn render_plain_git_log(cli: &Cli, probe: &PlainGitVerificationProbe, oneline: bool) -> Result<()> {
409    if should_output_json(cli, None) {
410        println!(
411            "{}",
412            serde_json::to_string(&serde_json::json!({
413                "output_kind": "log",
414                "status": "blocked",
415                "repository_capability": "plain-git",
416                "storage_model": "git",
417                "states": [],
418                "verification": &probe.trust,
419                "recommended_action": &probe.trust.recommended_action,
420                "recovery_commands": &probe.trust.recovery_commands,
421            }))?
422        );
423    } else if oneline {
424        println!("plain-git Heddle not initialized; next: heddle init");
425    } else {
426        println!("Git repo, Heddle not initialized");
427        if let Some(branch) = &probe.git_branch {
428            println!("Git branch: {}", style::bold(branch));
429        }
430        println!("History: unavailable until this Git repo is initialized and imported");
431        print_next_step("heddle init");
432        if let Some(branch) = &probe.git_branch {
433            println!(
434                "Then: {}",
435                style::bold(&canonical_adopt_ref_command(branch))
436            );
437        }
438    }
439    Ok(())
440}
441
442fn cmd_log_timeline(cli: &Cli, repo: &Repository, thread: &str, oneline: bool) -> Result<()> {
443    let store = TimelineStore::open(repo.heddle_dir())?;
444    let snapshot = repo.timeline_navigation_snapshot(&store, thread)?;
445    let output = TimelineLogOutput::from_snapshot(repo, snapshot);
446
447    if should_output_json(cli, Some(repo.config())) {
448        println!("{}", serde_json::to_string(&output)?);
449    } else {
450        let stdout = std::io::stdout();
451        let mut handle = stdout.lock();
452        if oneline {
453            let _ = write_timeline_oneline(&mut handle, &output);
454        } else {
455            let _ = write_timeline_full(&mut handle, &output, cli.verbose > 0);
456        }
457    }
458
459    Ok(())
460}
461
462fn cmd_log_reflog(cli: &Cli, repo: &Repository, limit: usize, oneline: bool) -> Result<()> {
463    let output = ReflogOutput {
464        output_kind: "log_reflog",
465        status: "completed",
466        repository_capability: repo.capability_label().to_string(),
467        storage_model: repo.storage_model_label().to_string(),
468        entries: collect_reflog_entries(repo.root(), limit)?,
469    };
470
471    if should_output_json(cli, Some(repo.config())) {
472        println!("{}", serde_json::to_string(&output)?);
473    } else {
474        let stdout = std::io::stdout();
475        let mut handle = stdout.lock();
476        if oneline {
477            let _ = write_reflog_oneline(&mut handle, &output);
478        } else {
479            let _ = write_reflog_full(&mut handle, &output);
480        }
481    }
482
483    Ok(())
484}
485
486impl TimelineLogOutput {
487    fn from_snapshot(repo: &Repository, snapshot: TimelineNavigationSnapshot) -> Self {
488        Self {
489            output_kind: "timeline_log",
490            status: "completed",
491            repository_capability: repo.capability_label().to_string(),
492            storage_model: repo.storage_model_label().to_string(),
493            thread: snapshot.thread,
494            cursor: TimelineCursorOutput {
495                branch_id: snapshot.cursor.branch_id.map(|id| id.to_string()),
496                step_id: snapshot.cursor.step_id.map(|id| id.to_string()),
497                state: snapshot.cursor.state.map(|state| state.short()),
498                state_full: snapshot.cursor.state.map(|state| state.to_string_full()),
499            },
500            branches: snapshot
501                .branches
502                .into_iter()
503                .map(|branch| TimelineBranchOutput {
504                    branch_id: branch.branch_id.to_string(),
505                    parent_branch_id: branch.parent_branch_id.map(|id| id.to_string()),
506                    forked_from_step_id: branch.forked_from_step_id.map(|id| id.to_string()),
507                    forked_from_state: branch.forked_from_state.map(|state| state.short()),
508                    reason: branch.reason.as_ref().map(timeline_branch_reason),
509                    created_at_ms: branch.created_at_ms,
510                    step_ids: branch.step_ids.iter().map(ToString::to_string).collect(),
511                    is_active: branch.is_active,
512                    is_on_active_path: branch.is_on_active_path,
513                })
514                .collect(),
515            steps: snapshot
516                .steps
517                .into_iter()
518                .map(TimelineStepOutput::from_step)
519                .collect(),
520            active_branch_path: snapshot
521                .active_branch_path
522                .iter()
523                .map(ToString::to_string)
524                .collect(),
525            actions: TimelineActionsOutput {
526                can_undo: snapshot.actions.can_undo,
527                can_redo: snapshot.actions.can_redo,
528            },
529            recovery: snapshot.recovery.map(|recovery| TimelineRecoveryOutput {
530                status: timeline_recovery_status(recovery.status).to_string(),
531                branch_id: recovery.branch_id.to_string(),
532                from_step_id: recovery.from_step_id.map(|id| id.to_string()),
533                to_step_id: recovery.to_step_id.map(|id| id.to_string()),
534                from_state: recovery.from_state.short(),
535                to_state: recovery.to_state.short(),
536                reason: timeline_cursor_reason(&recovery.reason).to_string(),
537                moved_at_ms: recovery.moved_at_ms,
538                checkout_state: recovery.checkout_state.map(|state| state.short()),
539            }),
540        }
541    }
542}
543
544impl TimelineStepOutput {
545    fn from_step(step: TimelineNavigationStep) -> Self {
546        Self {
547            step_id: step.step_id.to_string(),
548            branch_id: step.branch_id.to_string(),
549            parent_step_id: step.parent_step_id.map(|id| id.to_string()),
550            native: step.native.map(|native| TimelineNativeOutput {
551                harness: native.harness,
552                session_id: native.session_id,
553                message_id: native.message_id,
554                tool_call_id: native.tool_call_id,
555            }),
556            tool_name: step.tool_name,
557            status: step.status.as_ref().map(timeline_tool_status),
558            changed: step.changed,
559            touched_paths: step.touched_paths,
560            labels: step.labels.iter().map(timeline_label).collect(),
561            before_state: step.before_state.map(|state| state.short()),
562            after_state: step.after_state.map(|state| state.short()),
563            capture_state: step.capture_state.map(|state| state.short()),
564            cursor_state: step.cursor_state.map(|state| state.short()),
565            cursor_state_full: step.cursor_state.map(|state| state.to_string_full()),
566            payload_summary: step.payload_summary,
567            payload_hash: step.payload_hash.map(|hash| hash.short()),
568            capture_oplog_batch_id: step.capture_oplog_batch_id,
569            started_at_ms: step.started_at_ms,
570            finished_at_ms: step.finished_at_ms,
571            operation_ids: step
572                .operation_ids
573                .iter()
574                .map(|id| id.to_string_full())
575                .collect(),
576            is_current: step.is_current,
577            is_on_active_branch_path: step.is_on_active_branch_path,
578            can_seek: step.can_seek,
579            can_fork: step.can_fork,
580            can_reset: step.can_reset,
581            can_materialize: step.can_materialize,
582            has_boundary_warning: step.has_boundary_warning,
583        }
584    }
585}
586
587fn write_timeline_oneline<W: std::io::Write>(
588    out: &mut W,
589    output: &TimelineLogOutput,
590) -> std::io::Result<()> {
591    for step in &output.steps {
592        writeln!(out, "{}", timeline_step_line(step, false))?;
593    }
594    Ok(())
595}
596
597fn write_timeline_full<W: std::io::Write>(
598    out: &mut W,
599    output: &TimelineLogOutput,
600    verbose: bool,
601) -> std::io::Result<()> {
602    writeln!(out, "Timeline: {}", style::bold(&output.thread))?;
603    writeln!(
604        out,
605        "Cursor: {} {} {}",
606        output.cursor.branch_id.as_deref().unwrap_or("-"),
607        output.cursor.step_id.as_deref().unwrap_or("-"),
608        output.cursor.state.as_deref().unwrap_or("-")
609    )?;
610    writeln!(
611        out,
612        "Actions: undo={} redo={}",
613        yes_no(output.actions.can_undo),
614        yes_no(output.actions.can_redo)
615    )?;
616    if let Some(recovery) = &output.recovery {
617        writeln!(
618            out,
619            "Recovery: {} {} -> {}",
620            recovery.status, recovery.from_state, recovery.to_state
621        )?;
622    }
623
624    for branch in &output.branches {
625        writeln!(out)?;
626        let active = if branch.is_active {
627            " current"
628        } else if branch.is_on_active_path {
629            " path"
630        } else {
631            ""
632        };
633        let parent = branch
634            .parent_branch_id
635            .as_deref()
636            .map(|parent| format!(" <- {parent}"))
637            .unwrap_or_default();
638        writeln!(
639            out,
640            "{}{}{}",
641            style::bold(&branch.branch_id),
642            style::dim(&parent),
643            style::dim(active)
644        )?;
645
646        for step in output
647            .steps
648            .iter()
649            .filter(|step| step.branch_id == branch.branch_id)
650        {
651            writeln!(out, "  {}", timeline_step_line(step, verbose))?;
652            if verbose {
653                if let Some(summary) = &step.payload_summary {
654                    writeln!(out, "    {}", style::dim(summary))?;
655                }
656                if !step.labels.is_empty() {
657                    writeln!(out, "    labels: {}", style::dim(&step.labels.join(", ")))?;
658                }
659            }
660        }
661    }
662
663    Ok(())
664}
665
666fn timeline_step_line(step: &TimelineStepOutput, verbose: bool) -> String {
667    let marker = if step.is_current { "*" } else { " " };
668    let tool = step.tool_name.as_deref().unwrap_or("tool");
669    let native = step
670        .native
671        .as_ref()
672        .map(|native| format!("{}:{}", native.harness, native.tool_call_id))
673        .unwrap_or_else(|| "-".to_string());
674    let state = step.cursor_state.as_deref().unwrap_or("-");
675    let status = step.status.as_deref().unwrap_or("-");
676    let paths = summarize_paths(&step.touched_paths);
677
678    if verbose {
679        format!(
680            "{} {} {} {} {} {} {}",
681            marker,
682            style::change_id(&step.step_id),
683            style::dim(&step.branch_id),
684            style::bold(tool),
685            style::dim(status),
686            style::dim(&native),
687            style::dim(&format!("{state} {paths}"))
688        )
689    } else {
690        format!(
691            "{} {} {} {} {}",
692            marker,
693            style::change_id(&step.step_id),
694            style::bold(tool),
695            style::dim(&native),
696            style::dim(&format!("{state} {paths}"))
697        )
698    }
699}
700
701fn timeline_label(label: &TimelineLabel) -> String {
702    core_timeline_label(label).to_string()
703}
704
705fn timeline_tool_status(status: &TimelineToolCallStatus) -> String {
706    core_timeline_tool_status(status).to_string()
707}
708
709fn timeline_branch_reason(reason: &TimelineBranchReason) -> String {
710    core_timeline_branch_reason(reason).to_string()
711}
712
713fn timeline_cursor_reason(reason: &TimelineCursorMoveReason) -> &'static str {
714    core_timeline_cursor_reason(reason)
715}
716
717fn timeline_recovery_status(status: TimelineNavigationRecoveryStatus) -> &'static str {
718    core_timeline_recovery_status(status)
719}
720
721fn collect_reflog_entries(root: &Path, limit: usize) -> Result<Vec<ReflogEntry>> {
722    let mut entries = Vec::new();
723    for (source, logs_dir) in reflog_roots(root)? {
724        collect_reflog_dir(&source, &logs_dir, &mut entries)
725            .with_context(|| format!("reading reflog entries from {}", logs_dir.display()))?;
726    }
727
728    entries.sort_by(|a, b| {
729        b.timestamp
730            .cmp(&a.timestamp)
731            .then_with(|| a.reference.cmp(&b.reference))
732            .then_with(|| a.message.cmp(&b.message))
733    });
734    entries.truncate(limit);
735    Ok(entries)
736}
737
738fn reflog_roots(root: &Path) -> Result<Vec<(String, PathBuf)>> {
739    let mut roots = Vec::new();
740
741    if let Some(git_dir) = checkout_git_dir(root)? {
742        let logs = git_dir.join("logs");
743        if logs.is_dir() {
744            roots.push(("checkout".to_string(), logs));
745        }
746    }
747
748    let mirror_logs = root.join(".heddle").join("git").join("logs");
749    if mirror_logs.is_dir() {
750        roots.push(("mirror".to_string(), mirror_logs));
751    }
752
753    Ok(roots)
754}
755
756fn checkout_git_dir(root: &Path) -> Result<Option<PathBuf>> {
757    let dot_git = root.join(".git");
758    if dot_git.is_dir() {
759        return Ok(Some(dot_git));
760    }
761    if !dot_git.is_file() {
762        return Ok(None);
763    }
764
765    let contents = fs::read_to_string(&dot_git)
766        .with_context(|| format!("reading gitdir pointer {}", dot_git.display()))?;
767    let Some(path) = contents.trim().strip_prefix("gitdir:") else {
768        return Ok(None);
769    };
770    let path = PathBuf::from(path.trim());
771    if path.is_absolute() {
772        Ok(Some(path))
773    } else {
774        Ok(Some(root.join(path)))
775    }
776}
777
778fn collect_reflog_dir(source: &str, logs_dir: &Path, entries: &mut Vec<ReflogEntry>) -> Result<()> {
779    let mut stack = vec![logs_dir.to_path_buf()];
780    while let Some(dir) = stack.pop() {
781        for entry in fs::read_dir(&dir)? {
782            let entry = entry?;
783            let path = entry.path();
784            if path.is_dir() {
785                stack.push(path);
786                continue;
787            }
788            let Ok(reference) = path.strip_prefix(logs_dir) else {
789                continue;
790            };
791            let reference = reference
792                .to_string_lossy()
793                .replace(std::path::MAIN_SEPARATOR, "/");
794            read_reflog_file(source, &reference, &path, entries)?;
795        }
796    }
797    Ok(())
798}
799
800fn read_reflog_file(
801    source: &str,
802    reference: &str,
803    path: &Path,
804    entries: &mut Vec<ReflogEntry>,
805) -> Result<()> {
806    let file = fs::File::open(path)?;
807    for line in io::BufReader::new(file).lines() {
808        if let Some(entry) = parse_reflog_entry(source, reference, &line?) {
809            entries.push(entry);
810        }
811    }
812    Ok(())
813}
814
815fn parse_reflog_entry(source: &str, reference: &str, line: &str) -> Option<ReflogEntry> {
816    parse_reflog_line(source, reference, line).map(ReflogEntry::from)
817}
818
819fn write_reflog_oneline<W: std::io::Write>(
820    out: &mut W,
821    output: &ReflogOutput,
822) -> std::io::Result<()> {
823    for entry in &output.entries {
824        writeln!(
825            out,
826            "{} {} {} {}",
827            style::dim(&entry.source),
828            style::change_id(short_oid(&entry.new_oid)),
829            style::dim(&entry.reference),
830            style::bold(&entry.message)
831        )?;
832    }
833    Ok(())
834}
835
836fn write_reflog_full<W: std::io::Write>(out: &mut W, output: &ReflogOutput) -> std::io::Result<()> {
837    writeln!(
838        out,
839        "Repository: {}",
840        crate::cli::render::repository_mode_label(
841            &output.repository_capability,
842            &output.storage_model
843        )
844    )?;
845    writeln!(out, "Reflog: {} entrie(s)", output.entries.len())?;
846    if output.entries.is_empty() {
847        if let Some(line) = format_next_step_dim(
848            "make a checkpoint, fetch, pull, push, or run `heddle adopt`",
849            0,
850        ) {
851            writeln!(out, "{line}")?;
852        }
853        return Ok(());
854    }
855
856    let mut by_ref: BTreeMap<(&str, &str), Vec<&ReflogEntry>> = BTreeMap::new();
857    for entry in &output.entries {
858        by_ref
859            .entry((&entry.source, &entry.reference))
860            .or_default()
861            .push(entry);
862    }
863
864    for ((source, reference), entries) in by_ref {
865        writeln!(out)?;
866        writeln!(
867            out,
868            "{} {}",
869            style::bold(reference),
870            style::dim(&format!("({source})"))
871        )?;
872        for entry in entries {
873            writeln!(
874                out,
875                "  {} {} -> {} {}",
876                style::dim(entry.timestamp.as_deref().unwrap_or("unknown-time")),
877                style::dim(short_oid(&entry.old_oid)),
878                style::accent(short_oid(&entry.new_oid)),
879                style::bold(&entry.message)
880            )?;
881            if !entry.actor.is_empty() {
882                writeln!(out, "    by {}", style::dim(&entry.actor))?;
883            }
884        }
885    }
886    Ok(())
887}
888
889fn write_oneline<W: std::io::Write>(
890    out: &mut W,
891    output: &LogOutput,
892    verbose: bool,
893) -> std::io::Result<()> {
894    for entry in &output.states {
895        let intent = entry.intent.as_deref().unwrap_or("(no intent)");
896        let checkpoint = if verbose && entry.git_checkpoint.is_some() {
897            " [git]"
898        } else {
899            ""
900        };
901        let collapsed = if let Some(collapsed) = &entry.collapsed {
902            format!(" [collapsed:{}]", collapsed.source_count)
903        } else {
904            String::new()
905        };
906        if verbose {
907            // Three columns of decreasing emphasis: id (dim,
908            // structural), hash (dim, structural), intent (bold, the
909            // part you read). Default text hides the content hash
910            // because the stable change id is the user-facing anchor.
911            writeln!(
912                out,
913                "{} {} {}{}{}",
914                style::change_id(&entry.change_id),
915                style::dim(&entry.content_hash),
916                style::bold(intent),
917                checkpoint,
918                style::dim(&collapsed),
919            )?;
920        } else {
921            writeln!(
922                out,
923                "{} {}{}",
924                style::change_id(&entry.change_id),
925                style::bold(intent),
926                style::dim(&collapsed),
927            )?;
928        }
929    }
930    Ok(())
931}
932
933fn write_full<W: std::io::Write>(
934    out: &mut W,
935    output: &LogOutput,
936    verbose: bool,
937) -> std::io::Result<()> {
938    // The mode preamble is diagnostic noise on the common read path
939    // (heddle#275); `heddle status` already exposes it. Keep it under
940    // `-v` for troubleshooting.
941    let mut wrote_header = false;
942    if verbose {
943        writeln!(
944            out,
945            "Repository: {}",
946            crate::cli::render::repository_mode_label(
947                &output.repository_capability,
948                &output.storage_model
949            )
950        )?;
951        wrote_header = true;
952    }
953    if let Some(hint) = &output.import_guidance {
954        writeln!(
955            out,
956            "{}",
957            crate::cli::render::git_only_branch_summary(
958                &hint.missing_branches,
959                hint.missing_branch_count,
960            )
961        )?;
962        if let Some(line) = format_next_step_dim(&hint.recommended_command, 0) {
963            writeln!(out, "{line}")?;
964        }
965        wrote_header = true;
966    }
967    // Only emit the spacer when a header preceded it; otherwise it would be
968    // an orphaned leading blank line (heddle#275 r2).
969    if wrote_header {
970        writeln!(out)?;
971    }
972    for (i, entry) in output.states.iter().enumerate() {
973        if i > 0 {
974            writeln!(out)?;
975        }
976
977        if verbose {
978            // Header: change-id and tree hash dim, timestamp dim.
979            // Nothing carries semantic color here — these are
980            // structurally important but not the focus.
981            writeln!(
982                out,
983                "{} ({}) {}",
984                style::change_id(&entry.change_id),
985                style::dim(&entry.content_hash),
986                style::dim(&entry.created_at),
987            )?;
988        } else {
989            writeln!(
990                out,
991                "{} {}",
992                style::change_id(&entry.change_id),
993                style::dim(&entry.created_at),
994            )?;
995        }
996
997        if let Some(intent) = &entry.intent {
998            // Intent is the editorial line — bold, no color.
999            writeln!(out, "  {}", style::bold(intent))?;
1000        }
1001        if let Some(collapsed) = &entry.collapsed {
1002            writeln!(
1003                out,
1004                "  {}",
1005                style::dim(&format!(
1006                    "Collapsed: expandable with `heddle expand {}` ({} captures)",
1007                    entry.change_id, collapsed.source_count
1008                ))
1009            )?;
1010        }
1011
1012        if verbose {
1013            writeln!(
1014                out,
1015                "  Principal: {}",
1016                style::principal(&entry.principal_name, &entry.principal_email)
1017            )?;
1018        }
1019
1020        if verbose && let Some(agent) = &entry.agent {
1021            writeln!(out, "  Agent: {}", style::dim(agent))?;
1022        }
1023
1024        // Confidence only renders when set — the per-entry "Confidence: —"
1025        // sentinel was high-noise on long logs and conveyed "value absent"
1026        // information the absence-of-line itself already encodes. JSON output
1027        // still carries `confidence: null` so agents can distinguish.
1028        if entry.confidence.is_some() {
1029            let confidence_text = format_confidence(entry.confidence);
1030            writeln!(
1031                out,
1032                "  Confidence: {}",
1033                style::confidence(entry.confidence, &confidence_text)
1034            )?;
1035        }
1036        // Durability only renders when there's something to say — a Git
1037        // checkpoint binds the capture into Git history. The "Capture
1038        // durability: local only" fallback used to repeat on every entry and
1039        // told the user nothing they couldn't infer from the absence of a
1040        // Git-checkpoint line.
1041        if verbose && let Some(git_checkpoint) = &entry.git_checkpoint {
1042            writeln!(
1043                out,
1044                "  Git checkpoint: {}",
1045                style::dim(&git_checkpoint[..std::cmp::min(12, git_checkpoint.len())])
1046            )?;
1047        }
1048    }
1049    Ok(())
1050}
1051
1052impl From<CollapseAnnotation> for CollapsedEntry {
1053    fn from(annotation: CollapseAnnotation) -> Self {
1054        Self {
1055            expandable: true,
1056            source_count: annotation.source_count,
1057        }
1058    }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use serial_test::serial;
1064
1065    use super::*;
1066
1067    fn sample_entry() -> StateEntry {
1068        StateEntry {
1069            change_id: "hd-abc123".to_string(),
1070            content_hash: "deadbeef".to_string(),
1071            intent: Some("Capture audit pipeline".to_string()),
1072            principal: "Ada <ada@example.com>".to_string(),
1073            principal_name: "Ada".to_string(),
1074            principal_email: "ada@example.com".to_string(),
1075            agent: Some("anthropic/claude-opus-4".to_string()),
1076            confidence: Some(0.95),
1077            created_at: "2026-05-01 12:00:00".to_string(),
1078            parents: vec![],
1079            git_checkpoint: Some("abc123def456".to_string()),
1080            collapsed: None,
1081        }
1082    }
1083
1084    fn sample_timeline_output() -> TimelineLogOutput {
1085        TimelineLogOutput {
1086            output_kind: "timeline_log",
1087            status: "completed",
1088            repository_capability: "git-overlay".to_string(),
1089            storage_model: "git+heddle-sidecar".to_string(),
1090            thread: "main".to_string(),
1091            cursor: TimelineCursorOutput {
1092                branch_id: Some("tlb-main".to_string()),
1093                step_id: Some("tls-two".to_string()),
1094                state: Some("hd-cursor".to_string()),
1095                state_full: Some("hd-cursor-full".to_string()),
1096            },
1097            branches: vec![TimelineBranchOutput {
1098                branch_id: "tlb-main".to_string(),
1099                parent_branch_id: None,
1100                forked_from_step_id: None,
1101                forked_from_state: None,
1102                reason: Some("explicit-fork".to_string()),
1103                created_at_ms: Some(1),
1104                step_ids: vec!["tls-one".to_string(), "tls-two".to_string()],
1105                is_active: true,
1106                is_on_active_path: true,
1107            }],
1108            steps: vec![
1109                TimelineStepOutput {
1110                    step_id: "tls-one".to_string(),
1111                    branch_id: "tlb-main".to_string(),
1112                    parent_step_id: None,
1113                    native: Some(TimelineNativeOutput {
1114                        harness: "opencode".to_string(),
1115                        session_id: Some("session-1".to_string()),
1116                        message_id: Some("message-1".to_string()),
1117                        tool_call_id: "call-1".to_string(),
1118                    }),
1119                    tool_name: Some("shell".to_string()),
1120                    status: Some("succeeded".to_string()),
1121                    changed: Some(true),
1122                    touched_paths: vec!["src/one.rs".to_string()],
1123                    labels: vec!["repo-reversible".to_string()],
1124                    before_state: Some("hd-before".to_string()),
1125                    after_state: Some("hd-one".to_string()),
1126                    capture_state: Some("hd-one".to_string()),
1127                    cursor_state: Some("hd-one".to_string()),
1128                    cursor_state_full: Some("hd-one-full".to_string()),
1129                    payload_summary: Some("first call".to_string()),
1130                    payload_hash: None,
1131                    capture_oplog_batch_id: Some(1),
1132                    started_at_ms: None,
1133                    finished_at_ms: Some(2),
1134                    operation_ids: vec!["hto-one".to_string()],
1135                    is_current: false,
1136                    is_on_active_branch_path: true,
1137                    can_seek: true,
1138                    can_fork: true,
1139                    can_reset: true,
1140                    can_materialize: true,
1141                    has_boundary_warning: false,
1142                },
1143                TimelineStepOutput {
1144                    step_id: "tls-two".to_string(),
1145                    branch_id: "tlb-main".to_string(),
1146                    parent_step_id: Some("tls-one".to_string()),
1147                    native: Some(TimelineNativeOutput {
1148                        harness: "opencode".to_string(),
1149                        session_id: Some("session-1".to_string()),
1150                        message_id: Some("message-1".to_string()),
1151                        tool_call_id: "call-2".to_string(),
1152                    }),
1153                    tool_name: Some("edit".to_string()),
1154                    status: Some("succeeded".to_string()),
1155                    changed: Some(true),
1156                    touched_paths: vec!["src/two.rs".to_string()],
1157                    labels: vec!["repo-reversible".to_string()],
1158                    before_state: Some("hd-one".to_string()),
1159                    after_state: Some("hd-cursor".to_string()),
1160                    capture_state: Some("hd-cursor".to_string()),
1161                    cursor_state: Some("hd-cursor".to_string()),
1162                    cursor_state_full: Some("hd-cursor-full".to_string()),
1163                    payload_summary: Some("second call".to_string()),
1164                    payload_hash: None,
1165                    capture_oplog_batch_id: Some(2),
1166                    started_at_ms: None,
1167                    finished_at_ms: Some(3),
1168                    operation_ids: vec!["hto-two".to_string()],
1169                    is_current: true,
1170                    is_on_active_branch_path: true,
1171                    can_seek: true,
1172                    can_fork: true,
1173                    can_reset: true,
1174                    can_materialize: true,
1175                    has_boundary_warning: false,
1176                },
1177            ],
1178            active_branch_path: vec!["tlb-main".to_string()],
1179            actions: TimelineActionsOutput {
1180                can_undo: true,
1181                can_redo: false,
1182            },
1183            recovery: None,
1184        }
1185    }
1186
1187    /// Render-site smoke test: with color disabled, neither the
1188    /// oneline nor the full renderer must emit any ANSI escape.
1189    /// This is the integration check that `style::*` helpers
1190    /// short-circuit to plain text — if a future helper forgets
1191    /// the gate, this test catches the leak before it reaches a
1192    /// user pipe or test snapshot.
1193    #[test]
1194    #[serial(color_state)]
1195    fn render_sites_no_ansi_when_disabled() {
1196        style::force_for_test(false);
1197        let output = LogOutput {
1198            output_kind: "log",
1199            status: "completed",
1200            repository_capability: "git-overlay".to_string(),
1201            storage_model: "git+heddle-sidecar".to_string(),
1202            import_guidance: None,
1203            states: vec![sample_entry()],
1204        };
1205
1206        let mut buf = Vec::new();
1207        write_oneline(&mut buf, &output, false).unwrap();
1208        let s = String::from_utf8(buf).unwrap();
1209        assert!(!s.contains('\x1b'), "oneline leaked ANSI: {s:?}");
1210        assert!(s.contains("hd-abc123"));
1211        assert!(s.contains("Capture audit pipeline"));
1212
1213        let mut buf = Vec::new();
1214        write_full(&mut buf, &output, false).unwrap();
1215        let s = String::from_utf8(buf).unwrap();
1216        assert!(!s.contains('\x1b'), "full leaked ANSI: {s:?}");
1217        assert!(!s.contains("Ada <ada@example.com>"));
1218        assert!(s.contains("Confidence: 0.95"));
1219
1220        let mut buf = Vec::new();
1221        write_full(&mut buf, &output, true).unwrap();
1222        let s = String::from_utf8(buf).unwrap();
1223        assert!(s.contains("Ada <ada@example.com>"));
1224    }
1225
1226    /// With color enabled, the same renderer must emit escapes —
1227    /// otherwise the gate is stuck off and we'd silently ship a
1228    /// monochrome CLI. This pairs with the negative test above to
1229    /// pin both directions of the gate.
1230    #[test]
1231    #[serial(color_state)]
1232    fn render_sites_emit_ansi_when_enabled() {
1233        style::force_for_test(true);
1234        let output = LogOutput {
1235            output_kind: "log",
1236            status: "completed",
1237            repository_capability: "git-overlay".to_string(),
1238            storage_model: "git+heddle-sidecar".to_string(),
1239            import_guidance: None,
1240            states: vec![sample_entry()],
1241        };
1242        let mut buf = Vec::new();
1243        write_oneline(&mut buf, &output, true).unwrap();
1244        let s = String::from_utf8(buf).unwrap();
1245        assert!(s.contains('\x1b'), "expected ANSI in oneline: {s:?}");
1246    }
1247
1248    #[test]
1249    #[serial(color_state)]
1250    fn timeline_renderer_marks_current_step_without_ansi_when_disabled() {
1251        style::force_for_test(false);
1252        let output = sample_timeline_output();
1253
1254        let mut buf = Vec::new();
1255        write_timeline_oneline(&mut buf, &output).unwrap();
1256        let s = String::from_utf8(buf).unwrap();
1257        assert!(!s.contains('\x1b'), "timeline oneline leaked ANSI: {s:?}");
1258        assert!(s.contains("* tls-two"));
1259        assert!(s.contains("opencode:call-2"));
1260
1261        let mut buf = Vec::new();
1262        write_timeline_full(&mut buf, &output, true).unwrap();
1263        let s = String::from_utf8(buf).unwrap();
1264        assert!(s.contains("Timeline: main"));
1265        assert!(s.contains("Actions: undo=yes redo=no"));
1266        assert!(s.contains("labels: repo-reversible"));
1267    }
1268
1269    /// The default full text view must lead with log data, not the
1270    /// `Repository:` mode preamble — that line is noise on every read
1271    /// (heddle#275). `-v` keeps it for diagnostic context.
1272    #[test]
1273    #[serial(color_state)]
1274    fn write_full_gates_repository_preamble_on_verbose() {
1275        style::force_for_test(false);
1276        let output = LogOutput {
1277            output_kind: "log",
1278            status: "completed",
1279            repository_capability: "git-overlay".to_string(),
1280            storage_model: "git+heddle-sidecar".to_string(),
1281            import_guidance: None,
1282            states: vec![sample_entry()],
1283        };
1284
1285        let mut buf = Vec::new();
1286        write_full(&mut buf, &output, false).unwrap();
1287        let s = String::from_utf8(buf).unwrap();
1288        assert!(
1289            !s.contains("Repository:"),
1290            "default full log leaked the mode preamble: {s:?}"
1291        );
1292        // Dropping the preamble must also drop the spacer that followed it;
1293        // the default view must lead with log data, not a blank line
1294        // (heddle#275 r2).
1295        assert!(
1296            !s.starts_with('\n'),
1297            "default full log starts with an orphaned blank line: {s:?}"
1298        );
1299
1300        let mut buf = Vec::new();
1301        write_full(&mut buf, &output, true).unwrap();
1302        let s = String::from_utf8(buf).unwrap();
1303        assert!(
1304            s.contains("Repository:"),
1305            "verbose full log should retain the mode preamble: {s:?}"
1306        );
1307        // With the preamble present, the spacer separating it from the log
1308        // entries is still expected.
1309        assert!(
1310            s.contains("\n\n"),
1311            "verbose full log should keep the spacer after the preamble: {s:?}"
1312        );
1313    }
1314}