balls 0.3.0

Git-native task tracker for parallel agent workflows
Documentation
use crate::config::Config;
use crate::error::{BallError, Result};
use crate::git;
use crate::store_init::{ensure_main_gitignore, setup_state_branch, STATE_WORKTREE_REL};
use crate::store_paths::{find_main_root, resolve_tasks_dir, stealth_tasks_dir};
use crate::task::{self, Task};
use crate::task_io;
use fs2::FileExt;
use std::fs;
use std::path::{Path, PathBuf};

/// Acquire an exclusive flock on the given path. The lock is released when
/// the returned guard is dropped.
pub struct LockGuard(fs::File);
impl Drop for LockGuard {
    fn drop(&mut self) {
        let _ = FileExt::unlock(&self.0);
    }
}

pub fn task_lock(store: &Store, id: &str) -> Result<LockGuard> {
    task::validate_id(id)?;
    acquire_flock(&store.lock_dir().join(format!("{id}.lock")))
}

/// Acquire the store-wide state-worktree lock. Held for the duration
/// of any write sequence targeting the state branch (commit_task,
/// commit_staged, remove_task, close_and_archive). Serializes
/// concurrent bl invocations from different tasks so git's
/// `index.lock` never sees contention.
fn state_worktree_flock(store: &Store) -> Result<LockGuard> {
    acquire_flock(&store.lock_dir().join("state-worktree.lock"))
}

fn acquire_flock(path: &Path) -> Result<LockGuard> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let f = fs::OpenOptions::new()
        .create(true)
        .truncate(false)
        .read(true)
        .write(true)
        .open(path)?;
    f.lock_exclusive()?;
    Ok(LockGuard(f))
}

pub struct Store {
    /// The main repo root (git-common-dir's parent, effectively the primary checkout)
    pub root: PathBuf,
    /// True when tasks live outside the repo (stealth mode).
    pub stealth: bool,
    /// Resolved tasks directory (may be external in stealth mode).
    tasks_dir_path: PathBuf,
}

impl Store {
    /// Discover the project root from a starting directory.
    /// In a worktree, returns the main repo root so that all writes go there.
    pub fn discover(from: &Path) -> Result<Self> {
        let _worktree_root = git::git_root(from)?;
        let common_dir = git::git_common_dir(from)?;
        let main_root = find_main_root(&common_dir)?;
        let balls_dir = main_root.join(".balls");
        if !balls_dir.exists() {
            return Err(BallError::NotInitialized);
        }
        let (tasks_dir_path, stealth) = resolve_tasks_dir(&main_root);
        // Non-stealth mode requires the state worktree to exist. If it's
        // missing, the user cloned without running `bl init` — fail fast
        // with a clear message instead of letting reads/writes land in
        // limbo.
        if !stealth && !tasks_dir_path.exists() {
            return Err(BallError::NotInitialized);
        }
        // A repo with no user.email/user.name causes `git commit` to fail
        // with "Author identity unknown". Worktrees share the main repo's
        // config, so seeding identity here covers every command path that
        // goes through discover (create, claim, review, close, sync, ...).
        git::git_ensure_user(&main_root)?;
        Ok(Store { root: main_root, stealth, tasks_dir_path })
    }

    pub fn init(from: &Path, stealth: bool) -> Result<Self> {
        let repo_root = git::git_root(from)?;
        git::git_ensure_user(&repo_root)?;
        git::git_init_commit(&repo_root)?;

        let balls_dir = repo_root.join(".balls");
        let plugins_dir = balls_dir.join("plugins");
        let local_dir = balls_dir.join("local");
        let already = balls_dir.join("config.json").exists();

        fs::create_dir_all(&plugins_dir)?;
        fs::create_dir_all(local_dir.join("claims"))?;
        fs::create_dir_all(local_dir.join("lock"))?;
        fs::create_dir_all(local_dir.join("plugins"))?;

        let config_path = balls_dir.join("config.json");
        if !config_path.exists() {
            Config::default().save(&config_path)?;
        }

        let (tasks_dir_path, is_stealth) = if stealth {
            let ext = stealth_tasks_dir(&repo_root);
            fs::create_dir_all(&ext)?;
            fs::write(local_dir.join("tasks_dir"), ext.to_string_lossy().as_bytes())?;
            (ext, true)
        } else {
            setup_state_branch(&repo_root)?;
            (repo_root.join(".balls/worktree/.balls/tasks"), false)
        };

        ensure_main_gitignore(&repo_root, is_stealth)?;
        let plugins_keep = plugins_dir.join(".gitkeep");
        if !plugins_keep.exists() {
            fs::write(&plugins_keep, "")?;
        }

        let paths: Vec<&Path> = vec![
            Path::new(".balls/config.json"),
            Path::new(".balls/plugins/.gitkeep"),
            Path::new(".gitignore"),
        ];
        git::git_add(&repo_root, &paths)?;
        let msg = if already { "balls: reinitialize" } else { "balls: initialize" };
        git::git_commit(&repo_root, msg)?;

        Ok(Store { root: repo_root, stealth: is_stealth, tasks_dir_path })
    }

    pub fn balls_dir(&self) -> PathBuf {
        self.root.join(".balls")
    }

    pub fn tasks_dir(&self) -> PathBuf {
        self.tasks_dir_path.clone()
    }

    /// Directory where git operations against task state should run.
    /// In non-stealth mode this is the state worktree (commits land on
    /// the `balls/tasks` orphan branch, never on main). In stealth mode
    /// the concept is meaningless — callers should branch on `stealth`
    /// before using this.
    pub fn state_worktree_dir(&self) -> PathBuf {
        self.root.join(STATE_WORKTREE_REL)
    }

    pub fn local_dir(&self) -> PathBuf {
        self.balls_dir().join("local")
    }

    pub fn claims_dir(&self) -> PathBuf {
        self.local_dir().join("claims")
    }

    pub fn lock_dir(&self) -> PathBuf {
        self.local_dir().join("lock")
    }

    pub fn local_plugins_dir(&self) -> PathBuf {
        self.local_dir().join("plugins")
    }

    pub fn config_path(&self) -> PathBuf {
        self.balls_dir().join("config.json")
    }

    pub fn load_config(&self) -> Result<Config> {
        Config::load(&self.config_path())
    }

    pub fn worktrees_root(&self) -> Result<PathBuf> {
        let cfg = self.load_config()?;
        Ok(self.root.join(cfg.worktree_dir))
    }

    pub fn task_path(&self, id: &str) -> Result<PathBuf> {
        task::validate_id(id)?;
        Ok(self.tasks_dir().join(format!("{id}.json")))
    }

    pub fn task_exists(&self, id: &str) -> bool {
        self.task_path(id).map(|p| p.exists()).unwrap_or(false)
    }

    pub fn load_task(&self, id: &str) -> Result<Task> {
        let p = self.task_path(id)?;
        if !p.exists() {
            return Err(BallError::TaskNotFound(id.to_string()));
        }
        Task::load(&p)
    }

    /// Persist a task. Callers must ensure serialization (typically via the
    /// per-task lock helper in `worktree.rs`); this path relies on atomic
    /// tmp+rename for filesystem integrity.
    pub fn save_task(&self, task: &Task) -> Result<()> {
        task.save(&self.task_path(&task.id)?)
    }

    /// Remove a task's files from disk and (non-stealth) from the
    /// state branch's index. Unified replacement for the previous
    /// `delete_task_file` + `rm_task_git` pair.
    /// Stage and commit a task file change on the state branch. No-op
    /// in stealth mode. Stages the sibling notes file too (always
    /// present after a `Task::save`). Holds the store-wide
    /// state-worktree lock for the duration of the git ops.
    pub fn commit_task(&self, id: &str, message: &str) -> Result<()> {
        if self.stealth {
            return Ok(());
        }
        let _g = state_worktree_flock(self)?;
        let dir = self.state_worktree_dir();
        let json = PathBuf::from(format!(".balls/tasks/{id}.json"));
        let notes = PathBuf::from(format!(".balls/tasks/{id}.notes.jsonl"));
        git::git_add(&dir, &[json.as_path(), notes.as_path()])?;
        git::git_commit(&dir, message)?;
        Ok(())
    }

    /// Archive a task and commit the archive on the state branch in a
    /// single locked sequence. Replaces the old `archive_task` +
    /// `commit_staged` pair, which could interleave with another
    /// worker's writes between the `git rm` and the `git commit`.
    ///
    /// The caller has already mutated `task` (status=Closed,
    /// closed_at set, etc.) but must NOT have called `save_task` —
    /// this method handles parent-side bookkeeping and file removal
    /// atomically under the state-worktree lock.
    pub fn close_and_archive(&self, task: &Task, commit_msg: &str) -> Result<()> {
        if self.stealth {
            let p = self.task_path(&task.id)?;
            if p.exists() {
                fs::remove_file(&p)?;
            }
            task_io::delete_notes_file(&p)?;
            return Ok(());
        }
        let _g = state_worktree_flock(self)?;
        let dir = self.state_worktree_dir();
        // Parent bookkeeping: record this task in closed_children on
        // the parent, if any.
        if let Some(pid) = &task.parent {
            if let Ok(mut parent) = self.load_task(pid) {
                parent.closed_children.push(task::ArchivedChild {
                    id: task.id.clone(),
                    title: task.title.clone(),
                    closed_at: task.closed_at.unwrap_or_else(chrono::Utc::now),
                });
                parent.touch();
                self.save_task(&parent)?;
                let rel = PathBuf::from(format!(".balls/tasks/{pid}.json"));
                git::git_add(&dir, &[rel.as_path()])?;
            }
        }
        // Stage removal of the task's files. `-f` because the working
        // tree may carry uncommitted field mutations (status=closed,
        // closed_at) we don't want to stage separately.
        let json = PathBuf::from(format!(".balls/tasks/{}.json", task.id));
        let notes = PathBuf::from(format!(".balls/tasks/{}.notes.jsonl", task.id));
        git::git_rm_force(&dir, &[json.as_path(), notes.as_path()])?;
        git::git_commit(&dir, commit_msg)?;
        Ok(())
    }

    pub fn all_tasks(&self) -> Result<Vec<Task>> {
        let dir = self.tasks_dir();
        if !dir.exists() {
            return Ok(Vec::new());
        }
        let mut out = Vec::new();
        for entry in fs::read_dir(&dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) != Some("json") {
                continue;
            }
            match Task::load(&path) {
                Ok(t) => out.push(t),
                Err(e) => {
                    // Surface malformed but don't abort on one bad file
                    eprintln!("warning: malformed task {}: {}", path.display(), e);
                }
            }
        }
        Ok(out)
    }
}