use anyhow::{Context, Result, bail};
use std::{collections::HashSet, ffi::OsString, fs, path::Path, process::Command};
const DEFAULT_IGNORED_DIRS: &[&str] = &[
".git",
"node_modules",
".venv",
"venv",
"env",
".env",
"dist",
"build",
".pytest_cache",
".mypy_cache",
".cache",
".tox",
"__pycache__",
"target",
];
pub fn init_snapshot_repo(gitdir: &Path) -> Result<()> {
let status = Command::new("git")
.args(["init"])
.env("GIT_DIR", gitdir)
.env("GIT_WORK_TREE", ".")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("failed to run git init")?;
if !status.success() {
bail!("git init failed for snapshot repo");
}
for (key, value) in [
("core.autocrlf", "false"),
("core.longpaths", "true"),
("core.symlinks", "true"),
("core.fsmonitor", "false"),
] {
let status = Command::new("git")
.args(["--git-dir", &gitdir.to_string_lossy(), "config", key, value])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.with_context(|| format!("failed to set git config {}", key))?;
if !status.success() {
bail!("git config {} failed", key);
}
}
Ok(())
}
pub fn sync_exclude(gitdir: &Path, worktree: &Path, extra: &[String]) -> Result<()> {
let source_exclude = worktree.join(".git").join("info").join("exclude");
let mut content = String::new();
if source_exclude.exists()
&& let Ok(text) = fs::read_to_string(&source_exclude)
{
content.push_str(&text);
}
for item in extra {
content.push_str(&format!("\n/{}", item.replace('\\', "/")));
}
let info_dir = gitdir.join("info");
fs::create_dir_all(&info_dir)
.with_context(|| format!("failed to create {}", info_dir.display()))?;
let target = info_dir.join("exclude");
fs::write(&target, content).with_context(|| format!("failed to write {}", target.display()))?;
Ok(())
}
pub fn find_changed_files(gitdir: &Path, worktree: &Path) -> Result<Vec<String>> {
let args: Vec<OsString> = vec![
OsString::from("-c"),
OsString::from("core.autocrlf=false"),
OsString::from("-c"),
OsString::from("core.longpaths=true"),
OsString::from("-c"),
OsString::from("core.symlinks=true"),
OsString::from("-c"),
OsString::from("core.quotepath=false"),
OsString::from("--git-dir"),
gitdir.into(),
OsString::from("--work-tree"),
worktree.into(),
OsString::from("diff-files"),
OsString::from("--name-only"),
OsString::from("-z"),
OsString::from("--"),
OsString::from("."),
];
let diff_output = Command::new("git")
.args(&args)
.output()
.context("failed to run git diff-files")?;
let tracked: Vec<String> = String::from_utf8_lossy(&diff_output.stdout)
.split('\0')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let args: Vec<OsString> = vec![
OsString::from("-c"),
OsString::from("core.autocrlf=false"),
OsString::from("-c"),
OsString::from("core.longpaths=true"),
OsString::from("-c"),
OsString::from("core.symlinks=true"),
OsString::from("-c"),
OsString::from("core.quotepath=false"),
OsString::from("--git-dir"),
gitdir.into(),
OsString::from("--work-tree"),
worktree.into(),
OsString::from("ls-files"),
OsString::from("--others"),
OsString::from("--exclude-standard"),
OsString::from("-z"),
OsString::from("--"),
OsString::from("."),
];
let untracked_output = Command::new("git")
.args(&args)
.output()
.context("failed to run git ls-files")?;
let untracked: Vec<String> = String::from_utf8_lossy(&untracked_output.stdout)
.split('\0')
.filter(|s| !s.is_empty())
.filter(|s| !should_ignore_path(s))
.map(|s| s.to_string())
.collect();
let mut all: Vec<String> = tracked;
all.extend(untracked);
all.sort();
all.dedup();
Ok(all)
}
fn should_ignore_path(path: &str) -> bool {
let path = Path::new(path);
path.components().any(|component| {
if let std::path::Component::Normal(name) = component
&& let Some(name_str) = name.to_str()
{
return DEFAULT_IGNORED_DIRS.contains(&name_str);
}
false
})
}
#[cfg(test)]
mod tests {
use super::should_ignore_path;
#[test]
fn ignores_git_metadata_paths() {
assert!(should_ignore_path(".git"));
assert!(should_ignore_path(".git/config"));
assert!(should_ignore_path("repo/.git/info/exclude"));
assert!(!should_ignore_path(".gitignore"));
assert!(!should_ignore_path("src/git.rs"));
}
}
pub fn check_ignored(gitdir: &Path, worktree: &Path, files: &[String]) -> Result<HashSet<String>> {
if files.is_empty() {
return Ok(HashSet::new());
}
let input = files.join("\0") + "\0";
let output = Command::new("git")
.current_dir(worktree)
.args([
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"check-ignore",
"--no-index",
"--stdin",
"-z",
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.context("failed to spawn git check-ignore")?;
if let Some(mut stdin) = output.stdin.as_ref() {
use std::io::Write;
stdin
.write_all(input.as_bytes())
.context("failed to write to git check-ignore stdin")?;
}
let result = output
.wait_with_output()
.context("failed to wait for git check-ignore")?;
if result.status.code() == Some(0) || result.status.code() == Some(1) {
let ignored: HashSet<String> = String::from_utf8_lossy(&result.stdout)
.split('\0')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
return Ok(ignored);
}
Ok(HashSet::new())
}
pub fn filter_large_files(worktree: &Path, files: &[String], limit: u64) -> Result<Vec<String>> {
let mut large = Vec::new();
for file in files {
let path = worktree.join(file);
match fs::metadata(&path) {
Ok(meta) => {
if meta.is_file() && meta.len() > limit {
large.push(file.clone());
}
}
Err(_) => continue,
}
}
Ok(large)
}
pub fn stage_files(gitdir: &Path, worktree: &Path, files: &[String]) -> Result<()> {
if files.is_empty() {
return Ok(());
}
let input = files.join("\0") + "\0";
let mut child = Command::new("git")
.args([
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"add",
"--all",
"--sparse",
"--pathspec-from-file=-",
"--pathspec-file-nul",
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("failed to spawn git add")?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
stdin
.write_all(input.as_bytes())
.context("failed to write to git add stdin")?;
}
let status = child.wait().context("failed to wait for git add")?;
let _ = status;
Ok(())
}
pub fn write_tree(gitdir: &Path) -> Result<String> {
let output = Command::new("git")
.args(["--git-dir", &gitdir.to_string_lossy(), "write-tree"])
.output()
.context("failed to run git write-tree")?;
if !output.status.success() {
bail!(
"git write-tree failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn diff_cached_names(gitdir: &Path, worktree: &Path, hash: &str) -> Result<Vec<String>> {
let output = Command::new("git")
.args([
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"diff",
"--cached",
"--no-ext-diff",
"--name-only",
hash,
"--",
".",
])
.output()
.context("failed to run git diff --cached")?;
let files: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(files)
}
pub fn diff_cached(gitdir: &Path, worktree: &Path, hash: &str) -> Result<String> {
let output = Command::new("git")
.args([
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"diff",
"--cached",
"--no-ext-diff",
hash,
"--",
".",
])
.output()
.context("failed to run git diff --cached")?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn checkout_file(gitdir: &Path, worktree: &Path, hash: &str, file: &str) -> Result<()> {
let status = Command::new("git")
.args([
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"checkout",
hash,
"--",
file,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("failed to run git checkout")?;
if !status.success() {
bail!("git checkout failed");
}
Ok(())
}
pub fn ls_tree(gitdir: &Path, hash: &str, rel: &str) -> Result<Option<String>> {
let output = Command::new("git")
.args([
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"--git-dir",
&gitdir.to_string_lossy(),
"ls-tree",
hash,
"--",
rel,
])
.output()
.context("failed to run git ls-tree")?;
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
if text.is_empty() {
Ok(None)
} else {
Ok(Some(text))
}
}
pub fn read_tree(gitdir: &Path, hash: &str) -> Result<()> {
let status = Command::new("git")
.args([
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"--git-dir",
&gitdir.to_string_lossy(),
"read-tree",
hash,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("failed to run git read-tree")?;
if !status.success() {
bail!("git read-tree failed");
}
Ok(())
}
pub fn checkout_index(gitdir: &Path, worktree: &Path) -> Result<()> {
let status = Command::new("git")
.args([
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"checkout-index",
"-a",
"-f",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("failed to run git checkout-index")?;
if !status.success() {
bail!("git checkout-index failed");
}
Ok(())
}
pub fn gc_prune(gitdir: &Path, period: &str) -> Result<()> {
let status = Command::new("git")
.args([
"--git-dir",
&gitdir.to_string_lossy(),
"gc",
&format!("--prune={}", period),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("failed to run git gc")?;
if !status.success() {
bail!("git gc failed");
}
Ok(())
}
pub fn drop_files(gitdir: &Path, worktree: &Path, files: &[String]) -> Result<()> {
if files.is_empty() {
return Ok(());
}
let input = files.join("\0") + "\0";
let mut child = Command::new("git")
.args([
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"rm",
"--cached",
"-f",
"--ignore-unmatch",
"--pathspec-from-file=-",
"--pathspec-file-nul",
])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("failed to spawn git rm")?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
stdin
.write_all(input.as_bytes())
.context("failed to write to git rm stdin")?;
}
let _ = child.wait().context("failed to wait for git rm")?;
Ok(())
}
pub fn ls_tree_names(gitdir: &Path, hash: &str, rels: &[&str]) -> Result<String> {
let mut args: Vec<OsString> = vec![
OsString::from("-c"),
OsString::from("core.longpaths=true"),
OsString::from("-c"),
OsString::from("core.symlinks=true"),
OsString::from("--git-dir"),
OsString::from(gitdir.to_string_lossy().to_string()),
OsString::from("ls-tree"),
OsString::from("--name-only"),
OsString::from(hash),
OsString::from("--"),
];
args.extend(rels.iter().map(|r| OsString::from(*r)));
let output = Command::new("git")
.args(&args)
.output()
.context("failed to run git ls-tree")?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn checkout_files(gitdir: &Path, worktree: &Path, hash: &str, files: &[&str]) -> Result<()> {
let mut args: Vec<OsString> = vec![
OsString::from("-c"),
OsString::from("core.longpaths=true"),
OsString::from("-c"),
OsString::from("core.symlinks=true"),
OsString::from("--git-dir"),
OsString::from(gitdir.to_string_lossy().to_string()),
OsString::from("--work-tree"),
OsString::from(worktree.to_string_lossy().to_string()),
OsString::from("checkout"),
OsString::from(hash),
OsString::from("--"),
];
args.extend(files.iter().map(|f| OsString::from(*f)));
let status = Command::new("git")
.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("failed to run git checkout")?;
if !status.success() {
bail!("git checkout failed");
}
Ok(())
}
pub fn diff_name_status(
gitdir: &Path,
worktree: &Path,
from: &str,
to: &str,
) -> Result<Vec<(String, String)>> {
let output = Command::new("git")
.args([
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"diff",
"--no-ext-diff",
"--name-status",
"--no-renames",
from,
to,
"--",
".",
])
.output()
.context("failed to run git diff --name-status")?;
let mut result = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
result.push((parts[0].to_string(), parts[1].to_string()));
}
}
Ok(result)
}
pub fn diff_numstat(
gitdir: &Path,
worktree: &Path,
from: &str,
to: &str,
) -> Result<Vec<(String, String, String)>> {
let output = Command::new("git")
.args([
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"diff",
"--no-ext-diff",
"--no-renames",
"--numstat",
from,
to,
"--",
".",
])
.output()
.context("failed to run git diff --numstat")?;
let mut result = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
result.push((
parts[0].to_string(),
parts[1].to_string(),
parts[2].to_string(),
));
}
}
Ok(result)
}
pub fn diff_file(
gitdir: &Path,
worktree: &Path,
from: &str,
to: &str,
file: &str,
) -> Result<String> {
let output = Command::new("git")
.args([
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
"--git-dir",
&gitdir.to_string_lossy(),
"--work-tree",
&worktree.to_string_lossy(),
"diff",
"--no-ext-diff",
"--no-renames",
from,
to,
"--",
file,
])
.output()
.context("failed to run git diff for file")?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}