1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
//! Review, close, and archive — the submit side of the task lifecycle.
//! Lives alongside `worktree.rs` (claim/drop/orphans) but kept separate
//! so neither file hits the 300-line cap.
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;
/// Return the IDs of any `gates`-linked children of `parent` that are
/// still open in the store. A child is "open" if its task file is still
/// present — once closed, `close_and_archive` removes it. Callers that
/// are about to close `parent` must reject the close if this list is
/// non-empty.
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;
}
// load_task returns TaskNotFound if the child is already
// archived (i.e. the gate has been satisfied). Any other
// error bubbles up — we don't want to silently skip a
// malformed gate child and let the parent close.
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)
}
/// Build the error returned when a close is blocked by open gates.
/// Factored out so both close paths produce the same message.
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(", "),
))
}
/// Public wrapper: check gates and return a ready-to-return error if
/// any are open. Both close paths share this to keep the message and
/// semantics aligned.
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(())
}
/// Submit for review: commit the worker's code, squash-merge to main as
/// the single feature commit, flip task status to review on the state
/// branch. Keeps the worktree so a rejected review can be re-worked in
/// place.
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."
),
)?;
// Squash merge the worker's branch into main. This is the single
// substantive feature commit — the delivery tag [bl-XXXX] is
// embedded in the title so tooling and humans can trace main
// <-> state branch. The message is formatted in standard git
// shape (title, blank, body) so `git log --oneline` stays
// readable. Merge-in above already reconciled main into the
// worktree, so this squash cannot itself produce fresh
// conflicts.
//
// An empty squash (nothing staged on main after merge --squash)
// means the worker delivered no code — e.g. a gate task or a
// bugfix that became a no-op once main moved forward. We skip
// the commit entirely rather than stamping delivered_in with
// the unrelated current HEAD, and the state-branch subject gets
// a `no-code` marker so half-push detection can tell this
// apart from an actual half-push.
let squash_msg = crate::commit_msg::format_squash(message, &task.title, id);
git::git_merge_squash(&store.root, &branch)?;
let delivered_sha = if git::has_staged_changes(&store.root)? {
git::git_commit(&store.root, &squash_msg)?;
Some(git::git_resolve_sha(&store.root, "HEAD")?)
} else {
eprintln!("no code delivered — checkpoint review for {id}");
None
};
// Flip the task to review on the state branch, embedding the
// delivery hint in the same commit so the state-branch history
// stays at one-commit-per-transition.
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)?;
// Sync main back into worktree so re-review after rejection only
// picks up new changes (squash merge doesn't record branch ancestry).
let _ = git::git_merge(&wt_path, &main_branch);
Ok(())
})
}
/// Review in no-git mode: flip status, no squash merge.
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(())
})
}
/// Close in no-git mode: archive task, no worktree teardown.
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)
})
}
/// Close a reviewed task: archive + remove worktree. Rejects from inside worktree.
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));
// close_and_archive is one atomic state-branch commit. The
// reviewer's message is embedded in the commit body so it
// survives the notes-file rm.
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)
})
}