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