Skip to main content

weavatrix_worktree/operation/
commit.rs

1use crate::{
2    error::{TransactionPhase, WorktreeError, WorktreeErrorCode},
3    filesystem::SlotEvidence,
4    journal::FinishOutcome,
5    report::{AbortReport, WorktreeApplyReport},
6};
7
8mod evidence;
9mod retained;
10mod rollback;
11
12use evidence::{classify, path_error, present, verify_all, verify_slot};
13use rollback::{finish_without_changes, required, rollback_changed};
14
15use super::{
16    PreparedWorktreeTransaction, journal::Record, model::StagedPath, operation_journal_error,
17    stage::cleanup,
18};
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub(super) enum MutationState {
22    Unchanged,
23    Changed,
24    LinkedInstall,
25    Ambiguous,
26}
27
28impl PreparedWorktreeTransaction {
29    /// Revalidates every path and atomically commits each unique slot in path order.
30    pub fn commit(mut self) -> Result<WorktreeApplyReport, WorktreeError> {
31        if let Err(error) = verify_all(&self) {
32            finish_without_changes(&mut self)?;
33            return Err(error.in_transaction(self.transaction_id.clone()));
34        }
35        let mut changed = Vec::with_capacity(self.paths.len());
36        for index in 0..self.paths.len() {
37            if let Err((error, state)) = commit_one(&mut self, index) {
38                match state {
39                    MutationState::Changed | MutationState::LinkedInstall => changed.push(index),
40                    MutationState::Unchanged => {}
41                    MutationState::Ambiguous => {
42                        return Err(error
43                            .in_transaction(self.transaction_id.clone())
44                            .requiring_recovery());
45                    }
46                }
47                rollback_changed(&mut self, &changed)?;
48                return Err(error.in_transaction(self.transaction_id.clone()));
49            }
50            changed.push(index);
51        }
52        self.journal
53            .append(&Record::Finished {
54                outcome: FinishOutcome::Committed,
55            })
56            .map_err(|error| {
57                operation_journal_error(
58                    TransactionPhase::Commit,
59                    "all operation paths changed but final journal sync failed",
60                    error,
61                )
62                .requiring_recovery()
63            })?;
64        cleanup(&self.paths)?;
65        self.control.remove_operation_journal().map_err(|error| {
66            WorktreeError::with_source(
67                WorktreeErrorCode::RecoveryRequired,
68                TransactionPhase::Cleanup,
69                "operation commit succeeded but journal cleanup failed",
70                error,
71            )
72            .requiring_recovery()
73        })?;
74        Ok(WorktreeApplyReport::new(
75            self.transaction_id,
76            self.operation,
77            self.operations,
78            self.paths.len(),
79        ))
80    }
81
82    /// Commits like [`Self::commit`] while retaining exact undo evidence.
83    ///
84    /// Backup artifacts survive the commit and a checksummed receipt bound to
85    /// this transaction is durably written before the final journal record, so
86    /// the committed state stays reversible through `Worktree::rollback_undo`.
87    pub fn commit_retained(
88        self,
89        retention: crate::operation::UndoRetention,
90    ) -> Result<crate::operation::RetainedApplyReport, WorktreeError> {
91        retained::commit_retained(self, retention)
92    }
93
94    /// Discards a prepared operation plan without changing a worktree path.
95    pub fn abort(mut self) -> Result<AbortReport, WorktreeError> {
96        self.journal
97            .append(&Record::Finished {
98                outcome: FinishOutcome::Aborted,
99            })
100            .map_err(|error| {
101                operation_journal_error(
102                    TransactionPhase::Cleanup,
103                    "failed to record explicit operation abort",
104                    error,
105                )
106                .requiring_recovery()
107            })?;
108        let removed = cleanup(&self.paths)?;
109        self.control.remove_operation_journal().map_err(|error| {
110            WorktreeError::with_source(
111                WorktreeErrorCode::RecoveryRequired,
112                TransactionPhase::Cleanup,
113                "operation abort succeeded but journal cleanup failed",
114                error,
115            )
116            .requiring_recovery()
117        })?;
118        Ok(AbortReport::new(
119            self.transaction_id,
120            self.paths.len(),
121            removed,
122        ))
123    }
124}
125
126fn commit_one(
127    transaction: &mut PreparedWorktreeTransaction,
128    index: usize,
129) -> Result<(), (WorktreeError, MutationState)> {
130    let path = &transaction.paths[index];
131    transaction
132        .root
133        .revalidate_parent(&path.access)
134        .map_err(|error| {
135            (
136                path_error(
137                    TransactionPhase::Commit,
138                    path,
139                    index,
140                    "path parent changed before commit",
141                    error,
142                ),
143                MutationState::Unchanged,
144            )
145        })?;
146    verify_slot(path, path.before, transaction, index, "source path changed")
147        .map_err(|error| (error, MutationState::Unchanged))?;
148    transaction
149        .journal
150        .append(&Record::CommitIntent {
151            index: path.stable_index,
152        })
153        .map_err(|error| {
154            (
155                operation_journal_error(
156                    TransactionPhase::Commit,
157                    "failed to synchronize operation commit intent",
158                    error,
159                )
160                .requiring_recovery(),
161                MutationState::Unchanged,
162            )
163        })?;
164    if let Err(error) = mutate(path, transaction) {
165        let state = classify(path, transaction);
166        return Err((
167            if state == MutationState::Ambiguous {
168                error.requiring_recovery()
169            } else {
170                error
171            },
172            state,
173        ));
174    }
175    path.access.sync_parent().map_err(|error| {
176        (
177            path_error(
178                TransactionPhase::Commit,
179                path,
180                index,
181                "path changed but parent synchronization failed",
182                error,
183            )
184            .requiring_recovery(),
185            classify(path, transaction),
186        )
187    })?;
188    transaction
189        .journal
190        .append(&Record::Committed {
191            index: path.stable_index,
192        })
193        .map_err(|error| {
194            (
195                operation_journal_error(
196                    TransactionPhase::Commit,
197                    "path changed but completion record did not synchronize",
198                    error,
199                )
200                .requiring_recovery(),
201                MutationState::Changed,
202            )
203        })?;
204    Ok(())
205}
206
207fn mutate(
208    path: &StagedPath,
209    transaction: &PreparedWorktreeTransaction,
210) -> Result<(), WorktreeError> {
211    match (path.before, path.after) {
212        (SlotEvidence::Absent, SlotEvidence::Present(_)) => path
213            .access
214            .install_absent_from(required(path.stage_name.as_deref(), path)?)
215            .map_err(|error| {
216                mutation_error(path, "failed to install an absent destination", error)
217            }),
218        (SlotEvidence::Present(_), SlotEvidence::Absent) => path
219            .access
220            .remove_exact(
221                present(path.before).expect("present match arm"),
222                transaction.options.limits.max_source_bytes_per_file,
223            )
224            .map_err(|error| mutation_error(path, "failed to remove an exact source", error)),
225        (SlotEvidence::Present(_), SlotEvidence::Present(_)) => path
226            .access
227            .replace_from(required(path.stage_name.as_deref(), path)?)
228            .map_err(|error| mutation_error(path, "failed to replace an exact path", error)),
229        (SlotEvidence::Absent, SlotEvidence::Absent) => Err(WorktreeError::new(
230            WorktreeErrorCode::InvalidPlan,
231            TransactionPhase::Commit,
232            "operation contains an empty path transition",
233        )
234        .at_path(path.path.clone())),
235    }
236}
237
238fn mutation_error(path: &StagedPath, message: &str, source: std::io::Error) -> WorktreeError {
239    path_error(
240        TransactionPhase::Commit,
241        path,
242        path.stable_index as usize,
243        message,
244        source,
245    )
246}