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 /// Git directory of the owning worktree, holding the capture store.
76 git_dir: PathBuf,
77 /// Transaction registry root.
78 registry_root: PathBuf,
79 /// Whether disposal already ran.
80 disposed: bool,
81}
82
83/// What:
84/// Publish a fresh transaction directory owned by this process.
85/// Why:
86/// The owner record is published with the directory, so recovery never sees an unowned directory.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// const workspace = await createCommitTransactionWorkspace({ capture });
91/// ```
92pub fn create_commit_workspace(layout: &InvocationLayout) -> Result<CommitWorkspace, WorkspaceError> {
93 ensure_transaction_root(layout.registry_root.as_path()).map_err(WorkspaceError::Registry)?;
94 let transaction_id: String = random_uuid().map_err(|error| {
95 return WorkspaceError::Random(format!("a transaction ID could not be generated: {error:?}"));
96 })?;
97 let owner: TransactionOwner =
98 current_transaction_owner(transaction_id.as_str(), layout.invoked_at.as_str()).map_err(WorkspaceError::Identity)?;
99 let directory: PathBuf = publish_transaction_directory(
100 layout.registry_root.as_path(),
101 transaction_id.as_str(),
102 encode_transaction_owner(&owner).as_bytes(),
103 )
104 .map_err(WorkspaceError::Registry)?;
105 let shadow: PathBuf = shadow_repository_path(layout.common_dir.as_path(), transaction_id.as_str());
106 debug(TAG, format!("published transaction {transaction_id}").as_str());
107 return Ok(CommitWorkspace {
108 landing: LandingWorkspace {
109 transaction_id,
110 commit_index: directory.join("commit.index"),
111 captured_index: directory.join("captured.index"),
112 shadow: shadow.clone(),
113 directory: directory.clone(),
114 preserved: false,
115 },
116 object_directory: shadow.join("objects"),
117 git_dir: layout.git_dir.clone(),
118 registry_root: layout.registry_root.clone(),
119 disposed: false,
120 });
121}
122
123impl CommitWorkspace {
124 /// What:
125 /// Mark durable completion, so disposal removes the recovery artifacts.
126 /// Why:
127 /// After the landed commit's cleanup the landing's `preserved` mark no longer protects anything.
128 ///
129 /// In TS you'd write (pseudocode):
130 /// ```ts
131 /// workspace.finishTransaction();
132 /// ```
133 pub fn finish_transaction(&mut self) {
134 self.landing.preserved = false;
135 }
136
137 /// What:
138 /// Remove the shadow repository, then the directory, then prune landed captures, unless recovery owns them.
139 /// Why:
140 /// Later calls do nothing, and a failed removal is reported while the directory stays for recovery.
141 ///
142 /// In TS you'd write (pseudocode):
143 /// ```ts
144 /// await workspace[Symbol.asyncDispose]();
145 /// ```
146 pub fn dispose(&mut self) -> Result<(), RecoveryError> {
147 if self.disposed {
148 return Ok(());
149 }
150 self.disposed = true;
151 if self.landing.preserved {
152 debug(
153 TAG,
154 format!("leaving transaction {} for recovery", self.landing.transaction_id).as_str(),
155 );
156 return Ok(());
157 }
158 dispose_landed_transaction(LandedTransactionPaths {
159 shadow: self.landing.shadow.as_path(),
160 directory: self.landing.directory.as_path(),
161 git_dir: self.git_dir.as_path(),
162 registry_root: self.registry_root.as_path(),
163 })?;
164 return Ok(());
165 }
166
167 /// What:
168 /// The Git directory of the owning worktree.
169 /// Why:
170 /// Post-landing completion prunes the capture store there.
171 ///
172 /// In TS you'd write (pseudocode):
173 /// ```ts
174 /// capture.gitDir
175 /// ```
176 pub fn git_dir(&self) -> &std::path::Path {
177 return self.git_dir.as_path();
178 }
179
180 /// What:
181 /// The transaction registry root.
182 /// Why:
183 /// Post-landing completion and the reservation read it.
184 ///
185 /// In TS you'd write (pseudocode):
186 /// ```ts
187 /// capture.registryRoot
188 /// ```
189 pub fn registry_root(&self) -> &std::path::Path {
190 return self.registry_root.as_path();
191 }
192
193 /// What:
194 /// Hand the disposal to a later step, which has already removed what the workspace owns.
195 /// Why:
196 /// The post-landing completion removes the shadow and the directory itself, so the drop must not repeat it.
197 ///
198 /// In TS you'd write (pseudocode):
199 /// ```ts
200 /// workspace.finishTransaction(); await workspace[Symbol.asyncDispose]();
201 /// ```
202 pub fn mark_disposed(&mut self) {
203 self.disposed = true;
204 }
205}
206
207impl Drop for CommitWorkspace {
208 /// What:
209 /// Dispose of the workspace when the transaction ends by any route.
210 /// Why:
211 /// A failure before the landing must not leave a shadow repository or a directory behind.
212 ///
213 /// In TS you'd write (pseudocode):
214 /// ```ts
215 /// await using workspace = ...;
216 /// ```
217 fn drop(&mut self) {
218 if let Err(error) = self.dispose() {
219 warn(TAG, format!("disposing of the workspace failed: {}", error.0).as_str());
220 }
221 }
222}
223
224/// Controls for this module stay out of the release executable.
225#[cfg(test)]
226#[path = "commit_workspace_tests.rs"]
227mod tests;