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    #[cfg(not(unix))]
107    {
108        let _ = (metadata, mode);
109        return true;
110    }
111}
112
113/// What: Why the worktree copy rules out the file, if it does. `Err` is a read the system
114///       refused for another reason than the file being absent.
115/// Why:  The copy must be a plain file, not a link, with `HEAD`'s executable bit and bytes.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// async function worktreeRefusal(file: string, mode, headBytes): Promise<string | undefined>;
120/// ```
121pub fn worktree_refusal(
122    file: &Path,
123    mode: CandidateMode,
124    head_bytes: &[u8],
125) -> Result<Option<&'static str>, std::io::Error> {
126    let metadata: std::fs::Metadata = match std::fs::symlink_metadata(file) {
127        Ok(found) => found,
128        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
129            return Ok(Some(ADDED_PATH_MISSING_REASON));
130        }
131        Err(error) => return Err(error),
132    };
133    if !metadata.is_file() || !executable_matches(&metadata, mode) {
134        return Ok(Some(ADDED_PATH_UNSTAGED_REASON));
135    }
136    if std::fs::read(file)? != head_bytes {
137        return Ok(Some(ADDED_PATH_UNSTAGED_REASON));
138    }
139    return Ok(None);
140}
141
142/// Precondition and message controls stay out of the release executable.
143#[cfg(test)]
144#[path = "policy_added_path_tests.rs"]
145mod tests;