1use std::path::{Path, PathBuf};
7
8use serde::Serialize;
9
10use crate::git::Git;
11
12#[derive(Debug, Clone, Serialize)]
14pub struct Commit {
15 pub id: String,
17 pub subject: String,
19 pub task: Option<String>,
21 pub verdict: Option<String>,
23 pub date: String,
25}
26
27#[derive(Debug, Clone, Serialize)]
29pub struct File {
30 pub path: String,
32 pub add: Option<u64>,
34 pub del: Option<u64>,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct Changes {
41 pub branch: Option<String>,
43 pub base: Option<String>,
45 pub commits: Vec<Commit>,
47 pub files: Vec<File>,
49 pub patch: String,
51 pub cut: bool,
53}
54
55pub const PATCH_LIMIT: usize = 2 * 1024 * 1024;
57pub const FILE_LIMIT: usize = 512 * 1024;
59
60fn branch_of(root: &Path, feature: Option<&str>) -> Option<String> {
63 let inspected = crate::inspect(root, feature);
64 let Some(speccoder_core::CommandData::Inspect(snapshot)) = inspected.data() else {
65 return None;
66 };
67 let branch = format!("speccoder/{}", snapshot.feature.as_ref()?.feature.name);
68 Git::at(root)
69 .succeeds(&[
70 "rev-parse",
71 "--verify",
72 "-q",
73 &format!("refs/heads/{branch}"),
74 ])
75 .unwrap_or(false)
76 .then_some(branch)
77}
78
79#[must_use]
81pub fn changes(root: &Path, feature: Option<&str>) -> Changes {
82 let empty = |branch| Changes {
83 branch,
84 base: None,
85 commits: Vec::new(),
86 files: Vec::new(),
87 patch: String::new(),
88 cut: false,
89 };
90 let Some(branch) = branch_of(root, feature) else {
91 return empty(None);
92 };
93 let g = Git::at(root);
94 let Ok(head) = g.line(&["rev-parse", "HEAD"]) else {
95 return empty(Some(branch));
96 };
97 let Ok(base) = g.line(&["merge-base", &branch, &head]) else {
98 return empty(Some(branch));
99 };
100 let range = format!("{base}..{branch}");
101 let commits = g
102 .run(&[
103 "log",
104 "--no-color",
105 "--format=%H%x1f%s%x1f%(trailers:key=Spec-Task,valueonly,separator=%x2c)%x1f%(trailers:key=Verdict,valueonly,separator=%x2c)%x1f%aI%x1e",
106 &range,
107 ])
108 .map(|b| {
109 String::from_utf8_lossy(&b)
110 .split('\x1e')
111 .filter_map(|rec| {
112 let f: Vec<&str> = rec.trim().split('\x1f').collect();
113 let [id, subject, task, verdict, date] = f.as_slice() else { return None };
114 let some = |s: &str| (!s.trim().is_empty()).then(|| s.trim().to_owned());
115 Some(Commit {
116 id: (*id).to_owned(),
117 subject: (*subject).to_owned(),
118 task: some(task),
119 verdict: some(verdict),
120 date: (*date).to_owned(),
121 })
122 })
123 .collect()
124 })
125 .unwrap_or_default();
126 let files = g
129 .run(&["diff", "--numstat", "-z", "-M", &base, &branch])
130 .map(|b| numstat(&b))
131 .unwrap_or_default();
132 let mut patch = g
133 .run(&["diff", "--no-color", "-M", &base, &branch])
134 .map(|b| String::from_utf8_lossy(&b).into_owned())
135 .unwrap_or_default();
136 let cut = patch.len() > PATCH_LIMIT;
137 if cut {
138 let mut at = PATCH_LIMIT;
139 while !patch.is_char_boundary(at) {
140 at -= 1;
141 }
142 patch.truncate(at);
143 }
144 Changes {
145 branch: Some(branch),
146 base: Some(base),
147 commits,
148 files,
149 patch,
150 cut,
151 }
152}
153
154fn numstat(out: &[u8]) -> Vec<File> {
157 let text = String::from_utf8_lossy(out);
158 let mut fields = text.split('\0');
159 let mut files = Vec::new();
160 while let Some(head) = fields.next() {
161 let mut it = head.splitn(3, '\t');
162 let (Some(a), Some(d), Some(p)) = (it.next(), it.next(), it.next()) else {
163 continue;
164 };
165 let path = if p.is_empty() {
166 let _old = fields.next();
168 fields.next().unwrap_or_default().to_owned()
169 } else {
170 p.to_owned()
171 };
172 files.push(File {
173 path,
174 add: a.parse().ok(),
175 del: d.parse().ok(),
176 });
177 }
178 files
179}
180
181fn revision(root: &Path, feature: Option<&str>) -> String {
183 branch_of(root, feature).unwrap_or_else(|| "HEAD".to_owned())
184}
185
186#[must_use]
188pub fn tree(root: &Path, feature: Option<&str>) -> (String, Vec<String>) {
189 let rev = revision(root, feature);
190 let files = Git::at(root)
191 .run(&["ls-tree", "-r", "--name-only", "-z", &rev])
192 .map(|b| {
193 crate::git::nul_fields(&b)
194 .into_iter()
195 .take(20_000)
196 .collect()
197 })
198 .unwrap_or_default();
199 (rev, files)
200}
201
202#[derive(Debug, Clone, Default, serde::Serialize)]
205pub struct WorktreeView {
206 pub path: Option<String>,
208 pub entries: Vec<WorktreeEntry>,
211 pub blocking: Vec<String>,
214 pub fix: Option<String>,
216}
217
218#[derive(Debug, Clone, serde::Serialize)]
220pub struct WorktreeEntry {
221 pub path: String,
223 pub state: &'static str,
225}
226
227#[must_use]
229pub fn worktree_view(root: &Path, feature: Option<&str>) -> WorktreeView {
230 let Some(branch) = branch_of(root, feature) else {
231 return WorktreeView::default();
232 };
233 let Ok(Some(wt)) = crate::worktree::worktree_for(&Git::at(root), &branch) else {
234 return WorktreeView::default();
235 };
236 let out = Git::at(&wt)
237 .run(&[
238 "status",
239 "--porcelain=v1",
240 "-z",
241 "--untracked-files=normal",
242 "--ignored=matching",
243 ])
244 .unwrap_or_default();
245 let overlaid = crate::held_out::overlaid(&wt);
248 let entries = crate::git::nul_fields(&out)
249 .into_iter()
250 .filter_map(|e| {
251 let (code, path) = (e.get(..2)?, e.get(3..)?.to_owned());
252 let state = match code {
253 "??" => "untracked",
254 "!!" => "ignored",
255 _ => "modified",
256 };
257 let hidden = state != "modified"
258 && overlaid.as_ref().is_none_or(|keys| {
259 keys.contains(&crate::held_out::overlay_key(path.trim_end_matches('/')))
260 });
261 (!hidden).then_some(WorktreeEntry { path, state })
262 })
263 .take(2000)
264 .collect();
265 let s = setup(root, feature);
266 WorktreeView {
267 path: Some(wt.display().to_string()),
268 entries,
269 blocking: s
270 .config
271 .iter()
272 .chain(&s.left.commit)
273 .chain(&s.left.ignore)
274 .cloned()
275 .collect(),
276 fix: (!s.is_empty()).then(|| s.command(root)),
277 }
278}
279
280struct Setup {
285 worktree: Option<PathBuf>,
286 left: crate::worktree::Leftovers,
287 config: Vec<String>,
288}
289
290const CONFIG: [&str; 2] = [crate::verify::config::FILE, ".config/nextest.toml"];
292
293fn setup(root: &Path, feature: Option<&str>) -> Setup {
294 let worktree = branch_of(root, feature).and_then(|b| {
295 crate::worktree::worktree_for(&Git::at(root), &b)
296 .ok()
297 .flatten()
298 });
299 let left = worktree
300 .as_deref()
301 .map(crate::worktree::leftovers)
302 .unwrap_or_default();
303 let g = Git::at(root);
304 let config = CONFIG
305 .iter()
306 .filter(|p| root.join(p).is_file())
307 .filter(|p| {
308 g.run(&["status", "--porcelain", "--", p])
309 .is_ok_and(|o| !o.trim_ascii().is_empty())
310 })
311 .map(|p| (*p).to_owned())
312 .collect();
313 Setup {
314 worktree,
315 left,
316 config,
317 }
318}
319
320impl Setup {
321 fn is_empty(&self) -> bool {
322 self.left.is_empty() && self.config.is_empty()
323 }
324
325 fn paths(&self) -> Vec<String> {
327 let mut paths: Vec<String> = self
328 .config
329 .iter()
330 .chain(&self.left.commit)
331 .cloned()
332 .collect();
333 if !self.left.ignore.is_empty() {
334 paths.push(".gitignore".to_owned());
335 }
336 paths
337 }
338
339 fn command(&self, root: &Path) -> String {
340 let wt = self.worktree.as_deref().unwrap_or(root);
341 self.left.fix(root, wt, &self.config)
342 }
343}
344
345#[must_use]
348pub fn setup_remedy(root: &Path, feature: Option<&str>) -> Option<speccoder_core::Remedy> {
349 let s = setup(root, feature);
350 if s.is_empty() {
351 return None;
352 }
353 let paths = s.paths();
354 let ignore = if s.left.ignore.is_empty() {
355 String::new()
356 } else {
357 format!(
358 "{} added to .gitignore, ",
359 speccoder_core::words::listed(&s.left.ignore)
360 )
361 };
362 Some(speccoder_core::Remedy {
363 label: format!("Commit {}", speccoder_core::words::listed(&paths)),
364 effect: format!("{ignore}in your checkout as `speccoder: setup`; the next run picks it up"),
365 command: s.command(root),
366 undo: Some("git reset --soft HEAD~1".to_owned()),
367 history: true,
368 run: speccoder_core::actions::Runnable::FixSetup,
369 })
370}
371
372#[must_use]
377pub fn fix_setup(root: &Path, feature: Option<&str>) -> speccoder_core::CommandResult {
378 use speccoder_core::{CommandResult, CommandStatus};
379 let refuse = |status, m: String| CommandResult::new("fix", status, &m);
380 let s = setup(root, feature);
381 if s.is_empty() {
382 return CommandResult::new("fix", CommandStatus::Ok, "nothing in the setup stops a run");
383 }
384 if let Some(wt) = &s.worktree {
385 for f in &s.left.commit {
386 let to = root.join(f);
387 if !to.exists()
388 && let Err(e) = std::fs::copy(wt.join(f), &to)
389 {
390 return refuse(CommandStatus::Error, format!("{f}: {e}"));
391 }
392 }
393 }
394 if !s.left.ignore.is_empty() {
395 let ignore = root.join(".gitignore");
396 let existing = std::fs::read_to_string(&ignore).unwrap_or_default();
397 let sep = if existing.is_empty() || existing.ends_with('\n') {
398 ""
399 } else {
400 "\n"
401 };
402 let text = format!("{existing}{sep}{}\n", s.left.ignore.join("\n"));
403 if let Err(e) = std::fs::write(&ignore, text) {
404 return refuse(CommandStatus::Error, format!(".gitignore: {e}"));
405 }
406 }
407 let paths = s.paths();
408 match crate::init::commit_written_as(root, &paths, "speccoder: setup") {
409 Ok(id) => CommandResult::new(
410 "fix",
411 CommandStatus::Ok,
412 &format!(
413 "committed {} as {id}; the next run takes it into the feature's branch (undo: git reset --soft HEAD~1)",
414 speccoder_core::words::listed(&paths)
415 ),
416 ),
417 Err(e) => refuse(CommandStatus::GitPrecondition, e),
418 }
419}
420
421pub fn worktree_file(root: &Path, feature: Option<&str>, path: &str) -> Result<String, String> {
428 let view = worktree_view(root, feature);
429 if !view
430 .entries
431 .iter()
432 .any(|e| e.path == path && e.state == "untracked")
433 {
434 return Err(format!("{path} is not an untracked file of the worktree"));
435 }
436 let wt = PathBuf::from(view.path.unwrap_or_default());
437 let full = wt.join(path);
438 let meta = std::fs::symlink_metadata(&full).map_err(|e| e.to_string())?;
441 if meta.file_type().is_symlink() {
442 return Err(format!("{path} is a symbolic link: not shown"));
443 }
444 let inside = match (std::fs::canonicalize(&wt), std::fs::canonicalize(&full)) {
445 (Ok(w), Ok(f)) => f.starts_with(&w) && f != w,
446 _ => false,
447 };
448 if !inside || !meta.is_file() {
449 return Err(format!("{path} is not a file of the worktree"));
450 }
451 let overlay = || crate::held_out::overlaid(&wt).is_none_or(|k| !k.is_empty());
452 if overlay() {
453 return Err(
454 "held-out tests are being checked in the worktree: look again when the check ends"
455 .to_owned(),
456 );
457 }
458 let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
459 if overlay() {
461 return Err(
462 "held-out tests are being checked in the worktree: look again when the check ends"
463 .to_owned(),
464 );
465 }
466 if bytes.len() > FILE_LIMIT {
467 return Err(format!("{path} is larger than {} KB", FILE_LIMIT / 1024));
468 }
469 String::from_utf8(bytes).map_err(|_| format!("{path} is not text"))
470}
471
472pub fn file(root: &Path, feature: Option<&str>, path: &str) -> Result<String, String> {
479 let (rev, files) = tree(root, feature);
480 if !files.iter().any(|f| f == path) {
481 return Err(format!("{path} is not in {rev}"));
482 }
483 let bytes = Git::at(root)
484 .run(&["show", &format!("{rev}:./{path}")])
487 .map_err(|e| e.0)?;
488 if bytes.len() > FILE_LIMIT {
489 return Err(format!("{path} is larger than {} KB", FILE_LIMIT / 1024));
490 }
491 if bytes.contains(&0) {
492 return Err(format!("{path} is binary"));
493 }
494 Ok(String::from_utf8_lossy(&bytes).into_owned())
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 fn git(dir: &Path, args: &[&str]) {
502 let ok = std::process::Command::new("git")
503 .args([
504 "-c",
505 "user.name=t",
506 "-c",
507 "user.email=t@e",
508 "-c",
509 "commit.gpgsign=false",
510 ])
511 .args(args)
512 .current_dir(dir)
513 .status()
514 .is_ok_and(|s| s.success());
515 assert!(ok, "git {args:?}");
516 }
517
518 #[test]
519 fn numstat_reads_renames_and_unusual_paths() {
520 let out = b"3\t1\tsrc/lib.rs\x002\t0\t\x00src/a.rs\x00src/b.rs\x00-\t-\tlogo.png\x001\t0\tdocs/\xc3\xbcber.md\x00";
521 let files = numstat(out);
522 let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
523 assert_eq!(
524 paths,
525 ["src/lib.rs", "src/b.rs", "logo.png", "docs/über.md"]
526 );
527 assert_eq!((files[2].add, files[2].del), (None, None));
528 assert_eq!(files[1].add, Some(2));
529 }
530
531 #[test]
534 fn a_nested_project_reads_its_files_and_nothing_else() {
535 let repo = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
536 let project = repo.path().join("apps/web");
537 std::fs::create_dir_all(project.join("src")).unwrap_or_else(|e| panic!("{e}"));
538 std::fs::write(project.join("src/lib.rs"), "pub fn ok() {}\n")
539 .unwrap_or_else(|e| panic!("{e}"));
540 std::fs::write(repo.path().join("secret.txt"), "outside\n")
541 .unwrap_or_else(|e| panic!("{e}"));
542 git(repo.path(), &["init", "-q"]);
543 git(repo.path(), &["add", "-A"]);
544 git(repo.path(), &["commit", "-qm", "init"]);
545 let (rev, files) = tree(&project, None);
546 assert_eq!(rev, "HEAD");
547 assert_eq!(files, ["src/lib.rs"]);
548 assert_eq!(
549 file(&project, None, "src/lib.rs").as_deref(),
550 Ok("pub fn ok() {}\n")
551 );
552 for bad in [
553 "../../secret.txt",
554 "secret.txt",
555 "src/../../../secret.txt",
556 "src",
557 ] {
558 assert!(file(&project, None, bad).is_err(), "{bad}");
559 }
560 }
561}