Skip to main content

git_policy_cli/
worktree_completion.rs

1//! What:
2//!  Bringing each added path's worktree copy to the landed bytes after a commit landed,
3//!  with
4//!       descriptor-bound checks that never overwrite a concurrent edit.
5//! Why:
6//!  An added path's committed bytes differ from its worktree copy (a policy corrected it);
7//!  the
8//!      copy is replaced only while it still holds the original bytes,
9//!  through a same-directory
10//!      temporary file.
11//!  A copy holding neither is kept and reported,
12//!  since the commit already
13//!      landed (`src/policy-engine/commit-transaction-added-paths.ts`,
14//!  `-worktree-check.ts`,
15//!      `-worktree-replace.ts`).
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! await installAddedWorktreeFiles({ gitPath, cwd, repositoryRoot, records, objectDirectory });
20//! ```
21
22/// The blob batch.
23use super::blob_batch::load_blob_batch;
24/// Diagnostics.
25use super::diagnostic_log::{debug, warn};
26/// Exact path bytes as file names, and as text for messages.
27use super::path_bytes::{os_from_path_bytes, path_message_text};
28/// Unique temporary names.
29use super::random_id::random_uuid;
30/// The fail-closed recovery failure.
31use super::recovery_error::{RecoveryError, io_failure};
32/// Running real Git.
33use super::transaction_git::GitContext;
34/// One added-path record.
35use super::transaction_journal::AddedPath;
36/// `HashMap` maps object IDs to bytes.
37use std::collections::HashMap;
38/// The trait that gives files `.read_to_end` and `.write_all`.
39use std::io::{Read, Write};
40/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
41use std::path::{Path, PathBuf};
42
43/// What:
44///  The identity of an unchanged original worktree file.
45/// Why:
46///   The replacement is installed only while the file still has exactly this identity.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// type WorktreeFileIdentity = { device; inode; modified; changed; size; mode };
51/// ```
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct WorktreeFileIdentity {
54    /// Device number.
55    pub device: u64,
56    /// Inode number.
57    pub inode: u64,
58    /// Modification time in nanoseconds.
59    pub modified: i128,
60    /// Status change time in nanoseconds.
61    pub changed: i128,
62    /// Size in bytes.
63    pub size: u64,
64    /// Permission bits.
65    pub mode: u32,
66}
67
68/// What:
69///  The exact post-commit state of one worktree copy.
70/// Why:
71///   `WorktreeFileState`.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// type WorktreeFileState = { kind: 'original'; identity } | { kind: 'intended' } | { kind: 'conflict' };
76/// ```
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum WorktreeFileState {
79    /// The original bytes,
80    ///  unchanged since the check began.
81    Original(WorktreeFileIdentity),
82    /// Already the landed bytes.
83    Intended,
84    /// Missing,
85    ///  linked,
86    ///  replaced,
87    ///  edited or of the wrong kind.
88    Conflict,
89}
90
91/// What:
92///  Rewritten and kept paths of one completion.
93/// Why:
94///   `AddedWorktreeInstallResult`.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// type AddedWorktreeInstallResult = { rewritten: Uint8Array[]; conflicted: Uint8Array[] };
99/// ```
100#[derive(Clone, Debug, Default, Eq, PartialEq)]
101pub struct InstallResult {
102    /// Paths whose copy now holds the landed bytes because this call wrote them.
103    pub rewritten: Vec<Vec<u8>>,
104    /// Paths left alone because their copy changed after the check.
105    pub conflicted: Vec<Vec<u8>>,
106}
107
108/// What:
109///  Whether an error means "the worktree changed under us" rather than a failure.
110/// Why:
111///   `WORKTREE_CONFLICT_CODES`:
112///  `ENOENT`,
113///  `ENOTDIR`,
114///  `ELOOP`,
115///  `EACCES`.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// WORKTREE_CONFLICT_CODES.has(error.code)
120/// ```
121pub fn is_conflict_error(error: &std::io::Error) -> bool {
122    if matches!(
123        error.kind(),
124        std::io::ErrorKind::NotFound
125            | std::io::ErrorKind::NotADirectory
126            | std::io::ErrorKind::PermissionDenied
127    ) {
128        return true;
129    }
130    #[cfg(unix)]
131    {
132        return error.raw_os_error() == Some(libc::ELOOP);
133    }
134    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
135    #[cfg(not(unix))]
136    {
137        return false;
138    }
139}
140
141/// What:
142///  The identity fields of metadata.
143/// Why:
144///   Compared before and after the read,
145///  and again before the replacement.
146///
147/// In TS you'd write (pseudocode):
148/// ```ts
149/// { device: m.dev, inode: m.ino, modified: m.mtimeNs, changed: m.ctimeNs, size: m.size, mode: m.mode & 0o777 }
150/// ```
151fn identity_of(metadata: &std::fs::Metadata) -> WorktreeFileIdentity {
152    #[cfg(unix)]
153    {
154        use std::os::unix::fs::MetadataExt;
155        return WorktreeFileIdentity {
156            device: metadata.dev(),
157            inode: metadata.ino(),
158            modified: i128::from(metadata.mtime()) * 1_000_000_000
159                + i128::from(metadata.mtime_nsec()),
160            changed: i128::from(metadata.ctime()) * 1_000_000_000
161                + i128::from(metadata.ctime_nsec()),
162            size: metadata.size(),
163            mode: metadata.mode() & 0o777,
164        };
165    }
166    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
167    #[cfg(not(unix))]
168    {
169        let modified: i128 = match metadata.modified() {
170            Ok(time) => match time.duration_since(std::time::UNIX_EPOCH) {
171                Ok(elapsed) => i128::try_from(elapsed.as_nanos()).unwrap_or(i128::MAX),
172                Err(_) => 0,
173            },
174            Err(_) => 0,
175        };
176        return WorktreeFileIdentity {
177            device: 0,
178            inode: 0,
179            modified,
180            changed: modified,
181            size: metadata.len(),
182            mode: if metadata.permissions().readonly() {
183                0o444
184            } else {
185                0o666
186            },
187        };
188    }
189}
190
191/// What:
192///  The link count of metadata;
193///  1 where the platform does not report one.
194/// Why:
195///   A hard-linked copy is shared with another path and is never rewritten.
196///
197/// In TS you'd write (pseudocode):
198/// ```ts
199/// metadata.nlink
200/// ```
201fn link_count(metadata: &std::fs::Metadata) -> u64 {
202    #[cfg(unix)]
203    {
204        use std::os::unix::fs::MetadataExt;
205        return metadata.nlink();
206    }
207    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
208    #[cfg(not(unix))]
209    {
210        let _ = metadata;
211        return 1;
212    }
213}
214
215/// What:
216///  Whether the directory of `destination` is its own canonical path.
217/// Why:
218///   A directory replaced by a link after the check would redirect the write.
219///
220/// In TS you'd write (pseudocode):
221/// ```ts
222/// (await realpath(dirname(destination))) === dirname(destination)
223/// ```
224fn directory_is_canonical(destination: &Path) -> std::io::Result<bool> {
225    let Some(parent) = destination.parent() else {
226        return Ok(false);
227    };
228    return Ok(std::fs::canonicalize(parent)? == parent);
229}
230
231/// What:
232///  Open a file for reading without following a link or blocking on a FIFO.
233/// Why:
234///   A FIFO swapped in after the `lstat` must not hang the wrapper.
235///
236/// In TS you'd write (pseudocode):
237/// ```ts
238/// await open(destination, O_RDONLY | O_NOFOLLOW | O_NONBLOCK)
239/// ```
240fn open_nonblocking(destination: &Path) -> std::io::Result<std::fs::File> {
241    let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
242    options.read(true);
243    #[cfg(unix)]
244    {
245        use std::os::unix::fs::OpenOptionsExt;
246        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
247    }
248    return options.open(destination);
249}
250
251/// What:
252///  Classify a worktree copy as original,
253///  intended or conflict.
254/// Why:
255///   `inspectWorktreeFile`:
256///  missing,
257///  linked and concurrently replaced paths are conflicts,
258///       not recovery failures.
259///
260/// In TS you'd write (pseudocode):
261/// ```ts
262/// async function inspectWorktreeFile({ destination, gitMode, original, intended }): Promise<WorktreeFileState>;
263/// ```
264pub fn inspect_worktree_file(
265    destination: &Path,
266    git_mode: &str,
267    original: &[u8],
268    intended: &[u8],
269) -> std::io::Result<WorktreeFileState> {
270    match inspect_or_fail(destination, git_mode, original, intended) {
271        Ok(state) => return Ok(state),
272        Err(error) if is_conflict_error(&error) => return Ok(WorktreeFileState::Conflict),
273        Err(error) => return Err(error),
274    }
275}
276
277/// What:
278///  The inspection steps,
279///  with every error returned.
280/// Why:
281///   `inspect_worktree_file` turns the conflict errors into a conflict.
282///
283/// In TS you'd write (pseudocode):
284/// ```ts
285/// // body of inspectWorktreeFile inside its try
286/// ```
287fn inspect_or_fail(
288    destination: &Path,
289    git_mode: &str,
290    original: &[u8],
291    intended: &[u8],
292) -> std::io::Result<WorktreeFileState> {
293    if !directory_is_canonical(destination)? {
294        return Ok(WorktreeFileState::Conflict);
295    }
296    let entry: std::fs::Metadata = std::fs::symlink_metadata(destination)?;
297    if !entry.file_type().is_file() || link_count(&entry) != 1 {
298        return Ok(WorktreeFileState::Conflict);
299    }
300    let mut handle: std::fs::File = open_nonblocking(destination)?;
301    let opened: std::fs::Metadata = handle.metadata()?;
302    let opened_identity: WorktreeFileIdentity = identity_of(&opened);
303    let entry_identity: WorktreeFileIdentity = identity_of(&entry);
304    if !opened.file_type().is_file()
305        || link_count(&opened) != 1
306        || entry_identity.device != opened_identity.device
307        || entry_identity.inode != opened_identity.inode
308    {
309        return Ok(WorktreeFileState::Conflict);
310    }
311    if (opened_identity.mode & 0o100 != 0) != (git_mode == "100755") {
312        return Ok(WorktreeFileState::Conflict);
313    }
314    // `mut` allows reading the whole content into the buffer.
315    let mut bytes: Vec<u8> = Vec::new();
316    handle.read_to_end(&mut bytes)?;
317    let after: WorktreeFileIdentity = identity_of(&std::fs::symlink_metadata(destination)?);
318    if after.device != opened_identity.device
319        || after.inode != opened_identity.inode
320        || after.modified != opened_identity.modified
321        || after.changed != opened_identity.changed
322        || after.size != opened_identity.size
323        || !directory_is_canonical(destination)?
324    {
325        return Ok(WorktreeFileState::Conflict);
326    }
327    if bytes == intended {
328        return Ok(WorktreeFileState::Intended);
329    }
330    if bytes != original {
331        return Ok(WorktreeFileState::Conflict);
332    }
333    return Ok(WorktreeFileState::Original(opened_identity));
334}
335
336/// What:
337///  Write `bytes` with `mode` into a fresh same-directory file.
338/// Why:
339///   The replacement is prepared completely before the destination is checked again.
340///
341/// In TS you'd write (pseudocode):
342/// ```ts
343/// await writeFile(prepared, bytes, { mode, flag: 'wx' }); await chmod(prepared, mode);
344/// ```
345fn write_prepared(prepared: &Path, bytes: &[u8], mode: u32) -> std::io::Result<()> {
346    let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
347    options.write(true).create_new(true);
348    #[cfg(unix)]
349    {
350        use std::os::unix::fs::OpenOptionsExt;
351        options.mode(mode);
352    }
353    let mut file: std::fs::File = options.open(prepared)?;
354    file.write_all(bytes)?;
355    drop(file);
356    return super::private_storage::protect_path(prepared, mode);
357}
358
359/// What:
360///  Replace one unchanged worktree file through a same-directory temporary file;
361///  returns
362///       whether it was installed without an observed conflict.
363/// Why:
364///   `replaceWorktreeFile`:
365///  the destination is checked again after the replacement is ready.
366///
367/// In TS you'd write (pseudocode):
368/// ```ts
369/// async function replaceWorktreeFile({ destination, bytes, mode, gitMode, original, identity }): Promise<boolean>;
370/// ```
371pub fn replace_worktree_file(
372    destination: &Path,
373    bytes: &[u8],
374    git_mode: &str,
375    original: &[u8],
376    identity: &WorktreeFileIdentity,
377) -> Result<bool, RecoveryError> {
378    let unique: String = match random_uuid() {
379        Ok(id) => id,
380        Err(error) => return Err(RecoveryError(error.0)),
381    };
382    let directory: &Path = destination.parent().unwrap_or(destination);
383    let prepared: PathBuf = directory.join(format!(".cli-git-added-{unique}"));
384    let outcome: Result<bool, RecoveryError> = replace_through(
385        prepared.as_path(),
386        destination,
387        bytes,
388        git_mode,
389        original,
390        identity,
391    );
392    match &outcome {
393        Ok(true) => {}
394        Ok(false) => {
395            let _ = std::fs::remove_file(&prepared);
396        }
397        Err(error) => {
398            warn(
399                "replaceWorktreeFile",
400                format!(
401                    "worktree completion failed for {}: {error}",
402                    destination.display()
403                )
404                .as_str(),
405            );
406            let _ = std::fs::remove_file(&prepared);
407        }
408    }
409    return outcome;
410}
411
412/// What:
413///  The replacement steps for a prepared name.
414/// Why:
415///   `replace_worktree_file` removes the prepared file on every outcome but success.
416///
417/// In TS you'd write (pseudocode):
418/// ```ts
419/// // body of replaceWorktreeFile inside its try
420/// ```
421fn replace_through(
422    prepared: &Path,
423    destination: &Path,
424    bytes: &[u8],
425    git_mode: &str,
426    original: &[u8],
427    identity: &WorktreeFileIdentity,
428) -> Result<bool, RecoveryError> {
429    if let Err(error) = write_prepared(prepared, bytes, identity.mode) {
430        return Err(io_failure(
431            "preparing the worktree replacement",
432            prepared,
433            &error,
434        ));
435    }
436    let current: WorktreeFileState =
437        match inspect_worktree_file(destination, git_mode, original, bytes) {
438            Ok(state) => state,
439            Err(error) => {
440                return Err(io_failure(
441                    "checking the worktree file",
442                    destination,
443                    &error,
444                ));
445            }
446        };
447    let WorktreeFileState::Original(again) = current else {
448        return Ok(false);
449    };
450    if again != *identity {
451        return Ok(false);
452    }
453    if let Err(error) = std::fs::rename(prepared, destination) {
454        return Err(io_failure(
455            "installing the worktree replacement",
456            destination,
457            &error,
458        ));
459    }
460    return Ok(true);
461}
462
463/// What:
464///  Bring every added path's worktree copy to its landed bytes.
465/// Why:
466///   `installAddedWorktreeFiles`:
467///  a copy already holding the landed bytes is left alone,
468///       which makes recovery idempotent;
469///  a changed copy is kept and reported.
470///
471/// In TS you'd write (pseudocode):
472/// ```ts
473/// async function installAddedWorktreeFiles({ gitPath, cwd, repositoryRoot, records, objectDirectory }): Promise<AddedWorktreeInstallResult>;
474/// ```
475pub fn install_added_worktree_files(
476    context: &GitContext,
477    cwd: &Path,
478    repository_root: &Path,
479    records: &[AddedPath],
480    object_directory: Option<&Path>,
481) -> Result<InstallResult, RecoveryError> {
482    // `mut` allows collecting outcomes per path.
483    let mut result: InstallResult = InstallResult::default();
484    if records.is_empty() {
485        return Ok(result);
486    }
487    let mut oids: Vec<String> = Vec::with_capacity(records.len() * 2);
488    for record in records {
489        oids.push(record.original_oid.clone());
490        oids.push(record.intended_oid.clone());
491    }
492    let blobs: HashMap<String, Vec<u8>> =
493        match load_blob_batch(context, cwd, oids.as_slice(), object_directory) {
494            Ok(found) => found,
495            Err(message) => return Err(RecoveryError(message)),
496        };
497    for record in records {
498        let destination: PathBuf = repository_root.join(os_from_path_bytes(record.path.as_slice()));
499        let shown: String = path_message_text(record.path.as_slice());
500        let (Some(intended), Some(original)) = (
501            blobs.get(&record.intended_oid),
502            blobs.get(&record.original_oid),
503        ) else {
504            return Err(RecoveryError(format!(
505                "Git blob batch omitted a worktree completion object for {}.",
506                shown
507            )));
508        };
509        let state: WorktreeFileState = match inspect_worktree_file(
510            destination.as_path(),
511            record.git_mode.as_str(),
512            original.as_slice(),
513            intended.as_slice(),
514        ) {
515            Ok(found) => found,
516            Err(error) => {
517                return Err(io_failure(
518                    "checking the worktree file",
519                    destination.as_path(),
520                    &error,
521                ));
522            }
523        };
524        let identity: WorktreeFileIdentity = match state {
525            WorktreeFileState::Intended => continue,
526            WorktreeFileState::Conflict => {
527                warn(
528                    "installAddedWorktreeFiles",
529                    format!(
530                        "Worktree copy of {path} changed or disappeared while cli-git completed the commit; \
531                         the committed bytes remain in HEAD and your worktree state was kept. \
532                         Compare it with HEAD (git diff HEAD -- {path}).",
533                        path = shown
534                    )
535                    .as_str(),
536                );
537                result.conflicted.push(record.path.clone());
538                continue;
539            }
540            WorktreeFileState::Original(found) => found,
541        };
542        if !replace_worktree_file(
543            destination.as_path(),
544            intended.as_slice(),
545            record.git_mode.as_str(),
546            original.as_slice(),
547            &identity,
548        )? {
549            warn(
550                "installAddedWorktreeFiles",
551                format!(
552                    "Worktree copy of {path} changed while its replacement was prepared; \
553                     the committed bytes remain in HEAD and your edit was kept. \
554                     Compare it with HEAD (git diff HEAD -- {path}).",
555                    path = shown
556                )
557                .as_str(),
558            );
559            result.conflicted.push(record.path.clone());
560            continue;
561        }
562        debug(
563            "installAddedWorktreeFiles",
564            format!("rewrote {shown} with its landed bytes").as_str(),
565        );
566        result.rewritten.push(record.path.clone());
567    }
568    return Ok(result);
569}
570
571/// Worktree completion controls stay out of the release executable.
572#[cfg(test)]
573#[path = "worktree_completion_tests.rs"]
574mod tests;