limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Cross-repo discovery used by `--all` and `limb pick`.
//!
//! A "repo" is any directory that contains either a `.bare/` subdirectory
//! (bare-clone layout, where each worktree lives as a sibling) or a `.git`
//! entry (plain clone, file or directory). Non-repo children are ignored.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

/// Returns the direct children of `root` that look like git repos.
///
/// Results are sorted alphabetically by path. Nested repositories are not
/// descended into. `limb` is shallow on purpose, since
/// `projects.roots` is meant to enumerate workspaces, not trees.
///
/// # Errors
///
/// Returns an error if `root` cannot be read (missing directory, EACCES,
/// etc.).
pub fn repos_under(root: &Path) -> Result<Vec<PathBuf>> {
    let mut out: Vec<PathBuf> = std::fs::read_dir(root)
        .with_context(|| format!("read {}", root.display()))?
        .filter_map(std::result::Result::ok)
        .map(|e| e.path())
        .filter(|p| p.is_dir() && is_repo(p))
        .collect();
    out.sort();
    Ok(out)
}

/// Returns `true` if `dir` looks like a git repo root.
///
/// Detects both the bare-clone layout (`.bare/` subdirectory) and the plain
/// layout (`.git` file or directory).
#[must_use]
pub fn is_repo(dir: &Path) -> bool {
    dir.join(".bare").is_dir() || dir.join(".git").exists()
}

/// Returns the path that should be passed as `git -C` for operations on
/// `repo_dir`, or `None` if the directory is not a repo.
///
/// For bare clones this is the `.bare/` subdirectory; for plain clones
/// it is the directory itself (git finds `.git/` from there).
#[must_use]
pub fn anchor_for(repo_dir: &Path) -> Option<PathBuf> {
    let bare = repo_dir.join(".bare");
    if bare.is_dir() {
        Some(bare)
    } else if repo_dir.join(".git").exists() {
        Some(repo_dir.to_path_buf())
    } else {
        None
    }
}

/// Returns the display name for `repo_dir`. Its file-name component.
///
/// Returns an empty string if the path has no trailing component (e.g. `/`).
///
/// # Examples
///
/// ```
/// use std::path::Path;
/// use limb::discover::repo_name;
///
/// assert_eq!(repo_name(Path::new("/a/b/myrepo")), "myrepo");
/// assert_eq!(repo_name(Path::new("/")), "");
/// ```
#[must_use]
pub fn repo_name(repo_dir: &Path) -> String {
    repo_dir
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default()
}

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

    fn tmp() -> TempDir {
        tempfile::tempdir().expect("tempdir")
    }

    #[test]
    fn detects_bare_clone() {
        let d = tmp();
        std::fs::create_dir_all(d.path().join(".bare")).unwrap();
        assert!(is_repo(d.path()));
        assert_eq!(anchor_for(d.path()), Some(d.path().join(".bare")));
    }

    #[test]
    fn detects_plain_clone() {
        let d = tmp();
        std::fs::create_dir_all(d.path().join(".git")).unwrap();
        assert!(is_repo(d.path()));
        assert_eq!(anchor_for(d.path()), Some(d.path().to_path_buf()));
    }

    #[test]
    fn detects_git_file_in_worktree() {
        let d = tmp();
        std::fs::write(d.path().join(".git"), "gitdir: /somewhere\n").unwrap();
        assert!(is_repo(d.path()));
        assert_eq!(anchor_for(d.path()), Some(d.path().to_path_buf()));
    }

    #[test]
    fn not_repo_when_empty() {
        let d = tmp();
        assert!(!is_repo(d.path()));
        assert!(anchor_for(d.path()).is_none());
    }

    #[test]
    fn repos_under_finds_immediate_children() {
        let root = tmp();
        std::fs::create_dir_all(root.path().join("a/.bare")).unwrap();
        std::fs::create_dir_all(root.path().join("b/.git")).unwrap();
        std::fs::create_dir_all(root.path().join("not-a-repo")).unwrap();
        let found = repos_under(root.path()).unwrap();
        assert_eq!(found.len(), 2);
        assert_eq!(found[0].file_name().unwrap(), "a");
        assert_eq!(found[1].file_name().unwrap(), "b");
    }

    #[test]
    fn repos_under_missing_root_errors() {
        assert!(repos_under(Path::new("/nonexistent/path")).is_err());
    }

    #[test]
    fn repo_name_returns_basename() {
        assert_eq!(repo_name(Path::new("/a/b/myrepo")), "myrepo");
    }
}