1use std::path::{Path, PathBuf};
13
14use amont_agent_guard::git;
15
16const MAGIC: &str = "amont-provenance-v1";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Review {
21 Reviewed,
22 ByPatch,
23 Overruled,
24}
25
26impl Review {
27 fn word(self) -> &'static str {
28 match self {
29 Review::Reviewed => "reviewed",
30 Review::ByPatch => "by-patch",
31 Review::Overruled => "overruled",
32 }
33 }
34}
35
36pub struct Record<'a> {
38 pub commit: &'a str,
39 pub tree: &'a str,
40 pub patch: Option<&'a str>,
41 pub plan_path: &'a str,
42 pub plan_sha: &'a str,
43 pub review: Review,
44 pub review_content: &'a str,
46 pub preview: crate::preview::Outcome,
47 pub version: &'a str,
48}
49
50pub fn payload(r: &Record) -> String {
52 let mut out = format!("{MAGIC}\ncommit {}\ntree {}\n", r.commit, r.tree);
53 if let Some(p) = r.patch {
54 out.push_str(&format!("patch {p}\n"));
55 }
56 out.push_str(&format!(
57 "plan {} {}\nreview {} {}\npreview {}\nproducer amont-agent {}\n",
58 r.plan_path,
59 r.plan_sha,
60 r.review.word(),
61 r.review_content,
62 r.preview.word(),
63 r.version
64 ));
65 out
66}
67
68pub fn record_file(repo: &str, commit: &str) -> Option<PathBuf> {
71 if repo.is_empty()
72 || repo.contains('/')
73 || repo.contains('\\')
74 || repo.contains("..")
75 || commit.is_empty()
76 || !commit.bytes().all(|b| b.is_ascii_hexdigit())
77 {
78 return None;
79 }
80 Some(
81 amont_agent_guard::journal::dir()?
82 .join("provenance")
83 .join(repo)
84 .join(commit),
85 )
86}
87
88fn is_pointer(text: &str) -> bool {
91 let (front, _) = crate::plans::split_front_matter(text);
92 crate::plans::field(front, "canonical").is_some_and(|c| !c.is_empty())
93}
94
95pub fn plan_of(repo: &Path, commit: &str, plans: &[String]) -> Result<(String, String), String> {
100 let mut found: Vec<(&String, String)> = Vec::new();
101 for p in plans
102 .iter()
103 .filter(|p| p.ends_with(".md") && !p.ends_with(".reviews.md"))
104 {
105 if let Some(bytes) = git::bytes_in(repo, &["show", &format!("{commit}:{p}")], None)
106 .map_err(|e| format!("git could not be run: {e}"))?
107 {
108 found.push((p, String::from_utf8_lossy(&bytes).into_owned()));
109 }
110 }
111 let (path, text) = found
112 .iter()
113 .find(|(_, t)| !is_pointer(t))
114 .or_else(|| found.first())
115 .ok_or("no plan file under docs/plans/ exists in the pushed tree")?;
116 let shas =
117 crate::plan_text::shas(text).map_err(|e| format!("the plan has no body sha: {e}"))?;
118 Ok((path.to_string(), shas.sha))
119}
120
121pub struct Push<'a> {
123 pub repo: &'a Path,
124 pub name: &'a str,
125 pub pushed: &'a str,
126 pub patch: Option<&'a str>,
127 pub plans: &'a [String],
128 pub target: &'a amont_agent_guard::push_target::Target,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum Filed {
135 Written(PathBuf),
137 Kept(PathBuf),
139}
140
141pub fn write(
145 p: &Push,
146 review: Review,
147 review_content: &str,
148 preview: crate::preview::Outcome,
149) -> Result<Filed, String> {
150 let commit = git::stdout_in(
151 p.repo,
152 &[
153 "rev-parse",
154 "--verify",
155 "--end-of-options",
156 &format!("{}^{{commit}}", p.pushed),
157 ],
158 )
159 .ok_or("the pushed commit cannot be resolved")?;
160 let file = record_file(p.name, &commit).ok_or("no provenance directory is known")?;
163 if file.exists() {
164 return Ok(Filed::Kept(file));
165 }
166 let tree = git::stdout_in(
167 p.repo,
168 &[
169 "rev-parse",
170 "--verify",
171 "--end-of-options",
172 &format!("{commit}^{{tree}}"),
173 ],
174 )
175 .ok_or("the pushed tree cannot be resolved")?;
176 let (plan_path, plan_sha) = plan_of(p.repo, &commit, p.plans)?;
177 let body = payload(&Record {
178 commit: &commit,
179 tree: &tree,
180 patch: p.patch,
181 plan_path: &plan_path,
182 plan_sha: &plan_sha,
183 review,
184 review_content,
185 preview,
186 version: env!("CARGO_PKG_VERSION"),
187 });
188 let dir = file.parent().ok_or("no provenance directory is known")?;
189 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
190 amont_agent_guard::journal::private(dir, 0o700);
191 amont_agent_guard::atomic::write_atomic(&file, &body)
192 .map_err(|e| format!("{}: {e}", file.display()))?;
193 amont_agent_guard::journal::private(&file, 0o600);
194 Ok(Filed::Written(file))
195}
196
197pub enum Filing {
199 Deferred,
201 Written(PathBuf),
202 Kept(PathBuf),
203 Failed(String),
204}
205
206pub fn file(push: &Push, review: Review, review_content: &str) -> Filing {
210 let Some(preview) = crate::preview::outcome(push.repo, push.target) else {
211 return Filing::Deferred;
212 };
213 match write(push, review, review_content, preview) {
214 Ok(Filed::Written(f)) => Filing::Written(f),
215 Ok(Filed::Kept(f)) => Filing::Kept(f),
216 Err(why) => Filing::Failed(why),
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn rec(patch: Option<&'static str>) -> Record<'static> {
225 Record {
226 commit: "c0",
227 tree: "t0",
228 patch,
229 plan_path: "docs/plans/p.md",
230 plan_sha: "s0",
231 review: Review::ByPatch,
232 review_content: "k1",
233 preview: crate::preview::Outcome::NotRequired,
234 version: "1.2.3",
235 }
236 }
237
238 #[test]
239 fn payload_lines_are_in_order() {
240 assert_eq!(
241 payload(&rec(Some("p0"))),
242 "amont-provenance-v1\ncommit c0\ntree t0\npatch p0\nplan docs/plans/p.md s0\nreview by-patch k1\npreview not-required\nproducer amont-agent 1.2.3\n"
243 );
244 }
245
246 #[test]
247 fn payload_omits_patch_without_one() {
248 assert!(!payload(&rec(None)).lines().any(|l| l.starts_with("patch ")));
249 }
250
251 #[test]
252 fn record_file_refuses_escapes() {
253 assert!(record_file("../x", "ab").is_none());
254 assert!(record_file("r", "../ab").is_none());
255 assert!(record_file("r/s", "ab").is_none());
256 }
257
258 fn git_in(dir: &Path, args: &[&str]) {
259 let ok = std::process::Command::new("git")
260 .current_dir(dir)
261 .args(args)
262 .env("GIT_CONFIG_GLOBAL", "/dev/null")
263 .env("GIT_CONFIG_SYSTEM", "/dev/null")
264 .env("GIT_AUTHOR_NAME", "t")
265 .env("GIT_AUTHOR_EMAIL", "t@example.com")
266 .env("GIT_COMMITTER_NAME", "t")
267 .env("GIT_COMMITTER_EMAIL", "t@example.com")
268 .status()
269 .unwrap()
270 .success();
271 assert!(ok, "git {args:?}");
272 }
273
274 #[test]
275 fn a_reviews_sidecar_is_never_the_plan() {
276 let dir = std::env::temp_dir().join(format!("amont-plan-of-{}", std::process::id()));
277 let _ = std::fs::remove_dir_all(&dir);
278 std::fs::create_dir_all(dir.join("docs/plans")).unwrap();
279 git_in(&dir, &["init", "-q", "-b", "main"]);
280 std::fs::write(
281 dir.join("docs/plans/a.md"),
282 "---\nstatus: active\n---\n# A\n\nbody\n",
283 )
284 .unwrap();
285 std::fs::write(
286 dir.join("docs/plans/a.reviews.md"),
287 "---\nstatus: active\n---\n# R\n\nreviews\n",
288 )
289 .unwrap();
290 std::fs::write(
291 dir.join("docs/plans/b.md"),
292 "---\ncanonical: x/b.md\n---\n# B\n",
293 )
294 .unwrap();
295 git_in(&dir, &["add", "-A"]);
296 git_in(&dir, &["commit", "-q", "-m", "x"]);
297 let plans: Vec<String> = [
298 "docs/plans/a.reviews.md",
299 "docs/plans/b.md",
300 "docs/plans/a.md",
301 ]
302 .map(String::from)
303 .to_vec();
304 let (path, _) = plan_of(&dir, "HEAD", &plans).unwrap();
305 let _ = std::fs::remove_dir_all(&dir);
306 assert_eq!(path, "docs/plans/a.md");
307 }
308}