1use prikk_error::{PrikkError, Result};
10use prikk_object::{
11 CanonicalEncode, ObjectEnvelope, ObjectId, ObjectType, PatchPurpose, RefStatePayload,
12};
13
14use crate::active::prepare_empty_active_ref_for_append;
15use crate::author_signing::{AuthorSigner, author_signature};
16use crate::layout::RepositoryLayout;
17use crate::lock::ActiveLock;
18use crate::object_store::{ObjectReadSnapshot, ObjectReader};
19use crate::patch_inverse::{PatchInverseOperationSummary, prepare_patch_inverse_plan};
20use crate::refs::RefStore;
21use crate::rollback_preview::{RollbackPreviewChange, prepare_rollback_preview};
22use crate::wal::Wal;
23use crate::{
24 ActiveRefMetadata, read_active_ref_metadata, remove_active_ref_metadata,
25 validate_local_branch_ref,
26};
27
28pub(crate) fn is_rollback_draft_envelope(envelope: &ObjectEnvelope) -> Result<bool> {
30 if envelope.object_type != ObjectType::Patch {
31 return Ok(false);
32 }
33 Ok(
34 PatchPurpose::decode_from_patch_payload(&envelope.canonical_payload)?
35 == PatchPurpose::RollbackDraft,
36 )
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RollbackDraftReport {
42 pub ref_name: String,
44 pub target_block_id: ObjectId,
46 pub inverse_patch_id: ObjectId,
48 pub author_key_id: String,
50 pub wal_sequence: u64,
52 pub block_count: usize,
54 pub patch_count: usize,
56 pub inverse_operation_count: usize,
58 pub preview_change_count: usize,
60 pub would_create_files: usize,
62 pub would_delete_files: usize,
64 pub would_replace_files: usize,
66 pub operations: Vec<PatchInverseOperationSummary>,
68 pub preview_changes: Vec<RollbackPreviewChange>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73struct RollbackTargetTip {
74 ref_state_id: ObjectId,
75 target_block_id: ObjectId,
76}
77
78pub fn append_rollback_draft(
84 layout: &RepositoryLayout,
85 ref_name: &str,
86 message: &str,
87 signer: &impl AuthorSigner,
88) -> Result<RollbackDraftReport> {
89 layout.require_current_format()?;
90 crate::refs::ensure_no_incomplete_publication(layout)?;
91 let canonical_ref = validate_local_branch_ref(ref_name)?;
92 if message.trim().is_empty() {
93 return Err(PrikkError::InvalidName(
94 "rollback draft message must not be empty".to_string(),
95 ));
96 }
97
98 let planned_tip = read_target_tip(layout, &canonical_ref)?;
99 let mut inverse = prepare_patch_inverse_plan(layout, &canonical_ref)?;
100 if inverse.inverse_operation_count == 0 {
101 return Err(PrikkError::InvalidName(
102 "rollback draft has no supported inverse operations to append".to_string(),
103 ));
104 }
105 if inverse.target_block_id != planned_tip.target_block_id {
106 return Err(PrikkError::Integrity(format!(
107 "rollback inverse target {} does not match current ref target {}",
108 inverse.target_block_id, planned_tip.target_block_id
109 )));
110 }
111 let preview = prepare_rollback_preview(layout, &canonical_ref)?;
112 if preview.target_block_id != inverse.target_block_id {
113 return Err(PrikkError::Integrity(format!(
114 "rollback preview target {} does not match inverse target {}",
115 preview.target_block_id, inverse.target_block_id
116 )));
117 }
118
119 inverse.inverse_payload.purpose = PatchPurpose::RollbackDraft;
120 let canonical_payload = inverse.inverse_payload.to_canonical_bytes()?;
121 let mut envelope = ObjectEnvelope::unsigned(ObjectType::Patch, 1, canonical_payload);
122 let signature = author_signature(signer, envelope.object_id())?;
123 envelope.add_signature(signature)?;
124 let inverse_patch_id = envelope.object_id();
125
126 let wal = Wal::for_layout(layout);
127 let active_lock = ActiveLock::acquire(layout)?;
128 crate::refs::ensure_no_incomplete_publication(layout)?;
129 let replay = wal.replay()?;
130 if replay.trailing_partial_bytes != 0 {
131 return Err(PrikkError::Integrity(format!(
132 "active WAL has {} trailing partial bytes; run doctor before rollback-draft",
133 replay.trailing_partial_bytes
134 )));
135 }
136 if replay.has_item_failure() {
139 return Err(PrikkError::Integrity(
140 "active WAL has a damaged record; run doctor before rollback-draft".to_string(),
141 ));
142 }
143 if !replay.records.is_empty() {
149 return Err(PrikkError::LockConflict(
150 "rollback-draft requires an empty active WAL".to_string(),
151 ));
152 }
153 let current_tip = read_target_tip(layout, &canonical_ref)?;
154 if current_tip != planned_tip {
155 return Err(PrikkError::LockConflict(
156 "rollback-draft target ref changed during planning; retry rollback-draft".to_string(),
157 ));
158 }
159 match read_active_ref_metadata(layout)? {
160 ActiveRefMetadata::Missing => {}
161 ActiveRefMetadata::Valid(_) | ActiveRefMetadata::Invalid(_) => {
162 remove_active_ref_metadata(layout)?;
163 }
164 }
165 prepare_empty_active_ref_for_append(layout, &canonical_ref)?;
166 crate::author_key_index::record_author_key_material(
175 layout,
176 signer.key_id(),
177 signer.public_key_bytes(),
178 &active_lock,
179 )?;
180 let wal_sequence = wal.append_patch(&envelope)?;
181
182 Ok(RollbackDraftReport {
183 ref_name: canonical_ref,
184 target_block_id: inverse.target_block_id,
185 inverse_patch_id,
186 author_key_id: signer.key_id().to_string(),
187 wal_sequence,
188 block_count: inverse.block_count,
189 patch_count: inverse.patch_count,
190 inverse_operation_count: inverse.inverse_operation_count,
191 preview_change_count: preview.change_count,
192 would_create_files: preview.would_create_files,
193 would_delete_files: preview.would_delete_files,
194 would_replace_files: preview.would_replace_files,
195 operations: inverse.operations,
196 preview_changes: preview.changes,
197 })
198}
199
200fn read_target_tip(layout: &RepositoryLayout, ref_name: &str) -> Result<RollbackTargetTip> {
201 let ref_store = RefStore::new(layout.clone());
202 let ref_state_id = ref_store
203 .read_current_ref_state_id(ref_name)?
204 .ok_or_else(|| PrikkError::InvalidName(format!("ref {ref_name} is not published")))?;
205 let object_store = ObjectReadSnapshot::open(layout)?;
208 let envelope = object_store
209 .read_typed(ref_state_id, ObjectType::RefState)?
210 .ok_or_else(|| {
211 PrikkError::Integrity(format!(
212 "published ref {ref_name} points to missing RefState"
213 ))
214 })?;
215 let payload =
216 RefStatePayload::decode_canonical(&envelope.canonical_payload, envelope.schema_version)?;
217 if payload.ref_name != ref_name {
218 return Err(PrikkError::Integrity(format!(
219 "published RefState name mismatch: expected {ref_name}, got {}",
220 payload.ref_name
221 )));
222 }
223 Ok(RollbackTargetTip {
224 ref_state_id,
225 target_block_id: payload.target_object_id,
226 })
227}
228
229#[cfg(test)]
230mod tests;