use ignore::WalkBuilder;
use ignore::WalkState;
use std::sync::{Arc, Mutex};
fn detect_index_hash_kind(repo_root: &std::path::Path) -> gix_hash::Kind {
let config_path = repo_root.join(".git/config");
let Ok(config) = std::fs::read_to_string(&config_path) else {
return gix_hash::Kind::Sha1;
};
let mut in_extensions = false;
for raw in config.lines() {
let line = raw
.split(['#', ';'])
.next()
.unwrap_or("")
.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix('[') {
let section = rest.trim_end_matches(']').trim();
let name = section.split_whitespace().next().unwrap_or("");
in_extensions = name.eq_ignore_ascii_case("extensions");
continue;
}
if !in_extensions {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
if key.trim().eq_ignore_ascii_case("objectformat")
&& value.trim().eq_ignore_ascii_case("sha256")
{
return gix_hash::Kind::Sha256;
}
}
gix_hash::Kind::Sha1
}
pub fn list_files(root: &str) -> Result<Vec<String>, String> {
let files: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let walker = WalkBuilder::new(root)
.hidden(false)
.ignore(false)
.filter_entry(|e| {
e.file_name() != ".git" && e.file_name() != ".jj"
})
.build_parallel();
walker.run(|| {
let files = Arc::clone(&files);
Box::new(move |entry| {
let Ok(e) = entry else {
return WalkState::Continue;
};
let is_file = e.file_type().map(|t| t.is_file()).unwrap_or(false);
if !is_file {
return WalkState::Continue;
}
if let Some(s) = e.path().to_str() {
files.lock().unwrap().push(s.to_string());
}
WalkState::Continue
})
});
let mut files = match Arc::try_unwrap(files) {
Ok(m) => m.into_inner().map_err(|e| format!("walk poisoned: {}", e))?,
Err(arc) => arc.lock().map_err(|e| format!("walk poisoned: {}", e))?.clone(),
};
let root_path = std::path::Path::new(root);
let index_path = root_path.join(".git/index");
let hash_kind = detect_index_hash_kind(root_path);
if let Ok(index) = gix_index::File::at(
&index_path,
hash_kind,
true,
gix_index::decode::Options::default(),
) {
let mut seen: std::collections::HashSet<String> = files
.iter()
.map(|p| p.trim_start_matches("./").to_string())
.collect();
for entry in index.entries() {
let mode = entry.mode;
if !mode.contains(gix_index::entry::Mode::FILE)
&& !mode.contains(gix_index::entry::Mode::FILE_EXECUTABLE)
{
continue;
}
let path_bytes: &[u8] = entry.path(&index);
let Ok(rel) = std::str::from_utf8(path_bytes) else {
continue;
};
let normalized = rel.trim_start_matches("./").to_string();
if seen.insert(normalized.clone()) {
files.push(format!("./{}", normalized));
}
}
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::list_files;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
fn unique_tmp(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"fs-walk-test-{}-{}",
label,
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create tmp dir");
dir
}
fn run_git(dir: &PathBuf, args: &[&str]) {
let git_bin = if std::path::Path::new("/usr/bin/git").exists() {
"/usr/bin/git"
} else {
"git"
};
let status = Command::new(git_bin)
.args(args)
.current_dir(dir)
.status()
.expect("git command failed to spawn");
assert!(
status.success(),
"git {:?} failed in {:?}",
args,
dir
);
}
#[test]
fn list_files_includes_force_added_gitignored_file() {
let dir = unique_tmp("bug3-tracked-ignored");
run_git(&dir, &["init", "-q"]);
run_git(&dir, &["config", "user.email", "t@t"]);
run_git(&dir, &["config", "user.name", "t"]);
fs::write(dir.join(".gitignore"), "*.ignored\n").expect("write .gitignore");
fs::write(dir.join("tracked.ignored"), "secret content")
.expect("write tracked.ignored");
fs::write(dir.join("normal.txt"), "normal content").expect("write normal.txt");
run_git(&dir, &["add", "-f", ".gitignore", "tracked.ignored", "normal.txt"]);
run_git(
&dir,
&["commit", "-q", "-m", "initial", ".gitignore", "tracked.ignored", "normal.txt"],
);
let files = list_files(dir.to_str().expect("dir utf8")).expect("list_files");
let basenames: Vec<String> = files
.iter()
.filter_map(|p| {
std::path::Path::new(p)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.collect();
assert!(
basenames.iter().any(|b| b == "normal.txt"),
"normal tracked file must be listed; got {:?}",
basenames
);
assert!(
basenames.iter().any(|b| b == "tracked.ignored"),
"BUG 3: force-added gitignored file must be listed; got {:?}",
basenames
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn list_files_excludes_submodule_gitlink_entries() {
let dir = unique_tmp("gix-mode-filter");
run_git(&dir, &["init", "-q"]);
run_git(&dir, &["config", "user.email", "t@t"]);
run_git(&dir, &["config", "user.name", "t"]);
fs::write(dir.join("real.txt"), "ordinary").expect("write real.txt");
run_git(&dir, &["add", "-f", "real.txt"]);
run_git(
&dir,
&[
"update-index",
"--add",
"--cacheinfo",
"160000,0000000000000000000000000000000000000001,vendor/sub",
],
);
run_git(
&dir,
&["commit", "-q", "-m", "initial", "real.txt"],
);
let files = list_files(dir.to_str().expect("dir utf8")).expect("list_files");
let normalized: Vec<String> = files
.iter()
.map(|p| p.trim_start_matches("./").to_string())
.collect();
assert!(
normalized.iter().any(|p| p.ends_with("real.txt")),
"regular tracked file must still be listed; got {:?}",
normalized
);
assert!(
!normalized.iter().any(|p| p.ends_with("vendor/sub")),
"submodule gitlink (Mode::COMMIT) must NOT be listed; got {:?}",
normalized
);
let _ = fs::remove_dir_all(&dir);
}
fn write_config(dir: &Path, contents: &str) {
let git_dir = dir.join(".git");
fs::create_dir_all(&git_dir).expect("create .git");
fs::write(git_dir.join("config"), contents).expect("write .git/config");
}
#[test]
fn detect_sha256_in_standard_config_shape() {
let dir = unique_tmp("detect-sha256-standard");
write_config(
&dir,
"[extensions]\n\tobjectformat = sha256\n[core]\n\tbare = false\n",
);
assert_eq!(super::detect_index_hash_kind(&dir), gix_hash::Kind::Sha256);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_sha1_when_extensions_absent() {
let dir = unique_tmp("detect-sha1-default");
write_config(&dir, "[core]\n\tbare = false\n[user]\n\temail = t@t\n");
assert_eq!(super::detect_index_hash_kind(&dir), gix_hash::Kind::Sha1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_sha1_when_config_missing() {
let dir = unique_tmp("detect-sha1-noconfig");
assert_eq!(super::detect_index_hash_kind(&dir), gix_hash::Kind::Sha1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_sha256_with_comments_and_irregular_whitespace() {
let dir = unique_tmp("detect-sha256-comments");
write_config(
&dir,
"# comment line\n[Extensions]\n ObjectFormat= sha256 ; trailing\n[core]\n",
);
assert_eq!(super::detect_index_hash_kind(&dir), gix_hash::Kind::Sha256);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_sha1_when_objectformat_is_outside_extensions_section() {
let dir = unique_tmp("detect-sha1-wrong-section");
write_config(
&dir,
"[core]\n\tobjectformat = sha256\n",
);
assert_eq!(super::detect_index_hash_kind(&dir), gix_hash::Kind::Sha1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn detect_sha256_with_extensions_subsection() {
let dir = unique_tmp("detect-sha256-subsection");
write_config(
&dir,
"[extensions \"weird\"]\n\tobjectformat = sha256\n",
);
assert_eq!(super::detect_index_hash_kind(&dir), gix_hash::Kind::Sha256);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn list_files_handles_sha256_repo_force_added_gitignored() {
let dir = unique_tmp("sha256-force-added");
run_git(&dir, &["init", "--object-format=sha256", "-q"]);
run_git(&dir, &["config", "user.email", "t@t"]);
run_git(&dir, &["config", "user.name", "t"]);
fs::write(dir.join(".gitignore"), "*.ignored\n").expect("write .gitignore");
fs::write(dir.join("tracked.ignored"), "secret content")
.expect("write tracked.ignored");
fs::write(dir.join("normal.txt"), "normal content").expect("write normal.txt");
run_git(&dir, &["add", "-f", ".gitignore", "tracked.ignored", "normal.txt"]);
run_git(
&dir,
&["commit", "-q", "-m", "initial", ".gitignore", "tracked.ignored", "normal.txt"],
);
let files = list_files(dir.to_str().expect("dir utf8")).expect("list_files");
let basenames: Vec<String> = files
.iter()
.filter_map(|p| {
std::path::Path::new(p)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.collect();
assert!(
basenames.iter().any(|b| b == "normal.txt"),
"sha256 repo: normal tracked file must be listed; got {:?}",
basenames
);
assert!(
basenames.iter().any(|b| b == "tracked.ignored"),
"sha256 repo: force-added gitignored file must be listed via \
gix-index path; got {:?}",
basenames
);
let _ = fs::remove_dir_all(&dir);
}
}