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};
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")))
}
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,
pub no_git: bool,
tasks_dir_path: PathBuf,
}
impl Store {
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()
}
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)
}
pub fn save_task(&self, task: &Task) -> Result<()> {
task.save(&self.task_path(&task.id)?)
}
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(())
}
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) => {
eprintln!("warning: malformed task {}: {}", path.display(), e);
}
}
}
Ok(out)
}
}