use std::path::{Path, PathBuf};
use std::process::Command;
use super::files::{is_excluded, is_link_to_no_file};
#[derive(Debug, Clone)]
pub(crate) struct Repository {
directory: PathBuf,
toplevel: PathBuf,
prefix: String,
}
impl Repository {
pub(crate) fn find(directory: &Path) -> Option<Self> {
run(directory, &["ls-files", "--error-unmatch", "Cargo.toml"])?;
Self::enclosing(directory)
}
pub(crate) fn enclosing(directory: &Path) -> Option<Self> {
let toplevel = PathBuf::from(text(directory, &["rev-parse", "--show-toplevel"])?);
let prefix = text(directory, &["rev-parse", "--show-prefix"])?;
Some(Self {
directory: directory.to_path_buf(),
toplevel,
prefix,
})
}
pub(crate) fn directory(&self) -> &Path {
&self.directory
}
pub(crate) fn toplevel(&self) -> &Path {
&self.toplevel
}
pub(crate) fn prefix(&self) -> &str {
&self.prefix
}
pub(crate) fn commit(&self) -> String {
let full = text(&self.directory, &["rev-parse", "HEAD"]).unwrap_or_default();
full.chars().take(8).collect()
}
pub(crate) fn commit_date(&self) -> String {
let arguments = [
"-c",
"log.showSignature=false",
"log",
"-1",
"--format=%cd",
"--date=short",
];
text(&self.directory, &arguments)
.filter(|date| is_date(date))
.unwrap_or_default()
}
pub(crate) fn tracked(&self, path: &Path) -> Option<String> {
let parent = path.parent()?;
let levels = self.directory.ancestors().position(|ancestor| ancestor == parent)?;
let name = path.file_name()?.to_str()?;
let below_root: Vec<&str> = self.prefix.split('/').filter(|part| !part.is_empty()).collect();
let kept = below_root.len().checked_sub(levels)?;
let mut pathspec: String = below_root[..kept].iter().map(|part| format!("{part}/")).collect();
pathspec.push_str(name);
let from_root = format!(":(top,literal){pathspec}");
run(
&self.directory,
&["ls-files", "--error-unmatch", "--", from_root.as_str()],
)?;
Some(pathspec)
}
pub(crate) fn dirty(&self, pathspecs: &[String]) -> Option<bool> {
if pathspecs.is_empty() {
return None;
}
let mut arguments = vec![
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--no-renames",
"--",
];
arguments.extend(pathspecs.iter().map(String::as_str));
let output = run(&self.toplevel, &arguments)?;
let output = String::from_utf8_lossy(&output);
Some(
output
.split('\0')
.filter_map(|record| record.get(3..))
.filter(|path| !path.is_empty())
.any(|path| {
pathspecs
.iter()
.any(|pathspec| match path.strip_prefix(pathspec.as_str()) {
Some("") => true,
Some(rest) => rest.strip_prefix('/').is_some_and(|below| {
!is_excluded(below) && !is_link_to_no_file(&self.toplevel.join(path))
}),
None => false,
})
}),
)
}
pub(crate) fn watched(&self) -> Vec<PathBuf> {
let mut paths = Vec::new();
paths.extend(self.git_path("HEAD"));
let logs = self.git_path("logs/HEAD").filter(|logs| logs.is_file());
let has_logs = logs.is_some();
paths.extend(logs);
if let Some(common) = self.common_dir() {
let reftable = common.join("reftable");
if reftable.is_dir() {
paths.push(reftable);
} else {
let branch = text(&self.directory, &["symbolic-ref", "-q", "HEAD"]);
paths.extend(branch.and_then(|branch| self.git_path(&branch)));
paths.push(common.join("packed-refs"));
if !has_logs {
paths.push(common.join("refs").join("heads"));
}
}
}
paths.retain(|path| path.exists());
paths
}
fn git_path(&self, path: &str) -> Option<PathBuf> {
self.absolute_path(&["--git-path", path])
}
fn common_dir(&self) -> Option<PathBuf> {
self.absolute_path(&["--git-common-dir"])
}
fn absolute_path(&self, query: &[&str]) -> Option<PathBuf> {
let mut arguments = vec!["rev-parse", PATH_FORMAT];
arguments.extend_from_slice(query);
let printed = text(&self.directory, &arguments)?;
let printed = if echoes_path_format(&printed) {
let mut arguments = vec!["rev-parse"];
arguments.extend_from_slice(query);
text(&self.directory, &arguments)?
} else {
printed
};
Some(self.directory.join(printed))
}
}
const PATH_FORMAT: &str = "--path-format=absolute";
pub(crate) fn echoes_path_format(printed: &str) -> bool {
printed.lines().next() == Some(PATH_FORMAT)
}
pub(crate) fn list_sources(directory: &Path) -> Vec<String> {
let arguments = [
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
"--",
"src",
];
let Some(output) = run(directory, &arguments) else {
return Vec::new();
};
String::from_utf8_lossy(&output)
.split('\0')
.filter_map(|path| path.strip_prefix("src/"))
.map(str::to_owned)
.collect()
}
pub(crate) fn is_date(text: &str) -> bool {
let bytes = text.as_bytes();
bytes.len() == 10
&& bytes.iter().enumerate().all(|(index, byte)| match index {
4 | 7 => *byte == b'-',
_ => byte.is_ascii_digit(),
})
}
fn run(directory: &Path, arguments: &[&str]) -> Option<Vec<u8>> {
let output = Command::new("git")
.args(arguments)
.current_dir(directory)
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_COMMON_DIR")
.output()
.ok()?;
output.status.success().then_some(output.stdout)
}
fn text(directory: &Path, arguments: &[&str]) -> Option<String> {
let output = run(directory, arguments)?;
Some(String::from_utf8_lossy(&output).trim().to_owned())
}