1use 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 assert!(
1310 s.contains("\n\n"),
1311 "verbose full log should keep the spacer after the preamble: {s:?}"
1312 );
1313 }
1314}