gitpane 0.7.14

Multi-repo Git workspace dashboard TUI
Documentation
use crate::config::Config;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

/// Cheap check that a `.git` entry looks real. A bare `mkdir .git` (which
/// can happen by accident, e.g. an aborted clone) is enough to fool a
/// `.git.exists()` test but `Repository::open` then fails on every status
/// query, so we treat such paths as not-a-repo at discovery time. Anything
/// produced by `git init` will have a `HEAD` file; that is what we look for.
///
/// Two layouts are accepted:
/// - A standard checkout, where `.git` is a directory containing `HEAD`.
/// - A submodule or linked worktree, where `.git` is a *file* of the form
///   `gitdir: <path>` pointing at the real git directory (e.g.
///   `<superproject>/.git/modules/<name>`). Without this branch, pinning a
///   submodule would pass `AddRepo`'s `.git.exists()` check but then vanish on
///   the next FS-driven rescan, because discovery couldn't find its `HEAD`.
fn is_real_git_dir(dot_git: &Path) -> bool {
    if dot_git.join("HEAD").is_file() {
        return true;
    }
    if dot_git.is_file()
        && let Some(gitdir) = read_gitdir_pointer(dot_git)
    {
        return gitdir.join("HEAD").is_file();
    }
    false
}

/// Resolve a `gitdir: <path>` pointer file (used by submodules and linked
/// worktrees) to the git directory it references. Relative targets resolve
/// against the directory holding the pointer file.
fn read_gitdir_pointer(dot_git_file: &Path) -> Option<PathBuf> {
    let contents = std::fs::read_to_string(dot_git_file).ok()?;
    let target = Path::new(contents.trim().strip_prefix("gitdir:")?.trim());
    if target.is_absolute() {
        Some(target.to_path_buf())
    } else {
        Some(dot_git_file.parent()?.join(target))
    }
}

pub(crate) fn discover_repos(config: &Config) -> Vec<PathBuf> {
    let mut seen = HashSet::new();
    let mut repos = Vec::new();

    // Pinned repos first
    for pinned in &config.pinned_repos {
        let canonical = pinned.canonicalize().unwrap_or_else(|_| pinned.clone());
        if is_real_git_dir(&canonical.join(".git")) && seen.insert(canonical.clone()) {
            repos.push(canonical);
        }
    }

    // Discover from root dirs
    for root in &config.root_dirs {
        if !root.exists() {
            continue;
        }
        for entry in WalkDir::new(root)
            .max_depth(config.scan_depth)
            .follow_links(false)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            if entry.file_name() == ".git"
                && entry.file_type().is_dir()
                && is_real_git_dir(entry.path())
            {
                let repo_path = entry
                    .path()
                    .parent()
                    .unwrap()
                    .canonicalize()
                    .unwrap_or_else(|_| entry.path().parent().unwrap().to_path_buf());

                // Check exclusions
                let repo_name = repo_path
                    .file_name()
                    .map(|n| n.to_string_lossy().to_string())
                    .unwrap_or_default();

                let path_str = repo_path.to_string_lossy();
                let excluded = config
                    .excluded_repos
                    .iter()
                    .any(|pattern| repo_name == *pattern || path_str.contains(pattern));

                if !excluded && seen.insert(repo_path.clone()) {
                    repos.push(repo_path);
                }
            }
        }
    }

    repos.sort_by(|a, b| {
        a.file_name()
            .unwrap_or_default()
            .to_ascii_lowercase()
            .cmp(&b.file_name().unwrap_or_default().to_ascii_lowercase())
    });

    // Re-prepend pinned repos at the top (they were sorted away)
    let pinned_set: HashSet<PathBuf> = config
        .pinned_repos
        .iter()
        .filter_map(|p| p.canonicalize().ok())
        .collect();

    if !pinned_set.is_empty() {
        let mut pinned: Vec<PathBuf> = repos
            .iter()
            .filter(|r| pinned_set.contains(*r))
            .cloned()
            .collect();
        let rest: Vec<PathBuf> = repos
            .into_iter()
            .filter(|r| !pinned_set.contains(r))
            .collect();
        pinned.extend(rest);
        repos = pinned;
    }

    repos
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn make_repo(parent: &std::path::Path, name: &str) -> PathBuf {
        let repo_dir = parent.join(name);
        let dot_git = repo_dir.join(".git");
        fs::create_dir_all(&dot_git).unwrap();
        // Mimic `git init`: a HEAD file is the minimum is_real_git_dir checks.
        fs::write(dot_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
        repo_dir
    }

    /// A git submodule (or linked worktree): `.git` is a *file* containing a
    /// `gitdir:` pointer to the real git directory, not a directory of its own.
    /// `make_repo` can't model this because it always creates a `.git` dir.
    fn make_submodule(
        parent: &std::path::Path,
        name: &str,
        super_git: &std::path::Path,
    ) -> PathBuf {
        let repo_dir = parent.join(name);
        fs::create_dir_all(&repo_dir).unwrap();
        // The real git dir lives under the superproject, e.g.
        // `<super>/.git/modules/<name>`, and carries the HEAD file.
        let module_git = super_git.join("modules").join(name);
        fs::create_dir_all(&module_git).unwrap();
        fs::write(module_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
        // The working tree's `.git` is a pointer file with an absolute target.
        fs::write(
            repo_dir.join(".git"),
            format!("gitdir: {}\n", module_git.display()),
        )
        .unwrap();
        repo_dir
    }

    /// Empty `.git` directory with no HEAD file — what an aborted clone or a
    /// stray `mkdir .git` looks like. Discovery must NOT pick this up because
    /// `Repository::open` will fail on every status query, which the watcher
    /// then loops on, producing infinite red error toasts in the status bar.
    fn make_phantom_git_dir(parent: &std::path::Path, name: &str) -> PathBuf {
        let repo_dir = parent.join(name);
        fs::create_dir_all(repo_dir.join(".git")).unwrap();
        repo_dir
    }

    #[test]
    fn test_discover_finds_git_repos() {
        let tmp = TempDir::new().unwrap();
        make_repo(tmp.path(), "alpha");
        make_repo(tmp.path(), "beta");

        let config = Config {
            root_dirs: vec![tmp.path().to_path_buf()],
            scan_depth: 2,
            ..Config::default()
        };

        let repos = discover_repos(&config);
        assert_eq!(repos.len(), 2);
    }

    #[test]
    fn test_excluded_repos_are_filtered() {
        let tmp = TempDir::new().unwrap();
        make_repo(tmp.path(), "good-repo");
        make_repo(tmp.path(), "node_modules");

        let config = Config {
            root_dirs: vec![tmp.path().to_path_buf()],
            excluded_repos: vec!["node_modules".into()],
            scan_depth: 2,
            ..Config::default()
        };

        let repos = discover_repos(&config);
        assert_eq!(repos.len(), 1);
        assert!(repos[0].ends_with("good-repo"));
    }

    #[test]
    fn test_discover_skips_phantom_dot_git_at_root() {
        // Reproduces the gcloud-h100 case: `~/Code/.git/` exists as an empty
        // dir (no HEAD), and `~/Code` is the configured root_dir. Without
        // the HEAD check, discover_repos would emit `~/Code` itself as a
        // repo, then every file change anywhere under `~/Code` would route
        // to it via the watcher's classifier, `Repository::open` would fail,
        // and we'd surface a Failed-to-query toast per event.
        let tmp = TempDir::new().unwrap();
        make_phantom_git_dir(tmp.path(), ""); // creates tmp/.git (empty)
        make_repo(tmp.path(), "real-repo");

        let config = Config {
            root_dirs: vec![tmp.path().to_path_buf()],
            scan_depth: 2,
            ..Config::default()
        };

        let repos = discover_repos(&config);
        assert_eq!(repos.len(), 1, "got {repos:?}");
        assert!(repos[0].ends_with("real-repo"));
    }

    #[test]
    fn test_discover_skips_phantom_dot_git_in_child() {
        let tmp = TempDir::new().unwrap();
        make_phantom_git_dir(tmp.path(), "broken");
        make_repo(tmp.path(), "ok");

        let config = Config {
            root_dirs: vec![tmp.path().to_path_buf()],
            scan_depth: 2,
            ..Config::default()
        };

        let repos = discover_repos(&config);
        assert_eq!(repos.len(), 1, "got {repos:?}");
        assert!(repos[0].ends_with("ok"));
    }

    #[test]
    fn test_pinned_phantom_repo_is_skipped() {
        let tmp = TempDir::new().unwrap();
        let phantom = make_phantom_git_dir(tmp.path(), "phantom");
        let real = make_repo(tmp.path(), "real");

        let config = Config {
            root_dirs: vec![],
            pinned_repos: vec![phantom, real.clone()],
            scan_depth: 2,
            ..Config::default()
        };

        let repos = discover_repos(&config);
        assert_eq!(repos.len(), 1, "got {repos:?}");
        assert!(repos[0].ends_with("real"));
    }

    /// Regression: pinning a submodule (whose `.git` is a pointer file, not a
    /// directory) must survive discovery. Previously `is_real_git_dir` only
    /// accepted a `.git` directory with HEAD, so a pinned submodule passed
    /// `AddRepo` but was pruned on the next FS-driven rescan, vanishing from
    /// the list almost immediately after being added.
    #[test]
    fn test_pinned_submodule_is_discovered() {
        let tmp = TempDir::new().unwrap();
        let super_git = tmp.path().join("superproject").join(".git");
        fs::create_dir_all(&super_git).unwrap();
        let submodule = make_submodule(tmp.path(), "vendored-lib", &super_git);

        let config = Config {
            root_dirs: vec![],
            pinned_repos: vec![submodule.clone()],
            scan_depth: 2,
            ..Config::default()
        };

        let repos = discover_repos(&config);
        assert_eq!(repos.len(), 1, "got {repos:?}");
        assert!(repos[0].ends_with("vendored-lib"));
    }

    /// A relative `gitdir:` pointer (the form real `git submodule` writes,
    /// e.g. `gitdir: ../../.git/modules/<name>`) must resolve against the
    /// working tree, not the process CWD.
    #[test]
    fn test_pinned_submodule_relative_gitdir_is_discovered() {
        let tmp = TempDir::new().unwrap();
        // Layout: tmp/super/.git/modules/sub  and  tmp/super/deps/sub
        let super_dir = tmp.path().join("super");
        let module_git = super_dir.join(".git").join("modules").join("sub");
        fs::create_dir_all(&module_git).unwrap();
        fs::write(module_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
        let work = super_dir.join("deps").join("sub");
        fs::create_dir_all(&work).unwrap();
        // Relative pointer from tmp/super/deps/sub back to the module git dir.
        fs::write(work.join(".git"), "gitdir: ../../.git/modules/sub\n").unwrap();

        let config = Config {
            root_dirs: vec![],
            pinned_repos: vec![work.clone()],
            scan_depth: 2,
            ..Config::default()
        };

        let repos = discover_repos(&config);
        assert_eq!(repos.len(), 1, "got {repos:?}");
        assert!(repos[0].ends_with("sub"));
    }

    #[test]
    fn test_pinned_repos_appear_first() {
        let tmp = TempDir::new().unwrap();
        let z_repo = make_repo(tmp.path(), "z-repo");
        make_repo(tmp.path(), "a-repo");

        let config = Config {
            root_dirs: vec![tmp.path().to_path_buf()],
            pinned_repos: vec![z_repo.clone()],
            scan_depth: 2,
            ..Config::default()
        };

        let repos = discover_repos(&config);
        assert_eq!(repos.len(), 2);
        assert!(repos[0].ends_with("z-repo"));
    }
}