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;