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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
//! What:
//! Which candidate paths have their `text` Git attribute unset (`-text`, which the `binary` macro includes),
//! resolved by `git check-attr` against the same state the candidates were listed from.
//! Why:
//! A path whose `text` attribute is unset declares that its exact bytes matter,
//! as a fuzz seed's trailing bytes do.
//! The attribute lives in the package's own `.gitattributes`,
//! so it moves with the package,
//! unlike a path list kept elsewhere.
//! Staged and projected candidates are answered from the private index,
//! so a `.gitattributes` staged by the same command applies to it
//! and an unstaged worktree edit does not;
//! landed and pushed candidates are answered from the tree of the commit they came from.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! // const unset = await attributes.textUnset([Buffer.from('seed/case')]);
//! ```
/// Import the layer's failure type and its causes.
use super::candidate_error::{CandidateError, CandidateFailure};
/// Import the validated object name a tree source is named by.
use super::candidate_object::ObjectId;
/// Import the captured-query runner and the byte helpers.
use super::git_metadata::{MetadataOutput, path_from_git_bytes, run_metadata_git, strip_git_line};
/// What:
/// `HashMap<K, V>` is a key-to-value table and `HashSet<T>` a collection of distinct values.
/// Why:
/// Paths are grouped by the tree that answers for them and returned as a set to test membership.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const byTree = new Map<string, Buffer[]>(); const unset = new Set<string>();
/// ```
use std::collections::{HashMap, HashSet};
/// Names and paths of the Git child.
use std::ffi::OsString;
/// `PathBuf` is an owned filesystem path.
use std::path::PathBuf;
/// How many paths one `git check-attr` command names,
/// as many as one pathspec command of the candidate layer.
pub const ATTRIBUTE_CHUNK_SIZE: usize = 2_048;
/// What:
/// Where the attributes of the candidates are read from.
/// `Index` is the private index the candidates were prepared on;
/// `Tree` is one commit or tree for every candidate;
/// `PerPath` names the commit or tree each path was first listed from.
/// Why:
/// Staged candidates are judged by what is staged.
/// A landed commit is judged by its own tree,
/// and a push lists several commits and trees,
/// each of which is judged by itself.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type AttributeSource = { kind: 'index' } | { kind: 'tree'; tree: ObjectId } | { kind: 'per-path'; trees: Map<string, ObjectId> };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AttributeSource {
/// The `.gitattributes` files of the private index.
Index,
/// The `.gitattributes` files of one commit or tree.
Tree(ObjectId),
/// The commit or tree each pathname was first listed from.
PerPath(HashMap<Vec<u8>, ObjectId>),
}
/// What:
/// The resolver of the `text` attribute for one prepared set of candidates.
/// It owns copies of the Git executable,
/// the global options and the child environment,
/// which already name the private index.
/// Why:
/// The policy asks lazily,
/// after the prepared candidates were handed over,
/// so the resolver cannot borrow the provider that prepared them.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// class CandidateAttributes { constructor(git, prefix, overlay, source) {} textUnset(paths: Buffer[]): Promise<Set<string>> {} }
/// ```
#[derive(Clone, Debug)]
pub struct CandidateAttributes {
/// The selected real Git executable.
real_git: PathBuf,
/// The arguments before the subcommand.
global_prefix: Vec<OsString>,
/// The child environment additions,
/// including `GIT_INDEX_FILE` of the private index.
overlay: Vec<(OsString, OsString)>,
/// Where the attributes are read from.
source: AttributeSource,
}
/// What:
/// The failure of an attribute lookup,
/// worded like the layer's other Git failures and naming no candidate.
/// Why:
/// The message reaches the person as the policy's failure.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const attributeFailure = (failure, detail) => new CandidateError(failure, `cli-git could not read the text attribute of candidate files: ${detail}`);
/// ```
fn attribute_failure(failure: CandidateFailure, detail: &str) -> CandidateError {
return CandidateError::new(
failure,
format!("cli-git could not read the text attribute of candidate files: {detail}").as_str(),
);
}
/// What:
/// Parse the `-z` output of `git check-attr text` for the paths asked about,
/// and return the asked paths whose value is `unset`.
/// `asked` holds the arguments given to Git (the top level and the path),
/// `paths` the candidate pathnames in the same order.
/// Why:
/// Each record is three NUL-terminated fields,
/// path, attribute name and value,
/// in the order asked.
/// Only the exact value `unset` preserves a file:
/// `set`, `auto`, `unspecified` and any other value leave it checked.
/// A record that does not match what was asked is an error,
/// never a default to text or to binary.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function parseTextUnset(output: Buffer, asked: Buffer[], paths: Buffer[]): Buffer[]; // throws CandidateError
/// ```
pub fn parse_text_unset(
output: &[u8],
asked: &[Vec<u8>],
paths: &[&[u8]],
) -> Result<Vec<Vec<u8>>, CandidateError> {
// `.split(..)` yields the NUL-separated fields; the last one is empty after the final terminator.
let mut fields: Vec<&[u8]> = output.split(|byte| { return *byte == 0; }).collect();
if fields.pop() != Some(b"".as_slice()) || fields.len() != asked.len() * 3 {
return Err(attribute_failure(
CandidateFailure::ListingMalformed,
"git check-attr did not print one record for each path it was asked about.",
));
}
let mut unset: Vec<Vec<u8>> = Vec::new();
// `.chunks(3)` yields each record's path, attribute name and value.
for (position, record) in fields.chunks(3).enumerate() {
if record[0] != asked[position].as_slice() || record[1] != b"text" {
return Err(attribute_failure(
CandidateFailure::ListingMalformed,
"git check-attr printed a record for a different path or attribute than it was asked about.",
));
}
if record[2] == b"unset" {
unset.push(paths[position].to_vec());
}
}
return Ok(unset);
}
/// What:
/// `impl CandidateAttributes { ... }` attaches the constructor and the lookup.
/// Why:
/// One owner builds the Git commands,
/// so a policy cannot ask against another state than the candidates'.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// class CandidateAttributes { textUnset(paths) {} }
/// ```
impl CandidateAttributes {
/// What:
/// A resolver over `source`,
/// running the given Git with the given options and environment.
/// Why:
/// The environment is the one that names the candidates' private index.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// constructor(realGit, globalPrefix, overlay, source) {}
/// ```
pub fn new(
real_git: &std::path::Path,
global_prefix: &[OsString],
overlay: &[(OsString, OsString)],
source: AttributeSource,
) -> CandidateAttributes {
return CandidateAttributes {
real_git: real_git.to_path_buf(),
global_prefix: global_prefix.to_vec(),
overlay: overlay.to_vec(),
source,
};
}
/// What:
/// Run one Git command and return its standard output.
/// Why:
/// A Git that cannot start or that exits unsuccessfully fails the lookup.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const stdout = await runGit([...prefix, ...command]);
/// ```
fn run(&self, command: &[OsString]) -> Result<Vec<u8>, CandidateError> {
let mut arguments: Vec<OsString> = self.global_prefix.clone();
arguments.extend_from_slice(command);
let operation: String = match command.first() {
Some(word) => word.to_string_lossy().into_owned(),
None => String::from("(no command)"),
};
let output: MetadataOutput = match run_metadata_git(
self.real_git.as_path(),
arguments.as_slice(),
self.overlay.as_slice(),
) {
Ok(captured) => captured,
Err(error) => {
return Err(attribute_failure(
CandidateFailure::GitNotStarted,
format!("git {operation} could not be started: {error}.").as_str(),
));
}
};
if !output.success {
return Err(attribute_failure(
CandidateFailure::GitFailed,
format!(
"git {operation} failed: {}",
String::from_utf8_lossy(output.stderr.as_slice()).trim()
)
.as_str(),
));
}
return Ok(output.stdout);
}
/// What:
/// The absolute path of the worktree's top level,
/// as Git prints it.
/// Why:
/// Candidate pathnames are relative to the top level,
/// and `git check-attr` resolves its arguments from the directory it runs in,
/// which may be a subdirectory or,
/// for a command run with `GIT_DIR` and `GIT_WORK_TREE`,
/// a directory outside the worktree.
/// An absolute path inside the worktree means the same file from anywhere.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const top = (await runGit(['rev-parse', '--show-toplevel'])).trimEnd();
/// ```
fn top_level(&self) -> Result<Vec<u8>, CandidateError> {
let output: Vec<u8> = self.run(&[
OsString::from("rev-parse"),
OsString::from("--show-toplevel"),
])?;
let top: &[u8] = strip_git_line(output.as_slice());
if top.is_empty() {
return Err(attribute_failure(
CandidateFailure::ListingMalformed,
"git rev-parse --show-toplevel printed no path.",
));
}
return Ok(top.to_vec());
}
/// What:
/// The `git check-attr` command for one chunk of names under one source.
/// Why:
/// The index source adds `--cached`,
/// which reads `.gitattributes` from the private index and ignores the worktree.
/// A tree source adds `--source`,
/// which reads them from that commit or tree.
/// The names follow `--`, so none can be read as an option.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const command = ['check-attr', '-z', ...sourceFlags, 'text', '--', ...names];
/// ```
fn command(&self, tree: Option<&ObjectId>, names: &[Vec<u8>]) -> Result<Vec<OsString>, CandidateError> {
let mut command: Vec<OsString> = vec![
OsString::from("check-attr"),
OsString::from("-z"),
];
match tree {
Some(object) => command.push(OsString::from(format!("--source={}", object.as_str()))),
None => command.push(OsString::from("--cached")),
}
command.push(OsString::from("text"));
command.push(OsString::from("--"));
for name in names {
let Some(native) = path_from_git_bytes(name.as_slice()) else {
return Err(attribute_failure(
CandidateFailure::ListingMalformed,
"a candidate name is not a path on this platform.",
));
};
command.push(native.into_os_string());
}
return Ok(command);
}
/// What:
/// The tree each path is answered by,
/// as groups of paths in first-seen order.
/// `None` stands for the private index.
/// Why:
/// One Git command asks about every path of a group.
/// A path without a recorded tree cannot be answered and fails the lookup.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const groups = groupBy(paths, (path) => treeOf(path));
/// ```
fn groups<'a>(
&self,
paths: &[&'a [u8]],
) -> Result<Vec<(Option<ObjectId>, Vec<&'a [u8]>)>, CandidateError> {
match &self.source {
AttributeSource::Index => return Ok(vec![(None, paths.to_vec())]),
AttributeSource::Tree(tree) => return Ok(vec![(Some(tree.clone()), paths.to_vec())]),
AttributeSource::PerPath(trees) => {
let mut groups: Vec<(Option<ObjectId>, Vec<&'a [u8]>)> = Vec::new();
for path in paths {
let Some(tree) = trees.get(*path) else {
return Err(attribute_failure(
CandidateFailure::StaleCandidate,
"a candidate was not listed from any commit or tree.",
));
};
match groups.iter_mut().find(|group| { return group.0.as_ref() == Some(tree); }) {
Some(group) => group.1.push(*path),
None => groups.push((Some(tree.clone()), vec![path])),
}
}
return Ok(groups);
}
}
}
/// What:
/// The given candidate pathnames whose `text` attribute is unset.
/// No path means no Git process.
/// Why:
/// A policy asks only about the files it would otherwise change,
/// and a commit of canonical files starts no attribute query.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async textUnset(paths: Buffer[]): Promise<Set<string>>; // throws CandidateError
/// ```
pub fn text_unset(&self, paths: &[&[u8]]) -> Result<HashSet<Vec<u8>>, CandidateError> {
let mut unset: HashSet<Vec<u8>> = HashSet::new();
if paths.is_empty() {
return Ok(unset);
}
let top: Vec<u8> = self.top_level()?;
for (tree, group) in self.groups(paths)? {
for chunk in group.chunks(ATTRIBUTE_CHUNK_SIZE) {
// Each asked name is the top level, a separator and the candidate's pathname.
let asked: Vec<Vec<u8>> = chunk
.iter()
.map(|path| { return [top.as_slice(), b"/", *path].concat(); })
.collect();
let command: Vec<OsString> = self.command(tree.as_ref(), asked.as_slice())?;
let output: Vec<u8> = self.run(command.as_slice())?;
unset.extend(parse_text_unset(output.as_slice(), asked.as_slice(), chunk)?);
}
}
return Ok(unset);
}
}
/// Parsing controls stay out of the release executable.
#[cfg(test)]
#[path = "candidate_attribute_tests.rs"]
mod tests;