use crate::error::{BallError, Result};
use crate::link::LinkType;
use crate::store::Store;
use crate::task::{Status, Task};
use crate::worktree::{claim_file_path, with_task_lock, worktree_path};
use crate::{git, task_io};
use std::fs;
pub fn open_gate_blockers(store: &Store, parent: &Task) -> Result<Vec<String>> {
let mut blockers = Vec::new();
for link in &parent.links {
if !matches!(link.link_type, LinkType::Gates) {
continue;
}
match store.load_task(&link.target) {
Ok(child) => {
if child.status != Status::Closed {
blockers.push(link.target.clone());
}
}
Err(BallError::TaskNotFound(_)) => {}
Err(e) => return Err(e),
}
}
Ok(blockers)
}
fn gate_blocked_error(parent_id: &str, blockers: &[String]) -> BallError {
BallError::Other(format!(
"cannot close {parent_id}: blocked by open gate{plural} {list}. \
Close the gate task{plural} first, or run `bl link rm {parent_id} gates <id>` to drop a gate.",
plural = if blockers.len() == 1 { "" } else { "s" },
list = blockers.join(", "),
))
}
pub fn enforce_gates(store: &Store, parent: &Task) -> Result<()> {
let blockers = open_gate_blockers(store, parent)?;
if !blockers.is_empty() {
return Err(gate_blocked_error(&parent.id, &blockers));
}
Ok(())
}
fn merge_or_fail(dir: &std::path::Path, branch: &str, ctx: &str) -> Result<()> {
if let git::MergeResult::Conflict = git::git_merge(dir, branch)? {
return Err(BallError::Conflict(ctx.to_string()));
}
Ok(())
}
pub fn review_worktree(
store: &Store,
id: &str,
message: Option<&str>,
identity: &str,
) -> Result<()> {
let wt_path = worktree_path(store, id)?;
let task = store.load_task(id)?;
let branch = task.branch.clone().unwrap_or_else(|| format!("work/{id}"));
with_task_lock(store, id, || {
git::git_add_all(&wt_path)?;
let _ = git::git_commit(&wt_path, &format!("wip: {id}"));
let main_branch = git::git_current_branch(&store.root)?;
merge_or_fail(
&wt_path,
&main_branch,
&format!(
"conflicts merging {main_branch} into work/{id}. Resolve in worktree, then retry."
),
)?;
let squash_msg = crate::commit_msg::format_squash(message, &task.title, id);
git::git_merge_squash(&store.root, &branch)?;
git::git_commit(&store.root, &squash_msg)?;
let delivered_sha = git::git_resolve_sha(&store.root, "HEAD")?;
let task_path = store.task_path(id)?;
let mut t = Task::load(&task_path)?;
t.status = Status::Review;
t.delivered_in = Some(delivered_sha);
t.touch();
t.save(&task_path)?;
if let Some(msg) = message {
task_io::append_note_to(&task_path, identity, msg)?;
}
store.commit_task(id, &format!("state: review {id}"))?;
let _ = git::git_merge(&wt_path, &main_branch);
Ok(())
})
}
pub fn review_no_git(store: &Store, id: &str, message: Option<&str>, identity: &str) -> Result<()> {
with_task_lock(store, id, || {
let task_path = store.task_path(id)?;
let mut t = Task::load(&task_path)?;
t.status = Status::Review;
t.touch();
t.save(&task_path)?;
if let Some(msg) = message {
task_io::append_note_to(&task_path, identity, msg)?;
}
store.commit_task(id, &format!("state: review {id}"))?;
Ok(())
})
}
pub fn close_no_git(store: &Store, id: &str, message: Option<&str>, identity: &str) -> Result<Task> {
with_task_lock(store, id, || {
let mut t = store.load_task(id)?;
enforce_gates(store, &t)?;
t.status = Status::Closed;
t.closed_at = Some(chrono::Utc::now());
t.touch();
let _ = fs::remove_file(claim_file_path(store, id));
let msg = match message {
Some(m) => format!("state: close {} - {}\n\n{}", id, t.title, m),
None => format!("state: close {} - {}", id, t.title),
};
if let Some(note) = message {
let task_path = store.task_path(id)?;
task_io::append_note_to(&task_path, identity, note)?;
}
store.close_and_archive(&t, &msg)?;
Ok(t)
})
}
pub fn close_worktree(
store: &Store,
id: &str,
message: Option<&str>,
identity: &str,
) -> Result<Task> {
let wt_path = worktree_path(store, id)?;
if let Ok(cwd) = std::env::current_dir() {
if cwd.starts_with(&wt_path) {
return Err(BallError::Other(
"cannot close from within the worktree — run from the repo root".into(),
));
}
}
with_task_lock(store, id, || {
let mut t = store.load_task(id)?;
enforce_gates(store, &t)?;
let branch = t.branch.clone().unwrap_or_else(|| format!("work/{id}"));
t.status = Status::Closed;
t.closed_at = Some(chrono::Utc::now());
t.touch();
if wt_path.exists() {
git::git_worktree_remove(&store.root, &wt_path, true)?;
}
let _ = git::git_branch_delete(&store.root, &branch, true);
let _ = fs::remove_file(claim_file_path(store, id));
let _ = identity;
let msg = match message {
Some(m) => format!("state: close {} - {}\n\n{}", id, t.title, m),
None => format!("state: close {} - {}", id, t.title),
};
store.close_and_archive(&t, &msg)?;
Ok(t)
})
}