git_policy_cli/commit_workspace.rs
1//! What:
2//! The disposable, recoverable workspace of one commit transaction:
3//! a published transaction directory with its owner record, the shadow repository path, and disposal that leaves
4//! everything in place once a landing record handed it to recovery.
5//! Why:
6//! The incumbent's `createCommitTransactionWorkspace` (`src/policy-engine/commit-transaction-workspace.ts`)
7//! publishes the directory before any Git mutation, so recovery can skip a live owner and retire a dead one,
8//! and disposal removes the shadow before the directory so no shadow outlives the journal that names it.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await using workspace = await createCommitTransactionWorkspace({ capture });
13//! ```
14
15use super::capture_invocation::InvocationLayout;
16use super::diagnostic_log::{debug, warn};
17use super::landed_disposal::{LandedTransactionPaths, dispose_landed_transaction};
18use super::landing_attempt::LandingWorkspace;
19use super::process_identity::ProcessIdentityError;
20use super::random_id::random_uuid;
21use super::recovery_error::RecoveryError;
22use super::shadow_git::shadow_repository_path;
23use super::transaction_owner::{TransactionOwner, current_transaction_owner, encode_transaction_owner};
24use super::transaction_registry::{ensure_transaction_root, publish_transaction_directory};
25use std::path::PathBuf;
26
27/// Module tag for diagnostics.
28const TAG: &str = "commitWorkspace";
29
30/// What:
31/// Why the workspace could not be published.
32/// Why:
33/// The transaction fails closed before any Git mutation, naming the step.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// throw new Error(message);
38/// ```
39#[derive(Debug)]
40pub enum WorkspaceError {
41 /// The registry root or the directory could not be created or published.
42 Registry(RecoveryError),
43 /// The process identity could not be read.
44 Identity(ProcessIdentityError),
45 /// The random transaction ID could not be generated.
46 Random(String),
47}
48
49impl std::fmt::Display for WorkspaceError {
50 /// Writes the wording of the failing step.
51 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 return match self {
53 Self::Registry(error) => formatter.write_str(error.0.as_str()),
54 Self::Identity(error) => write!(formatter, "{error:?}"),
55 Self::Random(text) => formatter.write_str(text.as_str()),
56 };
57 }
58}
59
60/// What:
61/// The owned state of one transaction.
62/// Why:
63/// The landing reads the `LandingWorkspace` part; the rest names the places disposal and preparation use.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// type CommitTransactionWorkspace = { transactionId; directory; commitIndexPath; capturedIndexPath; shadowPath; objectDirectory; hooksDirectory };
68/// ```
69#[derive(Debug)]
70pub struct CommitWorkspace {
71 /// The part the landing attempt reads and marks.
72 pub landing: LandingWorkspace,
73 /// Shadow object store receiving every object the transaction writes before landing migrates them.
74 pub object_directory: PathBuf,
75 /// Hook dispatcher directory.
76 pub hooks_directory: PathBuf,
77 /// Git directory of the owning worktree, holding the capture store.
78 git_dir: PathBuf,
79 /// Transaction registry root.
80 registry_root: PathBuf,
81 /// Whether disposal already ran.
82 disposed: bool,
83}
84
85/// What:
86/// Publish a fresh transaction directory owned by this process.
87/// Why:
88/// The owner record is published with the directory, so recovery never sees an unowned directory.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// const workspace = await createCommitTransactionWorkspace({ capture });
93/// ```
94pub fn create_commit_workspace(layout: &InvocationLayout) -> Result<CommitWorkspace, WorkspaceError> {
95 ensure_transaction_root(layout.registry_root.as_path()).map_err(WorkspaceError::Registry)?;
96 let transaction_id: String = random_uuid().map_err(|error| {
97 return WorkspaceError::Random(format!("a transaction ID could not be generated: {error:?}"));
98 })?;
99 let owner: TransactionOwner =
100 current_transaction_owner(transaction_id.as_str(), layout.invoked_at.as_str()).map_err(WorkspaceError::Identity)?;
101 let directory: PathBuf = publish_transaction_directory(
102 layout.registry_root.as_path(),
103 transaction_id.as_str(),
104 encode_transaction_owner(&owner).as_bytes(),
105 )
106 .map_err(WorkspaceError::Registry)?;
107 let shadow: PathBuf = shadow_repository_path(layout.common_dir.as_path(), transaction_id.as_str());
108 debug(TAG, format!("published transaction {transaction_id}").as_str());
109 return Ok(CommitWorkspace {
110 landing: LandingWorkspace {
111 transaction_id,
112 commit_index: directory.join("commit.index"),
113 captured_index: directory.join("captured.index"),
114 shadow: shadow.clone(),
115 directory: directory.clone(),
116 preserved: false,
117 },
118 object_directory: shadow.join("objects"),
119 hooks_directory: directory.join("hooks"),
120 git_dir: layout.git_dir.clone(),
121 registry_root: layout.registry_root.clone(),
122 disposed: false,
123 });
124}
125
126impl CommitWorkspace {
127 /// What:
128 /// Mark durable completion, so disposal removes the recovery artifacts.
129 /// Why:
130 /// After the landed commit's cleanup the landing's `preserved` mark no longer protects anything.
131 ///
132 /// In TS you'd write (pseudocode):
133 /// ```ts
134 /// workspace.finishTransaction();
135 /// ```
136 pub fn finish_transaction(&mut self) {
137 self.landing.preserved = false;
138 }
139
140 /// What:
141 /// Remove the shadow repository, then the directory, then prune landed captures, unless recovery owns them.
142 /// Why:
143 /// Later calls do nothing, and a failed removal is reported while the directory stays for recovery.
144 ///
145 /// In TS you'd write (pseudocode):
146 /// ```ts
147 /// await workspace[Symbol.asyncDispose]();
148 /// ```
149 pub fn dispose(&mut self) -> Result<(), RecoveryError> {
150 if self.disposed {
151 return Ok(());
152 }
153 self.disposed = true;
154 if self.landing.preserved {
155 debug(
156 TAG,
157 format!("leaving transaction {} for recovery", self.landing.transaction_id).as_str(),
158 );
159 return Ok(());
160 }
161 dispose_landed_transaction(LandedTransactionPaths {
162 shadow: self.landing.shadow.as_path(),
163 directory: self.landing.directory.as_path(),
164 git_dir: self.git_dir.as_path(),
165 registry_root: self.registry_root.as_path(),
166 })?;
167 return Ok(());
168 }
169
170 /// What:
171 /// The Git directory of the owning worktree.
172 /// Why:
173 /// Post-landing completion prunes the capture store there.
174 ///
175 /// In TS you'd write (pseudocode):
176 /// ```ts
177 /// capture.gitDir
178 /// ```
179 pub fn git_dir(&self) -> &std::path::Path {
180 return self.git_dir.as_path();
181 }
182
183 /// What:
184 /// The transaction registry root.
185 /// Why:
186 /// Post-landing completion and the reservation read it.
187 ///
188 /// In TS you'd write (pseudocode):
189 /// ```ts
190 /// capture.registryRoot
191 /// ```
192 pub fn registry_root(&self) -> &std::path::Path {
193 return self.registry_root.as_path();
194 }
195
196 /// What:
197 /// Hand the disposal to a later step, which has already removed what the workspace owns.
198 /// Why:
199 /// The post-landing completion removes the shadow and the directory itself, so the drop must not repeat it.
200 ///
201 /// In TS you'd write (pseudocode):
202 /// ```ts
203 /// workspace.finishTransaction(); await workspace[Symbol.asyncDispose]();
204 /// ```
205 pub fn mark_disposed(&mut self) {
206 self.disposed = true;
207 }
208}
209
210impl Drop for CommitWorkspace {
211 /// What:
212 /// Dispose of the workspace when the transaction ends by any route.
213 /// Why:
214 /// A failure before the landing must not leave a shadow repository or a directory behind.
215 ///
216 /// In TS you'd write (pseudocode):
217 /// ```ts
218 /// await using workspace = ...;
219 /// ```
220 fn drop(&mut self) {
221 if let Err(error) = self.dispose() {
222 warn(TAG, format!("disposing of the workspace failed: {}", error.0).as_str());
223 }
224 }
225}
226
227/// Controls for this module stay out of the release executable.
228#[cfg(test)]
229#[path = "commit_workspace_tests.rs"]
230mod tests;