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 crate::bare_squash::squashes_in_place(store, main_branch)? {
git::git_reset_hard(&store.root, pre_main_sha)?;
} else {
let refname = format!("refs/heads/{main_branch}");
git::run_git_ok(&store.root, &["update-ref", &refname, pre_main_sha])?;
}
Ok(())
}
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, main_branch)?;
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)?;
let target_marker = if had_delivery && t.target_branch.is_some() {
let repo_default = store.load_config()?.integration_branch(&store.root)?;
(main_branch != repo_default).then(|| main_branch.to_string())
} else {
None
};
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 = match (had_delivery, &target_marker) {
(false, _) => format!("state: review {id} no-code"),
(true, Some(b)) => format!("state: review {id} target={b}"),
(true, None) => format!("state: review {id}"),
};
store.commit_task(id, &state_msg)?;
Ok(())
}
#[cfg(test)]
#[path = "review_safety_tests.rs"]
mod tests;