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};
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,
tasks_dir_path: PathBuf,
}
impl Store {
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);
if !stealth && !tasks_dir_path.exists() {
return Err(BallError::NotInitialized);
}
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()
}
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 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 {
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()])?;
}
}
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)
}
}