git_policy_cli/candidate_private_index.rs
1//! What:
2//! A private copy of the real index,
3//! in its own directory beside it,
4//! removed when
5//! the copy is dropped.
6//! Why:
7//! Predicting what `git add` would stage means running it against an index nobody
8//! else reads,
9//! so the real index never changes before the policies have answered.
10//! The copy keeps the real index's access and modification times:
11//! Git re-reads an
12//! entry whose stat data is not older than the index file,
13//! and a copy with a fresh
14//! time would make it trust stat data from the same second as a later edit of the
15//! same size,
16//! so the prediction could miss that edit (the installed wrapper's
17//! `index-file-timestamps.ts`,
18//! issue #544).
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! // await using index = await createPrivateIndex(realIndexPath); // removed on dispose
23//! ```
24
25/// Import the layer's failure type and its closed list of causes.
26use super::candidate_error::{CandidateError, CandidateFailure};
27/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
28use std::path::{Path, PathBuf};
29/// What:
30/// `AtomicU64` is a counter that several threads may increase without a lock;
31/// `Ordering::Relaxed` asks only that each increase is counted once.
32/// Why:
33/// Two private indexes made by one process in the same nanosecond still get
34/// different directory names.
35///
36/// In TS you'd write (pseudocode):
37/// ```ts
38/// let created = 0n;
39/// ```
40use std::sync::atomic::{AtomicU64, Ordering};
41
42/// The name prefix of every private index directory;
43/// the installed wrapper uses the same one.
44pub const PRIVATE_INDEX_PREFIX: &str = "cli-git-add-policy-";
45
46/// How many unique names this process has made,
47/// for private index directories and sibling files.
48static CREATED: AtomicU64 = AtomicU64::new(0);
49
50/// What:
51/// A name part no other file of this process or another one chose:
52/// the process ID,
53/// the time in nanoseconds and this process's count,
54/// joined by `-`.
55/// Why:
56/// Private index directories and the direct fix's sibling files are created beside
57/// files other processes use,
58/// so their names must not collide.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// const uniqueSuffix = () => `${process.pid}-${nowNanoseconds()}-${created++}`;
63/// ```
64pub fn unique_suffix() -> String {
65 // What: `.fetch_add(1, ..)` returns the old count and stores one more;
66 // `.as_nanos()` is the time since the epoch in nanoseconds.
67 // Why: Process ID, time and count together name a file no one else chose.
68 //
69 // In TS you'd write (pseudocode):
70 // ```ts
71 // const count = created++;
72 // ```
73 let count: u64 = CREATED.fetch_add(1, Ordering::Relaxed);
74 let nanoseconds: u128 = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
75 {
76 Ok(elapsed) => elapsed.as_nanos(),
77 // A clock before 1970 still leaves the process ID and the count to tell names apart.
78 Err(_) => 0,
79 };
80 return format!("{}-{nanoseconds}-{count}", std::process::id());
81}
82
83/// What:
84/// A private index file in a directory this value owns.
85/// Fields are private,
86/// so the
87/// directory can be removed only by dropping the value.
88/// Why:
89/// Every path out of a prediction,
90/// failures included,
91/// removes the directory.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// class PrivateIndex { readonly #directory: string; readonly #index: string; [Symbol.dispose]() {} }
96/// ```
97#[derive(Debug)]
98pub struct PrivateIndex {
99 /// The directory that holds the copy and nothing else of the repository.
100 directory: PathBuf,
101 /// The private index file Git is pointed at.
102 index: PathBuf,
103}
104
105/// What:
106/// Build the failure of a private index that could not be prepared.
107/// `&std::io::Error` borrows the operating system's reason.
108/// Why:
109/// The message names the step and the reason;
110/// the directory is under the Git
111/// directory,
112/// never a candidate pathname.
113///
114/// In TS you'd write (pseudocode):
115/// ```ts
116/// function privateIndexFailure(step: string, error: Error): CandidateError;
117/// ```
118fn private_index_failure(step: &str, error: &std::io::Error) -> CandidateError {
119 return CandidateError::new(
120 CandidateFailure::PrivateIndexUnavailable,
121 format!("cli-git could not prepare a private copy of the index: {step}: {error}.").as_str(),
122 );
123}
124
125/// What:
126/// Create a directory only this user can read,
127/// failing when it already exists.
128/// Why:
129/// The copy holds the repository's staged entries;
130/// a fresh directory also proves
131/// no other process chose the same name.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// await mkdir(directory, { mode: 0o700 }); // throws EEXIST
136/// ```
137fn create_private_directory(directory: &Path) -> std::io::Result<()> {
138 #[cfg(unix)]
139 {
140 /// `DirBuilderExt` adds `mode`,
141 /// the permissions a new directory is created with.
142 use std::os::unix::fs::DirBuilderExt;
143 // `.mode(0o700)` configures the temporary builder; `.create` makes the directory.
144 return std::fs::DirBuilder::new().mode(0o700).create(directory);
145 }
146 #[cfg(not(unix))]
147 {
148 // Other systems have no mode bits to set; the directory is created as it is.
149 return std::fs::DirBuilder::new().create(directory);
150 }
151}
152
153/// What:
154/// Copy the real index into `destination` and give the copy the real index's times.
155/// `Ok(false)` means the real index does not exist,
156/// which Git reads as an empty index.
157/// Why:
158/// A missing index needs no copy:
159/// Git treats a missing index file as empty,
160/// so the
161/// private index path can simply name a file that does not exist yet.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// async function copyIndex(source: string, destination: string): Promise<boolean>;
166/// ```
167fn copy_index(source: &Path, destination: &Path) -> std::io::Result<bool> {
168 // `match` on the metadata read: a missing file is an answer, every other error is a failure.
169 let metadata: std::fs::Metadata = match std::fs::metadata(source) {
170 Ok(found) => found,
171 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
172 Err(error) => return Err(error),
173 };
174 // A trailing `?` returns the copy failure to our caller.
175 std::fs::copy(source, destination)?;
176 // What: `FileTimes::new().set_accessed(..).set_modified(..)` collects both times;
177 // `.set_times(..)` writes them to the open copy.
178 // Why: Git's racy-entry protection compares entry times with the index file's time.
179 //
180 // In TS you'd write (pseudocode):
181 // ```ts
182 // await utimes(destination, stats.atime, stats.mtime);
183 // ```
184 let times: std::fs::FileTimes = std::fs::FileTimes::new()
185 .set_accessed(metadata.accessed()?)
186 .set_modified(metadata.modified()?);
187 std::fs::File::options()
188 .write(true)
189 .open(destination)?
190 .set_times(times)?;
191 return Ok(true);
192}
193
194/// What:
195/// `impl PrivateIndex { ... }` attaches the constructor and the accessor.
196/// Why:
197/// The directory is created and filled in one step,
198/// so no caller sees a half-made copy.
199///
200/// In TS you'd write (pseudocode):
201/// ```ts
202/// class PrivateIndex { static create(realIndex: string): PrivateIndex; get path(): string }
203/// ```
204impl PrivateIndex {
205 /// What:
206 /// Create the directory beside `real_index` and copy the index into it.
207 /// Why:
208 /// Beside the real index keeps the copy on the same filesystem and inside the
209 /// Git directory,
210 /// where the installed wrapper also put it.
211 ///
212 /// In TS you'd write (pseudocode):
213 /// ```ts
214 /// static async create(realIndex: string): Promise<PrivateIndex>;
215 /// ```
216 pub fn create(real_index: &Path) -> Result<PrivateIndex, CandidateError> {
217 let Some(parent) = real_index.parent() else {
218 return Err(CandidateError::new(
219 CandidateFailure::PrivateIndexUnavailable,
220 "cli-git could not prepare a private copy of the index: Git named an index path without a directory.",
221 ));
222 };
223 let directory: PathBuf = parent.join(format!("{PRIVATE_INDEX_PREFIX}{}", unique_suffix()));
224 if let Err(error) = create_private_directory(directory.as_path()) {
225 return Err(private_index_failure("creating its directory", &error));
226 }
227 // From here on the value owns the directory, so a failure below removes it on drop.
228 let private: PrivateIndex = PrivateIndex {
229 index: directory.join("index"),
230 directory,
231 };
232 if let Err(error) = copy_index(real_index, private.index.as_path()) {
233 return Err(private_index_failure("copying the index", &error));
234 }
235 return Ok(private);
236 }
237
238 /// What:
239 /// The private index file Git is pointed at.
240 /// Why:
241 /// The caller sets `GIT_INDEX_FILE` to it for every command of the prediction.
242 ///
243 /// In TS you'd write (pseudocode):
244 /// ```ts
245 /// get path(): string
246 /// ```
247 pub fn path(&self) -> &Path {
248 return self.index.as_path();
249 }
250}
251
252/// What:
253/// `impl Drop for PrivateIndex` runs `drop` when the value goes out of scope,
254/// like
255/// a `[Symbol.dispose]` that the language calls on every exit path.
256/// Why:
257/// The directory and everything Git wrote into it are removed whether the
258/// prediction succeeded or failed.
259///
260/// In TS you'd write (pseudocode):
261/// ```ts
262/// [Symbol.dispose]() { rmSync(this.#directory, { recursive: true, force: true }); }
263/// ```
264impl Drop for PrivateIndex {
265 /// What:
266 /// Remove the directory and everything in it.
267 /// `&mut self` lends the value for
268 /// its last use.
269 /// Why:
270 /// Called by the language on every exit path,
271 /// failures included.
272 ///
273 /// In TS you'd write (pseudocode):
274 /// ```ts
275 /// [Symbol.dispose](): void
276 /// ```
277 fn drop(&mut self) {
278 // What: `let _ = ...` discards the removal's `Result` on purpose.
279 // Why: A drop cannot report a failure; a leftover directory under the Git
280 // directory is inert and carries the prefix that names its owner.
281 //
282 // In TS you'd write (pseudocode):
283 // ```ts
284 // try { rmSync(directory, { recursive: true }); } catch { /* inert leftover */ }
285 // ```
286 let _ = std::fs::remove_dir_all(self.directory.as_path());
287 }
288}
289
290/// Copy,
291/// timestamp and removal controls stay out of the release executable.
292#[cfg(test)]
293#[path = "candidate_private_index_tests.rs"]
294mod tests;