Skip to main content

git_policy_cli/
policy_added_path.rs

1//! What: Whether a direct fix may change a tracked file it did not select, and the
2//!       `patch-conflict` message when it may not.
3//! Why: `SPEC.md` ("Added paths") admits such a file only when `HEAD`, the real index, the
4//!      private index and the worktree all hold the blob the correction was computed
5//!      against, so the fix discards nobody's change. The reasons and the remedy are the
6//!      installed wrapper's words (`commit-transaction-added-paths.ts`, `assertAddablePath`
7//!      and its direct-fix message), checked in its order.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! // await assertAddablePath({ ..., path, oid, lifecycle: 'direct-fix' }); // throws AddedPathPreconditionError
12//! ```
13
14/// The modes and object names the precondition compares.
15use super::candidate_object::{CandidateMode, ObjectId};
16/// `Path` is a borrowed filesystem path.
17use std::path::Path;
18
19/// The reason `HEAD` does not hold the file the correction was computed against.
20pub const ADDED_PATH_HEAD_REASON: &str =
21    "HEAD does not hold it as the ordinary file the fix was computed against";
22
23/// The reason the index holds a different entry than `HEAD`.
24pub const ADDED_PATH_STAGED_REASON: &str = "it has staged changes";
25
26/// The reason the worktree has no copy of the file.
27pub const ADDED_PATH_MISSING_REASON: &str = "its worktree copy is missing";
28
29/// The reason the worktree copy differs from `HEAD`.
30pub const ADDED_PATH_UNSTAGED_REASON: &str = "its worktree copy has unstaged changes";
31
32/// What: An index or tree entry: its mode and object.
33/// Why:  Entries are compared field by field, as the installed wrapper compares mode text
34///       and object names.
35///
36/// In TS you'd write (pseudocode):
37/// ```ts
38/// type Entry = { mode: CandidateMode; object: ObjectId };
39/// ```
40pub type Entry = (CandidateMode, ObjectId);
41
42/// What: The direct-fix `patch-conflict` message for a file and a reason.
43/// Why:  It names both ways out: select the file, or restore it to `HEAD`.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// const directFixMessage = ({ path, reason }) => `A policy fix needs to change ${path}, ...`;
48/// ```
49pub fn added_path_message(path: &str, reason: &str) -> String {
50    return format!(
51        "A policy fix needs to change {path}, which this fix did not select, but {reason}. \
52         Include {path} in the fix pathspecs so the fix applies to its worktree copy, \
53         or restore it to match HEAD (git restore --staged --worktree -- {path}), \
54         then run the fix again."
55    );
56}
57
58/// What: Whether a mode is an ordinary file.
59/// Why:  Only regular and executable files take a full-content correction.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// const isOrdinary = (mode) => mode === 'regular' || mode === 'executable';
64/// ```
65fn is_ordinary(mode: CandidateMode) -> bool {
66    return mode == CandidateMode::Regular || mode == CandidateMode::Executable;
67}
68
69/// What: `HEAD`'s mode for the file, or why `HEAD` and the index rule it out. `index` is
70///       the private index's entry, whose object the correction was computed against; for
71///       a file the fix did not select it equals the real index's entry.
72/// Why:  `HEAD` must hold the same object as an ordinary file; the index must then hold it
73///       with `HEAD`'s mode. The mode is what the worktree copy is checked against next.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// function headMode(head: Entry | undefined, index: Entry): CandidateMode; // throws the reason
78/// ```
79pub fn head_mode(head: Option<&Entry>, index: &Entry) -> Result<CandidateMode, &'static str> {
80    let Some((mode, object)) = head else {
81        return Err(ADDED_PATH_HEAD_REASON);
82    };
83    if *object != index.1 || !is_ordinary(*mode) {
84        return Err(ADDED_PATH_HEAD_REASON);
85    }
86    if index.0 != *mode {
87        return Err(ADDED_PATH_STAGED_REASON);
88    }
89    return Ok(*mode);
90}
91
92/// What: Whether the file's executable bit matches `mode`. On systems without one, it does.
93/// Why:  A worktree copy whose bit differs from `HEAD` has an unstaged mode change.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// const executableMatches = ((stat.mode & 0o100) !== 0) === (mode === 'executable');
98/// ```
99fn executable_matches(metadata: &std::fs::Metadata, mode: CandidateMode) -> bool {
100    #[cfg(unix)]
101    {
102        /// Unix permission bits.
103        use std::os::unix::fs::PermissionsExt;
104        return (metadata.permissions().mode() & 0o100 != 0) == (mode == CandidateMode::Executable);
105    }
106    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
107    #[cfg(not(unix))]
108    {
109        let _ = (metadata, mode);
110        return true;
111    }
112}
113
114/// What: Why the worktree copy rules out the file, if it does. `Err` is a read the system
115///       refused for another reason than the file being absent.
116/// Why:  The copy must be a plain file, not a link, with `HEAD`'s executable bit and bytes.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// async function worktreeRefusal(file: string, mode, headBytes): Promise<string | undefined>;
121/// ```
122pub fn worktree_refusal(
123    file: &Path,
124    mode: CandidateMode,
125    head_bytes: &[u8],
126) -> Result<Option<&'static str>, std::io::Error> {
127    let metadata: std::fs::Metadata = match std::fs::symlink_metadata(file) {
128        Ok(found) => found,
129        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
130            return Ok(Some(ADDED_PATH_MISSING_REASON));
131        }
132        Err(error) => return Err(error),
133    };
134    if !metadata.is_file() || !executable_matches(&metadata, mode) {
135        return Ok(Some(ADDED_PATH_UNSTAGED_REASON));
136    }
137    if std::fs::read(file)? != head_bytes {
138        return Ok(Some(ADDED_PATH_UNSTAGED_REASON));
139    }
140    return Ok(None);
141}
142
143/// Precondition and message controls stay out of the release executable.
144#[cfg(test)]
145#[path = "policy_added_path_tests.rs"]
146mod tests;