use crate::indexer::git::{GitAnalyzer, GitChangedFiles};
use std::path::{Path, PathBuf};
const NESTED_GIT_MAX_DEPTH: usize = 4;
const SKIP_DIR_NAMES: &[&str] = &[
".git",
"node_modules",
"vendor",
"target",
"dist",
"build",
".worktrees",
".claude",
"browser-data",
];
pub fn has_git_context(root: &Path) -> bool {
GitAnalyzer::is_git_repo_at(root) || !discover_nested_git_repos(root).is_empty()
}
pub fn discover_nested_git_repos(root: &Path) -> Vec<PathBuf> {
let mut repos = Vec::new();
if !root.is_dir() {
return repos;
}
if GitAnalyzer::is_git_repo_at(root) {
repos.push(root.to_path_buf());
return repos;
}
let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = stack.pop() {
if depth >= NESTED_GIT_MAX_DEPTH {
continue;
}
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !file_type.is_dir() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if SKIP_DIR_NAMES.iter().any(|s| *s == name_str.as_ref()) {
continue;
}
let git_marker = path.join(".git");
if git_marker.exists() {
repos.push(path);
continue;
}
stack.push((path, depth + 1));
}
}
repos.sort();
repos
}
pub fn workspace_last_commit_time(root: &Path) -> Result<i64, Box<dyn std::error::Error>> {
if GitAnalyzer::is_git_repo_at(root) {
return GitAnalyzer::get_last_commit_time_at(root);
}
let repos = discover_nested_git_repos(root);
if repos.is_empty() {
return Err("No git repository found at workspace root or nested paths".into());
}
let mut max_ts: i64 = 0;
let mut any_ok = false;
for repo in &repos {
match GitAnalyzer::get_last_commit_time_at(repo) {
Ok(ts) => {
any_ok = true;
max_ts = max_ts.max(ts);
}
Err(e) => {
tracing::debug!(
"Skipping nested repo {} for commit time: {}",
repo.display(),
e
);
}
}
}
if !any_ok {
return Err("Failed to read commit time from any nested git repo".into());
}
Ok(max_ts)
}
pub fn workspace_changed_files(root: &Path) -> Result<GitChangedFiles, Box<dyn std::error::Error>> {
if GitAnalyzer::is_git_repo_at(root) {
return GitAnalyzer::get_changed_files_since_last_commit_at(root);
}
let repos = discover_nested_git_repos(root);
if repos.is_empty() {
return Err("No git repository found for incremental indexing".into());
}
let mut modified = Vec::new();
let mut added = Vec::new();
let mut deleted = Vec::new();
for repo in &repos {
let prefix = relative_prefix(root, repo);
let changed = GitAnalyzer::get_changed_files_since_last_commit_at(repo)?;
modified.extend(prefix_paths(&prefix, changed.modified));
added.extend(prefix_paths(&prefix, changed.added));
deleted.extend(prefix_paths(&prefix, changed.deleted));
}
Ok(GitChangedFiles {
modified,
added,
deleted,
})
}
pub fn workspace_untracked_files(root: &Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
if GitAnalyzer::is_git_repo_at(root) {
return GitAnalyzer::get_untracked_files_at(root);
}
let repos = discover_nested_git_repos(root);
let mut out = Vec::new();
for repo in &repos {
let prefix = relative_prefix(root, repo);
let files = GitAnalyzer::get_untracked_files_at(repo)?;
out.extend(prefix_paths(&prefix, files));
}
Ok(out)
}
fn relative_prefix(root: &Path, repo: &Path) -> String {
repo.strip_prefix(root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default()
}
fn prefix_paths(prefix: &str, files: Vec<String>) -> Vec<String> {
if prefix.is_empty() {
return files;
}
files
.into_iter()
.map(|f| format!("{}/{}", prefix, f.replace('\\', "/")))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn init_repo(path: &Path) {
std::fs::create_dir_all(path).unwrap();
assert!(Command::new("git")
.args(["init"])
.current_dir(path)
.status()
.unwrap()
.success());
let _ = Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(path)
.status();
let _ = Command::new("git")
.args(["config", "user.name", "test"])
.current_dir(path)
.status();
std::fs::write(path.join("main.go"), "package main\n").unwrap();
assert!(Command::new("git")
.args(["add", "main.go"])
.current_dir(path)
.status()
.unwrap()
.success());
assert!(Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.status()
.unwrap()
.success());
}
#[test]
fn has_git_context_false_for_empty_dir() {
let dir = tempfile::tempdir().unwrap();
assert!(!has_git_context(dir.path()));
}
#[test]
fn has_git_context_true_for_root_repo() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
assert!(has_git_context(dir.path()));
}
#[test]
fn discovers_nested_repos_under_platform_layout() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let nested_a = root.join("platform-food").join("svc-restaurant");
let nested_b = root.join("platform-core").join("svc-autos");
init_repo(&nested_a);
init_repo(&nested_b);
std::fs::create_dir_all(root.join("docs")).unwrap();
let repos = discover_nested_git_repos(root);
assert_eq!(repos.len(), 2);
assert!(has_git_context(root));
assert!(!GitAnalyzer::is_git_repo_at(root));
let ts = workspace_last_commit_time(root).unwrap();
assert!(ts > 0);
}
#[test]
fn workspace_changed_files_prefixes_nested_paths() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let nested = root.join("platform-food").join("be-mailer");
init_repo(&nested);
std::fs::write(nested.join("extra.go"), "package main\n").unwrap();
let changed = workspace_changed_files(root).unwrap();
let untracked = workspace_untracked_files(root).unwrap();
assert!(
untracked
.iter()
.any(|f| f == "platform-food/be-mailer/extra.go"),
"untracked={:?}",
untracked
);
let _ = changed;
}
}