use crate::config::Config;
use crate::error::{BallError, 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, remote: &str, linked: bool) -> Result<()> {
let has_remote = git::git_has_remote(root, remote);
if !git_state::branch_exists(root, STATE_BRANCH) {
if has_remote {
let _ = git::git_fetch(root, remote);
}
if has_remote && git_state::has_remote_branch(root, remote, STATE_BRANCH) {
git_state::create_tracking_branch(root, STATE_BRANCH, remote)?;
} else {
git_state::create_orphan_branch(root, STATE_BRANCH, "balls state")?;
if has_remote {
let _ = git::git_push(root, remote, 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_remote {
let _ = git::git_push(root, remote, STATE_BRANCH);
}
if linked && !has_remote {
eprintln!(
"note: .balls/config.json sets state_remote `{remote}`, but this \
clone has no git remote `{remote}`. Created an isolated local \
task store — your tasks are not shared with the project yet. \
Add the remote, then run `bl remaster {remote}` to join \
(non-destructive)."
);
}
Ok(())
}
pub(crate) fn bootstrap_bare_hub(source: &str, hubdir: &Path) -> Result<PathBuf> {
fs::create_dir_all(hubdir)?;
let hubdir = fs::canonicalize(hubdir).unwrap_or_else(|_| hubdir.to_path_buf());
let gitdir = hubdir.join(".git");
if gitdir.exists() {
if !crate::bare_squash::is_bare_repo(&hubdir).unwrap_or(false) {
return Err(BallError::Other(format!(
"{} exists and is not a bare repo; refusing to clobber it",
gitdir.display()
)));
}
} else {
git::git_clone_bare(source, &gitdir)?;
}
git::git_config_set(&hubdir, "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
let _ = git::git_fetch(&hubdir, "origin");
git::git_ensure_user(&hubdir)?;
let balls = hubdir.join(".balls");
for d in ["plugins", "local/claims", "local/lock", "local/plugins"] {
fs::create_dir_all(balls.join(d))?;
}
let config_path = balls.join("config.json");
if !config_path.exists() {
let cfg = git_state::show_file(&hubdir, "main", ".balls/config.json")?.ok_or_else(|| {
BallError::Other(
"source's `main` has no .balls/config.json — run `bl init` in a \
working clone and push first (README bootstrap step 1)"
.into(),
)
})?;
fs::write(&config_path, cfg)?;
}
let cfg = Config::load(&config_path)?;
setup_state_branch(&hubdir, cfg.state_remote(), cfg.state_remote.is_some())?;
Ok(hubdir)
}
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(())
}