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
//! §8 anvil git plumbing — the change worktree and the SEAL.
//!
//! [`Anvil`] is the narrow seam the op lifecycle ([`crate::lifecycle`])
//! drives: make the change worktree off the anvil, SEAL (commit + integrate
//! the worktree onto the anvil, atomically), un-seal a post-abort (§14
//! tier-1), and tear the worktree down. [`Git`] is the real implementation,
//! shelling out to git against one checkout; the lifecycle is
//! unit-tested against a fake while [`Git`] is tested here on throwaway repos.
//!
//! Topology: the checkout has the anvil branch at commit `T`.
//! A change worktree is a DETACHED worktree created at `T`; balls + plugins
//! edit it, then SEAL commits it to `C` (parent `T`) and fast-forwards the
//! anvil onto `C` in one act. A post-seal abort `git reset --hard`s the
//! anvil back to `T` — local and reversible, because core never pushes (§14).
use std::io;
use std::path::{Path, PathBuf};
use std::process::Stdio;
/// The git acts the §8 seal needs, behind a seam so the lifecycle can be tested
/// without a real repo. Each method is one atomic git act on the anvil.
pub trait Anvil {
/// The anvil tip — captured before the seal so a post-abort can un-seal.
fn head(&self) -> io::Result<String>;
/// (§8.1) Make the change worktree at `dir`, detached at the anvil tip.
fn open(&self, dir: &Path) -> io::Result<()>;
/// (§8.3) The paths the change worktree `dir` touched relative to the anvil
/// tip — what the seal-validation read (bl-528c) parses before committing.
/// Renames report as delete+add (`--no-renames`), so every entry is one path.
fn changed(&self, dir: &Path) -> io::Result<Vec<String>>;
/// (§8.3) SEAL: commit everything in `dir` with `message`, then fast-forward
/// the anvil onto it — atomically. Returns the sealed commit sha. A change
/// that stages NOTHING (the tree already equals the tip) seals to the
/// EXISTING tip — no empty commit, so a byte-identical re-run of an op
/// converges instead of erroring (§13 idempotence; `install` of identical
/// content is the canonical case).
fn seal(&self, dir: &Path, message: &str) -> io::Result<String>;
/// (§14 tier-1) Un-seal a post-abort: reset the anvil back to `sha`.
fn unseal(&self, sha: &str) -> io::Result<()>;
/// (§8.5 / §14) Remove the change worktree — teardown, or discard on abort.
fn close(&self, dir: &Path) -> io::Result<()>;
}
/// The real [`Anvil`]: shells out to git against one checkout.
pub struct Git {
checkout: PathBuf,
date: Option<i64>,
}
impl Git {
/// Operate against the anvil checkout rooted at `checkout`. Un-dated: the seal
/// commit takes git's own clock, as before bl-8b98.
pub fn at(checkout: &Path) -> Self {
Self { checkout: checkout.to_path_buf(), date: None }
}
/// Pin this anvil's seal commit to the op instant `t` (§8): its
/// `GIT_AUTHOR_DATE`/`GIT_COMMITTER_DATE` are set from `t` so the store commit
/// date derives from the SAME instant as the frontmatter and the delivery
/// squash ([`crate::clock`]), not a third independent clock read.
#[must_use]
pub fn dated(mut self, t: i64) -> Self {
self.date = Some(t);
self
}
}
/// Run `git -C <cwd> <args>`, optionally feeding `stdin`, returning stdout. A
/// non-zero exit becomes an [`io::Error`] carrying git's stderr — the one
/// git-invocation site. Shared between the §8 anvil seal ([`Git`]) and the
/// §12/§13 checkout-lifecycle ops ([`crate::substrate`]): both author LOCAL git
/// only — STORE remote talk (sync/push) is the tracker's alone (§0). The ONE
/// core exception is [`crate::adopt`]'s config install-transport: a `fetch` that
/// READS a center's config to copy in (§0 — "config crosses into a landing only
/// by the explicit copy `install` performs"), never a push, never the store.
pub(crate) fn run(cwd: &Path, args: &[&str], stdin: Option<&str>) -> io::Result<String> {
run_env(cwd, args, stdin, &[])
}
/// [`run`] plus extra environment on the child git — the seam the §8 seal uses to
/// stamp its commit with the op instant (`GIT_*_DATE`, [`crate::clock`]). `env`
/// is empty for every non-commit git act, so those stay byte-identical.
fn run_env(cwd: &Path, args: &[&str], stdin: Option<&str>, env: &[(&'static str, String)]) -> io::Result<String> {
let mut cmd = crate::safegit::at(cwd);
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
for (k, v) in env {
cmd.env(k, v);
}
if stdin.is_some() {
cmd.stdin(Stdio::piped());
}
let mut child = cmd.spawn()?;
if let Some(text) = stdin {
use io::Write;
child.stdin.take().expect("stdin was configured as a pipe").write_all(text.as_bytes())?;
}
let out = child.wait_with_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()
)))
}
}
impl Anvil for Git {
fn head(&self) -> io::Result<String> {
Ok(run(&self.checkout, &["rev-parse", "HEAD"], None)?.trim().to_string())
}
fn open(&self, dir: &Path) -> io::Result<()> {
run(&self.checkout, &["worktree", "add", "--detach", &dir.to_string_lossy(), "HEAD"], None)?;
Ok(())
}
fn changed(&self, dir: &Path) -> io::Result<Vec<String>> {
let out = run(dir, &["status", "--porcelain", "--no-renames"], None)?;
// Each line is `XY <path>`; byte 3 onward is the path.
Ok(out.lines().filter_map(|l| l.get(3..)).map(str::to_string).collect())
}
fn seal(&self, dir: &Path, message: &str) -> io::Result<String> {
run(dir, &["add", "-A"], None)?;
// Nothing staged (`diff --cached --quiet` exits 0) ⇒ the no-op seal:
// the op converges on the existing tip instead of an empty commit.
if run(dir, &["diff", "--cached", "--quiet"], None).is_ok() {
return self.head();
}
// The one dated git act (§8): the seal commit derives its date from the op
// instant `T` when set, so it agrees with the frontmatter and the delivery
// squash by construction. Un-dated ⇒ git's own clock, as before.
match self.date {
Some(t) => run_env(dir, &["commit", "-F", "-"], Some(message), &crate::clock::git_date_env(t))?,
None => run(dir, &["commit", "-F", "-"], Some(message))?,
};
let sha = run(dir, &["rev-parse", "HEAD"], None)?.trim().to_string();
if let Err(e) = run(&self.checkout, &["merge", "--ff-only", &sha], None) {
// A lost merge (e.g. the ref-lock race two simultaneous claims run,
// bl-07d6) can strand the loser's tree STAGED in the checkout
// index/worktree while HEAD never moved — wedging every later op
// ("Your local changes ... would be overwritten") and reading as a
// phantom claim. The seal is atomic: restore the unmoved HEAD
// (best-effort, like the §14 un-seal) before reporting the failure.
let _ = run(&self.checkout, &["reset", "--hard", "HEAD"], None);
return Err(e);
}
Ok(sha)
}
fn unseal(&self, sha: &str) -> io::Result<()> {
run(&self.checkout, &["reset", "--hard", sha], None)?;
Ok(())
}
fn close(&self, dir: &Path) -> io::Result<()> {
run(&self.checkout, &["worktree", "remove", "--force", &dir.to_string_lossy()], None)?;
Ok(())
}
}
#[cfg(test)]
#[path = "git_tests.rs"]
mod tests;