use crate::error::Result;
use crate::{git, git_state};
use std::fs;
use std::path::{Path, PathBuf};
pub(crate) const STATE_BRANCH: &str = "balls/tasks";
pub(crate) const STATE_WORKTREE_REL: &str = ".balls/worktree";
pub(crate) fn setup_state_branch(root: &Path) -> Result<()> {
let has_origin = git::git_has_remote(root, "origin");
if !git_state::branch_exists(root, STATE_BRANCH) {
if has_origin {
let _ = git::git_fetch(root, "origin");
}
if has_origin && git_state::has_remote_branch(root, "origin", STATE_BRANCH) {
git_state::create_tracking_branch(root, STATE_BRANCH, "origin")?;
} else {
git_state::create_orphan_branch(root, STATE_BRANCH, "balls state")?;
if has_origin {
let _ = git::git_push(root, "origin", STATE_BRANCH);
}
}
}
let state_wt = root.join(STATE_WORKTREE_REL);
if !state_wt.join(".git").exists() {
if let Some(parent) = state_wt.parent() {
fs::create_dir_all(parent)?;
}
git_state::worktree_add_existing(root, &state_wt, STATE_BRANCH)?;
}
seed_state_worktree(&state_wt)?;
ensure_tasks_symlink(root)?;
if has_origin {
let _ = git::git_push(root, "origin", STATE_BRANCH);
}
Ok(())
}
fn seed_state_worktree(state_wt: &Path) -> Result<()> {
let tasks = state_wt.join(".balls/tasks");
fs::create_dir_all(&tasks)?;
let attrs = tasks.join(".gitattributes");
let attrs_line = "*.notes.jsonl merge=union\n";
let need_attrs = match fs::read_to_string(&attrs) {
Ok(s) => !s.contains("*.notes.jsonl merge=union"),
Err(_) => true,
};
if need_attrs {
fs::write(&attrs, attrs_line)?;
}
let keep = tasks.join(".gitkeep");
if !keep.exists() {
fs::write(&keep, "")?;
}
if git::has_uncommitted_changes(state_wt)? {
git::git_add_all(state_wt)?;
git::git_commit(state_wt, "balls: seed state branch")?;
}
Ok(())
}
fn ensure_tasks_symlink(root: &Path) -> Result<()> {
let link = root.join(".balls/tasks");
if link.is_symlink() {
return Ok(());
}
if link.exists() {
return Err(crate::error::BallError::Other(format!(
"unexpected non-symlink at {}; remove it and re-run `bl init`",
link.display()
)));
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(PathBuf::from("worktree/.balls/tasks"), &link)?;
}
#[cfg(not(unix))]
{
return Err(crate::error::BallError::Other(
"symlink-mode bl init requires a POSIX filesystem; use stealth mode".into(),
));
}
Ok(())
}
pub(crate) fn commit_init(root: &Path, is_stealth: bool, already: bool) -> Result<()> {
ensure_main_gitignore(root, is_stealth)?;
let plugins_keep = root.join(".balls/plugins/.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(root, &paths)?;
let msg = if already { "balls: reinitialize" } else { "balls: initialize" };
git::git_commit(root, msg)?;
Ok(())
}
fn ensure_main_gitignore(root: &Path, is_stealth: bool) -> Result<()> {
let path = root.join(".gitignore");
let mut content = if path.exists() {
fs::read_to_string(&path)?
} else {
String::new()
};
let mut wanted: Vec<&str> = vec![".balls/local", ".balls-worktrees"];
if !is_stealth {
wanted.push(".balls/tasks");
wanted.push(".balls/worktree");
}
let mut dirty = false;
for entry in wanted {
if !content.lines().any(|l| l.trim() == entry) {
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(entry);
content.push('\n');
dirty = true;
}
}
if dirty {
fs::write(&path, content)?;
}
Ok(())
}