use crate::error::{BallError, Result};
use crate::git;
use crate::store::Store;
use crate::task::{Status, Task};
use crate::task_io;
use std::path::Path;
pub(crate) const RUNTIME_PATHS: &[&str] = &[".balls/local", ".balls/tasks", ".balls/worktree"];
pub fn add_user_changes(wt: &Path) -> Result<()> {
git::run_git_ok(wt, &["add", "-A"])?;
let mut args: Vec<&str> = vec!["rm", "--cached", "--ignore-unmatch", "-r", "--"];
args.extend(RUNTIME_PATHS.iter().copied());
git::run_git_ok(wt, &args)?;
Ok(())
}
pub fn commit_touches_runtime(repo: &Path, sha: &str) -> Result<Vec<String>> {
let out = git::run_git_ok(repo, &["show", "--name-only", "--format=", sha])?;
Ok(out
.lines()
.filter(|p| {
RUNTIME_PATHS
.iter()
.any(|r| *p == *r || p.starts_with(&format!("{r}/")))
})
.map(str::to_string)
.collect())
}
pub fn rewind_main(store: &Store, main_branch: &str, pre_main_sha: &str) -> Result<()> {
if is_bare(&store.root)? {
let refname = format!("refs/heads/{main_branch}");
git::run_git_ok(&store.root, &["update-ref", &refname, pre_main_sha])?;
} else {
git::git_reset_hard(&store.root, pre_main_sha)?;
}
Ok(())
}
fn is_bare(dir: &Path) -> Result<bool> {
Ok(git::run_git_ok(dir, &["rev-parse", "--is-bare-repository"])?.trim() == "true")
}
pub fn runtime_in_squash_error(id: &str, paths: &[String]) -> BallError {
BallError::Other(format!(
"bl review {id} aborted: squash would deliver balls runtime path{plural} {list}. \
The work branch has tracked `.balls/local`, `.balls/tasks`, or `.balls/worktree` — \
these are internal state, not deliverables. Run `bl init` in the repo to refresh \
`.gitignore`, then `git rm --cached` the offending paths on the work branch and retry.",
plural = if paths.len() == 1 { "" } else { "s" },
list = paths.join(", "),
))
}
#[allow(clippy::too_many_arguments)]
pub fn commit_squash_and_flip(
store: &Store,
id: &str,
branch: &str,
squash_msg: &str,
message: Option<&str>,
identity: &str,
pre_main_sha: &str,
main_branch: &str,
) -> Result<()> {
let delivered_sha = crate::bare_squash::squash_into_main(store, branch, squash_msg)?;
if let Some(sha) = delivered_sha.as_deref() {
let dirty = commit_touches_runtime(&store.root, sha)?;
if !dirty.is_empty() {
let _ = rewind_main(store, main_branch, pre_main_sha);
return Err(runtime_in_squash_error(id, &dirty));
}
} else {
eprintln!("no code delivered — checkpoint review for {id}");
}
let had_delivery = delivered_sha.is_some();
let task_path = store.task_path(id)?;
let mut t = Task::load(&task_path)?;
t.status = Status::Review;
t.delivered_in = delivered_sha;
t.touch();
t.save(&task_path)?;
if let Some(msg) = message {
task_io::append_note_to(&task_path, identity, msg)?;
}
let state_msg = if had_delivery {
format!("state: review {id}")
} else {
format!("state: review {id} no-code")
};
store.commit_task(id, &state_msg)?;
Ok(())
}
#[cfg(test)]
#[path = "review_safety_tests.rs"]
mod tests;