wsb 0.255.6379

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Workspace resolution — finding the right roots from any CWD.
//!
//! Three tiers of path resolution:
//!
//! 1. **CWD** — the directory where the user invoked `wsb`.
//!    Used by: refactor (file operations work relative to CWD).
//!
//! 2. **Project root** — nearest ancestor directory containing `.wsb/state.json`.
//!    Used by: update (reads StateConfig, WorkspaceState), template commands,
//!    init (creates `.wsb/` here), state config.
//!
//! 3. **Super root** — the most senior git repository root found by walking up
//!    from the project root. When there are no submodules, super root == project root.
//!    Used by: serve (DB location), session sync (the wsb.db lives here).
//!
//! In the common case (no submodules), project root == super root.
//! In a submodule scenario:
//!
//!   ~/projects/my-org/           ← super root (.git, .wsb/wsb.db)
//!     my-submodule/              ← project root (.git, .wsb/state.json)
//!       src/                     ← CWD
//!
//! Commands resolve what they need:
//!
//! | Command      | CWD | Project root | Super root |
//! |--------------|-----|-------------|------------|
//! | init         | ✓   | (creates)   | —          |
//! | update       | —   | ✓           | —          |
//! | serve        | —   | —           | ✓+         |
//! | refactor        | ✓   | —           | —          |
//! | template     | —   | ✓           | —          |
//! | db operations| —   | —           | ✓          |

use crate::prelude::*;
use std::path::PathBuf;

/// Walk up from `start_dir` looking for `.wsb/state.json`.
/// Returns the first ancestor (including `start_dir` itself) that contains it,
/// or `None` if no workspace is found.
pub fn find_project_root(start_dir: &Path) -> Option<PathBuf> {
    let mut dir = start_dir;
    loop {
        if dir.join(".wsb").join("state.json").exists() {
            return Some(dir.to_path_buf());
        }
        dir = dir.parent()?;
    }
}

/// Walk up from `start_dir` past all git repositories to find the most senior
/// (top-level) git repo root. This traverses submodule boundaries.
///
/// If `start_dir` is not inside any git repo, returns `None`.
pub fn find_super_root(start_dir: &Path) -> Option<PathBuf> {
    let repo = gix::discover(start_dir).ok()?;
    let workdir = repo.workdir()?.to_path_buf();

    // Walk up from the discovered repo's workdir looking for a parent git repo.
    // If we find one, that parent is the super root — recurse upward.
    // If not, this repo is the topmost.
    let mut current = workdir;
    while let Some(parent) = current.parent() {
        // Skip the root dir (no point checking /)
        if parent == current {
            break;
        }
        // Check if parent directory is a different git repo.
        // Use .git dir existence as a quick check — submodule parents
        // have their own .git as a file pointing to the super repo.
        if parent.join(".git").exists() {
            // Verify it's a real separate repo by trying to discover it
            if let Ok(parent_repo) = gix::discover(parent) {
                if let Some(parent_workdir) = parent_repo.workdir() {
                    let parent_workdir = parent_workdir.to_path_buf();
                    // Only continue if the parent is truly above us
                    if current.starts_with(&parent_workdir) {
                        current = parent_workdir;
                        continue;
                    }
                }
            }
        }
        break;
    }
    Some(current)
}

/// The workspace `start_dir` is in: its super root ([`find_super_root`]),
/// when wsb is set up there (`.wsb/state.json`, which `wsb init` writes).
///
/// A git repository is not a workspace by being one. Every repository on a
/// machine used to qualify, so a command run in `~/.claude` — a repository
/// of its own — had the daemon register it as a workspace, and nine commands
/// started nine daemons for it. The one definition the store and the daemon
/// both use.
pub fn find_workspace_root(start_dir: &Path) -> Option<PathBuf> {
    find_super_root(start_dir).filter(|root| {
        root.join(".wsb").join("state.json").is_file()
    })
}

/// Find the super root, falling back to the project root if no git repo is found.
/// This ensures we always have a valid DB location.
pub fn find_super_root_or_project(start_dir: &Path) -> PathBuf {
    find_super_root(start_dir)
        .or_else(|| find_project_root(start_dir))
        .unwrap_or_else(|| start_dir.to_path_buf())
}

/// Find the project root or fail with a descriptive error.
pub fn require_project_root(start_dir: &Path) -> Result<PathBuf> {
    find_project_root(start_dir).with_context(|| {
        format!(
            "Not in a wsb workspace (no .wsb/state.json found walking up from {}). \
             Run 'wsb init' to initialize one.",
            start_dir.display()
        )
    })
}

/// The files in `.wsb/` that are declarations a workspace commits, rather than
/// state: what its templates read, its test families, its continuity policy.
pub const DECLARATIONS: [&str; 3] = [
    crate::state::stamp_config::FILE,
    crate::catalogue::families::FILE,
    crate::peer::POLICY_FILE,
];

/// The lines `.git/info/exclude` carries for a workspace: everything in `.wsb/`
/// but the declarations.
///
/// `.wsb/*` rather than `.wsb/`: git never looks inside an excluded directory,
/// so a file in one cannot be taken back by a later `!` line, and the
/// declarations would be invisible to `git status` and `git add`.
fn exclusion_lines() -> Vec<String> {
    std::iter::once(".wsb/*".to_string())
        .chain(DECLARATIONS.iter().map(|file| format!("!{file}")))
        .collect()
}

/// Whether an exclude file hides `.wsb/` state as [`exclude_state_dir`] writes
/// it: every line of it, and no line that hides the whole directory.
pub fn excludes_state(content: &str) -> bool {
    exclusion_lines().iter().all(|ours| content.lines().any(|line| line.trim() == ours))
        && !content.lines().any(hides_the_directory)
}

/// Whether an exclude line hides `.wsb` itself, so that nothing in it — the
/// declarations included — can be seen.
///
/// Every spelling of it, not only the one an older wsb wrote: `.wsb` without
/// the slash is the same exclusion, and a checkout that had one kept its
/// committed declarations invisible to `git status` and `git add` after the
/// rewrite meant to show them.
fn hides_the_directory(line: &str) -> bool {
    matches!(line.trim(), ".wsb" | ".wsb/" | "/.wsb" | "/.wsb/")
}

/// Make git ignore this workspace's `.wsb/` state, machine-locally, and see
/// the declarations committed beside it.
///
/// Anything that CREATES `.wsb` inside a repository must do this, because the
/// rest of what is in there is machine-local state — a database, logs, a
/// daemon's rendezvous — and none of it belongs to the repository: merely
/// untracked, it turns up in every `git status` and blocks a pull that touches
/// those paths. The declarations ([`DECLARATIONS`]) are the exception, and are
/// committed like any other file.
///
/// Written to the repository's `info/exclude` rather than `.gitignore`: the exclusion is
/// this machine's business, and several of the repositories this tool works on
/// are public. Idempotent, and safe to call on every registration; an exclude
/// written by an older wsb, which hid the whole directory, is brought up to
/// date in place.
pub fn exclude_state_dir(root: &Path) -> Result<()> {
    let exclude = exclude_file(root)?;
    if let Some(parent) = exclude.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let existing = match std::fs::read_to_string(&exclude) {
        Ok(text) => text,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(error) => {
            return Err(error).with_context(|| format!("cannot read {}", exclude.display()))
        }
    };
    let wanted = exclusion_lines();
    // Every line but ours, in order: what somebody else excluded stays.
    let mut lines: Vec<&str> = existing
        .lines()
        .filter(|line| !hides_the_directory(line) && !wanted.iter().any(|ours| ours == line.trim()))
        .collect();
    if excludes_state(&existing) {
        return Ok(());
    }
    lines.extend(wanted.iter().map(String::as_str));
    let mut content = lines.join("\n");
    content.push('\n');
    std::fs::write(&exclude, content)
        .with_context(|| format!("cannot write {}", exclude.display()))?;
    Ok(())
}

/// The exclude file git reads for the checkout at `root`.
///
/// Asked of git, never worked out from `.git`: a submodule's `.git` is a file
/// pointing at its repository, and a worktree's points at a directory of its
/// own that git reads no `info/exclude` from — it reads the one in the
/// repository every worktree shares. Written beside a worktree's own git
/// directory, the exclusion was never read, and `.wsb/` showed as untracked
/// in every worktree but main.
fn exclude_file(root: &Path) -> Result<PathBuf> {
    let output = crate::git_backend::git_command()
        .arg("-C")
        .arg(root)
        .args(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"])
        .output()
        .with_context(|| format!("cannot run git in {}", root.display()))?;
    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "{} is not a git repository, so there is nowhere to record the exclusion: {}",
            root.display(),
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }
    Ok(PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()))
}

#[cfg(test)]
mod exclude_tests {
    use super::*;

    fn repository() -> tempfile::TempDir {
        let dir = tempfile::tempdir().expect("a directory");
        let status = crate::git_backend::git_command()
            .args(["init", "--quiet"])
            .current_dir(dir.path())
            .status()
            .expect("git runs");
        assert!(status.success());
        dir
    }

    fn untracked(root: &Path) -> Vec<String> {
        let out = crate::git_backend::git_command()
            .args(["status", "--porcelain", "--untracked-files=all"])
            .current_dir(root)
            .output()
            .expect("git runs");
        String::from_utf8_lossy(&out.stdout)
            .lines()
            .map(|line| line[3..].to_string())
            .collect()
    }

    /// TEST20014: git ignores `.wsb/` state and still sees the declarations.
    ///
    /// The exclude hid the whole directory, so `.wsb/stamp.json`,
    /// `.wsb/test.json` and `.wsb/peer.json` — files a workspace is meant to
    /// commit — could only be added with `git add -f`, and a changed one never
    /// showed in `git status`. An exclude written that way by an older wsb is
    /// rewritten, and what else it held is kept.
    #[test]
    fn test20014_state_is_ignored_and_declarations_are_not() {
        let dir = repository();
        let root = dir.path();
        let exclude = root.join(".git").join("info").join("exclude");
        std::fs::create_dir_all(exclude.parent().unwrap()).unwrap();
        std::fs::write(&exclude, "*.swp\n.wsb/\n").unwrap();
        std::fs::create_dir_all(root.join(".wsb").join("disposable")).unwrap();
        for file in ["state.json", "wsb.db", "disposable/log.jsonl", "stamp.json", "test.json", "peer.json"] {
            std::fs::write(root.join(".wsb").join(file), "{}\n").unwrap();
        }

        exclude_state_dir(root).unwrap();

        let mut seen = untracked(root);
        seen.sort();
        assert_eq!(seen, vec![".wsb/peer.json", ".wsb/stamp.json", ".wsb/test.json"]);
        let written = std::fs::read_to_string(&exclude).unwrap();
        assert!(written.lines().any(|line| line == "*.swp"), "another exclusion was lost: {written}");
        assert!(!written.lines().any(|line| line == ".wsb/"), "the old line was kept: {written}");

        exclude_state_dir(root).unwrap();
        assert_eq!(std::fs::read_to_string(&exclude).unwrap(), written, "a second call changed the file");
    }

    /// TEST20051: every spelling that hides `.wsb` is replaced.
    ///
    /// `.wsb` without the slash hides the directory exactly as `.wsb/` does,
    /// and was left in place: a checkout carrying it kept its committed
    /// declarations invisible after the rewrite meant to show them.
    #[test]
    fn test20051_every_spelling_that_hides_the_directory_is_replaced() {
        for hiding in [".wsb", ".wsb/", "/.wsb", "/.wsb/"] {
            let dir = repository();
            let root = dir.path();
            let exclude = root.join(".git").join("info").join("exclude");
            std::fs::create_dir_all(exclude.parent().unwrap()).unwrap();
            std::fs::write(&exclude, format!("{hiding}\n")).unwrap();
            std::fs::create_dir_all(root.join(".wsb")).unwrap();
            std::fs::write(root.join(".wsb/wsb.db"), "db").unwrap();
            std::fs::write(root.join(".wsb/stamp.json"), "{}").unwrap();

            exclude_state_dir(root).unwrap();

            assert_eq!(untracked(root), vec![".wsb/stamp.json"], "`{hiding}` still hides the declarations");
        }
    }

    /// TEST20041: a worktree's `.wsb/` state is ignored, as main's is.
    ///
    /// git reads `info/exclude` from the repository its worktrees share, not
    /// from a worktree's own git directory, which is where the exclusion was
    /// written — so `.wsb/` showed as untracked in every worktree but main.
    #[test]
    fn test20041_a_worktrees_state_is_ignored() {
        let dir = repository();
        let root = dir.path();
        let run = |dir: &Path, args: &[&str]| {
            let output = crate::git_backend::git_command()
                .arg("-C")
                .arg(dir)
                .args(["-c", "user.name=t", "-c", "user.email=t@t"])
                .args(args)
                .output()
                .expect("git runs");
            assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
        };
        run(root, &["commit", "--quiet", "--allow-empty", "-m", "one"]);
        let worktree = root.join("fix-worktree");
        run(root, &["worktree", "add", "--quiet", "-b", "fix", worktree.to_str().unwrap()]);
        std::fs::create_dir_all(worktree.join(".wsb")).unwrap();
        std::fs::write(worktree.join(".wsb/wsb.db"), "db").unwrap();
        std::fs::write(worktree.join(".wsb/stamp.json"), "{}").unwrap();

        exclude_state_dir(&worktree).unwrap();

        assert_eq!(untracked(&worktree), vec![".wsb/stamp.json"], "the state is hidden, the declaration is not");
        assert!(excludes_state(&std::fs::read_to_string(root.join(".git/info/exclude")).unwrap()));
    }
}