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