diff --git a/CHANGELOG.md b/CHANGELOG.md index 5672992..62d3d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Cut a new section here as part of tagging a release — see "Cutting a release" ### Added - Viewer change navigator with per-layer region lists, X2 labels, framing, highlight, and rebindable N/P keys (#419). +- `etchy log [subdir]` lists, per commit in a git range that touches the + fab pack, which layers changed and by how much (area, regions), in summary, + Markdown and JSON. Each commit is diffed against its first parent, no checkout. + `--max-commits` (default 200) fails loud instead of truncating (#424). ### Fixed - A drill or placement file that fails to parse is no longer reported as diff --git a/README.md b/README.md index 37e2c95..e0369c5 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,14 @@ add the Action to a repo (full recipe in The job grants `pull-requests: write`; etchy supplies its own token and posts a sticky per-layer table (added / removed mm² and region counts). +**History over a git range** — `etchy log` walks the commits that touch the fab +pack and prints, per commit, which layers changed and by how much (no checkout; +`--format md|json` too; exit 1 if any commit changed a layer): + +```sh +etchy log v0.1.0..HEAD fab/ # newest first, each commit vs its first parent +``` + **PDF diff** — schematic PDFs get a page-by-page pixel diff (builds with `--features pdf`; release binaries ship it on): diff --git a/crates/etchy-cli/src/log.rs b/crates/etchy-cli/src/log.rs new file mode 100644 index 0000000..431fabc --- /dev/null +++ b/crates/etchy-cli/src/log.rs @@ -0,0 +1,420 @@ +//! `etchy log [SUBDIR]` — per-commit layer changes over a git range +//! (#424). Walks the first-parent commits in the range that touch the fab pack +//! and diffs each against its first parent, through the same git blob reader, +//! board loader and `compare_detailed` path as the two-ref mode. No checkout. + +use std::path::PathBuf; +use std::process::ExitCode; +use std::rc::Rc; + +use anyhow::{Context, Result}; +use clap::Parser; +use etchy_core::report::md_code_span; +use etchy_core::{compare_detailed, Board, BoardBuilder, DiffReport, GerberFormat, LayerStatus}; +use serde::Serialize; + +use crate::{ + git_bytes, git_stdout, git_subdir, load_board_git, report_with_load_warnings, status_str, + write_stdout, Exit, Format, +}; + +/// The JSON contract version of `etchy log --format json`. +const LOG_SCHEMA_VERSION: u32 = 1; + +/// List, per commit, which layers changed and by how much over a git range. +/// +/// Walks `git rev-list --first-parent -- ` (newest first) and +/// diffs each commit against its first parent — a merge is therefore reported +/// as everything it brought into the first-parent line. A commit where the fab +/// pack appears or disappears is reported as an added or removed pack. Exit 0 +/// when no commit changed any layer, 1 when one did, 2 on any error (including +/// a commit whose files don't parse). +#[derive(Parser, Debug)] +#[command(name = "etchy log", bin_name = "etchy log", version)] +struct LogCli { + /// A git revision range, as `git rev-list` takes it: `v0.1.0..HEAD`, + /// `main..feature`, or a single ref for its whole history. + range: String, + /// The subdirectory in the repo where the fab files live (default: the repo + /// root). Relative to the current directory, like a git pathspec. + subdir: Option, + /// Output format: a terminal summary, machine-readable JSON, or GitHub + /// Markdown (for a CI step-summary / PR comment). + #[arg(long, value_enum, default_value_t = Format::Summary)] + format: Format, + /// Fail (exit 2) when the range holds more than this many commits touching + /// the fab pack, rather than truncating the history silently. + #[arg(long, value_name = "N", default_value_t = 200, value_parser = clap::value_parser!(u32).range(1..))] + max_commits: u32, +} + +/// Is the fab pack present on both sides of a commit, or did it appear/vanish? +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum PackChange { + Present, + Added, + Removed, +} + +impl PackChange { + fn as_str(self) -> &'static str { + match self { + PackChange::Present => "present", + PackChange::Added => "added", + PackChange::Removed => "removed", + } + } +} + +/// One commit's entry: its identity and the diff against its first parent. +#[derive(Serialize)] +struct CommitEntry { + commit: String, + /// The first parent, or `null` for a root commit. + parent: Option, + subject: String, + pack: PackChange, + report: DiffReport, +} + +/// The whole `etchy log` result. +#[derive(Serialize)] +struct LogReport { + schema_version: u32, + tool_version: &'static str, + range: String, + subdir: String, + /// Always true: commits are walked along, and diffed against, first parents. + first_parent: bool, + any_changes: bool, + /// Newest first, like `git log`. + commits: Vec, +} + +type Loaded = (Board, Option, Vec); + +/// Entry point for `etchy log …`; owns the 0/1/2 exit contract like `main`. +pub(crate) fn main() -> ExitCode { + // Skip the binary name so clap sees `log` as its own name. + let cli = LogCli::parse_from(std::env::args_os().skip(1)); + if std::path::Path::new("log").is_dir() { + eprintln!( + "etchy: note: `log` is the history subcommand; to diff a directory named log, \ + write ./log" + ); + } + match run(&cli) { + Ok(any_changes) => Exit::from_passed(!any_changes).into(), + Err(e) => { + eprintln!("etchy: error: {e:#}"); + Exit::Error.into() + } + } +} + +fn run(cli: &LogCli) -> Result { + // A range starting with `-` would be read by git as an option. + if cli.range.starts_with('-') { + anyhow::bail!(" `{}` must not start with '-'", cli.range); + } + let subdir = git_subdir(cli.subdir.as_deref())?; + if cli.subdir.is_none() { + eprintln!( + "etchy: note: no [SUBDIR] given — diffing every fab file in the whole tree at \ + each commit; pass a subdirectory to scope the comparison" + ); + } + + let commits = commits_in_range(&cli.range, subdir, cli.max_commits)?; + let mut entries = Vec::with_capacity(commits.len()); + // Oldest first, so a commit's parent side is usually the previous commit's + // new side and the board is reused instead of re-read. + let mut cache: Option<(String, Rc)> = None; + for sha in commits.iter().rev() { + let entry = diff_commit(sha, subdir, &mut cache) + .with_context(|| format!("commit {}", short(sha)))?; + entries.push(entry); + } + entries.reverse(); + + let any_changes = entries.iter().any(|e| e.report.any_changes()); + let log = LogReport { + schema_version: LOG_SCHEMA_VERSION, + tool_version: env!("CARGO_PKG_VERSION"), + range: cli.range.clone(), + subdir: subdir.to_string(), + first_parent: true, + any_changes, + commits: entries, + }; + let out = match cli.format { + Format::Json => serde_json::to_string_pretty(&log).expect("LogReport serializes"), + Format::Md => format_markdown(&log), + Format::Summary => { + for e in &log.commits { + for w in &e.report.warnings { + eprintln!("etchy: warning: {}: {w}", short(&e.commit)); + } + } + format_summary(&log) + } + }; + write_stdout(&out, Exit::from_passed(!any_changes))?; + Ok(any_changes) +} + +/// The first-parent commits in `range` that touch `subdir`, newest first. More +/// than `max` is an error, never a silent truncation. +fn commits_in_range(range: &str, subdir: &str, max: u32) -> Result> { + let limit = format!("--max-count={}", u64::from(max) + 1); + let listing = git_stdout(&["rev-list", "--first-parent", &limit, range, "--", subdir]) + .with_context(|| format!("listing commits in {range} touching {subdir}"))?; + let commits: Vec = listing.split_whitespace().map(str::to_string).collect(); + if commits.len() > max as usize { + anyhow::bail!( + "{range} has more than {max} commits touching {subdir}; narrow the range or \ + raise --max-commits" + ); + } + Ok(commits) +} + +/// Diff one commit against its first parent. +fn diff_commit( + sha: &str, + subdir: &str, + cache: &mut Option<(String, Rc)>, +) -> Result { + // `git show` without a pathspec: the real parents, not the simplified ones. + let meta = git_stdout(&["show", "-s", "--format=%P%x00%s", sha]) + .context("reading the commit's parents and subject")?; + let (parents, subject) = meta.split_once('\0').unwrap_or((meta.as_str(), "")); + let parent = parents.split_whitespace().next().map(str::to_string); + let subject = subject.trim_end_matches(['\r', '\n']).to_string(); + + let old = match &parent { + Some(p) => load_side(p, subdir, cache).context("loading the first parent")?, + None => None, + }; + let new = load_side(sha, subdir, cache).context("loading the commit")?; + let pack = match (&old, &new) { + (None, Some(_)) => PackChange::Added, + (Some(_), None) => PackChange::Removed, + _ => PackChange::Present, + }; + let empty = Rc::new(BoardBuilder::new().finish()); + let old = old.unwrap_or_else(|| empty.clone()); + let new = new.unwrap_or(empty); + + let diff = compare_detailed(&old.0, &new.0).context("comparing with the first parent")?; + let report = report_with_load_warnings(&diff, old.2.clone(), new.2.clone(), old.1, new.1); + Ok(CommitEntry { + commit: sha.to_string(), + parent, + subject, + pack, + report, + }) +} + +/// Load the fab pack at `rev`, or `None` when `subdir` holds no files there. +/// The tree listing (blob ids included) keys a one-entry cache. +fn load_side( + rev: &str, + subdir: &str, + cache: &mut Option<(String, Rc)>, +) -> Result>> { + let listing = git_bytes(&["ls-tree", "-r", "-z", rev, "--", subdir]) + .with_context(|| format!("listing files at {}:{subdir}", short(rev)))?; + if listing.is_empty() { + return Ok(None); + } + let key = String::from_utf8_lossy(&listing).into_owned(); + if let Some((k, board)) = cache.as_ref() { + if *k == key { + return Ok(Some(board.clone())); + } + } + let board = Rc::new(load_board_git(rev, subdir)?); + *cache = Some((key, board.clone())); + Ok(Some(board)) +} + +fn short(sha: &str) -> &str { + &sha[..sha.len().min(12)] +} + +fn changed_layers(report: &DiffReport) -> impl Iterator { + report + .layers + .iter() + .filter(|l| l.status != LayerStatus::Unchanged) +} + +fn format_summary(log: &LogReport) -> String { + use std::fmt::Write; + let mut s = String::new(); + for e in &log.commits { + let _ = writeln!(s, "{} {}", short(&e.commit), e.subject); + match e.pack { + PackChange::Added => s.push_str(" pack added (no files at the parent)\n"), + PackChange::Removed => s.push_str(" pack removed (no files at this commit)\n"), + PackChange::Present => {} + } + if !e.report.any_changes() { + s.push_str(" no layer changes\n\n"); + continue; + } + let _ = writeln!( + s, + " {:<16} {:<13} {:>12} {:>12} {:>9} {:>9}", + "layer", "status", "added_mm2", "removed_mm2", "+regions", "-regions" + ); + for l in changed_layers(&e.report) { + let _ = writeln!( + s, + " {:<16} {:<13} {:>12.5} {:>12.5} {:>9} {:>9}", + l.name(), + status_str(l.status), + l.added_area_mm2, + l.removed_area_mm2, + l.added_regions, + l.removed_regions + ); + } + s.push('\n'); + } + let changed = log + .commits + .iter() + .filter(|e| e.report.any_changes()) + .count(); + let _ = writeln!( + s, + "{} commit(s) touching {} in {} (first-parent); {} with layer changes", + log.commits.len(), + log.subdir, + log.range, + changed + ); + s.push_str(if log.any_changes { + "result: differences found" + } else { + "result: no differences" + }); + s +} + +/// A commit subject or warning as an inert table cell (#298: this text can be +/// posted as a bot PR comment). +fn md_cell(text: &str) -> String { + md_code_span(text).replace('|', "\\|") +} + +fn format_markdown(log: &LogReport) -> String { + use std::fmt::Write; + let changed = log + .commits + .iter() + .filter(|e| e.report.any_changes()) + .count(); + let mut s = String::from("## etchy — PCB log\n\n"); + let _ = writeln!( + s, + "{} in {} · {} commit(s), {} with layer changes (first-parent, newest first)\n", + md_code_span(&log.range), + md_code_span(&log.subdir), + log.commits.len(), + changed + ); + if log.commits.is_empty() { + s.push_str("✅ **No commits touch the fab pack in this range.**\n"); + return s; + } + s.push_str( + "| commit | subject | pack | layers changed | +mm² | −mm² | +regions | −regions |\n", + ); + s.push_str("|---|---|---|--:|--:|--:|--:|--:|\n"); + for e in &log.commits { + let t = &e.report.totals; + let _ = writeln!( + s, + "| `{}` | {} | {} | {} | {:.4} | {:.4} | {} | {} |", + short(&e.commit), + md_cell(&e.subject), + e.pack.as_str(), + t.layers_changed, + t.added_area_mm2, + t.removed_area_mm2, + t.added_regions, + t.removed_regions + ); + } + for e in log.commits.iter().filter(|e| e.report.any_changes()) { + let _ = writeln!(s, "\n### `{}` — changed layers\n", short(&e.commit)); + s.push_str("| layer | status | +mm² | −mm² | +regions | −regions |\n"); + s.push_str("|---|---|--:|--:|--:|--:|\n"); + for l in changed_layers(&e.report) { + let _ = writeln!( + s, + "| {} | {} | {:.4} | {:.4} | {} | {} |", + l.name(), + status_str(l.status), + l.added_area_mm2, + l.removed_area_mm2, + l.added_regions, + l.removed_regions + ); + } + } + let warnings: Vec<_> = log + .commits + .iter() + .flat_map(|e| e.report.warnings.iter().map(move |w| (&e.commit, w))) + .collect(); + if !warnings.is_empty() { + s.push_str("\n⚠️ **Warnings**\n"); + for (commit, w) in warnings { + let _ = writeln!(s, "- `{}`: {}", short(commit), md_code_span(w)); + } + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_cli_defaults_and_bounds() { + let cli = LogCli::try_parse_from(["log", "a..b", "fab"]).unwrap(); + assert_eq!(cli.max_commits, 200); + assert_eq!(cli.format, Format::Summary); + assert!(LogCli::try_parse_from(["log", "a..b", "--max-commits", "0"]).is_err()); + } + + #[test] + fn markdown_subject_is_inert() { + let log = LogReport { + schema_version: LOG_SCHEMA_VERSION, + tool_version: "0", + range: "a..b".into(), + subdir: "fab".into(), + first_parent: true, + any_changes: false, + commits: vec![CommitEntry { + commit: "0123456789abcdef".into(), + parent: None, + subject: "@team | [x](y)".into(), + pack: PackChange::Added, + report: DiffReport::new(Vec::new(), Vec::new()), + }], + }; + let md = format_markdown(&log); + assert!( + md.contains("| `0123456789ab` | `@team \\| [x](y)` | added |"), + "{md}" + ); + } +} diff --git a/crates/etchy-cli/src/main.rs b/crates/etchy-cli/src/main.rs index c338125..899b7e7 100644 --- a/crates/etchy-cli/src/main.rs +++ b/crates/etchy-cli/src/main.rs @@ -16,6 +16,7 @@ use etchy_core::{ }; use serde::Serialize; +mod log; #[cfg(feature = "pdf")] mod pdf; @@ -51,7 +52,13 @@ impl From for ExitCode { /// Fast, trustworthy PCB visual + geometric diff. Point it at two revisions of a /// board's Gerber output and it shows — and measures — exactly what changed. #[derive(Parser, Debug)] -#[command(name = "etchy", version, about)] +#[command( + name = "etchy", + version, + about, + after_help = "Per-commit history: `etchy log [SUBDIR]` lists the layer changes of \ + every commit in a git range (see `etchy log --help`)." +)] struct Cli { /// Old revision: a directory of Gerber/Excellon files, or a git ref (see /// `--git` / the optional [SUBDIR] argument). @@ -383,6 +390,11 @@ impl GateVerdict { } fn main() -> ExitCode { + // `etchy log …` is the one subcommand. It is dispatched by hand because the + // two-revision form takes bare positional paths. + if std::env::args_os().nth(1).is_some_and(|a| a == "log") { + return log::main(); + } let cli = Cli::parse(); // Validate the gate BEFORE the (expensive) diff, so a bad --gate-layers is a // loud, fast exit 2 — never a silently disarmed gate. @@ -502,14 +514,7 @@ fn run(cli: &Cli, gate: &Gate) -> Result { } let (old, of, ow, new, nf, nw) = if git_mode { - // A non-UTF-8 subdir can't be passed to git as a pathspec here; fail loud - // rather than silently widening the scan to the whole tree (#299). - let subdir = match cli.subdir.as_deref() { - None => ".", - Some(p) => p - .to_str() - .with_context(|| format!("[SUBDIR] {} is not valid UTF-8", p.display()))?, - }; + let subdir = git_subdir(cli.subdir.as_deref())?; if cli.subdir.is_none() { // Whole-repo scans flatten every Gerber/Excellon blob at the ref into // one board — fine for a single-board repo, garbage when the tree @@ -535,20 +540,7 @@ fn run(cli: &Cli, gate: &Gate) -> Result { }; let diff = compare_detailed(&old, &new).context("comparing revisions")?; - let mut report = diff.report.clone(); - // Layer-classification cross-check warnings (#239), old rev then new. A - // conflict repeated on both revisions is reported once per side, tagged by - // revision so it's clear which pack the mismatch is in. - for (rev, ws) in [("old", ow), ("new", nw)] { - for w in ws { - report.warnings.push(format!("{rev} {w}")); - } - } - if let (Some(o), Some(n)) = (of, nf) { - if let Some(w) = coordinate_mismatch_warning(&o, &n) { - report.warnings.push(w); - } - } + let report = report_with_load_warnings(&diff, ow, nw, of, nf); // Optional SVG export: one file per changed layer (headless render). if let Some(dir) = &cli.svg { @@ -601,6 +593,43 @@ fn run(cli: &Cli, gate: &Gate) -> Result { }) } +/// The `[SUBDIR]` of a git-refs run as a pathspec (`.` when omitted). A non-UTF-8 +/// subdir can't be passed to git as a pathspec here; fail loud rather than +/// silently widening the scan to the whole tree (#299). +fn git_subdir(subdir: Option<&Path>) -> Result<&str> { + match subdir { + None => Ok("."), + Some(p) => p + .to_str() + .with_context(|| format!("[SUBDIR] {} is not valid UTF-8", p.display())), + } +} + +/// The diff's report plus the loaders' warnings: layer-classification +/// cross-checks (#239), old rev then new, and the coordinate-format check. A +/// conflict repeated on both revisions is reported once per side, tagged by +/// revision so it's clear which pack the mismatch is in. +fn report_with_load_warnings( + diff: &etchy_core::BoardDiff, + old_warnings: Vec, + new_warnings: Vec, + old_format: Option, + new_format: Option, +) -> DiffReport { + let mut report = diff.report.clone(); + for (rev, ws) in [("old", old_warnings), ("new", new_warnings)] { + for w in ws { + report.warnings.push(format!("{rev} {w}")); + } + } + if let (Some(o), Some(n)) = (old_format, new_format) { + if let Some(w) = coordinate_mismatch_warning(&o, &n) { + report.warnings.push(w); + } + } + report +} + /// Write a finished report to stdout, treating a downstream pipe that closed /// early (`etchy … | head`) as a clean exit instead of the panic `println!` /// raises on a broken pipe (which escaped the 0/1/2 contract as exit 101 — #261). diff --git a/crates/etchy-cli/tests/log_cli.rs b/crates/etchy-cli/tests/log_cli.rs new file mode 100644 index 0000000..6ed7c46 --- /dev/null +++ b/crates/etchy-cli/tests/log_cli.rs @@ -0,0 +1,250 @@ +//! `etchy log` contract tests (#424): a temp git repo with a few commits of the +//! demo fab pack, driven through the real `etchy` binary. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn etchy(repo: &Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_etchy")); + c.current_dir(repo); + c +} + +fn demo(side: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../etchy-gui/assets/demo") + .join(side) +} + +fn git(repo: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .current_dir(repo) + .args([ + "-c", + "user.name=etchy", + "-c", + "user.email=etchy@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + ]) + .args(args) + .output() + .expect("git runs"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + +fn copy_pack(from: &Path, to: &Path) { + fs::create_dir_all(to).unwrap(); + for entry in fs::read_dir(from).unwrap() { + let path = entry.unwrap().path(); + fs::copy(&path, to.join(path.file_name().unwrap())).unwrap(); + } +} + +fn commit(repo: &Path, subject: &str, tag: &str) { + git(repo, &["add", "-A"]); + git(repo, &["commit", "-q", "-m", subject]); + git(repo, &["tag", tag]); +} + +/// t1 add pack · t2 notes only · t3 outside the pack · t4 rev B · t5 drop the +/// top silk · t6 remove the pack. +fn history(tag: &str) -> PathBuf { + let repo = std::env::temp_dir().join(format!("etchy-log-{}-{tag}", std::process::id())); + let _ = fs::remove_dir_all(&repo); + fs::create_dir_all(&repo).unwrap(); + git(&repo, &["init", "-q"]); + let fab = repo.join("fab"); + copy_pack(&demo("old"), &fab); + commit(&repo, "add the pack", "t1"); + fs::write(fab.join("notes.txt"), "fab notes\n").unwrap(); + commit(&repo, "notes only", "t2"); + fs::write(repo.join("README.md"), "readme\n").unwrap(); + commit(&repo, "outside the pack", "t3"); + copy_pack(&demo("new"), &fab); + commit(&repo, "rev B", "t4"); + fs::remove_file(fab.join("Mad_RP2040.gto")).unwrap(); + commit(&repo, "drop the top silk", "t5"); + fs::remove_dir_all(&fab).unwrap(); + commit(&repo, "remove the pack", "t6"); + repo +} + +fn json(out: &Output) -> serde_json::Value { + serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "{e}: {}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }) +} + +#[test] +fn log_lists_each_commit_touching_the_pack() { + let repo = history("walk"); + let out = etchy(&repo) + .args(["log", "t6", "fab", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1), "a changed commit exits 1"); + let v = json(&out); + assert_eq!(v["schema_version"], 1); + assert_eq!(v["any_changes"], true); + let commits = v["commits"].as_array().unwrap(); + let subjects: Vec<_> = commits + .iter() + .map(|c| c["subject"].as_str().unwrap()) + .collect(); + // Newest first; the README commit doesn't touch fab/. + assert_eq!( + subjects, + [ + "remove the pack", + "drop the top silk", + "rev B", + "notes only", + "add the pack" + ] + ); + let packs: Vec<_> = commits + .iter() + .map(|c| c["pack"].as_str().unwrap()) + .collect(); + assert_eq!(packs, ["removed", "present", "present", "present", "added"]); + let changes: Vec<_> = commits + .iter() + .map(|c| c["report"]["any_changes"].as_bool().unwrap()) + .collect(); + assert_eq!(changes, [true, true, true, false, true]); + assert!(commits[4]["parent"].is_null(), "root commit has no parent"); + assert_eq!(commits[0]["parent"], commits[1]["commit"]); + + let silk = commits[1]["report"]["layers"] + .as_array() + .unwrap() + .iter() + .filter(|l| l["status"] != "unchanged") + .collect::>(); + assert_eq!(silk.len(), 1, "only the top silk changed: {silk:?}"); + assert_eq!(silk[0]["kind"], "top-silk"); + assert_eq!(silk[0]["status"], "removed-layer"); + assert!(silk[0]["removed_area_mm2"].as_f64().unwrap() > 0.0); + let removed = &commits[0]["report"]["layers"]; + assert!(removed + .as_array() + .unwrap() + .iter() + .all(|l| l["status"] == "removed-layer")); + + let out = etchy(&repo) + .args(["log", "t1..t6", "fab"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let text = String::from_utf8(out.stdout).unwrap(); + assert!(text.contains("drop the top silk"), "{text}"); + assert!(text.contains("top-silk"), "{text}"); + assert!(text.contains("removed-layer"), "{text}"); + assert!(text.contains("pack removed"), "{text}"); + assert!(text.contains("4 commit(s) touching fab"), "{text}"); + assert!(text.ends_with("result: differences found\n"), "{text}"); + + let out = etchy(&repo) + .args(["log", "t1..t6", "fab", "--format", "md"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1)); + let md = String::from_utf8(out.stdout).unwrap(); + assert!(md.contains("## etchy — PCB log"), "{md}"); + assert!(md.contains("| `drop the top silk` | present | 1 |"), "{md}"); + assert!(md.contains("| top-silk | removed-layer |"), "{md}"); + let _ = fs::remove_dir_all(repo); +} + +#[test] +fn log_without_layer_changes_exits_0() { + let repo = history("unchanged"); + // t1..t3 holds the notes-only commit (and the README one, outside fab/). + let out = etchy(&repo) + .args(["log", "t1..t3", "fab"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + let text = String::from_utf8(out.stdout).unwrap(); + assert!(text.contains("notes only"), "{text}"); + assert!(text.contains("no layer changes"), "{text}"); + assert!(!text.contains("outside the pack"), "{text}"); + assert!(text.ends_with("result: no differences\n"), "{text}"); + + // An empty range is not a difference either. + let out = etchy(&repo) + .args(["log", "t3..t3", "fab"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(0)); + let _ = fs::remove_dir_all(repo); +} + +#[test] +fn log_over_the_commit_cap_fails_loud() { + let repo = history("cap"); + let out = etchy(&repo) + .args(["log", "t6", "fab", "--max-commits", "4"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("more than 4 commits"), "{err}"); + assert!(err.contains("--max-commits"), "{err}"); + + let out = etchy(&repo) + .args(["log", "t6", "fab", "--max-commits", "5"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1), "exactly at the cap runs"); + let _ = fs::remove_dir_all(repo); +} + +#[test] +fn log_names_an_unparseable_commit() { + let repo = history("broken"); + let fab = repo.join("fab"); + copy_pack(&demo("old"), &fab); + // Step-and-repeat is unsupported: the load must fail loud, not be skipped. + fs::write( + fab.join("Mad_RP2040.gtl"), + "%FSLAX46Y46*%\n%MOMM*%\n%ADD10C,0.5*%\n%SRX2Y1I5J0*%\nD10*\nX0Y0D03*\nM02*\n", + ) + .unwrap(); + commit(&repo, "broken copper", "t7"); + let sha = git(&repo, &["rev-parse", "t7"]); + let out = etchy(&repo) + .args(["log", "t6..t7", "fab"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains(&format!("commit {}", &sha[..12])), "{err}"); + + // A bad range is an error too, and an option-like one never reaches git. + let out = etchy(&repo) + .args(["log", "nope..t7", "fab"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + let out = etchy(&repo) + .args(["log", "--", "-n1", "fab"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + let _ = fs::remove_dir_all(repo); +} diff --git a/crates/etchy-core/src/report.rs b/crates/etchy-core/src/report.rs index cd2bb2e..0f23bb0 100644 --- a/crates/etchy-core/src/report.rs +++ b/crates/etchy-core/src/report.rs @@ -687,7 +687,7 @@ impl DiffReport { /// backtick is padded with a space so the pad, not the backtick, meets the fence. /// A code span cannot contain a line break at this position, so `\r`/`\n` are /// collapsed to a space first. -fn md_code_span(s: &str) -> String { +pub fn md_code_span(s: &str) -> String { let text = s.replace(['\r', '\n'], " "); let longest_run = text.split(|c| c != '`').map(str::len).max().unwrap_or(0); let fence = "`".repeat(longest_run + 1); diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index cbdd260..bfc811b 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -82,6 +82,7 @@ numbered requirements with their source and current status. | CLI-11 | **GitLab CI recipe**: one `.gitlab-ci.yml` job downloads and checks the release binary, gates the merge request on the exit code, keeps the HTML report and layer SVGs as a job artifact, and posts the Markdown summary as a sticky MR note ([`docs/ci-recipes/gitlab.md`](ci-recipes/gitlab.md)). | #427, #450 | ✅ | | CLI-12 | **Drill table diff**: for each Excellon drill layer, hole and slot counts per tool size, old vs new, with added / removed / changed sizes and a per-layer total, in the summary, Markdown, HTML and JSON (`drill_table`, additive). Sizes compare as whole micrometres after unit conversion, so inch and metric tools of one size match. A table of counts from the drill file, not a BOM; counts do not change the exit code. Not shown in the viewer. | #422, #451 | ✅ | | CLI-13 | Changed layers list their changed regions with area, bounding box and centroid in JSON, with the largest regions in Markdown and HTML reports. Gerber X2 object attributes label touched regions with reference designators, pins and nets when present. | #419, #420, #442 | ✅ | +| CLI-14 | **`etchy log [subdir]`**: walks the first-parent commits in a git range that touch the fab pack and diffs each against its first parent (merges included) through the same git reader, board loader and diff as CLI-4, no checkout. Per commit: which layers changed, added/removed mm² and regions, in summary, Markdown and JSON. A pack that appears or disappears is reported as added/removed, an unparseable commit is a named exit 2, and more commits than `--max-commits` (default 200) is an error, never a truncation. Exit 0/1/2 as CLI-1 (1 = any commit changed a layer). | #424 | ✅ | ## 6. GUI — shell (the locked 2026-07 redesign) diff --git a/site/docs.html b/site/docs.html index 18c8745..8af45b7 100644 --- a/site/docs.html +++ b/site/docs.html @@ -194,6 +194,12 @@

Diff two commits, no checkout

etchy v1.0 HEAD fab/          # <refA> <refB> [subdir]
 etchy --git v1.0 HEAD          # whole repo; prints a scoping note
+

Per-commit history: etchy log

+

Give etchy log a git range and it walks the first-parent commits that touch the fab pack, newest first, and diffs each one against its first parent through the same loader and diff as the two-ref form — no checkout. Each commit lists the layers it changed with added / removed mm² and region counts. A merge is reported against its first parent, so it shows everything it brought into the branch. A commit that adds or deletes the whole pack is reported as pack added / pack removed, never skipped.

+
etchy log v1.0..HEAD fab/                 # <range> [subdir]
+etchy log main..feature fab/ --format md   # or --format json
+

Exit 0 when no commit changed a layer, 1 when one did, 2 on any error — including a commit whose files don't parse, which is named rather than skipped. --max-commits <N> (default 200) caps the walk; a range with more commits is an error, never a silently truncated history. To diff a directory that is literally named log, write ./log.

+

Schematic PDFs

Hand etchy two .pdf files instead of two directories and it does a page-by-page pixel diff, reporting added / removed / changed pixels and a changed-region count per page. Pages pair by content, not index, so inserting one sheet shows up as a single extra row rather than making every later page look rewritten.

$ etchy old.pdf new.pdf --out overlays/