balls 0.3.6

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::{commit_init, setup_state_branch, STATE_WORKTREE_REL};
use crate::store_paths::{find_balls_root, 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 {
    pub root: PathBuf,
    pub stealth: bool,
    /// True when no git repository is available. Implies stealth.
    pub no_git: bool,
    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> {
        match Self::discover_git(from) {
            Err(BallError::NotARepo) => Self::discover_no_git(from),
            other => other,
        }
    }

    fn discover_git(from: &Path) -> Result<Self> {
        git::git_root(from)?;
        let common_dir = git::git_common_dir(from)?;
        let main_root = find_main_root(&common_dir)?;
        if !main_root.join(".balls").exists() {
            return Err(BallError::NotInitialized);
        }
        let (tasks_dir_path, stealth) = resolve_tasks_dir(&main_root);
        if !stealth && !tasks_dir_path.exists() {
            return Err(BallError::NotInitialized);
        }
        git::git_ensure_user(&main_root)?;
        Ok(Store { root: main_root, stealth, no_git: false, tasks_dir_path })
    }

    fn discover_no_git(from: &Path) -> Result<Self> {
        let root = find_balls_root(from)?;
        let (tasks_dir_path, stealth) = resolve_tasks_dir(&root);
        if !stealth || !tasks_dir_path.exists() {
            return Err(BallError::NotInitialized);
        }
        Ok(Store { root, stealth, no_git: true, tasks_dir_path })
    }

    pub fn init(from: &Path, stealth: bool, tasks_dir: Option<String>) -> Result<Self> {
        if let Some(ref td) = tasks_dir {
            if !Path::new(td).is_absolute() {
                return Err(BallError::Other(format!("--tasks-dir must be an absolute path, got: {td}")));
            }
        }
        let (repo_root, no_git) = match git::git_root(from) {
            Ok(r) => (r, false),
            Err(BallError::NotARepo) if tasks_dir.is_some() => {
                (fs::canonicalize(from).unwrap_or_else(|_| from.to_path_buf()), true)
            }
            Err(e) => return Err(e),
        };
        if !no_git {
            git::git_ensure_user(&repo_root)?;
            git::git_init_commit(&repo_root)?;
        }

        let balls_dir = repo_root.join(".balls");
        let local_dir = balls_dir.join("local");
        let already = balls_dir.join("config.json").exists();
        fs::create_dir_all(balls_dir.join("plugins"))?;
        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 use_stealth = stealth || tasks_dir.is_some();
        let (tasks_dir_path, is_stealth) = if use_stealth {
            let ext = match tasks_dir {
                Some(td) => PathBuf::from(td),
                None => 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)
        };

        if !no_git {
            commit_init(&repo_root, is_stealth, already)?;
        }
        Ok(Store { root: repo_root, stealth: is_stealth, no_git, 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 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)?;
            }
        }
        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();
        if let Some(pid) = &task.parent {
            let rel = PathBuf::from(format!(".balls/tasks/{pid}.json"));
            git::git_add(&dir, &[rel.as_path()])?;
        }
        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)
    }
}