Skip to main content

git_policy_cli/
repo_read_query.rs

1//! What:
2//!  Answers, without a Git process, to the exact read-only Git command lines the wrapper itself issues:
3//!  `ls-files --stage`, `ls-files --unmerged`, `diff-index --cached`, `diff --cached --name-only`, `diff-tree --root -r -m`
4//!  and `show --no-patch --format=%s`.
5//! Why:
6//!  Each is a start of Git for a fact two in-process readers (the index, the object database) can produce byte for byte.
7//!  The reader takes the same argument list the caller would give Git and returns Git's standard output and exit code,
8//!  or `None` for any command line, repository or configuration it does not fully understand, so a caller tries it first
9//!  and starts Git on `None`.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const answer = answerInProcess({ args, directory, variables }); // undefined: start Git
14//! ```
15
16#![cfg(unix)]
17
18use super::repo_read_config_scan::{ScopeEnvironment, content_key, entries_avoid_keys, file_avoids_keys, outer_scopes_avoid_keys};
19use super::repo_read_config_text::{CONFIG_TEXT_LIMIT, parse_config_text};
20use super::repo_read_diff::{PathFilter, diff_sorted, name_lines, parse_diff_filter, raw_lines, select};
21use super::repo_read_index::{INTENT_TO_ADD, IndexEntry, parse_index};
22use super::repo_read_layout::{RepoLayout, env_config_avoids, read_layout_with};
23use super::repo_read_object::{ObjectKind, from_hex, name_length, to_hex};
24
25use super::repo_read_odb::ObjectDatabase;
26use super::repo_read_query_refs::{config_list, for_each_ref, remote_url, rev_parse, symbolic_ref};
27use super::repo_read_tree::{FlatEntry, flatten_tree, parse_commit, subject_of};
28use std::ffi::{OsStr, OsString};
29use std::path::{Path, PathBuf};
30
31/// The command line and surroundings of one query.
32pub struct Query<'a> {
33    /// The arguments given to Git, global options first.
34    pub arguments: &'a [OsString],
35    /// The directory Git would run in; `None` is the process directory.
36    pub directory: Option<&'a Path>,
37    /// The variables added to Git's environment for this run.
38    pub variables: &'a [(OsString, OsString)],
39    /// The bytes sent to Git's standard input, for the commands that read object names from it.
40    pub input: Option<&'a [u8]>,
41}
42
43/// What Git would have printed and returned.
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct Answer {
46    /// Standard output.
47    pub stdout: Vec<u8>,
48    /// Standard error.
49    pub stderr: Vec<u8>,
50    /// The exit code.
51    pub code: i32,
52}
53
54/// The parts of a command line after its global options.
55pub(super) struct Parsed<'a> {
56    /// The subcommand word.
57    pub(super) command: &'a str,
58    /// Options before `--`.
59    pub(super) flags: Vec<&'a str>,
60    /// Non-option arguments before `--`.
61    pub(super) operands: Vec<&'a [u8]>,
62    /// Arguments after `--`.
63    pub(super) specs: Vec<&'a [u8]>,
64}
65
66/// Split the arguments: leading `-C <dir>` pairs move the start directory, `--no-literal-pathspecs` is Git's default.
67fn parse<'a>(arguments: &'a [OsString], start: &mut PathBuf) -> Option<Parsed<'a>> {
68    let mut index: usize = 0;
69    while index < arguments.len() {
70        let word: &OsStr = arguments[index].as_os_str();
71        if word == "-C" {
72            let target: &OsString = arguments.get(index + 1)?;
73            if !target.is_empty() {
74                *start = start.join(target);
75            }
76            // Git changes directory at each `-C` in turn and fails at the first one that is not a directory.
77            if !std::fs::metadata(&*start).is_ok_and(|found| { return found.is_dir(); }) {
78                return None;
79            }
80            index += 2;
81        } else if word == "--no-literal-pathspecs" {
82            index += 1;
83        } else {
84            break;
85        }
86    }
87    let command: &str = arguments.get(index)?.to_str()?;
88    let mut parsed: Parsed<'a> = Parsed { command, flags: Vec::new(), operands: Vec::new(), specs: Vec::new() };
89    let mut after_separator: bool = false;
90    for argument in &arguments[index + 1..] {
91        let bytes: &[u8] = argument.as_encoded_bytes();
92        if after_separator {
93            parsed.specs.push(bytes);
94        } else if bytes == b"--" {
95            after_separator = true;
96        } else if bytes.starts_with(b"-") {
97            parsed.flags.push(argument.to_str()?);
98        } else {
99            parsed.operands.push(bytes);
100        }
101    }
102    return Some(parsed);
103}
104
105/// Whether `flags` holds exactly the options of `allowed` (each at most once) and every one of `required`.
106pub(super) fn flags_are(flags: &[&str], required: &[&str], allowed: &[&str]) -> bool {
107    return required.iter().all(|flag| { return flags.contains(flag); })
108        && flags.iter().all(|flag| { return allowed.contains(flag) || required.contains(flag); })
109        && flags.iter().enumerate().all(|(position, flag)| { return !flags[..position].contains(flag); });
110}
111
112/// The environment Git would see, without the two variables this reader handles itself.
113fn environment(
114    base: Vec<(OsString, OsString)>,
115    variables: &[(OsString, OsString)],
116) -> (Vec<(OsString, OsString)>, Option<OsString>, Option<OsString>) {
117    let mut all: Vec<(OsString, OsString)> = base
118        .into_iter()
119        .filter(|(name, _)| { return !variables.iter().any(|(added, _)| { return added == name; }); })
120        .collect();
121    all.extend(variables.iter().cloned());
122    let find = |wanted: &str| -> Option<OsString> {
123        // The last assignment wins, as it does for the child's environment.
124        return all.iter().rev().find(|(name, _)| { return name == wanted; }).map(|(_, value)| { return value.clone(); });
125    };
126    let index: Option<OsString> = find("GIT_INDEX_FILE");
127    let objects: Option<OsString> = find("GIT_OBJECT_DIRECTORY");
128    all.retain(|(name, _)| { return name != "GIT_INDEX_FILE" && name != "GIT_OBJECT_DIRECTORY"; });
129    return (all, index, objects);
130}
131
132/// Whether no configuration file Git reads for these queries sets a key that could change the output.
133fn content_config_is_plain(layout: &RepoLayout, scope: &ScopeEnvironment) -> Option<bool> {
134    let path: PathBuf = layout.common_dir.join("config");
135    let bytes: Vec<u8> = super::private_storage::read_regular_file(path.as_path(), CONFIG_TEXT_LIMIT as u64).ok()?;
136    let entries = parse_config_text(bytes.as_slice())?;
137    if !entries_avoid_keys(entries.as_slice(), path.as_path(), scope.home.as_deref(), 0, &content_key)? {
138        return Some(false);
139    }
140    // The per-worktree file is where `git sparse-checkout` writes `core.sparseCheckout`; it is scanned whenever it
141    // exists, also when the extension that makes Git read it is off, which only makes the readers decline more often.
142    if !file_avoids_keys(layout.git_dir.join("config.worktree").as_path(), scope.home.as_deref(), 0, &content_key)? {
143        return Some(false);
144    }
145    return outer_scopes_avoid_keys(scope, &content_key);
146}
147
148/// Everything one query needs, opened lazily.
149pub(super) struct Context {
150    /// The repository.
151    pub(super) layout: RepoLayout,
152    /// The index file Git would read.
153    index_path: PathBuf,
154    /// The object directory override, if any.
155    objects: Option<PathBuf>,
156    /// Where the system and global configuration files are.
157    pub(super) scope: ScopeEnvironment,
158    /// The environment Git would see, without the index and object directory variables.
159    pub(super) variables: Vec<(OsString, OsString)>,
160}
161
162impl Context {
163    /// The object database.
164    pub(super) fn database(&self) -> Option<ObjectDatabase> {
165        return ObjectDatabase::open(self.layout.common_dir.as_path(), self.objects.as_deref(), self.layout.object_format);
166    }
167
168    /// The index entries.
169    fn index(&self) -> Option<Vec<IndexEntry>> {
170        let bytes: Vec<u8> = super::private_storage::read_regular_file(self.index_path.as_path(), 1 << 30).ok()?;
171        return parse_index(bytes.as_slice(), self.layout.object_format);
172    }
173
174    /// The index as flat entries, refusing unmerged or intent-to-add entries.
175    fn resolved_index(&self) -> Option<Vec<FlatEntry>> {
176        let entries: Vec<IndexEntry> = self.index()?;
177        if entries.iter().any(|entry| { return entry.stage != 0 || entry.extended & INTENT_TO_ADD != 0; }) {
178            return None;
179        }
180        return Some(entries.into_iter().map(|entry| { return FlatEntry { path: entry.path, mode: entry.mode, id: entry.id }; }).collect());
181    }
182
183    /// The root tree of a commit or tree named by `hex`.
184    fn tree_of(&self, database: &ObjectDatabase, hex: &[u8]) -> Option<Vec<u8>> {
185        let name: Vec<u8> = from_hex(hex, self.layout.object_format)?;
186        let object = database.read(name.as_slice())?;
187        return match object.kind {
188            ObjectKind::Tree => Some(name),
189            ObjectKind::Commit => Some(parse_commit(object.bytes.as_slice(), self.layout.object_format)?.tree),
190            ObjectKind::Blob | ObjectKind::Tag => None,
191        };
192    }
193
194    /// The zero object name spelled out for raw lines.
195    fn zero(&self) -> String {
196        return "0".repeat(self.layout.object_format.hex_length());
197    }
198}
199
200/// `ls-files --stage -z` and `ls-files --unmerged -z`.
201fn list_files(context: &Context, parsed: &Parsed<'_>) -> Option<Answer> {
202    let unmerged: bool = parsed.flags.contains(&"--unmerged");
203    let required: [&str; 2] = [if unmerged { "--unmerged" } else { "--stage" }, "-z"];
204    if !flags_are(&parsed.flags, &required, &["--full-name"]) || !parsed.operands.is_empty() || !context.layout.prefix.is_empty() {
205        return None;
206    }
207    let filter: PathFilter = PathFilter::parse(parsed.specs.as_slice(), true)?;
208    let mut stdout: Vec<u8> = Vec::new();
209    for entry in context.index()? {
210        if (unmerged && entry.stage == 0) || !filter.matches(entry.path.as_slice()) {
211            continue;
212        }
213        stdout.extend_from_slice(format!("{:06o} {} {}\t", entry.mode, to_hex(entry.id.as_slice()), entry.stage).as_bytes());
214        stdout.extend_from_slice(entry.path.as_slice());
215        stdout.push(0);
216    }
217    return Some(Answer { stdout, stderr: Vec::new(), code: 0 });
218}
219
220/// `diff-index --cached` and `diff --cached --name-only`.
221fn diff_cached(context: &Context, parsed: &Parsed<'_>, porcelain: bool) -> Option<Answer> {
222    let raw: bool = parsed.flags.contains(&"--raw");
223    let mut required: Vec<&str> = vec!["--cached", "-z"];
224    required.push(if raw { "--raw" } else { "--name-only" });
225    if raw {
226        required.push("--no-abbrev");
227    }
228    let mut statuses: Option<Vec<u8>> = None;
229    let mut others: Vec<&str> = Vec::new();
230    for flag in &parsed.flags {
231        match flag.strip_prefix("--diff-filter=") {
232            // A second `--diff-filter` adds its letters in Git; the reader leaves that spelling to it.
233            Some(letters) if !porcelain && statuses.is_none() => statuses = Some(parse_diff_filter(letters)?),
234            Some(_) => return None,
235            _ => others.push(flag),
236        }
237    }
238    let allowed: &[&str] = if porcelain { &[] } else { &["--no-renames"] };
239    if (porcelain && raw) || !flags_are(&others, &required, allowed) || parsed.operands.len() != 1 || (porcelain && !parsed.specs.is_empty()) {
240        return None;
241    }
242    let filter: PathFilter = PathFilter::parse(parsed.specs.as_slice(), context.layout.prefix.is_empty())?;
243    let database: ObjectDatabase = context.database()?;
244    let tree: Vec<u8> = context.tree_of(&database, parsed.operands[0])?;
245    let old: Vec<FlatEntry> = flatten_tree(&database, tree.as_slice(), context.layout.object_format)?;
246    let changes = diff_sorted(old.as_slice(), context.resolved_index()?.as_slice())?;
247    // The porcelain pairs an added and a deleted path as a rename; plain lists cannot say which pairs it would choose.
248    if porcelain && changes.iter().any(|change| { return change.status == b'A'; }) && changes.iter().any(|change| { return change.status == b'D'; }) {
249        return None;
250    }
251    let kept = select(changes, &filter, statuses.as_deref());
252    let stdout: Vec<u8> = if raw { raw_lines(kept.as_slice(), context.zero().as_str()) } else { name_lines(kept.as_slice()) };
253    return Some(Answer { stdout, stderr: Vec::new(), code: 0 });
254}
255
256/// `diff-tree --root --no-commit-id -r -z -m --no-renames --no-abbrev <commit>` for a commit with at most one parent.
257fn diff_tree(context: &Context, parsed: &Parsed<'_>) -> Option<Answer> {
258    let required: [&str; 7] = ["--root", "--no-commit-id", "-r", "-z", "-m", "--no-renames", "--no-abbrev"];
259    if !flags_are(&parsed.flags, &required, &[]) || parsed.operands.len() != 1 || !parsed.specs.is_empty() {
260        return None;
261    }
262    let database: ObjectDatabase = context.database()?;
263    let name: Vec<u8> = from_hex(parsed.operands[0], context.layout.object_format)?;
264    let object = database.read(name.as_slice())?;
265    if object.kind != ObjectKind::Commit {
266        return None;
267    }
268    let commit = parse_commit(object.bytes.as_slice(), context.layout.object_format)?;
269    if commit.parents.len() > 1 {
270        return None;
271    }
272    let new: Vec<FlatEntry> = flatten_tree(&database, commit.tree.as_slice(), context.layout.object_format)?;
273    let old: Vec<FlatEntry> = match commit.parents.first() {
274        Some(parent) => {
275            let parent_object = database.read(parent.as_slice())?;
276            if parent_object.kind != ObjectKind::Commit {
277                return None;
278            }
279            let parent_tree = parse_commit(parent_object.bytes.as_slice(), context.layout.object_format)?.tree;
280            flatten_tree(&database, parent_tree.as_slice(), context.layout.object_format)?
281        }
282        None => Vec::new(),
283    };
284    let changes = diff_sorted(old.as_slice(), new.as_slice())?;
285    return Some(Answer { stdout: raw_lines(changes.as_slice(), context.zero().as_str()), stderr: Vec::new(), code: 0 });
286}
287
288/// `show --no-patch --format=%s <commit>`.
289fn show_subject(context: &Context, parsed: &Parsed<'_>) -> Option<Answer> {
290    if !flags_are(&parsed.flags, &["--no-patch", "--format=%s"], &[]) || parsed.operands.len() != 1 || !parsed.specs.is_empty() {
291        return None;
292    }
293    let database: ObjectDatabase = context.database()?;
294    let name: Vec<u8> = from_hex(parsed.operands[0], context.layout.object_format)?;
295    let object = database.read(name.as_slice())?;
296    if object.kind != ObjectKind::Commit {
297        return None;
298    }
299    let commit = parse_commit(object.bytes.as_slice(), context.layout.object_format)?;
300    let mut stdout: Vec<u8> = subject_of(commit.message.as_slice())?;
301    stdout.push(b'\n');
302    return Some(Answer { stdout, stderr: Vec::new(), code: 0 });
303}
304
305/// `cat-file --batch` fed full object names, one per line: for each, `<name> <kind> <size>`, the content and a line feed.
306fn batch_objects(context: &Context, parsed: &Parsed<'_>, input: Option<&[u8]>) -> Option<Answer> {
307    if !flags_are(&parsed.flags, &["--batch"], &[]) || !parsed.operands.is_empty() || !parsed.specs.is_empty() {
308        return None;
309    }
310    let database: ObjectDatabase = context.database()?;
311    let mut stdout: Vec<u8> = Vec::new();
312    let text: &[u8] = input?.strip_suffix(b"\n")?;
313    for line in text.split(|byte| { return *byte == b'\n'; }) {
314        let name: Vec<u8> = from_hex(line, context.layout.object_format)?;
315        // A name the store lacks is Git's `missing` line, which the reader leaves to it.
316        let object = database.read(name.as_slice())?;
317        stdout.extend_from_slice(format!("{} {} {}\n", to_hex(name.as_slice()), object.kind.word(), object.bytes.len()).as_bytes());
318        stdout.extend_from_slice(object.bytes.as_slice());
319        stdout.push(b'\n');
320    }
321    return Some(Answer { stdout, stderr: Vec::new(), code: 0 });
322}
323
324/// What:
325///  The answer to `query`, or `None` when Git must be started.
326/// Why:
327///   This is the one entry point; every reason to decline (an unknown command line, a repository or configuration
328///  the readers do not fully understand, a missing object) is the same `None`.
329///
330/// In TS you'd write (pseudocode):
331/// ```ts
332/// const answer = answer(query); // undefined: start Git
333/// ```
334pub fn answer(query: &Query<'_>) -> Option<Answer> {
335    let mut base: Vec<(OsString, OsString)> = std::env::vars_os().collect();
336    base.extend(query.variables.iter().cloned());
337    let scope: ScopeEnvironment = ScopeEnvironment::from_variables(base.as_slice());
338    return answer_with(query, std::env::vars_os().collect(), &scope);
339}
340
341/// The same with the process environment and the configuration scope facts given, so a control does not depend on its host.
342pub fn answer_with(query: &Query<'_>, base: Vec<(OsString, OsString)>, scope: &ScopeEnvironment) -> Option<Answer> {
343    let mut start: PathBuf = match query.directory {
344        Some(directory) => directory.to_path_buf(),
345        None => std::env::current_dir().ok()?,
346    };
347    let parsed: Parsed<'_> = parse(query.arguments, &mut start)?;
348    if !matches!(parsed.command, "ls-files" | "diff-index" | "diff" | "diff-tree" | "show" | "config" | "remote" | "rev-parse" | "for-each-ref" | "symbolic-ref" | "cat-file") {
349        return None;
350    }
351    let (variables, index_variable, objects_variable) = environment(base, query.variables);
352    let layout: RepoLayout = read_layout_with(start.as_path(), variables.as_slice(), scope)?;
353    let index_path: PathBuf = match index_variable {
354        Some(file) => start.join(file),
355        None => layout.git_dir.join("index"),
356    };
357    let objects: Option<PathBuf> = objects_variable.map(|directory| { return start.join(directory); });
358    let content_command: bool = matches!(parsed.command, "ls-files" | "diff-index" | "diff" | "diff-tree" | "show");
359    if content_command && !content_config_is_plain(&layout, scope)? {
360        return None;
361    }
362    // Entries injected through the environment are configuration too.
363    if content_command && !env_config_avoids(variables.as_slice(), &|key: &str| { return content_key(key.to_ascii_lowercase().as_str()); }) {
364        return None;
365    }
366    let context: Context = Context { layout, index_path, objects, scope: scope.clone(), variables };
367    if name_length(context.layout.object_format) == 0 {
368        return None;
369    }
370    return match parsed.command {
371        "ls-files" => list_files(&context, &parsed),
372        "diff-index" => diff_cached(&context, &parsed, false),
373        "diff" => diff_cached(&context, &parsed, true),
374        "diff-tree" => diff_tree(&context, &parsed),
375        "show" => show_subject(&context, &parsed),
376        "config" => config_list(&context, &parsed),
377        "remote" => remote_url(&context, &parsed),
378        "for-each-ref" => for_each_ref(&context, &parsed),
379        "rev-parse" => rev_parse(&context, &parsed),
380        "symbolic-ref" => symbolic_ref(&context, &parsed),
381        "cat-file" => batch_objects(&context, &parsed, query.input),
382        _ => None,
383    };
384}
385
386#[cfg(test)]
387#[path = "repo_read_query_tests.rs"]
388pub(crate) mod tests;