use std::{ffi::OsString, io, process::Command, time::Instant};
use camino::{Utf8Path, Utf8PathBuf};
use tracing::{Span, debug, field};
use super::{
git_failure::GitListError,
git_output::{CandidateListing, relayable, split_nul_delimited},
};
const PROGRAM: &str = "git";
const SUBCOMMAND: &str = "ls-files";
const FRAMING: [&str; 3] = ["-z", "--deduplicate", "--cached"];
const REV_PARSE: &str = "rev-parse";
const ABSOLUTE_GIT_DIR: &str = "--absolute-git-dir";
const UNTRACKED: [&str; 2] = ["--others", "--exclude-standard"];
const REDIRECTING: [&str; 4] = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Operation {
LsFiles,
RevParse,
}
impl Operation {
const fn subcommand(self) -> &'static str {
match self {
Self::LsFiles => SUBCOMMAND,
Self::RevParse => REV_PARSE,
}
}
const fn name(self) -> &'static str {
match self {
Self::LsFiles => "ls_files",
Self::RevParse => "rev_parse",
}
}
}
#[derive(Debug, Clone)]
pub struct GitLsFiles {
program: OsString,
include_untracked: bool,
}
impl GitLsFiles {
#[must_use]
pub fn new(include_untracked: bool) -> Self { Self::with_program(PROGRAM, include_untracked) }
#[must_use]
pub fn with_program(program: impl Into<OsString>, include_untracked: bool) -> Self {
Self {
program: program.into(),
include_untracked,
}
}
pub fn list_candidates(&self, dir: &Utf8Path) -> Result<CandidateListing, GitListError> {
let mut args = vec![SUBCOMMAND];
args.extend_from_slice(&FRAMING);
if self.include_untracked {
args.extend_from_slice(&UNTRACKED);
}
let output = self.run(Operation::LsFiles, &args, dir)?;
Ok(split_nul_delimited(&output.stdout))
}
pub fn resolve_git_dir(&self, dir: &Utf8Path) -> Result<Utf8PathBuf, GitListError> {
let output = self.run(Operation::RevParse, &[REV_PARSE, ABSOLUTE_GIT_DIR], dir)?;
let reported = std::str::from_utf8(&output.stdout).map_err(|_| GitListError::NoGitDir {
command: self.label(Operation::RevParse),
})?;
let reported = without_line_terminator(reported);
if reported.is_empty() {
return Err(GitListError::NoGitDir {
command: self.label(Operation::RevParse),
});
}
Ok(Utf8PathBuf::from(reported))
}
fn label(&self, operation: Operation) -> String {
format!(
"{} {}",
self.program.to_string_lossy(),
operation.subcommand()
)
}
#[tracing::instrument(
level = "debug",
name = "git",
skip(self, args, dir),
fields(
operation = operation.name(),
outcome = field::Empty,
elapsed_seconds = field::Empty
)
)]
fn run(
&self,
operation: Operation,
args: &[&str],
dir: &Utf8Path,
) -> Result<std::process::Output, GitListError> {
let started = Instant::now();
let result = self.invoke(operation, args, dir);
let elapsed_seconds = started.elapsed().as_secs_f64();
let span = Span::current();
span.record("outcome", outcome_label(&result));
span.record("elapsed_seconds", elapsed_seconds);
match &result {
Ok(_) => debug!(
operation = operation.name(),
outcome = outcome_label(&result),
elapsed_seconds,
"git invocation completed"
),
Err(error) => debug!(
operation = operation.name(),
outcome = outcome_label(&result),
elapsed_seconds,
failure = error.category(),
"git invocation failed"
),
}
result
}
fn command(&self, args: &[&str], dir: &Utf8Path) -> Command {
let mut command = Command::new(&self.program);
command.args(args).current_dir(dir);
for variable in REDIRECTING {
command.env_remove(variable);
}
command
}
fn invoke(
&self,
operation: Operation,
args: &[&str],
dir: &Utf8Path,
) -> Result<std::process::Output, GitListError> {
let output = self.command(args, dir).output().map_err(|source| {
let program = self.program.to_string_lossy().into_owned();
if source.kind() == io::ErrorKind::NotFound {
GitListError::ProgramNotFound { program }
} else {
GitListError::Spawn {
command: self.label(operation),
source,
}
}
})?;
if !output.status.success() {
return Err(GitListError::Failed {
command: self.label(operation),
status: output.status,
stderr: relayable(&output.stderr),
});
}
Ok(output)
}
}
fn outcome_label<T, E>(result: &Result<T, E>) -> &'static str {
if result.is_ok() { "success" } else { "error" }
}
fn without_line_terminator(reported: &str) -> &str {
match reported.strip_suffix('\n') {
Some(line) => line.strip_suffix('\r').unwrap_or(line),
None => reported,
}
}
#[cfg(test)]
#[path = "git_ls_files_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "git_ls_files_git_tests.rs"]
mod git_tests;