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
//! §11 delivery plugin — the real project-repo git seam ([`Project`]).
//!
//! [`Project`] is the production [`crate::delivery::Repo`]: it shells out to git
//! against the PROJECT repo at the invocation path, owning the `work/<id>` code
//! worktree and the direct (local-squash) delivery onto the integration branch.
//! Every act is idempotent — it recomputes from `(path, branch)` and checks the
//! filesystem/refs first, so a re-run is a no-op rather than an error (§11). The
//! squash itself is plumbing (`commit-tree` + `update-ref`) so it never disturbs
//! a checked-out integration working tree — the work happens in the code
//! worktree, where delivery folds integration in and runs the repo's own
//! pre-commit gate before anything lands (bl-ee85). The squash is the BINDING
//! commit point (§14): an abort never resets it — a retried close detects it
//! by its delivery tag and converges ([`crate::delivery_standing`]).
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// The production [`Repo`]: git against one project-repo root.
pub struct Project {
pub(crate) root: PathBuf,
}
impl Project {
/// Operate against the project repo rooted at `root` (the §7 invocation path).
#[must_use]
pub fn at(root: &Path) -> Self {
Self { root: root.to_path_buf() }
}
/// `git -C <cwd> <args>` as an unspawned [`Command`] — the one place the
/// binary name and the `-C` cwd flag are spelled. Callers set only their own
/// stdio + exit policy ([`Self::run`] captures, [`Self::ok`] discards,
/// `standing` pipes for stdout).
pub(crate) fn git(cwd: &Path, args: &[&str]) -> Command {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(cwd).args(args);
cmd
}
/// Run `git -C <cwd> <args>`, returning stdout; a non-zero exit becomes an
/// [`io::Error`] carrying git's stderr (the one failure funnel).
pub(crate) fn run(cwd: &Path, args: &[&str]) -> io::Result<String> {
let out = Self::git(cwd, args).stdout(Stdio::piped()).stderr(Stdio::piped()).output()?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(io::Error::other(format!(
"git {}: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
)))
}
}
/// Run `git -C <cwd> <args>` purely for its exit code — a predicate (does a
/// ref exist? do two trees differ?). `Ok(true)` on exit 0, `Ok(false)` on
/// any non-zero; only a spawn failure is an error.
pub(crate) fn ok(cwd: &Path, args: &[&str]) -> io::Result<bool> {
Ok(Self::git(cwd, args).stdout(Stdio::null()).stderr(Stdio::null()).status()?.success())
}
/// Does local branch `branch` exist?
pub(crate) fn branch_exists(&self, branch: &str) -> io::Result<bool> {
Self::ok(&self.root, &["rev-parse", "--verify", "--quiet", &format!("refs/heads/{branch}")])
}
/// EVERY root-commit reachable from HEAD, newest-first:
/// `git rev-list --max-parents=0 HEAD` prints one root per line, and a
/// multi-root repo (an unrelated history merged in — vendoring) has more than
/// one. These are the project identities this checkout answers to; the claim
/// guard (bl-0161) admits a ball whose recorded root is ANY of them, so
/// merging an unrelated history never flips identity and strands earlier
/// balls. Empty when `root` is not a git repo, carries no commit yet, or any
/// git call fails — fail-open, the guard withholds nothing it cannot prove.
/// The set-returning read is the reusable primitive: root-aware `list`
/// (bl-5965) scopes on the same call.
#[must_use]
pub fn root_commits(&self) -> Vec<String> {
Self::run(&self.root, &["rev-list", "--max-parents=0", "HEAD"])
.map(|out| out.lines().map(str::to_string).collect())
.unwrap_or_default()
}
/// This project's canonical, REMOTE-FREE root-commit stamp: the first
/// (newest) of [`Self::root_commits`]. Intrinsic to history and identical
/// across clones/hosts, it is what `create` records on a ball (bl-1ce7).
/// `None` off a non-repo / commitless checkout — a ball created there records
/// nothing and is unconstrained (back-compat). The stamp stays singular; the
/// SET is the read side (the guard, and future list scope).
#[must_use]
pub fn root_commit(&self) -> Option<String> {
self.root_commits().into_iter().next()
}
/// Capture any pending worktree work onto `branch` as a commit (squashed
/// away later), so an uncommitted change is never lost at delivery.
/// `--no-verify`: the delivery gate ([`Self::gate`]) runs ONCE, later, on
/// the final delivered tree — not here, where it would fire only when the
/// worktree happened to be dirty (the bl-ee85 asymmetry). The caller has
/// already run the strict-fold guard
/// ([`crate::delivery_fold::ensure_no_merge_in_progress`]) — over a
/// half-merge, this `add -A` + commit would CONCLUDE the merge with a
/// silent work-side resolution (bl-a04a).
pub(crate) fn capture(path: &Path, subject: &str) -> io::Result<()> {
Self::run(path, &["add", "-A"])?;
if Self::ok(path, &["diff", "--cached", "--quiet"])? {
return Ok(()); // nothing staged — the worktree is clean
}
Self::run(path, &["commit", "--no-verify", "-m", subject])?;
Ok(())
}
/// Fold `integration` into the work branch IN the worktree, so the tree the
/// gate checks IS the tree the squash delivers even when integration moved
/// since claim. STRICT (bl-a04a): git's default merge, no `-X`/strategy
/// side-picking ever — anything git marks conflicted (modify/delete and
/// rename/delete included) aborts. Already-up-to-date is a commitless
/// no-op; a conflict aborts the half-merge (the worktree stays clean for
/// the agent to merge by hand) and surfaces as the delivery-conflict error.
pub(crate) fn reintegrate(path: &Path, integration: &str) -> io::Result<()> {
if let Err(e) = Self::run(path, &["merge", "--no-verify", "--no-edit", integration]) {
let _ = Self::run(path, &["merge", "--abort"]); // best-effort: a never-started merge has nothing to abort
return Err(io::Error::other(format!("delivery conflict merging {integration} into the work branch: {e}")));
}
Ok(())
}
/// The delivery gate (bl-ee85): run the project repo's own `pre-commit`
/// hook — resolved exactly as git resolves it (`--git-path` honors
/// `core.hooksPath`), skipped exactly as git skips it (absent or
/// non-executable) — against the worktree holding the to-be-delivered tree.
/// The squash is plumbing and would silently bypass the hook every porcelain
/// commit runs; this restores that gate at the one moment it is
/// representative: after capture + reintegration. A failure aborts the
/// close BEFORE the seal, so the task stays claimed and the worktree stays
/// up for the fix. The hook's stdout joins stderr — diagnostics, never the
/// product channel (§6).
pub(crate) fn gate(path: &Path) -> io::Result<()> {
let printed = Self::run(path, &["rev-parse", "--git-path", "hooks/pre-commit"])?;
let hook = path.join(printed.trim());
let Ok(meta) = fs::metadata(&hook) else {
return Ok(()); // no hook → an ungated project delivers as before
};
if !is_executable(&meta) {
return Ok(()); // git's rule: a non-executable hook is ignored
}
let status = Command::new(&hook).current_dir(path).stdout(Stdio::from(io::stderr())).status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::other(format!("delivery gate {} failed: {status}", hook.display())))
}
}
/// The `marker`-tagged commits reachable from `revs` (a ref or a range),
/// NEWEST FIRST — the one tag-scan the retry standing ([`Project::standing`])
/// reads through, and the derived "where was `<id>` delivered?" query (§11):
/// no stored field. Recency order resolves the id-reuse ambiguity bl-d7a5
/// deferred — a reused id only begins after the prior incarnation CLOSED, so
/// deliveries are monotonic with incarnations and the k-th-most-recent
/// incarnation maps to the k-th element, the same live-first-else-most-recent
/// walk §9 applies to the ball file. The `--grep` is `--fixed-strings` so the
/// `[`/`]` match literally, not as a regex. Empty when `marker` is absent.
/// (`git log`'s default order IS recency, so this is "do not reverse it".)
pub(crate) fn marked(&self, revs: &str, marker: &str) -> io::Result<Vec<String>> {
let grep = format!("--grep={marker}");
let out = Self::run(&self.root, &["log", "--format=%H", "--fixed-strings", &grep, revs])?;
Ok(out.lines().map(str::to_string).collect())
}
}
/// The `tasks/<id>.md` paths the op changed in the change worktree at `cwd` —
/// how a `close.pre` hook recovers the id off the pre wire (§7). Reads the
/// working tree against `HEAD`, so a staged-or-unstaged deletion both show.
pub fn changed_task_paths(cwd: &Path) -> io::Result<Vec<String>> {
let out = Project::run(cwd, &["diff", "--name-only", "HEAD", "--", "tasks"])?;
Ok(out.lines().map(str::to_string).collect())
}
/// Whether `meta` describes an executable regular file. On Unix this is the
/// owner-or-group-or-other `+x` bit — git's own rule for hook execution. On
/// Windows there is no executable bit and git-for-Windows resolves hook
/// runnability at launch time (extension / shebang), so we report every file
/// as executable here and let [`Command::new`] surface a real failure if the
/// hook can't actually be launched.
#[cfg(unix)]
fn is_executable(meta: &fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(windows)]
fn is_executable(_meta: &fs::Metadata) -> bool {
true
}
// The [`crate::delivery::Repo`] trait impl (worktree lifecycle + squash
// delivery) lives in a sibling; an `impl` block registers on [`Project`]
// regardless of module, so no re-export is needed.
#[path = "delivery_repo_acts.rs"]
mod acts;
#[cfg(test)]
#[path = "delivery_repo_tests.rs"]
pub(crate) mod tests;
#[cfg(test)]
#[path = "delivery_repo_deliver_tests.rs"]
mod deliver_tests;
#[cfg(test)]
#[path = "delivery_repo_gate_tests.rs"]
mod gate_tests;