1use std::path::PathBuf;
18
19use anyhow::{Result, anyhow};
20use chrono::{DateTime, Utc};
21use objects::{
22 HeddleError, RecoveryDetails,
23 object::{ContentHash, StateId},
24};
25use oplog::{OpBatch, RedactionUndoClass};
26use repo::Repository;
27use schemars::JsonSchema;
28use serde::Serialize;
29
30use crate::{
31 ExecutionContext, HeddleReport, MachineOutputKind, OutputDiscriminator, ReportContract,
32 schema_for_report,
33};
34
35pub fn human_operation_description(description: &str) -> String {
37 if description.starts_with("git checkpoint ") {
38 return "Git commit written".to_string();
39 }
40 description.to_string()
41}
42
43pub fn human_post_undo_trust_status(status: &str) -> String {
45 if matches!(status, "dirty_worktree" | "uncaptured") {
46 "changes to save".to_string()
47 } else {
48 status.to_string()
49 }
50}
51
52#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
54pub struct UndoListReport {
55 pub output_kind: &'static str,
56 pub batches: Vec<UndoBatchSummary>,
57}
58
59impl UndoListReport {
60 pub const CONTRACT: ReportContract = ReportContract {
61 schema_name: "undo_list",
62 machine_output_kind: MachineOutputKind::Json,
63 output_discriminator: Some(OutputDiscriminator {
64 field: "output_kind",
65 value: "undo_list",
66 }),
67 schema: schema_for_report::<UndoListReport>,
68 };
69}
70
71impl HeddleReport for UndoListReport {
72 const CONTRACT: ReportContract = UndoListReport::CONTRACT;
73}
74
75#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
77pub struct UndoBatchSummary {
78 pub batch_id: u64,
79 pub timestamp: String,
80 pub undone: bool,
81 pub partial: bool,
82 pub operations: Vec<UndoOperationSummary>,
83}
84
85#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
87pub struct UndoOperationSummary {
88 pub id: u64,
89 pub description: String,
90 pub timestamp: String,
91 pub undone: bool,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum UndoHistoryAction {
97 Undo,
98 Redo,
99}
100
101impl UndoHistoryAction {
102 pub fn as_str(self) -> &'static str {
103 match self {
104 Self::Undo => "undo",
105 Self::Redo => "redo",
106 }
107 }
108
109 pub fn empty_kind(self) -> &'static str {
110 match self {
111 Self::Undo => "nothing_to_undo",
112 Self::Redo => "nothing_to_redo",
113 }
114 }
115}
116
117#[derive(Debug, Clone)]
122pub struct UndoPlan {
123 pub action: UndoHistoryAction,
124 pub steps_requested: usize,
125 pub batches: Vec<OpBatch>,
126}
127
128impl UndoPlan {
129 pub fn batch_summaries(&self) -> Vec<UndoBatchSummary> {
130 self.batches.iter().map(summarize_batch).collect()
131 }
132}
133
134pub fn list_undo_history(repo: &Repository, depth: usize) -> Result<UndoListReport> {
140 let scope = repo.op_scope();
141 let batches = repo
142 .oplog()
143 .recent_user_batches_scoped(depth, Some(&scope))?;
144 Ok(UndoListReport {
145 output_kind: "undo_list",
146 batches: batches.iter().map(summarize_batch).collect(),
147 })
148}
149
150pub fn list_undo_history_ctx(ctx: &ExecutionContext, depth: usize) -> Result<UndoListReport> {
152 let repo = ctx.require_repo()?;
153 list_undo_history(repo, depth)
154}
155
156pub fn plan_undo_batches(repo: &Repository, steps: usize) -> Result<UndoPlan> {
161 let scope = repo.op_scope();
162 let batches = repo.oplog().undo_batches_scoped(steps, Some(&scope))?;
163 require_nonempty_history(UndoHistoryAction::Undo, &batches).map_err(|e| anyhow!(e))?;
164 Ok(UndoPlan {
165 action: UndoHistoryAction::Undo,
166 steps_requested: steps,
167 batches,
168 })
169}
170
171pub fn plan_redo_batches(repo: &Repository, steps: usize) -> Result<UndoPlan> {
176 let scope = repo.op_scope();
177 let batches = repo.oplog().redo_batches_scoped(steps, Some(&scope))?;
178 require_nonempty_history(UndoHistoryAction::Redo, &batches).map_err(|e| anyhow!(e))?;
179 Ok(UndoPlan {
180 action: UndoHistoryAction::Redo,
181 steps_requested: steps,
182 batches,
183 })
184}
185
186pub fn validate_undo_list_preview_modes(list: bool, preview: bool) -> Result<(), HeddleError> {
188 if list && preview {
189 Err(undo_mode_conflict())
190 } else {
191 Ok(())
192 }
193}
194
195pub fn require_nonempty_history(
197 action: UndoHistoryAction,
198 batches: &[OpBatch],
199) -> Result<(), HeddleError> {
200 if batches.is_empty() {
201 Err(empty_history_refusal(action))
202 } else {
203 Ok(())
204 }
205}
206
207pub fn undo_mode_conflict() -> HeddleError {
209 HeddleError::recovery(
210 RecoveryDetails::safety_refusal(
211 "undo_mode_conflict",
212 "Use either --list or --preview, not both",
213 "Run `heddle undo --list` to inspect history, or `heddle undo --preview` to preview the next undo.",
214 "--list and --preview are mutually exclusive undo modes",
215 "combining them would make the command output ambiguous between history listing and undo preview",
216 "repository state was left unchanged",
217 )
218 .with_recovery_commands(vec![
219 "heddle undo --list".to_string(),
220 "heddle undo --preview".to_string(),
221 ]),
222 )
223}
224
225pub fn empty_history_refusal(action: UndoHistoryAction) -> HeddleError {
227 let noun = action.as_str();
228 HeddleError::recovery(
229 RecoveryDetails::safety_refusal(
230 action.empty_kind(),
231 format!("Nothing to {noun}"),
232 "Inspect recent undo history with `heddle undo --list`.",
233 format!("there are no {noun} entries in the current checkout lane"),
234 format!("{noun} would need to move Heddle and Git state, but no eligible batch exists"),
235 "repository state was left unchanged",
236 )
237 .with_recovery_commands(vec!["heddle undo --list".to_string()]),
238 )
239}
240
241pub fn summarize_batch(batch: &OpBatch) -> UndoBatchSummary {
243 let (undone, partial) = batch_status(batch);
244 let timestamp = batch
245 .entries
246 .iter()
247 .map(|entry| entry.timestamp)
248 .max()
249 .map(format_timestamp)
250 .unwrap_or_else(|| "unknown".to_string());
251
252 UndoBatchSummary {
253 batch_id: batch.id,
254 timestamp,
255 undone,
256 partial,
257 operations: batch
258 .entries
259 .iter()
260 .map(|entry| UndoOperationSummary {
261 id: entry.id,
262 description: entry.operation.description(),
263 timestamp: format_timestamp(entry.timestamp),
264 undone: entry.undone,
265 })
266 .collect(),
267 }
268}
269
270pub fn batch_status(batch: &OpBatch) -> (bool, bool) {
272 let any_undone = batch.entries.iter().any(|entry| entry.undone);
273 let all_undone = batch.entries.iter().all(|entry| entry.undone);
274 (all_undone, any_undone && !all_undone)
275}
276
277fn format_timestamp(timestamp: DateTime<Utc>) -> String {
278 timestamp.format("%Y-%m-%d %H:%M:%S").to_string()
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct UndoApplyStep {
289 pub batch_id: u64,
290 pub entry_id: u64,
291 pub description: String,
292}
293
294#[derive(Debug, Clone)]
300pub struct UndoApplyPlan {
301 pub action: UndoHistoryAction,
302 pub preview: bool,
303 pub steps_requested: usize,
304 pub batches: Vec<OpBatch>,
305 pub steps: Vec<UndoApplyStep>,
307 pub message: String,
309 pub human_message: String,
311}
312
313impl UndoApplyPlan {
314 pub fn batch_summaries(&self) -> Vec<UndoBatchSummary> {
315 self.batches.iter().map(summarize_batch).collect()
316 }
317
318 pub fn batch_count(&self) -> usize {
319 self.batches.len()
320 }
321}
322
323pub fn plan_undo_apply(plan: UndoPlan, preview: bool) -> UndoApplyPlan {
325 let count = plan.batches.len();
326 let steps = match plan.action {
327 UndoHistoryAction::Undo => plan_undo_apply_steps(&plan.batches),
328 UndoHistoryAction::Redo => plan_redo_apply_steps(&plan.batches),
329 };
330 UndoApplyPlan {
331 action: plan.action,
332 preview,
333 steps_requested: plan.steps_requested,
334 batches: plan.batches,
335 steps,
336 message: machine_undo_redo_message(plan.action, count, preview),
337 human_message: human_undo_redo_message(plan.action, count, preview),
338 }
339}
340
341pub fn plan_undo_apply_steps(batches: &[OpBatch]) -> Vec<UndoApplyStep> {
343 let mut steps = Vec::new();
344 for batch in batches {
345 for entry in batch.entries.iter().rev() {
346 steps.push(UndoApplyStep {
347 batch_id: batch.id,
348 entry_id: entry.id,
349 description: entry.operation.description(),
350 });
351 }
352 }
353 steps
354}
355
356pub fn plan_redo_apply_steps(batches: &[OpBatch]) -> Vec<UndoApplyStep> {
358 let mut steps = Vec::new();
359 for batch in batches {
360 for entry in &batch.entries {
361 steps.push(UndoApplyStep {
362 batch_id: batch.id,
363 entry_id: entry.id,
364 description: entry.operation.description(),
365 });
366 }
367 }
368 steps
369}
370
371pub fn machine_undo_redo_message(action: UndoHistoryAction, count: usize, preview: bool) -> String {
373 let noun = if count == 1 { "batch" } else { "batches" };
374 match (action, preview) {
375 (UndoHistoryAction::Undo, true) => format!("Would undo {count} {noun}"),
376 (UndoHistoryAction::Undo, false) => format!("Undone {count} {noun}"),
377 (UndoHistoryAction::Redo, true) => format!("Would redo {count} {noun}"),
378 (UndoHistoryAction::Redo, false) => format!("Redone {count} {noun}"),
379 }
380}
381
382pub fn human_undo_redo_message(action: UndoHistoryAction, count: usize, preview: bool) -> String {
384 let noun = if count == 1 {
385 "saved change"
386 } else {
387 "saved changes"
388 };
389 let verb = match (action, preview) {
390 (UndoHistoryAction::Undo, true) => "Would undo",
391 (UndoHistoryAction::Undo, false) => "Undid",
392 (UndoHistoryAction::Redo, true) => "Would redo",
393 (UndoHistoryAction::Redo, false) => "Redid",
394 };
395 format!("{verb} {count} {noun}")
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct PurgeOpRef {
401 pub op_id: u64,
402 pub redaction_id: ContentHash,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq)]
407pub struct RedactOpRef {
408 pub op_id: u64,
409 pub blob: ContentHash,
410 pub state: StateId,
411 pub path: String,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Default)]
416pub struct RedactionUndoBatchFacts {
417 pub purges: Vec<PurgeOpRef>,
418 pub redacts: Vec<RedactOpRef>,
419}
420
421pub fn collect_redaction_undo_facts(batches: &[OpBatch]) -> RedactionUndoBatchFacts {
423 let mut facts = RedactionUndoBatchFacts::default();
424 for batch in batches {
425 for entry in &batch.entries {
426 match entry.operation.redaction_undo_class() {
427 RedactionUndoClass::Purge { redaction_id } => {
428 facts.purges.push(PurgeOpRef {
429 op_id: entry.id,
430 redaction_id: *redaction_id,
431 });
432 }
433 RedactionUndoClass::Redact { blob, state, path } => {
434 facts.redacts.push(RedactOpRef {
435 op_id: entry.id,
436 blob: *blob,
437 state: *state,
438 path: path.to_string(),
439 });
440 }
441 RedactionUndoClass::Other => {}
442 }
443 }
444 }
445 facts
446}
447
448pub fn check_redaction_undo_safe(
456 facts: &RedactionUndoBatchFacts,
457 purged_redact_op_ids: &[u64],
459 allow_redact_undo: bool,
460) -> Result<(), UndoApplyPreflightError> {
461 if !facts.purges.is_empty() {
462 return Err(UndoApplyPreflightError::IrreversiblePurge {
463 ops: facts.purges.clone(),
464 });
465 }
466 if facts.redacts.is_empty() {
467 return Ok(());
468 }
469 let purged: Vec<RedactOpRef> = facts
470 .redacts
471 .iter()
472 .filter(|r| purged_redact_op_ids.contains(&r.op_id))
473 .cloned()
474 .collect();
475 if !purged.is_empty() {
476 return Err(UndoApplyPreflightError::RedactionBytesPurged { ops: purged });
477 }
478 if !allow_redact_undo {
479 return Err(UndoApplyPreflightError::RedactionUndoRequiresConfirmation {
480 ops: facts.redacts.clone(),
481 });
482 }
483 Ok(())
484}
485
486pub fn live_materialized_path_blocks_undo(path_exists: bool) -> bool {
488 path_exists
489}
490
491#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct ThreadWorktreeHazard {
494 pub op_id: u64,
495 pub thread_name: String,
496}
497
498pub fn collect_thread_worktree_hazards(batches: &[OpBatch]) -> Vec<ThreadWorktreeHazard> {
500 let mut out = Vec::new();
501 for batch in batches {
502 for entry in &batch.entries {
503 if let Some(name) = entry.operation.thread_worktree_undo_hazard_name() {
504 out.push(ThreadWorktreeHazard {
505 op_id: entry.id,
506 thread_name: name.to_string(),
507 });
508 }
509 }
510 }
511 out
512}
513
514#[derive(Debug, Clone, PartialEq, Eq)]
516pub struct LiveThreadWorktree {
517 pub op_id: u64,
518 pub thread_name: String,
519 pub path: PathBuf,
520}
521
522pub fn check_thread_worktree_undo_safe(
524 live: &[LiveThreadWorktree],
525) -> Result<(), UndoApplyPreflightError> {
526 if live.is_empty() {
527 Ok(())
528 } else {
529 Err(UndoApplyPreflightError::ThreadWorktreeUndoUnsafe {
530 live: live.to_vec(),
531 })
532 }
533}
534
535#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct RequiredStateRef {
538 pub op_id: u64,
539 pub state: StateId,
540}
541
542pub fn collect_undo_required_states(batches: &[OpBatch]) -> Vec<RequiredStateRef> {
544 let mut out = Vec::new();
545 for batch in batches {
546 for entry in &batch.entries {
547 for state in entry.operation.states_required_for_undo() {
548 out.push(RequiredStateRef {
549 op_id: entry.id,
550 state,
551 });
552 }
553 }
554 }
555 out
556}
557
558pub fn collect_redo_required_states(batches: &[OpBatch]) -> Vec<RequiredStateRef> {
560 let mut out = Vec::new();
561 for batch in batches {
562 for entry in &batch.entries {
563 for state in entry.operation.states_required_for_redo() {
564 out.push(RequiredStateRef {
565 op_id: entry.id,
566 state,
567 });
568 }
569 }
570 }
571 out
572}
573
574pub fn check_states_reachable(
576 action: UndoHistoryAction,
577 missing: &[RequiredStateRef],
578) -> Result<(), UndoApplyPreflightError> {
579 if missing.is_empty() {
580 return Ok(());
581 }
582 match action {
583 UndoHistoryAction::Undo => Err(UndoApplyPreflightError::UndoStateMissing {
584 missing: missing.to_vec(),
585 }),
586 UndoHistoryAction::Redo => Err(UndoApplyPreflightError::RedoStateMissing {
587 missing: missing.to_vec(),
588 }),
589 }
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct UnsupportedRedoOp {
595 pub op_id: u64,
596 pub label: &'static str,
597}
598
599pub fn collect_unsupported_redo_ops(batches: &[OpBatch]) -> Vec<UnsupportedRedoOp> {
601 let mut out = Vec::new();
602 for batch in batches {
603 for entry in &batch.entries {
604 if let Some(label) = entry.operation.redo_unsupported_label() {
605 out.push(UnsupportedRedoOp {
606 op_id: entry.id,
607 label,
608 });
609 }
610 }
611 }
612 out
613}
614
615pub fn check_redaction_redo_supported(batches: &[OpBatch]) -> Result<(), UndoApplyPreflightError> {
617 let blocking = collect_unsupported_redo_ops(batches);
618 if blocking.is_empty() {
619 Ok(())
620 } else {
621 Err(UndoApplyPreflightError::RedactionRedoUnsupported { ops: blocking })
622 }
623}
624
625#[derive(Debug, Clone, PartialEq, Eq)]
627pub enum UndoApplyPreflightError {
628 IrreversiblePurge { ops: Vec<PurgeOpRef> },
629 RedactionBytesPurged { ops: Vec<RedactOpRef> },
630 RedactionUndoRequiresConfirmation { ops: Vec<RedactOpRef> },
631 RedactionRedoUnsupported { ops: Vec<UnsupportedRedoOp> },
632 ThreadWorktreeUndoUnsafe { live: Vec<LiveThreadWorktree> },
633 UndoStateMissing { missing: Vec<RequiredStateRef> },
634 RedoStateMissing { missing: Vec<RequiredStateRef> },
635}
636
637impl UndoApplyPreflightError {
638 pub fn kind(&self) -> &'static str {
640 match self {
641 Self::IrreversiblePurge { .. } => "irreversible_purge_undo",
642 Self::RedactionBytesPurged { .. } => "redaction_bytes_purged",
643 Self::RedactionUndoRequiresConfirmation { .. } => {
644 "redaction_undo_requires_confirmation"
645 }
646 Self::RedactionRedoUnsupported { .. } => "redaction_redo_unsupported",
647 Self::ThreadWorktreeUndoUnsafe { .. } => "thread_worktree_undo_unsafe",
648 Self::UndoStateMissing { .. } => "undo_state_missing",
649 Self::RedoStateMissing { .. } => "redo_state_missing",
650 }
651 }
652}
653
654impl std::fmt::Display for UndoApplyPreflightError {
655 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656 write!(f, "{}", self.kind())
657 }
658}
659
660impl std::error::Error for UndoApplyPreflightError {}
661
662#[cfg(test)]
663mod tests {
664 use std::sync::Arc;
665
666 use objects::object::{ContentHash, StateId};
667 use oplog::OpRecord;
668 use tempfile::TempDir;
669
670 use super::*;
671
672 fn sample_entry(id: u64, undone: bool) -> oplog::OpEntry {
673 use objects::object::Principal;
674 oplog::OpEntry {
675 id,
676 timestamp: Utc::now(),
677 operation: OpRecord::TransactionCommit {
678 transaction_id: format!("t{id}"),
679 op_count: 0,
680 },
681 undone,
682 batch_id: 1,
683 batch_index: id as u32,
684 scope: None,
685 actor: Arc::new(Principal::new("tester", "tester@example.com")),
686 operation_id: None,
687 }
688 }
689
690 #[test]
691 fn list_preview_modes_are_mutually_exclusive() {
692 assert!(validate_undo_list_preview_modes(false, false).is_ok());
693 assert!(validate_undo_list_preview_modes(true, false).is_ok());
694 assert!(validate_undo_list_preview_modes(false, true).is_ok());
695 let err = validate_undo_list_preview_modes(true, true).unwrap_err();
696 match err {
697 HeddleError::Recovery(details) => {
698 assert_eq!(details.kind, "undo_mode_conflict");
699 assert!(details.error.contains("--list") || details.error.contains("preview"));
700 }
701 other => panic!("expected recovery error, got {other:?}"),
702 }
703 }
704
705 #[test]
706 fn empty_history_kinds_match_action() {
707 let undo = empty_history_refusal(UndoHistoryAction::Undo);
708 let redo = empty_history_refusal(UndoHistoryAction::Redo);
709 match undo {
710 HeddleError::Recovery(d) => {
711 assert_eq!(d.kind, "nothing_to_undo");
712 assert!(d.error.contains("Nothing to undo"));
713 }
714 other => panic!("unexpected {other:?}"),
715 }
716 match redo {
717 HeddleError::Recovery(d) => {
718 assert_eq!(d.kind, "nothing_to_redo");
719 assert!(d.error.contains("Nothing to redo"));
720 }
721 other => panic!("unexpected {other:?}"),
722 }
723 }
724
725 #[test]
726 fn batch_status_flags_partial_and_full() {
727 let mixed = OpBatch {
728 id: 7,
729 entries: vec![sample_entry(1, true), sample_entry(2, false)],
730 };
731 assert_eq!(batch_status(&mixed), (false, true));
732
733 let all = OpBatch {
734 id: 8,
735 entries: vec![sample_entry(3, true), sample_entry(4, true)],
736 };
737 assert_eq!(batch_status(&all), (true, false));
738
739 let none = OpBatch {
740 id: 9,
741 entries: vec![sample_entry(5, false)],
742 };
743 assert_eq!(batch_status(&none), (false, false));
744 }
745
746 #[test]
747 fn summarize_batch_preserves_stable_json_field_names() {
748 let batch = OpBatch {
749 id: 42,
750 entries: vec![sample_entry(10, false)],
751 };
752 let summary = summarize_batch(&batch);
753 let value = serde_json::to_value(&summary).unwrap();
754 assert_eq!(value["batch_id"], 42);
755 assert!(value["timestamp"].is_string());
756 assert_eq!(value["undone"], false);
757 assert_eq!(value["partial"], false);
758 assert!(value["operations"].is_array());
759 assert_eq!(value["operations"][0]["id"], 10);
760 assert!(value["operations"][0]["description"].is_string());
761 assert!(value["operations"][0]["timestamp"].is_string());
762 assert_eq!(value["operations"][0]["undone"], false);
763 }
764
765 #[test]
766 fn list_undo_history_empty_repo_returns_empty_batches() {
767 let temp = TempDir::new().unwrap();
768 let repo = Repository::init_default(temp.path()).unwrap();
769 let report = list_undo_history(&repo, 10).unwrap();
770 assert_eq!(report.output_kind, "undo_list");
771 assert!(report.batches.is_empty());
772 let value = serde_json::to_value(&report).unwrap();
773 assert_eq!(value["output_kind"], "undo_list");
774 assert_eq!(value["batches"], serde_json::json!([]));
775 }
776
777 #[test]
778 fn plan_undo_empty_repo_refuses_with_nothing_to_undo() {
779 let temp = TempDir::new().unwrap();
780 let repo = Repository::init_default(temp.path()).unwrap();
781 let err = plan_undo_batches(&repo, 1).unwrap_err();
782 let heddle = err
783 .downcast_ref::<HeddleError>()
784 .expect("domain refusal should be HeddleError");
785 match heddle {
786 HeddleError::Recovery(d) => assert_eq!(d.kind, "nothing_to_undo"),
787 other => panic!("unexpected {other:?}"),
788 }
789 }
790
791 #[test]
792 fn plan_redo_empty_repo_refuses_with_nothing_to_redo() {
793 let temp = TempDir::new().unwrap();
794 let repo = Repository::init_default(temp.path()).unwrap();
795 let err = plan_redo_batches(&repo, 1).unwrap_err();
796 let heddle = err
797 .downcast_ref::<HeddleError>()
798 .expect("domain refusal should be HeddleError");
799 match heddle {
800 HeddleError::Recovery(d) => assert_eq!(d.kind, "nothing_to_redo"),
801 other => panic!("unexpected {other:?}"),
802 }
803 }
804
805 #[test]
806 fn list_and_plan_see_recorded_user_batch() {
807 let temp = TempDir::new().unwrap();
808 let repo = Repository::init_default(temp.path()).unwrap();
809 std::fs::write(temp.path().join("f.txt"), "x").unwrap();
810 let _ = repo
811 .snapshot(Some("s".to_string()), None)
812 .expect("snapshot");
813
814 let list = list_undo_history(&repo, 5).unwrap();
815 assert!(
816 !list.batches.is_empty(),
817 "snapshot should produce listable history"
818 );
819
820 let plan = plan_undo_batches(&repo, 1).unwrap();
821 assert_eq!(plan.action, UndoHistoryAction::Undo);
822 assert_eq!(plan.batches.len(), 1);
823 assert_eq!(plan.batch_summaries().len(), 1);
824
825 let apply = plan_undo_apply(plan, true);
826 assert!(apply.preview);
827 assert_eq!(apply.action, UndoHistoryAction::Undo);
828 assert!(apply.message.starts_with("Would undo"));
829 assert!(apply.human_message.starts_with("Would undo"));
830 assert_eq!(apply.batch_count(), 1);
831 assert!(!apply.steps.is_empty());
832 }
833
834 fn batch_with_entries(id: u64, entry_ids: &[u64]) -> OpBatch {
835 OpBatch {
836 id,
837 entries: entry_ids
838 .iter()
839 .map(|&eid| sample_entry(eid, false))
840 .collect(),
841 }
842 }
843
844 #[test]
845 fn undo_apply_steps_reverse_entries_within_batch() {
846 let batches = vec![batch_with_entries(1, &[10, 11, 12])];
847 let steps = plan_undo_apply_steps(&batches);
848 assert_eq!(
849 steps.iter().map(|s| s.entry_id).collect::<Vec<_>>(),
850 vec![12, 11, 10]
851 );
852 let redo = plan_redo_apply_steps(&batches);
853 assert_eq!(
854 redo.iter().map(|s| s.entry_id).collect::<Vec<_>>(),
855 vec![10, 11, 12]
856 );
857 }
858
859 #[test]
860 fn redaction_undo_preflight_precedence() {
861 let blob = ContentHash::from_bytes([1u8; 32]);
862 let redaction_id = ContentHash::from_bytes([2u8; 32]);
863 let state = StateId::from_bytes([3u8; 32]);
864 let facts = RedactionUndoBatchFacts {
865 purges: vec![PurgeOpRef {
866 op_id: 1,
867 redaction_id,
868 }],
869 redacts: vec![RedactOpRef {
870 op_id: 2,
871 blob,
872 state,
873 path: "secret.txt".into(),
874 }],
875 };
876 let err = check_redaction_undo_safe(&facts, &[2], true).unwrap_err();
878 assert_eq!(err.kind(), "irreversible_purge_undo");
879
880 let facts_redact_only = RedactionUndoBatchFacts {
881 purges: vec![],
882 redacts: facts.redacts.clone(),
883 };
884 let err = check_redaction_undo_safe(&facts_redact_only, &[2], true).unwrap_err();
885 assert_eq!(err.kind(), "redaction_bytes_purged");
886
887 let err = check_redaction_undo_safe(&facts_redact_only, &[], false).unwrap_err();
888 assert_eq!(err.kind(), "redaction_undo_requires_confirmation");
889
890 assert!(check_redaction_undo_safe(&facts_redact_only, &[], true).is_ok());
891 assert!(check_redaction_undo_safe(&RedactionUndoBatchFacts::default(), &[], false).is_ok());
892 }
893
894 #[test]
895 fn thread_worktree_and_state_reachability_predicates() {
896 assert!(!live_materialized_path_blocks_undo(false));
897 assert!(live_materialized_path_blocks_undo(true));
898
899 assert!(check_thread_worktree_undo_safe(&[]).is_ok());
900 let live = vec![LiveThreadWorktree {
901 op_id: 9,
902 thread_name: "feature/x".into(),
903 path: PathBuf::from("/tmp/wt"),
904 }];
905 let err = check_thread_worktree_undo_safe(&live).unwrap_err();
906 assert_eq!(err.kind(), "thread_worktree_undo_unsafe");
907
908 assert!(check_states_reachable(UndoHistoryAction::Undo, &[]).is_ok());
909 let missing = vec![RequiredStateRef {
910 op_id: 3,
911 state: StateId::from_bytes([4u8; 32]),
912 }];
913 assert_eq!(
914 check_states_reachable(UndoHistoryAction::Undo, &missing)
915 .unwrap_err()
916 .kind(),
917 "undo_state_missing"
918 );
919 assert_eq!(
920 check_states_reachable(UndoHistoryAction::Redo, &missing)
921 .unwrap_err()
922 .kind(),
923 "redo_state_missing"
924 );
925 }
926
927 #[test]
928 fn machine_and_human_messages_match_cli_shapes() {
929 assert_eq!(
930 machine_undo_redo_message(UndoHistoryAction::Undo, 1, true),
931 "Would undo 1 batch"
932 );
933 assert_eq!(
934 machine_undo_redo_message(UndoHistoryAction::Undo, 2, false),
935 "Undone 2 batches"
936 );
937 assert_eq!(
938 human_undo_redo_message(UndoHistoryAction::Redo, 1, true),
939 "Would redo 1 saved change"
940 );
941 assert_eq!(
942 human_undo_redo_message(UndoHistoryAction::Redo, 3, false),
943 "Redid 3 saved changes"
944 );
945 }
946}