Skip to main content

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;