aec2a50..HEAD

1/373923b54Edu Ramírez

Render a branch as one walkthrough, chapter per commit

Add throughline branch <range>: it renders a stack of commits as a single navigable page — each commit a chapter, in reading order — so a branch reads as one narrative while every commit keeps its own complete diff.

A new subcommand takes either a bare tip (diffed against --base, default main) or an explicit base..tip range.

src/cli.rs
@@ -1,4 +1,5 @@-//! Command-line surface: `throughline render <commit> [-o out.html]`.1+//! Command-line surface: `throughline render <commit> [-o out.html]` and2+//! `throughline branch <tip|base..tip> [-o out.html]`.3 4 use std::path::PathBuf;5 @@ -27,4 +28,23 @@28         #[arg(long)]29         debug: bool,30     },31+32+    /// Render a branch — a range of commits — as one navigable, multi-commit page.33+    ///34+    /// Each commit becomes a chapter (its own complete walkthrough), in reading35+    /// order, with a contents list and previous/next links.36+    Branch {37+        /// The branch tip to render (a branch name, SHA, or tag), or an explicit38+        /// `base..tip` range. A bare tip is diffed against `--base`.39+        branch: String,40+41+        /// Base to take the commit range from when `branch` is a bare tip42+        /// (`base..tip`). Ignored when `branch` is already a `..` range.43+        #[arg(long, default_value = "main")]44+        base: String,45+46+        /// Write the HTML here instead of `throughline-branch-<sha>.html`.47+        #[arg(short, long)]48+        output: Option<PathBuf>,49+    },50 }

The spine is a range walk. range_commits lists base..tip oldest-first (topological + reverse, so a parent always precedes its child) and hands back just enough per-commit metadata to render and label each chapter.

src/git.rs
@@ -44,0 +56,25 @@56+    /// The commits in `base..tip` — reachable from `tip` but not `base` — in57+    /// reading order (oldest first), each with display metadata.58+    ///59+    /// This is the spine of a branch walkthrough: render each commit in turn and60+    /// the pages compose into one narrative. The completeness guarantee still61+    /// holds **per commit** (each diffs against its own parent); the range view62+    /// composes per-commit documents rather than squashing them into one diff.63+    pub fn range_commits(&self, base: &str, tip: &str) -> Result<Vec<RangeCommit>> {64+        let mut walk = self.inner.revwalk()?;65+        // Oldest-ancestor-first so chapters read in commit order; topological so66+        // a parent always precedes its child regardless of commit timestamps.67+        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;68+        walk.push_range(&format!("{base}..{tip}"))?;69+70+        let mut out = Vec::new();71+        for oid in walk {72+            let oid = oid?;73+            let commit = self.inner.find_commit(oid)?;74+            let id = oid.to_string();75+            let short = self.short_id(&id)?;76+            let author = commit.author().name().unwrap_or_default().to_string();77+            out.push(RangeCommit { id, short, author });78+        }79+        Ok(out)80+    }

Each commit still renders through the existing per-commit pipeline; the new work is composition. to_html_branch lays the resulting documents out as chapters — a contents list, numbered headers, previous/next links — using only in-page anchors so the page stays one self-contained file with no JavaScript. It shares the extracted document_body / page helpers with to_html, so a chapter is just a standalone page minus its <h1>.

src/html.rs
@@ -73,0 +106,50 @@106+/// Render a range of commits as one navigable, self-contained page: a contents107+/// list, then each commit's walkthrough as a chapter, with previous/next links.108+/// Each chapter keeps its own completeness guarantee (it is a per-commit109+/// [`Document`]); the page composes them in reading order rather than squashing.110+pub fn to_html_branch(title: &str, chapters: &[Chapter]) -> String {111+    let total = chapters.len();112+    let mut body = String::new();113+    let _ = writeln!(body, "<h1 id=\"top\">{}</h1>", escape(title));114+115+    // Contents: one linked row per commit. Pure in-page anchors, so navigation116+    // needs no JavaScript and the page stays a single self-contained file.117+    body.push_str("<nav class=\"toc\" aria-label=\"Commits\">\n<ol>\n");118+    for (i, ch) in chapters.iter().enumerate() {119+        let _ = writeln!(120+            body,121+            "<li><a href=\"#commit-{n}\">{title}</a> <span class=\"sha\">{short}</span></li>",122+            n = i + 1,123+            title = escape(&ch.doc.title),124+            short = escape(&ch.short),125+        );126+    }127+    body.push_str("</ol>\n</nav>\n");128+129+    for (i, ch) in chapters.iter().enumerate() {130+        let n = i + 1;131+        let _ = write!(132+            body,133+            "<section class=\"chapter\" id=\"commit-{n}\">\n\134+             <div class=\"chapter-head\"><span class=\"chapter-no\">{n}/{total}</span>\135+             <span class=\"sha\">{short}</span><span class=\"author\">{author}</span></div>\n\136+             <h2 class=\"chapter-title\">{ctitle}</h2>\n",137+            short = escape(&ch.short),138+            author = escape(&ch.author),139+            ctitle = escape(&ch.doc.title),140+        );141+        body.push_str(&document_body(&ch.doc));142+143+        body.push_str("<nav class=\"chapter-nav\">");144+        if n > 1 {145+            let _ = write!(body, "<a href=\"#commit-{}\">\u{2190} previous</a>", n - 1);146+        }147+        body.push_str("<a href=\"#top\">contents</a>");148+        if n < total {149+            let _ = write!(body, "<a href=\"#commit-{}\">next \u{2192}</a>", n + 1);150+        }151+        body.push_str("</nav>\n</section>\n");152+    }153+154+    page(title, &body)155+}

The binary wires it together: parse the range form, render each commit, compose, and write throughline-branch-<sha>.html.

src/main.rs
@@ -21,6 +21,11 @@21                 run_render(&commit, output.as_deref())22             }23         }24+        Command::Branch {25+            branch,26+            base,27+            output,28+        } => run_branch(&branch, &base, output.as_deref()),29     }30 }31 @@ -49,6 +54,57 @@54     Ok(())55 }56 57+/// Render a range of commits as one navigable page (the `branch` subcommand).58+fn run_branch(branch: &str, base: &str, output: Option<&Path>) -> Result<()> {59+    let repo =60+        git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;61+62+    // Accept either an explicit `base..tip` range or a bare tip ref (+ `--base`).63+    let (base, tip) = match branch.split_once("..") {64+        Some((b, t)) => (b.to_string(), t.to_string()),65+        None => (base.to_string(), branch.to_string()),66+    };67+68+    let commits = repo69+        .range_commits(&base, &tip)70+        .with_context(|| format!("listing commits in `{base}..{tip}`"))?;71+    if commits.is_empty() {72+        anyhow::bail!("no commits in `{base}..{tip}` — is the range empty or reversed?");73+    }74+75+    // Each commit renders to its own complete document; the page composes them.76+    let mut chapters = Vec::with_capacity(commits.len());77+    for c in &commits {78+        let doc = render::render(&repo, &c.id)79+            .with_context(|| format!("rendering commit `{}`", c.short))?;80+        chapters.push(html::Chapter {81+            short: c.short.clone(),82+            author: c.author.clone(),83+            doc,84+        });85+    }86+87+    let title = format!("{base}..{tip}");88+    let page = html::to_html_branch(&title, &chapters);89+90+    let path = match output {91+        Some(p) => p.to_path_buf(),92+        None => {93+            let short = repo.short_id(&tip).unwrap_or_else(|_| "branch".to_string());94+            PathBuf::from(format!("throughline-branch-{short}.html"))95+        }96+    };97+    std::fs::write(&path, page).with_context(|| format!("writing `{}`", path.display()))?;98+99+    println!(100+        "Rendered {} commit(s) of {} to {}",101+        chapters.len(),102+        title,103+        path.display()104+    );105+    Ok(())106+}107+108 /// `--debug`: print what the message parser understands, without rendering.109 fn debug_message(commit: &str) -> Result<()> {110     let repo =

The guarantee that matters is per commit, not per branch: each commit's change lands in its own chapter and nowhere else. A fixture of two children over a root pins the chapter order and that completeness holds per chapter.

tests/branch.rs
@@ -0,0 +1,154 @@1+//! The `branch` view: a range of commits composed into one navigable page.2+//!3+//! `Repo::range_commits` lists `base..tip` oldest-first, and `html::to_html_branch`4+//! renders each as a chapter. The guarantees under test: chapters are in reading5+//! order, each commit's own changes appear in its chapter (so completeness holds6+//! per commit across the whole range), and the contents/nav scaffolding is present.7+8+use std::fs;9+use std::path::Path;10+11+use commit_throughline::git::Repo;12+use commit_throughline::{html, render};13+use git2::{Repository, Signature};14+use tempfile::TempDir;15+16+fn write(dir: &Path, name: &str, content: &str) {17+    fs::write(dir.join(name), content).unwrap();18+}19+20+/// A three-commit fixture: a root, then two commits that each touch a file.21+/// Returns the repo and the (root, first, second) revision strings.22+struct Fixture {23+    _dir: TempDir,24+    repo: Repo,25+    root: String,26+    first: String,27+    second: String,28+}29+30+fn build_fixture() -> Fixture {31+    let dir = TempDir::new().unwrap();32+    let git = Repository::init(dir.path()).unwrap();33+    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();34+35+    let commit_all = |msg: &str, parents: &[git2::Oid]| -> git2::Oid {36+        let mut index = git.index().unwrap();37+        index38+            .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)39+            .unwrap();40+        index.write().unwrap();41+        let tree = git.find_tree(index.write_tree().unwrap()).unwrap();42+        let parent_commits: Vec<git2::Commit> = parents43+            .iter()44+            .map(|p| git.find_commit(*p).unwrap())45+            .collect();46+        let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();47+        git.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parent_refs)48+            .unwrap()49+    };50+51+    // Root.52+    write(dir.path(), "a.txt", "alpha one\nalpha two\n");53+    let root = commit_all("Seed the fixture\n\nStart with alpha.\n", &[]);54+55+    // First child: add a brand-new file with a unique marker line.56+    write(dir.path(), "b.txt", "bravo_unique_marker\n");57+    let first = commit_all("Add bravo\n\nIntroduce the bravo file.\n", &[root]);58+59+    // Second child: extend the original file with another unique marker line.60+    write(61+        dir.path(),62+        "a.txt",63+        "alpha one\nalpha two\nalpha_extra_marker\n",64+    );65+    let second = commit_all("Extend alpha\n\nGrow the alpha file.\n", &[first]);66+67+    Fixture {68+        repo: Repo::open(dir.path()).unwrap(),69+        root: root.to_string(),70+        first: first.to_string(),71+        second: second.to_string(),72+        _dir: dir,73+    }74+}75+76+#[test]77+fn range_commits_lists_oldest_first_with_metadata() {78+    let fx = build_fixture();79+    let commits = fx.repo.range_commits(&fx.root, &fx.second).unwrap();80+81+    // `base..tip` excludes the base itself: just the two children, oldest first.82+    let ids: Vec<&str> = commits.iter().map(|c| c.id.as_str()).collect();83+    assert_eq!(ids, vec![fx.first.as_str(), fx.second.as_str()]);84+85+    assert!(commits.iter().all(|c| c.author == "Fixture"));86+    assert!(commits.iter().all(|c| !c.short.is_empty()));87+    assert!(commits.iter().all(|c| c.id.starts_with(&c.short)));88+}89+90+#[test]91+fn empty_range_yields_no_commits() {92+    let fx = build_fixture();93+    assert!(fx94+        .repo95+        .range_commits(&fx.second, &fx.second)96+        .unwrap()97+        .is_empty());98+}99+100+fn render_branch(fx: &Fixture) -> String {101+    let commits = fx.repo.range_commits(&fx.root, &fx.second).unwrap();102+    let chapters: Vec<html::Chapter> = commits103+        .iter()104+        .map(|c| html::Chapter {105+            short: c.short.clone(),106+            author: c.author.clone(),107+            doc: render::render(&fx.repo, &c.id).unwrap(),108+        })109+        .collect();110+    html::to_html_branch("range", &chapters)111+}112+113+#[test]114+fn branch_page_has_a_chapter_per_commit_in_order() {115+    let fx = build_fixture();116+    let page = render_branch(&fx);117+118+    // Contents scaffolding and one numbered chapter per commit.119+    assert!(page.contains("class=\"toc\""));120+    assert!(page.contains("id=\"commit-1\""));121+    assert!(page.contains("id=\"commit-2\""));122+    assert!(page.contains(">1/2</span>"));123+    assert!(page.contains(">2/2</span>"));124+125+    // Chapters appear in reading order (oldest commit first).126+    let c1 = page.find("id=\"commit-1\"").unwrap();127+    let c2 = page.find("id=\"commit-2\"").unwrap();128+    assert!(c1 < c2, "chapter 1 must precede chapter 2");129+}130+131+#[test]132+fn each_commit_changes_land_in_its_own_chapter() {133+    let fx = build_fixture();134+    let page = render_branch(&fx);135+136+    let c1 = page.find("id=\"commit-1\"").unwrap();137+    let c2 = page.find("id=\"commit-2\"").unwrap();138+139+    // Completeness holds per commit: the first commit's added line shows in its140+    // chapter, the second's in its own — neither bleeds nor goes missing.141+    let chapter1 = &page[c1..c2];142+    let chapter2 = &page[c2..];143+    assert!(144+        chapter1.contains("bravo_unique_marker"),145+        "first commit's change belongs in chapter 1"146+    );147+    assert!(148+        chapter2.contains("alpha_extra_marker"),149+        "second commit's change belongs in chapter 2"150+    );151+    // And they don't leak into the wrong chapter.152+    assert!(!chapter1.contains("alpha_extra_marker"));153+    assert!(!chapter2.contains("bravo_unique_marker"));154+}

Docs follow the change into their homes: usage in the README, the composition and its per-commit semantic in ARCHITECTURE, the shipped item plus the future squash-narrative and curation-workbench directions in ROADMAP, and a how-to page in the wiki.

Remaining changes

CLAUDE.md
@@ -38,7 +38,7 @@38 39 | File | Responsibility |40 |------|----------------|-| `src/cli.rs` | `throughline render <commit> [-o out.html]` argument parsing. |41+| `src/cli.rs` | `throughline render <commit>` / `throughline branch <range>` argument parsing. |42 | `src/message.rs` | Split a raw message into title, TL;DR (first paragraph), intro (lead-in to the first anchor), and an ordered list of prose/anchor blocks. |43 | `src/anchor.rs` | The anchor grammar: recognize a path-shaped line and parse `path[:range] [@focus] [!side]`. |44 | `src/conventional.rs` | Conventional Commits header (`type(scope): …`) and footer-trailer passthrough. |
README.md
@@ -94,6 +94,23 @@94 - `--debug` prints the parsed title / TL;DR / intro / anchors instead of HTML — handy95   for checking how a message parses.96 97+### A whole branch as one walkthrough98+99+`throughline branch` renders a *range* of commits as a single navigable page —100+each commit becomes a chapter (its own complete walkthrough), in reading order,101+with a contents list and previous/next links:102+103+```sh104+throughline branch <tip> [--base <ref>] [-o out.html]   # range is base..tip; default base: main105+throughline branch <base>..<tip> [-o out.html]          # explicit range106+```107+108+- `throughline branch my-feature` renders every commit in `main..my-feature`.109+- `throughline branch v1.0..v1.1` renders an explicit range.110+- Omit `-o` and it writes `throughline-branch-<tip-sha>.html`.111+- Completeness still holds **per commit** — each chapter diffs against its own112+  parent; the page composes the commits in order rather than squashing them.113+114 The output is a single standalone HTML file (inline CSS, no JavaScript, no external115 assets), so you can open it directly:116 
docs/ARCHITECTURE.md
@@ -36,14 +36,14 @@36 37 | Module | Role |38 |--------|------|-| `cli` | Parse `throughline render <commit> [-o out.html]` (clap, derive). |39+| `cli` | Parse `throughline render <commit>` and `throughline branch <range>` (clap, derive). |40 | `git` | Thin wrapper over `git2`: open/discover the repo, read a commit's raw message, compute the diff against the first parent, read blob lines by side. |41 | `anchor` | The anchor grammar — recognition (is this line a code reference?) and parsing into `AnchorRef { path, range, focus, side }`. Pure, no I/O. |42 | `message` | Split a raw message into `title`, `tldr` (the first paragraph), `intro` (the lead-in to the first anchor), and an ordered `Vec<Block>` of prose/anchor blocks. Pure, no I/O. |43 | `conventional` | Conventional Commits interop: parse the `type(scope)!: subject` header and pass footer trailers through untouched. |44 | `diff` | The diff data model (`Diff` → `FileDiff` → `Hunk` → `DiffLine`) and the per-hunk `Coverage` tracker. |45 | `render` | Orchestration. Resolves anchors against `git`, marks coverage, backfills the rest, and produces the ordered `Document`. |-| `html` | Lower a `Document` into a standalone HTML page with inlined CSS. |46+| `html` | Lower a `Document` into a standalone HTML page with inlined CSS (`to_html`); compose several `Document`s into one branch walkthrough (`to_html_branch`). |47 48 The two **pure** modules (`anchor`, `message`) define the format and are the most49 heavily unit-tested; the I/O and rendering modules wrap them.@@ -141,6 +141,32 @@141 `render::render` itself only returns `Err` for whole-repo failures (bad142 revision, Git errors) — never for a single bad anchor.143 144+## The branch view145+146+`throughline branch <range>` renders a *range* of commits as one page instead of147+one commit. It is a thin composition over the single-commit pipeline, not a second148+renderer:149+150+1. `git::Repo::range_commits(base, tip)` walks `base..tip` with151+   `Sort::TOPOLOGICAL | REVERSE` and returns one `RangeCommit { id, short, author }`152+   per commit, **oldest first** (reading order). The base itself is excluded153+   (`base..tip` semantics), so the range is exactly the commits the branch adds.154+2. The binary renders each commit with the *same* `render::render`, producing one155+   `Document` per commit — each complete against **its own parent**.156+3. `html::to_html_branch(title, chapters)` lays them out as chapters: a contents157+   list, then each `Document`'s body under a numbered header, with previous/next158+   links. `to_html` and `to_html_branch` share a private `document_body` (the159+   sections-plus-backfill body) and `page` (the standalone chrome), so a chapter160+   renders identically to a standalone page minus the `<h1>`. Navigation is plain161+   in-page anchors — still no JavaScript, still one self-contained file.162+163+The key semantic: **completeness holds per commit, not over the branch.** Each164+chapter shows every hunk of its own commit; the page does not squash the range165+into one diff, so it makes no whole-branch completeness claim. (Weaving a single166+narrative through a branch's *squashed* diff is a separate, future model — see167+[`ROADMAP.md`](ROADMAP.md).) Guarded by `tests/branch.rs`, which asserts the168+chapter order and that each commit's change lands in its own chapter.169+170 ## Conventional Commits171 172 `conventional::Header::parse` reads the subject line; footer trailers are split@@ -187,7 +213,9 @@213 orchestration (anchor resolution, per-hunk coverage with ranged-`!new` sub-hunk214 splitting, backfill, and delta-level markers), and the `html` emitter — with215 the completeness invariant covered by integration tests (`tests/completeness.rs`,-`tests/hunkless.rs`, `tests/mode_change.rs`, `tests/subhunk.rs`).216+`tests/hunkless.rs`, `tests/mode_change.rs`, `tests/subhunk.rs`). The `branch`217+view composes a commit range into one navigable page on top of that pipeline218+(`tests/branch.rs`).219 220 Stubbed: `conventional::Header::parse` (returns `None`) and footer-trailer221 splitting in `message::parse` (`trailers` is empty); both fall through harmlessly
docs/ROADMAP.md
@@ -72,6 +72,22 @@72   change across `SPEC.md`, [`llm-commit-guide.md`](llm-commit-guide.md), and73   [`wiki/field-notes/reading-model.md`](wiki/field-notes/reading-model.md).74 75+## Done — the branch view76+77+A branch is a narrative too. `throughline branch <range>` renders a range of78+commits as one navigable page: each commit a chapter, in reading order, with a79+contents list and previous/next links.80+81+- [x] **Range walkthrough.** `git::range_commits` lists `base..tip` oldest-first;82+  the binary renders each commit with the existing per-commit pipeline, and83+  `html::to_html_branch` composes the `Document`s into chapters (sharing the84+  private `document_body` / `page` helpers with `to_html`, so a chapter equals a85+  standalone page minus its `<h1>`). Accepts a bare tip (`--base`, default `main`)86+  or an explicit `base..tip`. Completeness holds **per commit** — the page87+  composes, it does not squash — and it is still one self-contained file with no88+  JavaScript. *(`git.rs` + `html.rs` + `cli.rs` / `main.rs`; guarded by89+  `tests/branch.rs`.)*90+91 ## Next — Conventional Commits interop92 93 Specced in [`SPEC.md`](SPEC.md) ("Conventional Commits interop") and currently@@ -138,5 +154,14 @@154 - [ ] **Syntax highlighting** of callouts (e.g. `syntect`); shipping escaped +155   diff-colored first was deliberate. *(spec: optional.)*156 - [ ] **Interactive presentation UI** — slide-by-slide CSS/JS over the HTML.157+- [ ] **Branch-level narrative** — weave a *single* narrative through a branch's158+  **squashed** diff (tip vs. merge-base), sourced from a cover letter / PR body /159+  squash message; completeness then holds over the squashed diff. The [branch160+  view](#done--the-branch-view) above composes *per commit*; this composes *per161+  branch* — the two correspond to a fast-forward vs. a squash merge.162+- [ ] **Curation workbench** — browse branches and commits, preview each as a163+  throughline, pick and reorder a subset, choose how to bring them across, and164+  emit both the git operation and the combined narrative. Turns "prepare a branch165+  for merge" into "compose the change's story."166 - [ ] **Content-based anchoring** instead of line numbers (v2; survives rebases).167 - [ ] **Export formats** beyond HTML.
docs/wiki/README.md
@@ -38,6 +38,9 @@38   - [`field-notes/reading-model.md`](field-notes/reading-model.md)39     — how a message maps to the page: TL;DR, lead-ins, and why order is convention.40 - **How-to** — acting on this codebase.41+  - [`how-to/render-a-branch.md`](how-to/render-a-branch.md) — render a range of42+    commits as one navigable page (`throughline branch`); the input forms and the43+    per-commit completeness semantic.44   - [`how-to/working-in-a-worktree.md`](how-to/working-in-a-worktree.md) — the two45     worktree gotchas: edit at the worktree root, and config / `main` are shared.46 - **Automation** — how this repo keeps itself honest.
docs/wiki/how-to/render-a-branch.md
@@ -0,0 +1,70 @@1+# How-to: render a branch as one walkthrough2+3+You have a branch (a stack of commits) and want a single page that reads as one4+narrative — each commit a chapter — instead of rendering each commit separately.5+This is the `throughline branch` subcommand. For the single-commit form see the6+[reading model](../field-notes/reading-model.md); for the internals see the7+[branch view](../../ARCHITECTURE.md#the-branch-view) in the architecture doc.8+9+## The two input forms10+11+```sh12+# A bare tip — the range is `base..tip`, with base defaulting to `main`:13+throughline branch my-feature14+throughline branch my-feature --base develop15+16+# An explicit `base..tip` range (the `--base` flag is then ignored):17+throughline branch main..my-feature18+throughline branch v1.0..v1.119+```20+21+Both resolve to the same thing internally: the commits in `base..tip` — reachable22+from the tip but not the base — so the **base commit itself is excluded**. Output23+goes to `throughline-branch-<tip-sha>.html` unless you pass `-o`.24+25+## What you get26+27+A self-contained HTML page (inline CSS, no JavaScript, like the single-commit28+output) with:29+30+- a **contents list** linking to each commit,31+- one **chapter per commit** in reading order (**oldest first**), each the32+  commit's own complete walkthrough under a numbered header (`2/5`, its short SHA,33+  the author),34+- **previous / next / contents** links between chapters.35+36+## The one semantic to keep straight37+38+Completeness holds **per commit, not over the branch.** Each chapter shows every39+hunk of *its own* commit (diffed against that commit's parent); the page composes40+the commits, it does not squash the range into one diff. So a file touched by41+three commits appears in three chapters — that is correct, the same way the42+single-commit renderer repeats a hunk rather than hiding it. `tests/branch.rs`43+pins both the chapter order and that each commit's change lands in its own44+chapter.45+46+Weaving one narrative through a branch's *squashed* diff is a different, not-yet-47+built model (it would need a branch-level prose source — a cover letter or PR48+body); it is tracked under "Branch-level narrative" in49+[`ROADMAP.md`](../../ROADMAP.md).50+51+## Gotchas52+53+- **`--base` defaults to `main`.** In a repo whose trunk is `master` (or anything54+  else), a bare `throughline branch <tip>` fails to resolve `main..<tip>` — pass55+  `--base master` or an explicit `base..tip` range.56+- **Two-dot ranges only.** The range is split on the first `..`; a symmetric-57+  difference `base...tip` (three dots) is not supported — use `base..tip`.58+- **Empty or reversed ranges** (e.g. `tip..base`, or a tip already merged into the59+  base) produce no commits and exit with an error rather than writing an empty60+  page.61+62+## Where the code lives63+64+- `src/git.rs` — `range_commits(base, tip)` walks the range oldest-first65+  (`Sort::TOPOLOGICAL | REVERSE`) and returns a `RangeCommit` per commit.66+- `src/html.rs` — `to_html_branch` composes the per-commit `Document`s into67+  chapters; it shares the private `document_body` / `page` helpers with `to_html`,68+  so a chapter is exactly a standalone page minus its `<h1>`.69+- `src/main.rs` — `run_branch` parses the range form, renders each commit, and70+  writes the page.
src/git.rs
@@ -11,6 +11,18 @@11     inner: git2::Repository,12 }13 14+/// One commit in a branch range, with just enough metadata to render it as a15+/// chapter: a revision to feed back into [`Repo::diff_against_parent`] /16+/// `render`, plus its abbreviated id and author for the chapter byline.17+pub struct RangeCommit {18+    /// Full 40-hex object id; usable anywhere a `rev` is expected.19+    pub id: String,20+    /// Abbreviated object id, for display.21+    pub short: String,22+    /// Author name, for the chapter byline (empty if unset).23+    pub author: String,24+}25+26 impl Repo {27     /// Open the repository rooted exactly at `path`.28     pub fn open(path: impl AsRef<Path>) -> Result<Repo> {@@ -41,6 +53,32 @@53         Ok(short.as_str().unwrap_or_default().to_string())54     }55 56+    /// The commits in `base..tip` — reachable from `tip` but not `base` — in57+    /// reading order (oldest first), each with display metadata.58+    ///59+    /// This is the spine of a branch walkthrough: render each commit in turn and60+    /// the pages compose into one narrative. The completeness guarantee still61+    /// holds **per commit** (each diffs against its own parent); the range view62+    /// composes per-commit documents rather than squashing them into one diff.63+    pub fn range_commits(&self, base: &str, tip: &str) -> Result<Vec<RangeCommit>> {64+        let mut walk = self.inner.revwalk()?;65+        // Oldest-ancestor-first so chapters read in commit order; topological so66+        // a parent always precedes its child regardless of commit timestamps.67+        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;68+        walk.push_range(&format!("{base}..{tip}"))?;69+70+        let mut out = Vec::new();71+        for oid in walk {72+            let oid = oid?;73+            let commit = self.inner.find_commit(oid)?;74+            let id = oid.to_string();75+            let short = self.short_id(&id)?;76+            let author = commit.author().name().unwrap_or_default().to_string();77+            out.push(RangeCommit { id, short, author });78+        }79+        Ok(out)80+    }81+82     /// The full diff of `rev` against its first parent (or the empty tree for a83     /// root commit), lowered into [`Diff`] in natural order: files alphabetical84     /// by path, hunks top-to-bottom within each file.
src/html.rs
@@ -64,12 +64,101 @@64 .warn { color: #b08800; font-style: italic; margin: 0; }65 .callout.marker .marker-note { margin: 0; padding: 0.5rem 0.8rem; color: var(--muted);66      font: 0.85rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }67+nav.toc { border: 1px solid var(--rule); border-radius: 8px; padding: 0.3rem 1rem;68+     margin: 1.2rem 0 2rem; background: rgba(127,127,127,0.04); }69+nav.toc ol { margin: 0.5rem 0; padding-left: 1.5rem; }70+nav.toc li { margin: 0.3rem 0; }71+nav.toc a { text-decoration: none; color: var(--accent); }72+nav.toc a:hover { text-decoration: underline; }73+.chapter { border-top: 2px solid var(--rule); margin-top: 2.5rem; padding-top: 0.6rem;74+     scroll-margin-top: 1rem; }75+.chapter:first-of-type { border-top: none; margin-top: 1.5rem; }76+.chapter-head { display: flex; gap: 0.7rem; align-items: baseline; flex-wrap: wrap;77+     font: 0.8rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }78+.chapter-no { font-weight: 600; color: var(--accent); }79+.sha { color: var(--muted); }80+.author { color: var(--muted); }81+.chapter-title { font-size: 1.4rem; line-height: 1.25; margin: 0.15rem 0 0.9rem; }82+nav.chapter-nav { display: flex; gap: 1.2rem; margin: 1.8rem 0 0.5rem; font-size: 0.9rem; }83+nav.chapter-nav a { text-decoration: none; color: var(--accent); }84+nav.chapter-nav a:hover { text-decoration: underline; }85 "#;86 87+/// One commit rendered as a chapter of a branch walkthrough: the per-commit88+/// [`Document`] plus the display metadata for its chapter header.89+pub struct Chapter {90+    /// Abbreviated object id, shown in the header and contents.91+    pub short: String,92+    /// Author name, shown in the chapter byline (may be empty).93+    pub author: String,94+    /// The commit's own rendered document (complete against its parent).95+    pub doc: Document,96+}97+98 /// Render `doc` to a complete, standalone HTML document.99 pub fn to_html(doc: &Document) -> String {100     let mut body = String::new();101     let _ = writeln!(body, "<h1>{}</h1>", escape(&doc.title));102+    body.push_str(&document_body(doc));103+    page(&doc.title, &body)104+}105+106+/// Render a range of commits as one navigable, self-contained page: a contents107+/// list, then each commit's walkthrough as a chapter, with previous/next links.108+/// Each chapter keeps its own completeness guarantee (it is a per-commit109+/// [`Document`]); the page composes them in reading order rather than squashing.110+pub fn to_html_branch(title: &str, chapters: &[Chapter]) -> String {111+    let total = chapters.len();112+    let mut body = String::new();113+    let _ = writeln!(body, "<h1 id=\"top\">{}</h1>", escape(title));114+115+    // Contents: one linked row per commit. Pure in-page anchors, so navigation116+    // needs no JavaScript and the page stays a single self-contained file.117+    body.push_str("<nav class=\"toc\" aria-label=\"Commits\">\n<ol>\n");118+    for (i, ch) in chapters.iter().enumerate() {119+        let _ = writeln!(120+            body,121+            "<li><a href=\"#commit-{n}\">{title}</a> <span class=\"sha\">{short}</span></li>",122+            n = i + 1,123+            title = escape(&ch.doc.title),124+            short = escape(&ch.short),125+        );126+    }127+    body.push_str("</ol>\n</nav>\n");128+129+    for (i, ch) in chapters.iter().enumerate() {130+        let n = i + 1;131+        let _ = write!(132+            body,133+            "<section class=\"chapter\" id=\"commit-{n}\">\n\134+             <div class=\"chapter-head\"><span class=\"chapter-no\">{n}/{total}</span>\135+             <span class=\"sha\">{short}</span><span class=\"author\">{author}</span></div>\n\136+             <h2 class=\"chapter-title\">{ctitle}</h2>\n",137+            short = escape(&ch.short),138+            author = escape(&ch.author),139+            ctitle = escape(&ch.doc.title),140+        );141+        body.push_str(&document_body(&ch.doc));142+143+        body.push_str("<nav class=\"chapter-nav\">");144+        if n > 1 {145+            let _ = write!(body, "<a href=\"#commit-{}\">\u{2190} previous</a>", n - 1);146+        }147+        body.push_str("<a href=\"#top\">contents</a>");148+        if n < total {149+            let _ = write!(body, "<a href=\"#commit-{}\">next \u{2192}</a>", n + 1);150+        }151+        body.push_str("</nav>\n</section>\n");152+    }153+154+    page(title, &body)155+}156+157+/// The body of one document: every section in order, then its backfill region.158+/// Shared by the single-commit page and each chapter of a branch page — it emits159+/// no `<h1>` and no page chrome, so the caller owns the heading.160+fn document_body(doc: &Document) -> String {161+    let mut body = String::new();162 163     // The backfill region is always the trailing run; collect its items164     // (hunks and hunk-less markers) so they can be grouped by file, then render@@ -94,6 +183,11 @@183         body.push_str(&backfill_html(&backfill));184     }185 186+    body187+}188+189+/// Wrap a body in the standalone page chrome (DOCTYPE, head, inline CSS).190+fn page(head_title: &str, body: &str) -> String {191     format!(192         "<!DOCTYPE html>\n\193          <html lang=\"en\">\n\@@ -105,7 +199,7 @@199          </head>\n\200          <body>\n{body}</body>\n\201          </html>\n",-        title = escape(&doc.title),202+        title = escape(head_title),203         css = STYLESHEET,204         body = body,205     )
2/3766df9b2Edu Ramírez

Document the commit-range walk in the git field note

The branch view's range walk had two git2 gotchas worth recording — base exclusion and oldest-first ordering — so fold them into the src/git.rs field note and stop the architecture overview reading as single-commit-only.

The field note gains a section on range_commits: what push_range excludes and why the walk forces topological + reverse order.

docs/wiki/field-notes/git-lowering.md
@@ -4,7 +4,8 @@4 `Diff` → `FileDiff` → `Hunk` model, and the parts that aren't obvious from5 reading it cold: binary and hunk-less detection, where file modes live, and why6 renames are not detected. Read this before touching `src/git.rs` or writing a-completeness fixture. The companion page,7+completeness fixture; the commit-*range* walk behind the branch view8+(`range_commits`) is covered in the last section. The companion page,9 [coverage-and-backfill](coverage-and-backfill.md), picks up where this one ends —10 what happens *after* the diff is in the model.11 @@ -74,6 +75,26 @@75 and `tests/mode_change.rs`. This is the only reliable way to exercise mode-only76 and mode-beside-content deltas.77 78+## Walking a commit range (the branch view)79+80+`range_commits` (`src/git.rs:63`) lists the commits for `throughline branch`, and81+two `git2` details are easy to get wrong:82+83+- **`push_range("base..tip")` excludes `base`** (`src/git.rs:68`): it yields the84+  commits reachable from `tip` but not `base` — exactly the commits a branch adds.85+  The base is the *cutoff*, never itself a chapter.86+- **The default walk order is newest-first**, but chapters must read oldest-first,87+  so the walk sets `Sort::TOPOLOGICAL | REVERSE` (`src/git.rs:67`) — topological so88+  a parent always precedes its child even when commit timestamps don't.89+90+Each listed commit then renders through `diff_against_parent` like any other: the91+range view *composes* per-commit documents, it does not squash, so completeness92+still holds per commit. `RangeCommit` (`src/git.rs:17`) carries only the metadata a93+chapter header needs. The user-facing shape is in the how-to94+[render-a-branch](../how-to/render-a-branch.md) and the structure in95+[`ARCHITECTURE.md`](../../ARCHITECTURE.md#the-branch-view); guarded by96+`tests/branch.rs`.97+98 ## Gotchas99 100 - **Line endings are normalized on the way in.** The lowering pops a trailing

The architecture overview now says outright that its pipeline diagram is the single-commit path, and that the branch view composes it once per commit.

docs/ARCHITECTURE.md
@@ -32,6 +32,10 @@32 step are the mechanism that enforces the completeness guarantee; everything else33 only *reorders* and *annotates*.34 35+This diagram is the single-commit path. `throughline branch` runs it once per36+commit and composes the resulting `Document`s into one page — see37+[The branch view](#the-branch-view) — without changing anything above.38+39 ## Module responsibilities40 41 | Module | Role |

Remaining changes

docs/wiki/README.md
@@ -32,7 +32,8 @@32 - **Field notes** — deep knowledge of one area, written for whoever touches it next.33   - [`field-notes/git-lowering.md`](field-notes/git-lowering.md)34     — how a commit's diff becomes the model: binary/hunk-less detection, where-    modes live, why renames aren't detected, and how to write a fixture.35+    modes live, why renames aren't detected, how to write a fixture, and walking a36+    commit range for the branch view.37   - [`field-notes/coverage-and-backfill.md`](field-notes/coverage-and-backfill.md)38     — the completeness-critical core: how coverage and backfill keep the guarantee.39   - [`field-notes/reading-model.md`](field-notes/reading-model.md)
3/3750c71caEdu Ramírez

Record the !new deletion-drop bug and scope the --open flag

A branch render exposed a real completeness hole — a whole-file !new anchor hides a hunk's deleted lines — so capture it (with the uncolored-callout UX it rides with) as the next completeness task, and sharpen the --open item.

The roadmap gains a completeness section: the bug, the correctness fix, and the test gap to close — plus a note that --open covers both render and branch.

docs/ROADMAP.md
@@ -55,6 +55,30 @@55   holds per *line*, not just per hunk. *(`diff.rs` model + `render.rs` coverage;56   guarded by `tests/subhunk.rs`.)*57 58+## Next — completeness: a whole-file `!new` anchor drops deletions59+60+A hole in the one hard guarantee, surfaced by a `throughline branch` render of doc61+commits. A **bare** anchor (`path`, no `!side`) defaults to `!new`, which shows the62+*post-change* blob as plain context; a **whole-file** `!new` anchor (no range) then63+calls `Coverage::mark_full`, so the hunk never backfills. But a `!new` callout shows64+only the new side — so any **deleted** lines in that hunk appear **nowhere**.65+Verified on a real commit (its removed line was absent from the page);66+`tests/completeness.rs` misses it because it only checks `+` lines.67+68+- [ ] **Correctness — deletions must survive a `!new` anchor.** A whole-file `!new`69+  anchor must not `mark_full` a hunk that has deletions; the `-` lines have to reach70+  the backfill. (The ranged-`!new` path keeps deletions that border an *uncovered*71+  run via `Hunk::uncovered_subhunks`; the whole-file path's `mark_full` leaves no72+  remainder at all.) Then **extend `tests/completeness.rs` to assert every `-` line73+  appears**, not only `+`. *(`render.rs` `resolve_blob`; mechanism in74+  [`coverage-and-backfill.md`](wiki/field-notes/coverage-and-backfill.md).)*75+- [ ] **UX — a `!new` callout gives no sign of what changed.** Even once deletions76+  survive, a bare-anchor commit still renders as flat, uncolored new-file text — no77+  `+`/`-`, no hunk headers — which surprises a reader expecting a diff. Decide: mark78+  added lines inside `!new` callouts, default bare anchors to `!diff`, or treat it as79+  authoring guidance in [`llm-commit-guide.md`](llm-commit-guide.md) /80+  [`SPEC.md`](SPEC.md).81+82 ## Done — reading model: TL;DR + lead-ins83 84 The walkthrough reads top-down like a tutorial — a summary up front, then each@@ -140,7 +164,9 @@164   ([`docs/wiki/automation/docs-sync-hook.md`](wiki/automation/docs-sync-hook.md)).165 - [ ] **Golden/snapshot tests** for the HTML output (e.g. `insta`) so rendering166   changes are reviewable as a diff.-- [ ] **`--open` flag** — render and open the page in one step.167+- [ ] **`--open` flag** — render and open the page in one step, for **both**168+  `render` and `branch`. Shell out to the platform opener (`open` on macOS,169+  `xdg-open` on Linux, `start` on Windows). *(Planned for a dedicated session.)*170 - [x] **Pre-commit hook** running `fmt` / `clippy -D warnings` / `test` locally before171   every commit — the local half of the CI checks, with a non-Rust fast path and a172   `--no-verify` bypass

The coverage field note no longer claims a whole-file !new shows the hunk whole; it shows only the new side, so deletions drop, and the table and gotchas now say so.

docs/wiki/field-notes/coverage-and-backfill.md
@@ -28,7 +28,8 @@28 29 | Anchor | Call | Effect |30 |--------|------|--------|-| whole-file (`New`, no range) or `!diff` | `Coverage::mark_full` | the hunk is shown whole; backfill skips it entirely |31+| `!diff` (whole hunk) | `Coverage::mark_full` | the hunk is shown whole — `+`, `-`, and context; backfill skips it |32+| whole-file `!new` (no range) | `Coverage::mark_full` | ⚠️ marks the hunk whole, but the callout shows only the **new side**, so its **deleted** lines are dropped — a known completeness hole ([ROADMAP](../../ROADMAP.md)) |33 | ranged `!new` | `Coverage::mark_new_range` | only that new-side interval is covered; the remainder is backfilled |34 | `!old` | *nothing* | old-side context only; the real diff still backfills in full |35 @@ -60,6 +61,12 @@61    delta being hunk-less *only* for binary/empty; a `Mode` marker is attached whether62    or not hunks accompany it. If you add a new delta-level fact a hunk can't carry, it63    must get a marker or it vanishes from the page.64+3. **A whole-file `!new` anchor currently drops deletions.** `mark_full` assumes the65+   hunk was shown whole, but a `!new` callout shows only the new side — so a modified66+   file's `-` lines reach neither the callout nor the backfill. The guards miss it67+   because they only assert `+` lines appear. Tracked in68+   [`ROADMAP.md`](../../ROADMAP.md); when you fix it, add the `-`-line assertion to69+   `tests/completeness.rs`.70 71 ## Before you change coverage — run the guards72 
4/373b36668Edu Ramírez

Anchors are always a diff: drop the !side selector

A whole-file !new anchor used to drop deleted lines — its callout showed only the new side and mark_full left no backfill — so deletions vanished from the page. Rather than patch !new, this removes the !new/!old/!diff selector entirely: every anchor now renders as a diff, which puts deletions on the page by construction and lets the per-line coverage machinery go. It bumps the spec to v0.4 and moves the docs in lockstep.

The grammar loses its side dimension — an anchor is now path[:range] [@focus] — and a legacy !side token from an older message is accepted and ignored, so old commits still render as anchors rather than prose.

src/anchor.rs
@@ -4,35 +4,16 @@4 //! than as prose. The grammar is:5 //!6 //! ```text-//! <path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>] [!<side>]7+//! <path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>]8 //! ```9 //!-//! See `docs/SPEC.md` for the full recognition and disambiguation rules.10+//! An anchor always renders as a diff (or, for code outside the diff, as plain11+//! context); there is no side selector. A legacy `!new`/`!old`/`!diff` token12+//! from an older commit message is accepted and ignored, so old messages still13+//! render. See `docs/SPEC.md` for the full recognition and disambiguation rules.14 15 use std::fmt;16 -/// Where an anchor pulls its code from.-#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]-pub enum Side {-    /// Final state at the commit (the default).-    #[default]-    New,-    /// State at the parent commit, before the change.-    Old,-    /// The hunk(s) themselves, with `+`/`-` lines.-    Diff,-}--impl fmt::Display for Side {-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {-        f.write_str(match self {-            Side::New => "new",-            Side::Old => "old",-            Side::Diff => "diff",-        })-    }-}-17 /// An inclusive, 1-indexed line range.18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]19 pub struct LineRange {@@ -67,8 +48,8 @@48     }49 }50 -/// A parsed reference to code: a path, an optional line range, an optional focus-/// sub-range to emphasize, and which side to read from.51+/// A parsed reference to code: a path, an optional line range, and an optional52+/// focus sub-range to emphasize.53 #[derive(Debug, Clone, PartialEq, Eq)]54 pub struct AnchorRef {55     /// Repository-relative path.@@ -77,8 +58,6 @@58     pub range: Option<LineRange>,59     /// A sub-range to visually emphasize.60     pub focus: Option<LineRange>,-    /// Where the code comes from.-    pub side: Side,61 }62 63 impl AnchorRef {@@ -101,20 +80,15 @@80         }81 82         let mut focus = None;-        let mut side = Side::New;-        let mut saw_side = false;83         for tok in tokens {84             if let Some(rest) = tok.strip_prefix('@') {85                 if focus.is_some() {86                     return None;87                 }88                 focus = Some(LineRange::parse(rest)?);-            } else if let Some(rest) = tok.strip_prefix('!') {-                if saw_side {-                    return None;-                }-                side = parse_side(rest)?;-                saw_side = true;89+            } else if tok.starts_with('!') {90+                // A legacy `!new`/`!old`/`!diff` side selector. Anchors are91+                // always diffs now, so accept and ignore it for back-compat.92             } else {93                 // Anything else means this is prose that merely begins with a94                 // path-shaped token.@@ -126,7 +100,6 @@100             path: path.to_string(),101             range,102             focus,-            side,103         })104     }105 }@@ -140,9 +113,6 @@113         if let Some(r) = self.focus {114             write!(f, " @{r}")?;115         }-        if self.side != Side::New {-            write!(f, " !{}", self.side)?;-        }116         Ok(())117     }118 }@@ -156,15 +126,6 @@126     }127 }128 -fn parse_side(s: &str) -> Option<Side> {-    match s {-        "new" => Some(Side::New),-        "old" => Some(Side::Old),-        "diff" => Some(Side::Diff),-        _ => None,-    }-}-129 /// The recognition heuristic: a token "looks like a path" if it contains a `/`130 /// or ends in a recognizable `.extension`.131 fn looks_like_path(token: &str) -> bool {@@ -197,7 +158,6 @@158         assert_eq!(a.path, "src/auth/middleware.rs");159         assert_eq!(a.range, None);160         assert_eq!(a.focus, None);-        assert_eq!(a.side, Side::New);161     }162 163     #[test]@@ -213,18 +173,23 @@173     }174 175     #[test]-    fn focus_and_side() {-        let a = anchor("docs/api.md:10-20 @14-16 !new");176+    fn range_and_focus() {177+        let a = anchor("docs/api.md:10-20 @14-16");178         assert_eq!(a.range, Some(LineRange { start: 10, end: 20 }));179         assert_eq!(a.focus, Some(LineRange { start: 14, end: 16 }));-        assert_eq!(a.side, Side::New);180     }181 182     #[test]-    fn diff_side_no_range() {183+    fn legacy_side_token_is_accepted_and_ignored() {184+        // Anchors are always diffs now; a `!new`/`!old`/`!diff` token left in an185+        // older commit message still parses (the line stays an anchor) but the186+        // selector carries no meaning.187         let a = anchor("src/config.rs !diff");188+        assert_eq!(a.path, "src/config.rs");189         assert_eq!(a.range, None);-        assert_eq!(a.side, Side::Diff);190+        let b = anchor("docs/api.md:10-20 @14-16 !old");191+        assert_eq!(b.range, Some(LineRange { start: 10, end: 20 }));192+        assert_eq!(b.focus, Some(LineRange { start: 14, end: 16 }));193     }194 195     #[test]@@ -263,7 +228,6 @@228             "src/auth/validator.rs:12-38",229             "src/auth/validator.rs:50",230             "src/auth/middleware.rs",-            "src/config.rs !diff",231             "docs/api.md:10-20 @14-16",232         ] {233             let a = anchor(line);

Resolution collapses to a single path. When the anchor's range overlaps changed hunks it shows the diff (the file's whole change, or the clip the range selects); otherwise the code is not in the diff, so it is read from the commit and shown as plain "unmodified" context. A hunk is marked covered only when an anchor shows it in full.

src/render.rs
@@ -1,7 +1,7 @@1 //! Render orchestration: resolve anchors against Git, track coverage, and build2 //! the ordered document model that the HTML emitter consumes.3 -use crate::anchor::{AnchorRef, LineRange, Side};4+use crate::anchor::{AnchorRef, LineRange};5 use crate::diff::{Coverage, DeltaMarker, Diff, Hunk};6 use crate::git::Repo;7 use crate::message::{self, Block};@@ -30,6 +30,10 @@30     /// `true` if the anchor could not be resolved. The renderer then shows a31     /// warning in place of the code (degrade, don't fail) and marks no hunks.32     pub degraded: bool,33+    /// `true` if the code is *not* part of the diff — a whole unchanged file or34+    /// an unchanged range pulled in as context. Shown as plain lines and35+    /// labeled "unmodified"; marks no hunks.36+    pub context: bool,37     /// Human-readable explanation, shown for degraded blocks.38     pub note: Option<String>,39 }@@ -97,30 +101,17 @@101         }102     }103 -    // Backfill: everything not already shown, in natural diff order. This is-    // the mechanism that enforces the completeness invariant.104+    // Backfill: every hunk not shown in full, in natural diff order — emitted105+    // whole, even if an anchor already showed part of it (repetition is allowed,106+    // and re-showing the whole hunk is what guarantees every deleted line107+    // appears). This is the mechanism that enforces the completeness invariant.108     for (fi, file) in diff.files.iter().enumerate() {109         for (hi, hunk) in file.hunks.iter().enumerate() {-            // A wholly-shown hunk (whole-file or `!diff` anchor) needs nothing.-            if coverage.is_full(fi, hi) {-                continue;-            }-            let covered = coverage.covered_intervals(fi, hi);-            if covered.is_empty() {-                // Untouched: backfill the hunk verbatim.110+            if !coverage.is_full(fi, hi) {111                 sections.push(Section::Backfill {112                     path: file.path.clone(),113                     hunk: hunk.clone(),114                 });-            } else {-                // A ranged `!new` anchor showed part of this hunk; split it and-                // backfill only the new-side runs it left unshown.-                for sub in hunk.uncovered_subhunks(covered) {-                    sections.push(Section::Backfill {-                        path: file.path.clone(),-                        hunk: sub,-                    });-                }115             }116         }117         // Surface the file's delta-level marker after its hunks: a hunk-less@@ -140,7 +131,14 @@131     })132 }133 -/// Resolve one anchor to a [`CodeBlock`], marking any diff hunks it covers.134+/// Resolve one anchor to a [`CodeBlock`], marking any hunks it shows in full.135+///136+/// If the anchor's path is in the diff and its range overlaps changed hunks it137+/// renders as a diff — the file's whole change, or the slice the range selects.138+/// A hunk shown in full is marked so the backfill skips it; a hunk shown only in139+/// part is left for the backfill, which re-emits it whole. Otherwise the code is140+/// not part of the diff (a whole unchanged file, or an unchanged range), so it141+/// renders as plain context read from the commit and marks nothing.142 fn resolve_anchor(143     repo: &Repo,144     rev: &str,@@ -148,25 +146,48 @@146     a: &AnchorRef,147     coverage: &mut Coverage,148 ) -> CodeBlock {-    match a.side {-        Side::New => resolve_blob(repo, rev, diff, a, coverage, /* mark */ true),-        // The pre-change version is context: show it, but mark no hunks so the-        // real diff still appears in full via backfill (spec: "marks no hunks").-        Side::Old => resolve_blob(repo, rev, diff, a, coverage, /* mark */ false),-        Side::Diff => resolve_diff(diff, a, coverage),149+    let covering = covering_hunks(diff, a);150+    if covering.is_empty() {151+        return resolve_context(repo, rev, a);152+    }153+154+    let mut lines = Vec::new();155+    for (fi, hi) in covering {156+        let hunk = &diff.files[fi].hunks[hi];157+        match a.range {158+            // Whole-file anchor: show every hunk of the file in full.159+            None => {160+                lines.extend(hunk_to_code_lines(hunk, a.focus));161+                coverage.mark_full(fi, hi);162+            }163+            // Ranged anchor: show the slice the range selects. Mark the hunk164+            // full only when the range spans its entire new side; otherwise the165+            // whole hunk is re-emitted by the backfill (completeness).166+            Some(r) => {167+                if let Some(clip) = hunk.clip_to_new_range(r.start, r.end) {168+                    lines.extend(hunk_to_code_lines(&clip, a.focus));169+                }170+                if hunk.contained_in_new_range(r.start, r.end) {171+                    coverage.mark_full(fi, hi);172+                }173+            }174+        }175+    }176+177+    CodeBlock {178+        path: a.path.clone(),179+        lines,180+        degraded: false,181+        context: false,182+        note: None,183     }184 }185 -/// Resolve a `New`/`Old` anchor by reading the blob and slicing the range.-fn resolve_blob(-    repo: &Repo,-    rev: &str,-    diff: &Diff,-    a: &AnchorRef,-    coverage: &mut Coverage,-    mark: bool,-) -> CodeBlock {-    let all = match repo.blob_lines(rev, &a.path, a.side) {186+/// Resolve an anchor whose code is not part of the diff: read the file at the187+/// commit and show the requested lines (or the whole file) as plain, unmodified188+/// context. Marks no hunks — the real diff still appears in full via backfill.189+fn resolve_context(repo: &Repo, rev: &str, a: &AnchorRef) -> CodeBlock {190+    let all = match repo.blob_lines(rev, &a.path) {191         Ok(lines) => lines,192         Err(e) => return degraded(&a.path, format!("could not read `{}`: {e}", a.path)),193     };@@ -190,51 +211,11 @@211         })212         .collect();213 -    // A new-side callout accounts for the hunks it overlaps; an unchanged-context-    // (`!old`) callout overlaps none and marks nothing. A whole-file `!new`-    // anchor shows every hunk in full; a ranged one shows only its new-side-    // lines, so it marks just that interval and the rest is split into backfill.-    if mark {-        for (fi, hi) in covering_hunks(diff, a) {-            match a.range {-                None => coverage.mark_full(fi, hi),-                Some(r) => coverage.mark_new_range(fi, hi, r.start, r.end),-            }-        }-    }--    CodeBlock {-        path: a.path.clone(),-        lines,-        degraded: false,-        note: None,-    }-}--/// Resolve a `Diff` anchor by showing the covering hunk(s) as a diff.-fn resolve_diff(diff: &Diff, a: &AnchorRef, coverage: &mut Coverage) -> CodeBlock {-    let covering = covering_hunks(diff, a);-    if covering.is_empty() {-        return degraded(-            &a.path,-            format!("the diff of this commit does not cover `{a}`"),-        );-    }--    // A `!diff` anchor displays each covering hunk in full (header and all its-    // `+`/`-` lines), so it marks the whole hunk — there is no remainder to-    // split out, even when the anchor's range is narrower than the hunk.-    let mut lines = Vec::new();-    for (fi, hi) in &covering {-        let hunk = &diff.files[*fi].hunks[*hi];-        lines.extend(hunk_to_code_lines(hunk, a.focus));-        coverage.mark_full(*fi, *hi);-    }-214     CodeBlock {215         path: a.path.clone(),216         lines,217         degraded: false,218+        context: true,219         note: None,220     }221 }@@ -339,6 +320,7 @@320         path: path.to_string(),321         lines: Vec::new(),322         degraded: true,323+        context: false,324         note: Some(note),325     }326 }

Coverage drops to a per-hunk set of fully-shown hunks — no more intervals, no sub-hunk splitting. A ranged anchor clips the hunk for display (clip_to_new_range); any hunk it did not cover whole is re-emitted whole by the backfill, and that repetition is what guarantees every line, deletions included, appears at least once.

src/diff.rs
@@ -1,6 +1,6 @@1 //! The diff model: files, hunks, and per-hunk coverage tracking.2 -use std::collections::HashMap;3+use std::collections::HashSet;4 use std::fmt;5 6 /// A single line within a hunk.@@ -31,69 +31,51 @@31     /// The last new-side line number this hunk spans. For a pure-deletion hunk32     /// (`new_lines == 0`) this collapses to `new_start`, the position the33     /// deletion sits *before* — which is exactly where its `-` lines probe.-    fn new_end(&self) -> u32 {34+    pub(crate) fn new_end(&self) -> u32 {35         self.new_start + self.new_lines.saturating_sub(1)36     }37 -    /// Split this hunk into the sub-hunks whose new-side lines are **not** in-    /// `covered` (a set of inclusive, 1-indexed new-line intervals), so the-    /// backfill can show the remainder a ranged `!new` anchor left unshown.-    ///-    /// Every line is preserved — nothing is dropped, that is the whole point.-    /// A `-` line has no new-side number, so it travels with the new-side-    /// position it sits *before* (clamped to the last new line when it trails-    /// the hunk); a deletion inside a covered run is therefore suppressed with-    /// that run, while one bordering an uncovered run is carried into it.38+    /// Whether the inclusive, 1-indexed new-side range `[start, end]` spans this39+    /// hunk's entire new side — i.e. a ranged anchor over it shows the hunk in40+    /// full, so the backfill can skip it.41+    pub fn contained_in_new_range(&self, start: u32, end: u32) -> bool {42+        start <= self.new_start && self.new_end() <= end43+    }44+45+    /// The contiguous sub-hunk of lines whose new-side position falls within the46+    /// inclusive, 1-indexed range `[start, end]`, renumbered to its real47+    /// position — what a ranged anchor shows as a diff. A `-` line has no48+    /// new-side number, so it travels with the new-side position it sits49+    /// *before* (clamped to the last new line when it trails the hunk), so a50+    /// deletion bordering the range is carried in and never orphaned.51     ///-    /// With `covered` empty the hunk is wholly uncovered and the result is one-    /// sub-hunk identical to `self`; with the whole new side covered the result-    /// is empty.-    pub fn uncovered_subhunks(&self, covered: &[(u32, u32)]) -> Vec<Hunk> {-        let is_covered = |n: u32| covered.iter().any(|&(s, e)| s <= n && n <= e);52+    /// Returns `None` when the range selects no line of the hunk; when the range53+    /// spans the whole new side the result equals the hunk itself.54+    pub fn clip_to_new_range(&self, start: u32, end: u32) -> Option<Hunk> {55         let new_end = self.new_end();56+        let in_range = |n: u32| start <= n && n <= end;57+58+        let mut old_start = 0;59+        let mut new_start = 0;60+        let (mut old_lines, mut new_lines) = (0u32, 0u32);61+        let mut lines = Vec::new();62 -        // Per line, in order: whether its new-side position is covered, and the-        // (old, new) counters as they stood *before* it — i.e. the 1-indexed-        // start numbers a sub-hunk beginning at this line would carry.-        let mut meta: Vec<(bool, u32, u32)> = Vec::with_capacity(self.lines.len());63         let mut old_no = self.old_start;64         let mut new_no = self.new_start;65         for line in &self.lines {-            let at = (old_no, new_no);-            let covered = match line.origin {-                '+' => {-                    let c = is_covered(new_no);-                    new_no += 1;-                    c-                }-                '-' => {-                    let c = is_covered(new_no.min(new_end));-                    old_no += 1;-                    c-                }-                _ => {-                    let c = is_covered(new_no);-                    old_no += 1;-                    new_no += 1;-                    c-                }66+            // The new-side position to test, and how this line advances the67+            // (old, new) counters.68+            let (probe, adv_old, adv_new) = match line.origin {69+                '+' => (new_no, false, true),70+                '-' => (new_no.min(new_end), true, false),71+                _ => (new_no, true, true),72             };-            meta.push((covered, at.0, at.1));-        }--        // Emit a sub-hunk for each maximal run of uncovered lines.-        let mut out = Vec::new();-        let mut i = 0;-        while i < self.lines.len() {-            if meta[i].0 {-                i += 1;-                continue;-            }-            let (_, old_start, new_start) = meta[i];-            let (mut old_lines, mut new_lines) = (0, 0);-            let mut lines = Vec::new();-            while i < self.lines.len() && !meta[i].0 {-                match self.lines[i].origin {73+            if in_range(probe) {74+                if lines.is_empty() {75+                    old_start = old_no;76+                    new_start = new_no;77+                }78+                match line.origin {79                     '+' => new_lines += 1,80                     '-' => old_lines += 1,81                     _ => {@@ -101,18 +83,26 @@83                         new_lines += 1;84                     }85                 }-                lines.push(self.lines[i].clone());-                i += 1;86+                lines.push(line.clone());87+            }88+            if adv_old {89+                old_no += 1;90             }-            out.push(Hunk {-                old_start,-                old_lines,-                new_start,-                new_lines,-                lines,-            });91+            if adv_new {92+                new_no += 1;93+            }94+        }95+96+        if lines.is_empty() {97+            return None;98         }-        out99+        Some(Hunk {100+            old_start,101+            old_lines,102+            new_start,103+            new_lines,104+            lines,105+        })106     }107 }108 @@ -181,29 +171,16 @@171     pub files: Vec<FileDiff>,172 }173 -/// What an anchor has shown of one hunk.-///-/// A whole-file or `!diff` anchor shows a hunk in its entirety (`full`); a-/// ranged `!new` anchor shows only some new-side lines, recorded as intervals-/// so the backfill can split out the rest (sub-hunk splitting).-#[derive(Debug, Default)]-struct HunkCoverage {-    /// The whole hunk has been shown, so it needs no backfill at all —-    /// including any deletions it contains.-    full: bool,-    /// Inclusive, 1-indexed new-side intervals shown by ranged `!new` anchors.-    new_lines: Vec<(u32, u32)>,-}--/// Tracks what has been shown of each hunk, so the backfill can collect the-/// rest. Keyed by `(file_index, hunk_index)` — coverage is per hunk, never per-/// file (a spec invariant), and within a hunk per new-side line for ranged-/// `!new` anchors. An anchor marks exactly the hunks its range overlaps,-/// leaving a partially-annotated file's other hunks (and a partially-annotated-/// hunk's other lines) for the backfill.174+/// Tracks which hunks an anchor has shown **in full**, so the backfill can175+/// collect the rest. Keyed by `(file_index, hunk_index)` — coverage is per176+/// hunk, never per file (a spec invariant). A hunk is "full" when an anchor177+/// displayed it whole: a whole-file anchor, or a ranged anchor whose range178+/// spans the hunk's entire new side. A hunk shown only in part is left out, so179+/// the backfill emits the whole hunk again — repetition is allowed, and it is180+/// how every deleted line is guaranteed to appear.181 #[derive(Debug, Default)]182 pub struct Coverage {-    hunks: HashMap<(usize, usize), HunkCoverage>,183+    full: HashSet<(usize, usize)>,184 }185 186 impl Coverage {@@ -211,34 +188,15 @@188         Coverage::default()189     }190 -    /// Mark the whole hunk shown (a whole-file or `!diff` anchor): it is-    /// displayed in full, so the backfill skips it entirely.191+    /// Mark the whole hunk shown: it is displayed in full, so the backfill192+    /// skips it entirely.193     pub fn mark_full(&mut self, file: usize, hunk: usize) {-        self.hunks.entry((file, hunk)).or_default().full = true;-    }--    /// Mark an inclusive new-side interval of a hunk shown (a ranged `!new`-    /// anchor). The hunk's uncovered new-side runs are split into the backfill.-    pub fn mark_new_range(&mut self, file: usize, hunk: usize, start: u32, end: u32) {-        self.hunks-            .entry((file, hunk))-            .or_default()-            .new_lines-            .push((start, end));194+        self.full.insert((file, hunk));195     }196 197     /// Whether the whole hunk has been shown and needs no backfill.198     pub fn is_full(&self, file: usize, hunk: usize) -> bool {-        self.hunks.get(&(file, hunk)).is_some_and(|c| c.full)-    }--    /// The covered new-side intervals for a hunk — empty when it is untouched-    /// (the backfill then emits the whole hunk).-    pub fn covered_intervals(&self, file: usize, hunk: usize) -> &[(u32, u32)] {-        self.hunks-            .get(&(file, hunk))-            .map(|c| c.new_lines.as_slice())-            .unwrap_or(&[])199+        self.full.contains(&(file, hunk))200     }201 }202 @@ -281,22 +239,30 @@239         assert!(c.is_full(0, 1));240         assert!(!c.is_full(0, 0));241         assert!(!c.is_full(1, 1));-        // A `full` hunk carries no intervals; an untouched one carries none either.-        assert!(c.covered_intervals(0, 1).is_empty());-        assert!(c.covered_intervals(2, 2).is_empty());242+        // An untouched hunk is never full.243+        assert!(!c.is_full(2, 2));244     }245 246     #[test]-    fn coverage_records_new_line_intervals() {-        let mut c = Coverage::new();-        c.mark_new_range(0, 0, 5, 8);-        c.mark_new_range(0, 0, 12, 12);-        assert!(!c.is_full(0, 0));-        assert_eq!(c.covered_intervals(0, 0), &[(5, 8), (12, 12)]);247+    fn range_containment_decides_full_coverage() {248+        // `@@ -0,0 +1,10 @@`: new-side lines 1..=10.249+        let h = Hunk {250+            old_start: 0,251+            old_lines: 0,252+            new_start: 1,253+            new_lines: 10,254+            lines: (1..=10).map(|i| add(&format!("line {i:02}"))).collect(),255+        };256+        // A range that spans the whole new side covers the hunk in full…257+        assert!(h.contained_in_new_range(1, 10));258+        assert!(h.contained_in_new_range(1, 99));259+        // …a narrower one does not, so it will be backfilled whole.260+        assert!(!h.contained_in_new_range(4, 6));261+        assert!(!h.contained_in_new_range(2, 10));262     }263 264     #[test]-    fn split_backfills_the_uncovered_runs_of_a_new_file_hunk() {265+    fn clip_to_new_range_extracts_the_middle() {266         // A brand-new file: one hunk of 10 additions, `@@ -0,0 +1,10 @@`.267         let h = Hunk {268             old_start: 0,@@ -306,42 +272,40 @@272             lines: (1..=10).map(|i| add(&format!("line {i:02}"))).collect(),273         };274 -        // A ranged `!new` callout showed new lines 4..=6; backfill the rest.-        let subs = h.uncovered_subhunks(&[(4, 6)]);-        assert_eq!(subs.len(), 2, "two runs flank the covered middle");--        // Leading run: lines 1..=3, still a pure addition (`@@ -0,0 +1,3 @@`).275+        // A ranged anchor over new lines 4..=6 shows exactly that slice,276+        // renumbered to its real position (`@@ -0,0 +4,3 @@`).277+        let clip = h.clip_to_new_range(4, 6).unwrap();278         assert_eq!(279             (-                subs[0].old_start,-                subs[0].old_lines,-                subs[0].new_start,-                subs[0].new_lines280+                clip.old_start,281+                clip.old_lines,282+                clip.new_start,283+                clip.new_lines284             ),-            (0, 0, 1, 3)285+            (0, 0, 4, 3)286         );-        assert_eq!(subs[0].lines.first().unwrap().content, "line 01");-        assert_eq!(subs[0].lines.last().unwrap().content, "line 03");287+        assert_eq!(clip.lines.first().unwrap().content, "line 04");288+        assert_eq!(clip.lines.last().unwrap().content, "line 06");289+        // Lines outside the range are not in the clip (the whole hunk repeats in290+        // the backfill instead).291+        assert!(!clip.lines.iter().any(|l| l.content == "line 03"));292+    }293 -        // Trailing run: lines 7..=10, renumbered from 7.-        assert_eq!(-            (subs[1].new_start, subs[1].new_lines),-            (7, 4),-            "the trailing run keeps its real new-side numbering"-        );-        assert_eq!(subs[1].lines.first().unwrap().content, "line 07");--        // The covered middle (the callout's job) appears in no sub-hunk.-        let backfilled: Vec<&str> = subs-            .iter()-            .flat_map(|s| &s.lines)-            .map(|l| l.content.as_str())-            .collect();-        assert!(!backfilled.contains(&"line 05"));294+    #[test]295+    fn clip_spanning_the_whole_new_side_reproduces_the_hunk() {296+        let h = Hunk {297+            old_start: 0,298+            old_lines: 0,299+            new_start: 1,300+            new_lines: 5,301+            lines: (1..=5).map(|i| add(&format!("l{i}"))).collect(),302+        };303+        assert_eq!(h.clip_to_new_range(1, 5).as_ref(), Some(&h));304+        assert_eq!(h.clip_to_new_range(1, 99).as_ref(), Some(&h));305     }306 307     #[test]-    fn split_carries_a_bordering_deletion_into_the_uncovered_run() {308+    fn clip_carries_a_bordering_deletion() {309         // `@@ -1,5 +1,3 @@`: delete two leading lines, keep three context lines.310         let h = Hunk {311             old_start: 1,@@ -372,49 +336,27 @@336             ],337         };338 -        // Cover new lines 2..=3 (ctx4, ctx5). The deletions sit before new line-        // 1 (uncovered), so they must travel into the leading run, not vanish.-        let subs = h.uncovered_subhunks(&[(2, 3)]);-        assert_eq!(subs.len(), 1);-        let s = &subs[0];339+        // A clip to new line 1 (ctx3) must carry the two deletions that sit340+        // before it — a `-` line travels with the new-side position it borders.341+        let clip = h.clip_to_new_range(1, 1).unwrap();342         assert_eq!(-            (s.old_start, s.old_lines, s.new_start, s.new_lines),343+            (344+                clip.old_start,345+                clip.old_lines,346+                clip.new_start,347+                clip.new_lines348+            ),349             (1, 3, 1, 1)350         );351         assert_eq!(-            s.lines.iter().map(|l| l.origin).collect::<Vec<_>>(),352+            clip.lines.iter().map(|l| l.origin).collect::<Vec<_>>(),353             ['-', '-', ' ']354         );-        assert_eq!(s.lines[0].content, "old1");-    }--    #[test]-    fn split_with_nothing_covered_reproduces_the_original_hunk() {-        let h = Hunk {-            old_start: 4,-            old_lines: 2,-            new_start: 4,-            new_lines: 3,-            lines: vec![-                DiffLine {-                    origin: ' ',-                    content: "ctx".into(),-                },-                DiffLine {-                    origin: '-',-                    content: "gone".into(),-                },-                add("added1"),-                add("added2"),-            ],-        };-        // An untouched hunk round-trips through the splitter unchanged, so the-        // backfill of a partially-annotated file stays byte-identical.-        assert_eq!(h.uncovered_subhunks(&[]), vec![h.clone()]);355+        assert_eq!(clip.lines[0].content, "old1");356     }357 358     #[test]-    fn split_with_the_whole_new_side_covered_yields_nothing() {359+    fn clip_outside_the_hunk_yields_none() {360         let h = Hunk {361             old_start: 0,362             old_lines: 0,@@ -422,7 +364,7 @@364             new_lines: 5,365             lines: (1..=5).map(|i| add(&format!("l{i}"))).collect(),366         };-        assert!(h.uncovered_subhunks(&[(1, 5)]).is_empty());367+        assert!(h.clip_to_new_range(6, 10).is_none());368     }369 370     #[test]

With no side left to choose, blob_lines simply reads the file at the commit — used only to show context that is not part of the diff.

src/git.rs
@@ -2,7 +2,6 @@2 3 use std::path::Path;4 -use crate::anchor::Side;5 use crate::diff::{DeltaMarker, DeltaStatus, Diff, DiffLine, FileDiff, Hunk};6 use crate::{Error, Result};7 @@ -169,24 +168,14 @@168         Ok(Diff { files })169     }170 -    /// Read the lines of `path` on the given [`Side`] at `rev`.171+    /// Read the lines of `path` as it stands at `rev` (the commit's own tree).172     ///-    /// `New` (and `Diff`, defensively) read the blob at the commit; `Old` reads-    /// it at the first parent. Returns an error if the path is absent on the-    /// requested side — the renderer turns that into a degraded callout.-    pub fn blob_lines(&self, rev: &str, path: &str, side: Side) -> Result<Vec<String>> {173+    /// Used to show code that is *not* part of the diff — a whole file or an174+    /// unchanged range pulled in as context. Returns an error if the path is175+    /// absent at the commit — the renderer turns that into a degraded callout.176+    pub fn blob_lines(&self, rev: &str, path: &str) -> Result<Vec<String>> {177         let commit = self.inner.revparse_single(rev)?.peel_to_commit()?;-        let tree = match side {-            Side::New | Side::Diff => commit.tree()?,-            Side::Old => match commit.parent_count() {-                0 => {-                    return Err(Error::Other(-                        "the root commit has no parent; `old` side is unavailable".to_string(),-                    ))-                }-                _ => commit.parent(0)?.tree()?,-            },-        };178+        let tree = commit.tree()?;179 180         let entry = tree.get_path(Path::new(path))?;181         let object = entry.to_object(&self.inner)?;

The emitter tags an out-of-diff callout "unmodified", so a reader never mistakes plain context for added code.

src/html.rs
@@ -60,6 +60,9 @@60 .line.del .sign { color: #cf222e; }61 .line.focus { box-shadow: inset 3px 0 0 #e3b341; }62 .line.meta { color: var(--muted); background: rgba(127,127,127,0.06); }63+figcaption .tag { font-weight: 400; text-transform: uppercase; letter-spacing: 0.04em;64+     font-size: 0.7rem; color: var(--muted); border: 1px solid var(--rule);65+     border-radius: 4px; padding: 0.05em 0.4em; margin-left: 0.4rem; }66 .degraded { padding: 0.8rem; border-color: #d4a72c; }67 .warn { color: #b08800; font-style: italic; margin: 0; }68 .callout.marker .marker-note { margin: 0; padding: 0.5rem 0.8rem; color: var(--muted);@@ -245,9 +248,20 @@248         );249     }250 251+    // Code pulled in as context (a whole unchanged file, or an unchanged range)252+    // is not a diff, so flag it "unmodified" — the reader shouldn't read its253+    // plain lines as "added".254+    let (class, caption) = if cb.context {255+        (256+            "callout context",257+            format!("{} <span class=\"tag\">unmodified</span>", escape(&cb.path)),258+        )259+    } else {260+        ("callout", escape(&cb.path))261+    };262+263     let mut out = format!(-        "<figure class=\"callout\"><figcaption>{}</figcaption><pre class=\"code\"><code>",-        escape(&cb.path),264+        "<figure class=\"{class}\"><figcaption>{caption}</figcaption><pre class=\"code\"><code>",265     );266     for line in &cb.lines {267         out.push_str(&line_html(line));

The completeness test now asserts every deleted line appears, not only additions, over a whole-file anchor on a modified file — the case that used to fail. A new ranged-anchor test pins the clip-plus-whole-hunk-repeat behavior and a context test covers code referenced outside the diff; sub-hunk splitting and its test are gone.

tests/completeness.rs
@@ -4,9 +4,9 @@4 //! backfill. This is the hard guarantee from the spec (and CLAUDE.md #1).5 //!6 //! The test builds a small fixture repository with a non-root commit that-//! modifies, adds, and deletes files (so the `Old`/deletion paths are exercised-//! alongside `New`/`Diff`), annotates only some of it, and then asserts the-//! invariant over the whole diff.7+//! modifies (with deletions), adds, and deletes files, annotates only some of8+//! it, and then asserts the invariant over the whole diff — checking that every9+//! added *and* deleted line appears, either in a diff callout or the backfill.10 11 use std::fs;12 use std::path::Path;@@ -55,6 +55,11 @@55     write(dir.path(), "a.txt", &a_v1);56     write(dir.path(), "b.txt", "bravo one\nbravo two\n");57     write(dir.path(), "c.txt", "charlie to be deleted\n");58+    write(59+        dir.path(),60+        "e.txt",61+        "echo one\necho two\necho GONE\necho four\n",62+    );63     write(dir.path(), "f.txt", &f_v1);64 65     let mut index = git.index().unwrap();@@ -73,12 +78,16 @@78     let f_v2 = replace_lines(&f_v1, &[(3, "FOXTROT_NEW_3"), (18, "FOXTROT_NEW_18")]);79     write(dir.path(), "a.txt", &a_v2);80     write(dir.path(), "b.txt", "BRAVO_NEW one\nBRAVO_NEW two\n");81+    // e.txt is modified with a real deletion: "echo GONE" is removed and a line82+    // is changed, so its single hunk carries `-` lines. Annotated whole-file, it83+    // is the case the old `!new` whole-file anchor used to drop.84+    write(dir.path(), "e.txt", "echo one\nECHO_NEW_two\necho four\n");85     write(dir.path(), "f.txt", &f_v2);86     write(dir.path(), "d.txt", "delta brand new file\n");87     fs::remove_file(dir.path().join("c.txt")).unwrap();88 89     let mut index = git.index().unwrap();-    for p in ["a.txt", "b.txt", "f.txt", "d.txt"] {90+    for p in ["a.txt", "b.txt", "e.txt", "f.txt", "d.txt"] {91         index.add_path(Path::new(p)).unwrap();92     }93     index.remove_path(Path::new("c.txt")).unwrap();@@ -86,18 +95,22 @@95     let tree = git.find_tree(index.write_tree().unwrap()).unwrap();96     let parent_commit = git.find_commit(parent).unwrap();97 -    // Annotate only b.txt (whole file) and the first hunk of f.txt; a.txt,-    // c.txt's deletion, and d.txt are left entirely for the backfill.98+    // Annotate b.txt (whole file), e.txt (whole file, has a deletion), and one99+    // line of f.txt; a.txt, c.txt's deletion, and d.txt are left entirely for100+    // the backfill.101     let message = "\102 Exercise the renderer103 104 A small commit that touches several files to exercise coverage and backfill.105 -b.txt !diff106+b.txt107 The whole change to b, shown as a diff.108 -f.txt:3 !diff-Only the first hunk of f; its second hunk is left for the backfill.109+e.txt110+The whole change to e — its removed line must appear in this diff callout.111+112+f.txt:3113+A single line of f; the rest of its hunk and its second hunk are backfilled.114 ";115     let v2 = git116         .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])@@ -154,20 +167,23 @@167 168     assert!(!diff.files.is_empty(), "the fixture must have a diff");169 -    // The core invariant: each hunk is either backfilled verbatim, or its added-    // lines are all present in a callout for that file.170+    // The core invariant: each hunk is either backfilled verbatim, or it was171+    // shown in full as a diff callout — in which case BOTH its added and its172+    // deleted lines are present in that callout. Checking `-` lines (not just173+    // `+`) is what guards the bug a whole-file `!new` anchor used to have.174     for file in &diff.files {175         let code_text = code_text_for(&doc, &file.path);176         for (hi, hunk) in file.hunks.iter().enumerate() {177             let in_backfill = backfill_has(&doc, &file.path, hunk);-            let added_shown = hunk-                .lines-                .iter()-                .filter(|l| l.origin == '+' && !l.content.is_empty())-                .all(|l| code_text.contains(&l.content));178+            let lines_shown = |origin: char| {179+                hunk.lines180+                    .iter()181+                    .filter(|l| l.origin == origin && !l.content.is_empty())182+                    .all(|l| code_text.contains(&l.content))183+            };184             assert!(-                in_backfill || added_shown,-                "completeness violated: hunk {hi} of `{}` (new @{}) appears nowhere",185+                in_backfill || (lines_shown('+') && lines_shown('-')),186+                "completeness violated: hunk {hi} of `{}` (new @{}) drops a line",187                 file.path,188                 hunk.new_start,189             );@@ -188,14 +204,22 @@204     assert!(!has_code(&doc, "a.txt"));205     assert_eq!(count_backfill(&doc, "a.txt"), 2);206 -    // b.txt: whole-file `!diff` anchor -> shown as code, never backfilled.207+    // b.txt: whole-file anchor -> shown in full as a diff, never backfilled.208     assert!(has_code(&doc, "b.txt"));209     assert_eq!(count_backfill(&doc, "b.txt"), 0);210 -    // f.txt: two hunks, only the first anchored -> one covered, one backfilled.211+    // e.txt: whole-file anchor on a modified file -> shown in full (deletion and212+    // all), marked full, so it is not backfilled.213+    assert_eq!(file("e.txt").hunks.len(), 1, "e.txt should have one hunk");214+    assert!(has_code(&doc, "e.txt"));215+    assert_eq!(count_backfill(&doc, "e.txt"), 0);216+217+    // f.txt: two hunks. A single-line anchor (`:3`) does not span the whole218+    // first hunk, so neither hunk is marked full -> both are backfilled whole,219+    // even though the callout already showed a slice of the first.220     assert_eq!(file("f.txt").hunks.len(), 2, "f.txt should have two hunks");221     assert!(has_code(&doc, "f.txt"));-    assert_eq!(count_backfill(&doc, "f.txt"), 1);222+    assert_eq!(count_backfill(&doc, "f.txt"), 2);223 224     // c.txt deletion and d.txt addition are unannotated -> backfilled.225     assert_eq!(count_backfill(&doc, "c.txt"), 1);@@ -203,3 +227,17 @@227 228     assert_eq!(doc.title, "Exercise the renderer");229 }230+231+#[test]232+fn a_whole_file_anchor_shows_deletions_in_its_callout() {233+    // The bug this redesign closes: a whole-file anchor on a modified file used234+    // to show only the new side (a `!new` callout), dropping deleted lines.235+    // Now every callout is a diff, so the removed line appears in the callout.236+    let fx = build_fixture();237+    let doc = render::render(&fx.repo, &fx.rev).unwrap();238+    let shown = code_text_for(&doc, "e.txt");239+    assert!(240+        shown.contains("echo GONE"),241+        "the removed line must appear in e.txt's diff callout, got:\n{shown}"242+    );243+}

The format spec is the source of truth, so it leads: one kind of anchor, always a diff, with whole-hunk repeat in place of sub-hunk splitting.

docs/SPEC.md
@@ -1,4 +1,4 @@-# Commit Throughline — PoC Specification (v0.3)1+# Commit Throughline — PoC Specification (v0.4)2 3 > Canonical specification for the format. The reference implementation is the4 > `throughline` CLI in this repository.@@ -28,8 +28,8 @@28 3. **Repetition is allowed.** A hunk may appear more than once — e.g. once as an author's29    focused callout and again in its natural position. That is not a conflict; it's the30    completeness guarantee doing its job. There is no "exactly once" rule.-4. **Don't duplicate code in prose.** Anchors reference coordinates (path, line range,-   side); the renderer extracts the real content from Git at render time, so the narrative31+4. **Don't duplicate code in prose.** Anchors reference coordinates (path, line range);32+   the renderer extracts the real content from Git at render time, so the narrative33    can't drift out of sync.34 5. **It's just Markdown with embedded diff.** Apart from anchor injection, the message is35    rendered by an ordinary Markdown engine. Interleaving prose and code is unremarkable —@@ -60,31 +60,32 @@60 ## Anchors61 62 An anchor is a line, by itself, that the renderer recognizes as a reference to code.-Everything else is ordinary Markdown prose. There is only one kind of anchor: it shows-some code, led in by the prose above it, and marks any diff hunks it overlaps as "already63+Everything else is ordinary Markdown prose. There is only one kind of anchor, and it64+always shows the **diff** of the code it names — the changed region with its `+`/`-`65+lines — led in by the prose above it, and marks the hunks it shows in full as "already66 shown" so the backfill won't be required to repeat them (though repetition is allowed).67 -An anchor may point at code that is part of the diff (a changed region) or at code that is-not (unchanged surrounding lines, a function elsewhere, the pre-change version) to provide-context. Both are the same construct — pulling in non-diff context simply shows that code-and marks no hunks as seen, and the real diff still appears in full via backfill.68+An anchor may also point at code that is **not part of the diff** — a whole file the69+commit never touched, or an unchanged range of a file it did — to provide context. There70+is no diff to show there, so that code renders as **plain, unmodified lines** (labeled as71+such) and marks no hunks, and the real diff still appears in full via backfill. The author72+chooses *which* code to surface and *in what order*; whether it shows as a diff or as plain73+context follows from the diff itself, not from a modifier.74 75 ### Recognition rule76 77 A line is an anchor if, after trimming, it begins with a token that **looks like a path**78 — it contains a `/` or ends in a recognizable `.extension` — optionally followed by a line-specifier and modifiers, and nothing else of substance on the line.79+specifier and a focus range, and nothing else of substance on the line.80 81 ```-<path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>] [!<side>]82+<path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>]83 ```84 85 - `<path>` — file path relative to the repository root.-- `:<start>` or `:<start>-<end>` — a single line or an inclusive line range (1-indexed).-  **Omitted entirely → the whole change to that file** is shown.86+- `:<start>` or `:<start>-<end>` — a single line or an inclusive line range (1-indexed),87+  on the new side. **Omitted entirely → the whole change to that file** is shown.88 - `@<focusStart>-<focusEnd>` — a sub-range to visually emphasize. Optional.-- `!<side>` — where the code comes from: `new` (final state, default), `old` (state-  before the commit), `diff` (the hunk with its `+`/`-` lines). Optional.89 90 Valid anchors:91 @@ -92,10 +93,14 @@93 src/auth/validator.rs:12-3894 src/auth/validator.rs:5095 src/auth/middleware.rs-src/config.rs !diff-docs/api.md:10-20 @14-16 !new96+docs/api.md:10-20 @14-1697 ```98 99+> **Legacy side modifier.** Earlier versions accepted a trailing `!new`/`!old`/`!diff`100+> selector. It no longer carries meaning — anchors are always diffs — but it is still101+> *accepted and ignored*, so a commit message written against an older version still102+> renders as an anchor rather than as prose.103+104 ### Disambiguation against prose105 106 The path-shaped pattern is strict (must contain `/` or a `.ext`) so it rarely collides@@ -106,9 +111,11 @@111 112 ## Coverage and backfill113 -Coverage is tracked **per hunk**, not per file. An anchor whose range overlaps one or more-diff hunks marks exactly those hunks as shown. A file annotated only in part leaves its-other hunks unplaced, so they still appear in the backfill.114+Coverage is tracked **per hunk**, not per file. A hunk counts as covered only when an115+anchor shows it **in full**: a whole-file anchor shows every hunk of the file, and a ranged116+anchor shows a hunk in full when its range spans the hunk's entire new side. A file117+annotated only in part leaves its other hunks unplaced, so they still appear in the118+backfill.119 120 - **Natural order** for the backfill is diff order as Git emits it: alphabetical by path,121   hunks top-to-bottom within each file.@@ -117,16 +124,15 @@124   guarantee is completeness; a reader loses nothing by seeing the full hunk again in125   context.126 -An anchor's range need not align with hunk boundaries. A ranged `!new` anchor that falls-inside a larger hunk marks only the new-side lines it actually shows; the hunk's uncovered-remainder is **split out and backfilled** as one or more sub-hunks, each renumbered to its-real position. (A removed line has no new-side number, so it travels with the run it-borders — a deletion inside the shown range is suppressed with it, one bordering an-uncovered run is carried into the backfill — and nothing is dropped.) A whole-file anchor-and a `!diff` anchor instead mark the enclosing hunk(s) as a unit: they display each hunk-in full, so there is no remainder to recover. Splitting keeps completeness at the level of-the changed *line*, not merely the hunk — most visibly on new files and big rewrites,-where an entire file is a single hunk.127+An anchor's range need not align with hunk boundaries. A ranged anchor that falls inside a128+larger hunk shows just the **clip** its range selects — the diff of those lines, renumbered129+to its real position (a removed line, having no new-side number, travels with the line it130+borders). Because the clip did not show the hunk in full, the **whole hunk is re-emitted in131+the backfill**, even though that repeats the lines the clip already showed. Re-showing the132+whole hunk — rather than splitting out only the unshown remainder — is deliberate: it keeps133+the model simple and guarantees every line, **including deletions**, appears at least once.134+This matters most on new files and big rewrites, where an entire file is a single hunk:135+the clip narrates a slice, and the full change still lands in the backfill.136 137 ### Delta-level markers138 @@ -152,7 +158,7 @@158 **Whole-file** — omit the range; the section shows everything that changed in that file.159 160 ```-src/config.rs !diff161+src/config.rs162 Bumped the default timeout from 5s to 30s.163 ```164 @@ -184,15 +190,16 @@190 src/auth/validator.rs:12-38 @20-24191 192 With validation gone, the middleware just delegates. Here's the whole change.-src/auth/middleware.rs !diff193+src/auth/middleware.rs194 ```195 196 Renders to: the title as `<h1>`; the first paragraph as the **TL;DR** lede; the lead-in-paragraph ("Here is the validator…"); a callout of lines 12-38 of `validator.rs` (20-24-emphasized); the next lead-in ("With validation gone…"); the diff of `middleware.rs`; then-the backfill — every remaining hunk of the commit (e.g. updated tests, and any hunks of-the two files above not covered by the anchors) in natural diff order, shown plainly. The-prose always precedes the code it describes, and no hunk is missing.197+paragraph ("Here is the validator…"); a diff clip of lines 12-38 of `validator.rs` (20-24198+emphasized); the next lead-in ("With validation gone…"); the whole diff of `middleware.rs`;199+then the backfill — every hunk not already shown in full (e.g. updated tests, and the rest200+of `validator.rs`'s hunk, since the `:12-38` clip did not span it whole) in natural diff201+order, shown plainly. The prose always precedes the code it describes, and no hunk is202+missing.203 204 ## Conventional Commits interop205 @@ -211,7 +218,7 @@218 src/auth/validator.rs:12-38 @20-24219 220 With validation gone, the middleware just delegates…-src/auth/middleware.rs !diff221+src/auth/middleware.rs222 223 BREAKING CHANGE: session tokens are no longer validated inline224 Refs: #142@@ -255,25 +262,27 @@262    and compute the full diff against the parent (the complete set of hunks).263 2. Parse the message: split off the title and the TL;DR (the first paragraph), collect the264    intro/lead-in, then walk the rest into an ordered list of blocks — each either *prose*-   (raw Markdown) or *anchor* (unresolved path + range + focus + side). Prose that precedes265+   (raw Markdown) or *anchor* (unresolved path + range + focus). Prose that precedes266    an anchor is its lead-in.267 3. Resolve each anchor against Git:-   - `new` -> blob at the commit; `old` -> blob at the parent; `diff` -> the hunk(s)-     covering the range.-   - Mark what the anchor shows: a whole-file or `diff` anchor marks each overlapped-     hunk whole; a ranged `new` anchor marks only its new-side lines.-4. Backfill: collect everything not shown, in natural diff order — each fully-unshown-   hunk whole, and the uncovered remainder of a partially-shown `new` hunk split into-   sub-hunks at the anchor boundary.-5. Emit HTML: render prose with a Markdown library; for each anchor emit a-   syntax-highlighted code block (focus lines marked; `+`/`-` colored in `diff` mode);268+   - If the path/range overlaps changed hunks, show the diff — every hunk of the file269+     (whole-file anchor), or the clip the range selects (ranged anchor).270+   - Otherwise the code is not in the diff (a whole unchanged file, or an unchanged271+     range): read the file at the commit and show those lines as plain context.272+   - Mark a hunk shown *in full* (a whole-file anchor, or a ranged anchor whose range273+     spans the hunk's whole new side); a clip that did not cover its hunk marks nothing.274+4. Backfill: emit every hunk not shown in full, **whole**, in natural diff order — even275+   one an anchor showed only a clip of (repetition guarantees every line appears).276+5. Emit HTML: render prose with a Markdown library; for each anchor emit a code block —277+   a diff (focus lines marked; `+`/`-` colored) or plain context labeled "unmodified";278    then emit the backfill hunks as a normal diff. Inline all CSS so the file is standalone.279 280 ### Degraded anchors281 -If a range is out of bounds, the path is missing on the requested side, or the diff-doesn't cover the range: emit the prose intact and a visible warning in place of the code.-Never abort. (A degraded anchor marks no hunks shown, so nothing is lost from the backfill.)282+If the path is missing at the commit, or a range is out of bounds for the file: emit the283+prose intact and a visible warning in place of the code. Never abort. (A degraded anchor284+marks no hunks shown, so nothing is lost from the backfill.) A range that simply isn't part285+of the diff is **not** a failure — it falls back to plain, unmodified context.286 287 ## Out of scope (PoC)288 @@ -298,15 +307,17 @@307   code already shown:308 309       <1-3 sentences leading into the code>-      path/to/file.ext:<start>-<end> [@<focusStart>-<focusEnd>] [!new|old|diff]310+      path/to/file.ext:<start>-<end> [@<focusStart>-<focusEnd>]311 -- The FULL diff is always shown regardless of what you annotate; you are choosing order-  and adding explanation, not selecting what to include. Anything you don't mention is-  appended automatically at the end in natural order.312+- Each reference shows the DIFF of the code it names (the +/- change). The FULL diff is313+  always shown regardless of what you annotate; you are choosing order and adding314+  explanation, not selecting what to include. Anything you don't mention is appended315+  automatically at the end in natural order.316 - Omit the range to show a file's whole change; repeat a path with narrow ranges to walk317   through it piece by piece. Both styles can mix.-- You may reference unchanged code (or !old) purely to give context; the real diff still-  appears in full.318+- You may reference code that DIDN'T change — a whole untouched file, or an unchanged319+  range — purely to give context; it shows as plain "unmodified" code, and the real diff320+  still appears in full.321 - Order references in the order best for reading, not alphabetically.322 - Never paste code into the prose; just reference the lines — the renderer injects it.323 ```

The rest of the docs follow into their homes: the always-diff model and the simplified coverage in ARCHITECTURE, the resolved item in ROADMAP, the authoring guidance in the LLM guide, a rewritten coverage-and-backfill field note, and a new field note on inspecting the HTML output — the line-packing and CSS-class-name gotchas that cost a wrong turn here.

Remaining changes

CLAUDE.md
@@ -40,10 +40,10 @@40 |------|----------------|41 | `src/cli.rs` | `throughline render <commit>` / `throughline branch <range>` argument parsing. |42 | `src/message.rs` | Split a raw message into title, TL;DR (first paragraph), intro (lead-in to the first anchor), and an ordered list of prose/anchor blocks. |-| `src/anchor.rs` | The anchor grammar: recognize a path-shaped line and parse `path[:range] [@focus] [!side]`. |43+| `src/anchor.rs` | The anchor grammar: recognize a path-shaped line and parse `path[:range] [@focus]` (a legacy trailing `!side` is accepted and ignored). |44 | `src/conventional.rs` | Conventional Commits header (`type(scope): …`) and footer-trailer passthrough. |-| `src/git.rs` | Open the repo; resolve a commit and its parent; compute the full diff; read blob lines by side. |-| `src/diff.rs` | Hunk / file-diff model, per-hunk (and per-line) coverage tracking, sub-hunk splitting, and backfill collection. |45+| `src/git.rs` | Open the repo; resolve a commit and its parent; compute the full diff; read a file's lines at the commit (for context outside the diff). |46+| `src/diff.rs` | Hunk / file-diff model, per-hunk coverage tracking, range clipping (`clip_to_new_range`), and backfill collection. |47 | `src/render.rs` | Resolve anchors against Git, track coverage, build the ordered `Document` model. |48 | `src/html.rs` | Emit a single self-contained HTML file with inline CSS. |49 @@ -52,11 +52,11 @@52 These are the engineering form of the spec's guiding principles. Any change to53 the render pipeline must preserve all of them:54 -1. **Completeness.** Every diff hunk of the commit appears **at least once** in-   the output. Coverage is tracked **per hunk** — and **per line** within a hunk-   for a ranged `!new` anchor — never per file. The backfill exists solely to-   enforce this: a partially-shown hunk is split at the anchor boundary so its-   uncovered remainder is still emitted.55+1. **Completeness.** Every diff hunk of the commit — and every line within it,56+   **deletions included** — appears **at least once** in the output. Coverage is57+   tracked **per hunk**, never per file, and a hunk counts as covered only when an58+   anchor shows it *in full*. The backfill enforces this: any hunk not shown in59+   full is re-emitted **whole**, even if an anchor already showed a clip of it.60 2. **Repetition is allowed.** A hunk may appear both as an authored callout and61    again in the backfill. Do *not* add "shown above" markers or dedupe the62    backfill copy — completeness wins over tidiness.@@ -79,11 +79,14 @@79 - **Lead-in** — prose placed immediately *before* an anchor; it prepares the reader for80   the code that follows (explanation first, then code).81 - **Hunk** — a contiguous changed region as Git emits it; the unit of coverage.-- **Coverage** — which hunks (and, for a ranged `!new` anchor, which lines within-  a hunk) an anchor has marked "shown".-- **Backfill** — the trailing section: every not-yet-shown hunk (or the uncovered-  sub-hunks split from a partially-shown one), in natural order.-- **Side** — where an anchor's code comes from: `new` (default), `old`, `diff`.82+- **Coverage** — which hunks an anchor has shown **in full** (the only ones the83+  backfill skips). Tracked per hunk.84+- **Clip** — the diff slice a ranged anchor shows (`Hunk::clip_to_new_range`); it85+  does not mark the hunk full, so the whole hunk still backfills.86+- **Backfill** — the trailing section: every hunk not shown in full, emitted87+  **whole**, in natural order (repetition is fine).88+- **Context** — code shown *outside* the diff (an unchanged file or range),89+  rendered as plain lines labeled "unmodified"; marks no hunks.90 91 ## Conventions in this repo92 
docs/ARCHITECTURE.md
@@ -41,8 +41,8 @@41 | Module | Role |42 |--------|------|43 | `cli` | Parse `throughline render <commit>` and `throughline branch <range>` (clap, derive). |-| `git` | Thin wrapper over `git2`: open/discover the repo, read a commit's raw message, compute the diff against the first parent, read blob lines by side. |-| `anchor` | The anchor grammar — recognition (is this line a code reference?) and parsing into `AnchorRef { path, range, focus, side }`. Pure, no I/O. |44+| `git` | Thin wrapper over `git2`: open/discover the repo, read a commit's raw message, compute the diff against the first parent, read a file's lines at the commit (for context outside the diff). |45+| `anchor` | The anchor grammar — recognition (is this line a code reference?) and parsing into `AnchorRef { path, range, focus }`. Pure, no I/O. |46 | `message` | Split a raw message into `title`, `tldr` (the first paragraph), `intro` (the lead-in to the first anchor), and an ordered `Vec<Block>` of prose/anchor blocks. Pure, no I/O. |47 | `conventional` | Conventional Commits interop: parse the `type(scope)!: subject` header and pass footer trailers through untouched. |48 | `diff` | The diff data model (`Diff` → `FileDiff` → `Hunk` → `DiffLine`) and the per-hunk `Coverage` tracker. |@@ -62,8 +62,7 @@62     first paragraph (the summary); `intro` is the remaining prose that leads into the first63     anchor.64   - `Block = Prose(String) | Anchor(AnchorRef)`-  - `AnchorRef { path, range: Option<LineRange>, focus: Option<LineRange>, side: Side }`-  - `Side = New | Old | Diff` (default `New`)65+  - `AnchorRef { path, range: Option<LineRange>, focus: Option<LineRange> }`66 - **Diff** — produced by `git::Repo::diff_against_parent`:67   - `Diff { files: Vec<FileDiff> }` in natural order (alphabetical by path)68   - `FileDiff { path, hunks: Vec<Hunk>, marker: Option<DeltaMarker> }`@@ -75,7 +74,9 @@74   - `Document { title, sections: Vec<Section> }`75   - `Section = Tldr(String) | Prose(String) | Code(CodeBlock) | Backfill { path, hunk }76     | Marker { path, marker }` — `Tldr` is the lede; `Prose` is a lead-in (or the intro).-  - `CodeBlock { path, lines: Vec<CodeLine>, degraded, note }`77+  - `CodeBlock { path, lines: Vec<CodeLine>, degraded, context, note }` — `context` is78+    `true` for code shown outside the diff (an unchanged file or range), rendered plain79+    and labeled "unmodified".80   - `CodeLine { origin, content, focus: bool, number: Option<u32> }` — focus and81     new-side line number are resolved per line here, where the line numbers are82     known, so the emitter stays a dumb formatter. `origin` is `'+'`/`'-'`/`' '`@@ -96,52 +97,55 @@97    intro (as a `Section::Prose`).98 4. Walk `msg.body` in order. For each block:99    - `Block::Prose(p)` → push `Section::Prose(p)` (a lead-in to the anchor that follows).-   - `Block::Anchor(a)` → resolve it:-     - Determine the lines to show from `a.side` (`New`/`Old` read a blob; `Diff`-       shows the covering hunks).-     - Mark what the anchor shows of every hunk it overlaps on the new side: a-       whole-file or `Diff` anchor calls `coverage.mark_full`; a ranged `New`-       anchor calls `coverage.mark_new_range` with just its new-side interval.-       Coverage is **per hunk**, and within a hunk **per line** for ranged `New`.-     - Push `Section::Code(CodeBlock { … })`. If resolution fails, push a-       degraded `CodeBlock` (`degraded = true`, a `note`) and mark **nothing**.-5. **Backfill**: iterate `diff` in natural order. For every `(file, hunk)`:-   skip it if `coverage.is_full`; emit it whole if untouched; otherwise split it-   with `Hunk::uncovered_subhunks` against its covered intervals and emit a-   `Section::Backfill` for each uncovered sub-hunk. After a file's hunks the loop-   emits its `Section::Marker` when `FileDiff::marker` is set — a hunk-less100+   - `Block::Anchor(a)` → resolve it (`resolve_anchor`):101+     - If the anchor's path/range overlaps changed hunks, show the diff — every102+       hunk of the file (whole-file anchor), or the clip the range selects103+       (ranged anchor, via `Hunk::clip_to_new_range`). Otherwise the code is not104+       in the diff (a whole unchanged file, or an unchanged range), so read it at105+       the commit and show those lines as plain context (`context = true`).106+     - Mark a hunk shown *in full*: `coverage.mark_full` for each hunk of a107+       whole-file anchor, and for a ranged anchor's hunk only when the range spans108+       its whole new side (`Hunk::contained_in_new_range`). A clip that did not109+       cover its hunk marks **nothing**, so the backfill re-emits the hunk whole.110+     - Push `Section::Code(CodeBlock { … })`. If resolution fails (missing path,111+       out-of-bounds range), push a degraded `CodeBlock` and mark **nothing**.112+5. **Backfill**: iterate `diff` in natural order. For every `(file, hunk)`: skip113+   it if `coverage.is_full`, else emit it **whole** as a `Section::Backfill` —114+   untouched hunks and partially-clipped hunks alike. After a file's hunks the115+   loop emits its `Section::Marker` when `FileDiff::marker` is set — a hunk-less116    delta's sole row (binary, mode-only, empty add/delete) or a `Mode` change117    riding alongside content hunks — keeping those facts visible (completeness at118    the *delta* level).119 -Because step 5 visits every hunk and emits whatever was not shown — the whole-hunk, or just its uncovered sub-hunks — plus a marker for every delta-level fact-a hunk can't carry, the union of authored callouts and backfill always covers the-whole diff at line granularity: the completeness invariant. The implementation never repeats a line-(a covered line is split out of the backfill), but the model permits it — a hunk-may be shown by *multiple* anchors, and the spec allows a hunk to recur; that is-fine and intended.--### Coverage is per hunk, and per line within a hunk--`diff::Coverage` maps `(file_index, hunk_index)` to what an anchor has shown of-that hunk: a `full` flag (a whole-file or `Diff` anchor displays the hunk whole)-or a list of covered new-side intervals (a ranged `New` anchor). An anchor never-marks a *file*; it marks exactly the hunks its range overlaps (`Hunk::overlaps_new`),-so a file annotated only in part leaves its other hunks for the backfill.--For a ranged `New` anchor the granularity is finer still: it shows only the-new-side lines in its range, so `Hunk::uncovered_subhunks` splits the hunk at-the anchor boundary and the backfill emits the uncovered runs as renumbered-sub-hunks. A `-` line, having no new-side number, travels with the run it borders-so no deletion is dropped. This closes the gap where a narrow `!new` anchor over-a large hunk (a new file, a big rewrite) used to suppress the whole hunk.120+Because step 5 emits every hunk not shown in full — whole — plus a marker for121+every delta-level fact a hunk can't carry, the union of authored callouts and122+backfill always covers the whole diff, every line (including deletions) and123+delta. A hunk an anchor only clipped is repeated whole in the backfill: the model124+allows — and here relies on — repetition rather than splitting out the unshown125+remainder. That keeps the mechanism simple and the guarantee airtight.126+127+### Coverage is per hunk128+129+`diff::Coverage` is a set of `(file_index, hunk_index)` pairs an anchor has shown130+**in full**. An anchor never marks a *file*; it marks exactly the hunks it shows131+whole, so a file annotated only in part leaves its other hunks for the backfill.132+133+A whole-file anchor shows — and marks — every hunk of the file. A ranged anchor134+shows the clip its range selects (`Hunk::clip_to_new_range`, which carries a135+bordering `-` line along so deletions render in the clip too) and marks the hunk136+full only when the range spans its whole new side (`Hunk::contained_in_new_range`).137+A clip narrower than its hunk marks nothing, so the backfill re-emits that hunk138+**whole** — repetition that guarantees every line, deletions included, appears.139+This is simpler than splitting out the unshown remainder, and it is what closes140+the gap where a whole-file or wide anchor over a modified file used to drop the141+deleted lines a new-side-only callout never showed.142 143 ### Degrade, don't fail144 -Anchor resolution returns a `CodeBlock` even on failure: out-of-bounds range,-missing path on the requested side, or a diff that doesn't cover the range all-produce `degraded = true` with an explanatory `note`, and mark no hunks shown.145+Anchor resolution returns a `CodeBlock` even on failure: a path missing at the146+commit, or an out-of-bounds range when reading a file as context, produce147+`degraded = true` with an explanatory `note`, and mark no hunks shown. (A range148+that simply isn't in the diff is not a failure — it falls back to plain context.)149 `render::render` itself only returns `Err` for whole-repo failures (bad150 revision, Git errors) — never for a single bad anchor.151 @@ -200,34 +204,35 @@204   and encode the format. The spec's worked example and its list of valid anchors205   are mirrored directly as test cases.206 - **`diff`** unit-tests the pure pieces: hunk/range overlap, coverage marking,-  `DeltaMarker` phrasing, and `Hunk::uncovered_subhunks` splitting.207+  `DeltaMarker` phrasing, `Hunk::contained_in_new_range`, and208+  `Hunk::clip_to_new_range`.209 - **Integration tests** (`tests/`) build a small fixture repository and assert210   end-to-end properties — above all the **completeness invariant**: for any-  commit and any set of anchors, every hunk of the diff appears at least once in-  the rendered `Document` (`tests/completeness.rs`), every hunk-*less* delta-  appears as a marker row (`tests/hunkless.rs`), a mode change riding alongside-  content hunks still surfaces its marker (`tests/mode_change.rs`), and a ranged-  `!new` anchor over a large hunk backfills its uncovered remainder as sub-hunks-  so completeness holds per *line* (`tests/subhunk.rs`).211+  commit and any set of anchors, every hunk (and every added *and deleted* line)212+  appears at least once in the rendered `Document` (`tests/completeness.rs`),213+  every hunk-*less* delta appears as a marker row (`tests/hunkless.rs`), a mode214+  change riding alongside content hunks still surfaces its marker215+  (`tests/mode_change.rs`), a ranged anchor shows a clip while the whole hunk is216+  repeated in the backfill (`tests/ranged_anchor.rs`), and code referenced217+  outside the diff renders as plain "unmodified" context (`tests/context_anchor.rs`).218 219 ## Status & roadmap220 221 Implemented: the full pipeline. The CLI surface, the `anchor` grammar and222 `message::parse`, `git::diff_against_parent` / `blob_lines`, the `render`-orchestration (anchor resolution, per-hunk coverage with ranged-`!new` sub-hunk-splitting, backfill, and delta-level markers), and the `html` emitter — with-the completeness invariant covered by integration tests (`tests/completeness.rs`,-`tests/hunkless.rs`, `tests/mode_change.rs`, `tests/subhunk.rs`). The `branch`-view composes a commit range into one navigable page on top of that pipeline-(`tests/branch.rs`).223+orchestration (always-diff anchor resolution, per-hunk coverage, whole-hunk224+backfill, and delta-level markers), and the `html` emitter — with the225+completeness invariant covered by integration tests (`tests/completeness.rs`,226+`tests/hunkless.rs`, `tests/mode_change.rs`, `tests/ranged_anchor.rs`,227+`tests/context_anchor.rs`). The `branch` view composes a commit range into one228+navigable page on top of that pipeline (`tests/branch.rs`).229 230 Stubbed: `conventional::Header::parse` (returns `None`) and footer-trailer231 splitting in `message::parse` (`trailers` is empty); both fall through harmlessly232 today.233 234 See [`ROADMAP.md`](ROADMAP.md) for the prioritized plan: completeness hardening-(hunk-less deltas, mode-beside-content markers, and sub-hunk splitting done),-Conventional Commits interop,-notation interop (accept the `#L12-L38` fragment and Markdown-link anchor forms),-and the spec's later items (interactive UI, content-based anchoring, non-HTML-export).235+(hunk-less deltas, mode-beside-content markers done; the always-diff redesign236+closed the deletion holes), Conventional Commits interop, notation interop237+(accept the `#L12-L38` fragment and Markdown-link anchor forms), and the spec's238+later items (interactive UI, content-based anchoring, non-HTML export).
docs/ROADMAP.md
@@ -14,7 +14,9 @@14 - [x] `git::diff_against_parent` — tree-to-parent diff lowered into the model via15   `git2::Patch`, natural order (alphabetical files, top-to-bottom hunks); root16   commits diff against the empty tree.-- [x] `git::blob_lines` — read a file by side (`new`@commit / `old`@parent).17+- [x] `git::blob_lines` — read a file's lines at the commit. *(Originally took a18+  `new`/`old` side; the side parameter was removed in the always-diff redesign — it now19+  reads the commit's tree, for showing context outside the diff.)*20 - [x] `render::render` — intro, body in message order, anchor resolution, per-hunk21   coverage, and backfill of every unmarked hunk.22 - [x] `html::to_html` — self-contained page (inline CSS, no JS): prose via@@ -46,38 +48,37 @@48   paths; guarded by `tests/mode_change.rs`.)*49 - [x] **Sub-hunk splitting** (the spec's documented v2). A ranged `!new` anchor over50   a large single hunk used to mark the *whole* hunk shown, so the un-annotated-  remainder was not backfilled — most visible on new files and big rewrites (e.g. a-  root commit, where each file is one hunk). A ranged `!new` anchor now marks only its-  new-side interval; `diff::Coverage` tracks covered intervals per hunk and-  `Hunk::uncovered_subhunks` splits the hunk at the anchor boundary, so the backfill-  emits the uncovered runs as renumbered sub-hunks (deletions travel with the run they-  border). Whole-file and `!diff` anchors still mark a hunk whole. Completeness now-  holds per *line*, not just per hunk. *(`diff.rs` model + `render.rs` coverage;-  guarded by `tests/subhunk.rs`.)*--## Next — completeness: a whole-file `!new` anchor drops deletions--A hole in the one hard guarantee, surfaced by a `throughline branch` render of doc-commits. A **bare** anchor (`path`, no `!side`) defaults to `!new`, which shows the-*post-change* blob as plain context; a **whole-file** `!new` anchor (no range) then-calls `Coverage::mark_full`, so the hunk never backfills. But a `!new` callout shows-only the new side — so any **deleted** lines in that hunk appear **nowhere**.-Verified on a real commit (its removed line was absent from the page);-`tests/completeness.rs` misses it because it only checks `+` lines.--- [ ] **Correctness — deletions must survive a `!new` anchor.** A whole-file `!new`-  anchor must not `mark_full` a hunk that has deletions; the `-` lines have to reach-  the backfill. (The ranged-`!new` path keeps deletions that border an *uncovered*-  run via `Hunk::uncovered_subhunks`; the whole-file path's `mark_full` leaves no-  remainder at all.) Then **extend `tests/completeness.rs` to assert every `-` line-  appears**, not only `+`. *(`render.rs` `resolve_blob`; mechanism in51+  remainder was not backfilled. The first fix split the hunk at the anchor boundary52+  (`Hunk::uncovered_subhunks` + per-line coverage) and backfilled the uncovered runs.53+  **Superseded by the always-diff redesign below**, which removed sub-hunk splitting54+  and per-line coverage entirely: a ranged anchor now shows a clip and the *whole* hunk55+  is repeated in the backfill (simpler, and it closed a deletion hole the split path56+  still had).57+58+## Done — anchors are always a diff (the redesign that closed the deletion holes)59+60+A whole-file `!new` anchor used to drop deletions: a `!new` callout showed only the61+*post-change* blob, so a modified file's `-` lines reached neither the callout (new62+side only) nor the backfill (`mark_full` left no remainder). The ranged-`!new` path63+hid the same root cause. Rather than patch `!new`, the whole `!side` selector was64+removed — every anchor now renders as a **diff**, so deletions are always on the page65+and the bug class is gone. This also resolved the UX concern (a bare anchor no longer66+renders as flat, uncolored text — it shows the diff with `+`/`-` and hunk headers).67+68+- [x] **Anchors always render as a diff** (old + new lines together). The grammar69+  dropped `!new`/`!old`/`!diff` (a legacy trailing token is accepted and ignored), and70+  `git`/`render`/`diff` collapsed to one resolution path. *(spec bumped to v0.4;71+  `anchor.rs` + `git.rs` + `diff.rs` + `render.rs` + `html.rs`.)*72+- [x] **Reference code outside the diff** — a whole unchanged file, or an unchanged73+  range — renders as plain code labeled "unmodified", the one place non-diff code74+  appears; it marks no hunks, so the real diff still backfills. *(guarded by75+  `tests/context_anchor.rs`.)*76+- [x] **Completeness by whole-hunk repeat.** A ranged anchor shows a *clip*77+  (`Hunk::clip_to_new_range`); a hunk it did not cover in full is re-emitted **whole**78+  in the backfill. Sub-hunk splitting and per-line coverage were deleted. *(guarded by79+  `tests/completeness.rs` — which now asserts every `-` line, not just `+` — and80+  `tests/ranged_anchor.rs`; mechanism in81   [`coverage-and-backfill.md`](wiki/field-notes/coverage-and-backfill.md).)*-- [ ] **UX — a `!new` callout gives no sign of what changed.** Even once deletions-  survive, a bare-anchor commit still renders as flat, uncolored new-file text — no-  `+`/`-`, no hunk headers — which surprises a reader expecting a diff. Decide: mark-  added lines inside `!new` callouts, default bare anchors to `!diff`, or treat it as-  authoring guidance in [`llm-commit-guide.md`](llm-commit-guide.md) /-  [`SPEC.md`](SPEC.md).82 83 ## Done — reading model: TL;DR + lead-ins84 @@ -139,9 +140,8 @@140   line is *already* a valid, clickable permalink on GitHub/GitLab and in many141   editors, so it degrades perfectly: a plain Markdown renderer shows a working link;142   Throughline injects the real code. Keep the terse bare-line form for authoring.-- [ ] **Document the lineage in [`SPEC.md`](SPEC.md)** — call out that `!old`/`!new`-  is the same notion as a review comment's *side* (`LEFT`/`RIGHT`), and that the-  hunk header we emit is the standard unified-diff `@@ -a,b +c,d @@` coordinate.143+- [ ] **Document the lineage in [`SPEC.md`](SPEC.md)** — call out that the hunk header144+  we emit is the standard unified-diff `@@ -a,b +c,d @@` coordinate.145 146 Prior art the grammar already leans on (for reference):147 @@ -149,12 +149,11 @@149 |---|---|---|150 | `path:12-38` | `file:line` / line range | compiler/`rustc`/`grep`/`rg`, editor "go to file:line" |151 | line ranges | `#L12-L38` URL fragment | GitHub & GitLab permalinks |-| `!old` / `!new` | `side: LEFT` / `RIGHT` | GitHub review-comment API |-| hunk display | `@@ -a,b +c,d @@` | unified diff (already emitted) |152+| hunk display | `@@ -a,b +c,d @@` | unified diff (always emitted — anchors are diffs) |153 | pulling real code by range into Markdown | `{{#include file:start:end}}` | mdBook include preprocessor |154 -`@focus` and `!side` have no forge-standard fragment spelling, so they remain a-Throughline extension regardless.155+`@focus` has no forge-standard fragment spelling, so it remains a Throughline156+extension regardless.157 158 ## Confidence & developer experience159 
docs/llm-commit-guide.md
@@ -17,15 +17,17 @@17   code already shown:18 19       <1-3 sentences leading into the code>-      path/to/file.ext:<start>-<end> [@<focusStart>-<focusEnd>] [!new|old|diff]20+      path/to/file.ext:<start>-<end> [@<focusStart>-<focusEnd>]21 -- The FULL diff is always shown regardless of what you annotate; you are choosing order-  and adding explanation, not selecting what to include. Anything you don't mention is-  appended automatically at the end in natural order.22+- Each reference shows the DIFF of the code it names (the +/- change). The FULL diff is23+  always shown regardless of what you annotate; you are choosing order and adding24+  explanation, not selecting what to include. Anything you don't mention is appended25+  automatically at the end in natural order.26 - Omit the range to show a file's whole change; repeat a path with narrow ranges to walk27   through it piece by piece. Both styles can mix.-- You may reference unchanged code (or !old) purely to give context; the real diff still-  appears in full.28+- You may reference code that DIDN'T change — a whole untouched file, or an unchanged29+  range — purely to give context; it shows as plain "unmodified" code, and the real diff30+  still appears in full.31 - Order references in the order best for reading, not alphabetically.32 - Never paste code into the prose; just reference the lines — the renderer injects it.33 ```
docs/wiki/README.md
@@ -38,6 +38,9 @@38     — the completeness-critical core: how coverage and backfill keep the guarantee.39   - [`field-notes/reading-model.md`](field-notes/reading-model.md)40     — how a message maps to the page: TL;DR, lead-ins, and why order is convention.41+  - [`field-notes/html-output.md`](field-notes/html-output.md)42+    — how `html.rs` shapes the page, and the two gotchas (line-packing, CSS class43+    names) that mislead you when you grep or split rendered output.44 - **How-to** — acting on this codebase.45   - [`how-to/render-a-branch.md`](how-to/render-a-branch.md) — render a range of46     commits as one navigable page (`throughline branch`); the input forms and the
docs/wiki/field-notes/coverage-and-backfill.md
@@ -2,88 +2,98 @@2 3 Read this before you touch anything that decides *what gets shown*. Coverage and the4 backfill are the machinery behind the project's one hard guarantee —-**completeness**: every diff hunk appears at least once. It is the easiest thing in-the codebase to break without noticing, because a regression here doesn't crash; it-silently *drops* a hunk. [`ARCHITECTURE.md`](../../ARCHITECTURE.md#the-render-algorithm)-has the design; this page is the "mind the gap" companion for editing it.5+**completeness**: every diff hunk (and every line within it, deletions included) appears6+at least once. It is the easiest thing in the codebase to break without noticing, because7+a regression here doesn't crash; it silently *drops* a line.8+[`ARCHITECTURE.md`](../../ARCHITECTURE.md#the-render-algorithm) has the design; this page9+is the "mind the gap" companion for editing it.10 11 ## The mental model12 -An anchor doesn't choose what to *hide*; it chooses what to show *early*, and marks-those lines covered so the backfill won't repeat them. Everything unmarked is emitted-at the end in natural order. So completeness is automatic *as long as marking and-backfill stay in agreement about what "covered" means.* That agreement is the whole-ballgame.13+Every anchor renders as a **diff** — the changed region with its `+`/`-` lines. (The one14+exception: code that isn't in the diff at all renders as plain *context*; see below.) An15+anchor doesn't choose what to *hide*; it chooses what to show *early*, and marks a hunk16+covered **only when it shows that hunk in full**. Everything not shown in full is re-emitted17+**whole** in the backfill, in natural order. So completeness is automatic: the backfill18+always re-shows the entire hunk, so no line can go missing.19 -Coverage is tracked at two granularities (`Coverage` in `src/diff.rs`):20+That last point is the whole ballgame, and it's deliberately *blunt*. We do **not** split a21+hunk to emit only the part an anchor didn't show. A hunk an anchor merely clipped is22+repeated whole — even though that duplicates the clipped lines. Repetition is the price of a23+guarantee that can't leak.24 -- **Per hunk.** `Coverage` keys on `(file_index, hunk_index)` — never on a file. A-  file annotated in part leaves its other hunks for the backfill.-- **Per line within a hunk**, for a ranged `!new` anchor. The covered new-side-  intervals are stored per hunk so a narrow anchor over a big hunk doesn't suppress-  the rest.25+Coverage is tracked **per hunk** (`Coverage` in `src/diff.rs`): a `HashSet` of26+`(file_index, hunk_index)` pairs shown in full. Never per file — a file annotated in part27+leaves its other hunks for the backfill.28 -A hunk is marked one of two ways, by anchor *side* (`resolve_blob` / `resolve_diff`-in `src/render.rs`):29+A hunk gets marked full in exactly two cases (`resolve_anchor` in `src/render.rs`):30 -| Anchor | Call | Effect |-|--------|------|--------|-| `!diff` (whole hunk) | `Coverage::mark_full` | the hunk is shown whole — `+`, `-`, and context; backfill skips it |-| whole-file `!new` (no range) | `Coverage::mark_full` | ⚠️ marks the hunk whole, but the callout shows only the **new side**, so its **deleted** lines are dropped — a known completeness hole ([ROADMAP](../../ROADMAP.md)) |-| ranged `!new` | `Coverage::mark_new_range` | only that new-side interval is covered; the remainder is backfilled |-| `!old` | *nothing* | old-side context only; the real diff still backfills in full |31+| Anchor | Shows | Marks full? |32+|--------|-------|-------------|33+| whole-file (no range) | every hunk of the file, as a diff | yes — every hunk |34+| ranged, spanning a hunk's whole new side | the hunk, as a diff | yes — that hunk (`Hunk::contained_in_new_range`) |35+| ranged, narrower than the hunk | a **clip** of the range (`Hunk::clip_to_new_range`) | **no** — the whole hunk backfills |36+| path/range **not in the diff** | the file's lines as plain context (`context = true`) | no — marks nothing |37+38+## The clip39+40+A ranged anchor inside a larger hunk shows just `Hunk::clip_to_new_range(start, end)` — the41+contiguous run of lines whose new-side position is in `[start, end]`, renumbered to its real42+offset. A `-` line has no new-side number, so it travels with the new-side position it sits43+*before* (clamped to the last new line when it trails the hunk), so deletions render inside44+the clip too. The clip is *display only*: it never affects the backfill, which always works45+from the original hunk.46 47 ## The backfill48 -The loop in `render::render` (`src/render.rs`, the step after the body) visits every-`(file, hunk)` in natural order and, per hunk:--1. `Coverage::is_full` → skip (already shown whole).-2. untouched → emit the hunk verbatim as `Section::Backfill`.-3. partially covered → `Hunk::uncovered_subhunks(covered)` splits it at the anchor-   boundary and emits each uncovered run as a renumbered sub-hunk.--Then, after a file's hunks, it emits the file's `DeltaMarker` if set — see below.--## The two gotchas that will bite you--1. **Deletions have no new-side line number.** Coverage and splitting reason in-   new-side coordinates, but a `-` line has no new-side number to compare. The rule-   (`Hunk::uncovered_subhunks`): **a deletion travels with the run it borders**, so it-   is never orphaned and never dropped. If you rewrite the splitter, this is the case-   the tests check hardest — a `-` line must land in exactly one emitted run.-2. **Some facts have no hunk to mark or backfill.** A binary file, a mode-only-   change, or an empty-file add/delete carries no text hunk at all; a mode change-   *beside* content has hunks for the edit but none for the bit it flips. The per-hunk-   machinery never sees these, so completeness for them holds at the *delta* level-   instead: `FileDiff::marker` carries a `DeltaMarker` and the backfill emits a-   one-line marker row for it, after the file's hunks. The marker is gated on the-   delta being hunk-less *only* for binary/empty; a `Mode` marker is attached whether-   or not hunks accompany it. If you add a new delta-level fact a hunk can't carry, it-   must get a marker or it vanishes from the page.-3. **A whole-file `!new` anchor currently drops deletions.** `mark_full` assumes the-   hunk was shown whole, but a `!new` callout shows only the new side — so a modified-   file's `-` lines reach neither the callout nor the backfill. The guards miss it-   because they only assert `+` lines appear. Tracked in-   [`ROADMAP.md`](../../ROADMAP.md); when you fix it, add the `-`-line assertion to-   `tests/completeness.rs`.49+The loop in `render::render` (`src/render.rs`, after the body) visits every `(file, hunk)`50+in natural order and, per hunk: skip it if `Coverage::is_full`, else emit it **whole** as a51+`Section::Backfill`. That's it — no splitting, no intervals. Then, after a file's hunks, it52+emits the file's `DeltaMarker` if set (see gotcha 3).53+54+## The gotchas that will bite you55+56+1. **Repetition is the mechanism, not a wart.** It is tempting to "optimize" the backfill to57+   emit only the lines a clip didn't already show. Don't. Splitting out the remainder is58+   exactly what this redesign *deleted* (it carried a deletion-drop bug: a clip showing only59+   the new side could strand a `-` line so it appeared nowhere). Whole-hunk repeat is what60+   makes the guarantee airtight. If duplication ever needs trimming, do it without61+   reintroducing per-line coverage — and re-run every guard below.62+2. **Deletions live in the diff, so they can't be dropped — keep it that way.** Both the63+   callout (a clip or a whole-hunk diff) and the backfill carry `-` lines. There is no64+   "new-side-only" rendering of a *changed* hunk anymore. If you add a code path that shows a65+   changed hunk without its `-` lines, you have re-opened the original hole.66+3. **Some facts have no hunk at all.** A binary file, a mode-only change, or an empty-file67+   add/delete carries no text hunk; a mode change *beside* content has hunks for the edit but68+   none for the bit it flips. The per-hunk machinery never sees these, so completeness for69+   them holds at the *delta* level: `FileDiff::marker` carries a `DeltaMarker` and the70+   backfill emits a one-line marker row after the file's hunks. The `Mode` marker rides71+   alongside content hunks; binary/empty markers are the delta's sole representation. Add a72+   new delta-level fact a hunk can't carry, and it must get a marker or it vanishes.73+4. **Out-of-diff context marks nothing.** An anchor pointing at code the commit didn't touch74+   (a whole unchanged file, or an unchanged range) has no hunk to mark; it reads the blob at75+   the commit, renders plain lines with `context = true` (the emitter labels it76+   "unmodified"), and the real diff still backfills in full.77 78 ## Before you change coverage — run the guards79 -These integration tests *are* the completeness invariant. Run all four after any-change to `Coverage`, `uncovered_subhunks`, the marking logic, or the backfill loop:80+These integration tests *are* the completeness invariant. Run all of them after any change81+to `Coverage`, `Hunk::clip_to_new_range` / `contained_in_new_range`, the marking logic, or82+the backfill loop:83 84 | Test file | What it pins down |85 |-----------|-------------------|-| `tests/completeness.rs` | `every_hunk_appears_at_least_once`; `coverage_is_per_hunk_and_backfill_collects_the_rest`. |-| `tests/subhunk.rs` | ranged `!new` splits its hunk and backfills the remainder; **every added line survives**; `!diff`/untouched anchors are *not* split. |86+| `tests/completeness.rs` | every hunk appears, and every added **and deleted** line appears — in a diff callout or the backfill. |87+| `tests/ranged_anchor.rs` | a ranged anchor shows a clip; a partial range repeats the **whole** hunk in the backfill; a full-span range is not repeated. |88+| `tests/context_anchor.rs` | code referenced outside the diff renders as plain "unmodified" context and marks nothing. |89 | `tests/hunkless.rs` | every hunk-less delta keeps a marker and appears in the document. |90 | `tests/mode_change.rs` | a mode change *beside* content hunks still surfaces its marker; a content-only delta gets none. |91 92 ```sh-cargo test --test completeness --test subhunk --test hunkless --test mode_change93+cargo test --test completeness --test ranged_anchor --test context_anchor \94+           --test hunkless --test mode_change95 ```96 -If you are adding a new way for an anchor to mark coverage, add a fixture that proves-the *unmarked remainder still appears* — that is the property that protects the-guarantee, and it is exactly the property a subtle bug here removes.97+If you are adding a new way for an anchor to mark coverage, add a fixture that proves the98+*unmarked remainder still appears whole* — that is the property that protects the guarantee,99+and it is exactly the property a subtle bug here removes.
docs/wiki/field-notes/git-lowering.md
@@ -102,10 +102,11 @@102   (`=` / `>` / `<`) are skipped rather than treated as code (the line loop in103   `diff_against_parent`, `src/git.rs`). Downstream code never has to think about104   EOF markers.-- **`blob_lines` reads whole-file content by side, not the diff.**-  `src/git.rs:139` reads the new blob for `New`/`Diff` and the parent blob for-  `Old`, splitting on `lines()`. A root commit has no parent, so an `!old` anchor-  there degrades. Because a `!new` slice comes from the new blob, it can never-  contain a deleted line — deletions exist only inside hunks (this is why a `!new`-  anchor marks coverage but a deletion is reasoned about separately; see-  [coverage-and-backfill](coverage-and-backfill.md#the-two-gotchas-that-will-bite-you)).105+- **`blob_lines` reads whole-file content at the commit, not the diff.**106+  `Repo::blob_lines` (`src/git.rs`) reads the file from the commit's own tree and107+  splits on `lines()`. It is used only to show code that is *not* part of the diff108+  — a whole unchanged file, or an unchanged range — as plain "unmodified" context;109+  everything that changed comes from the lowered hunks instead. (There is no longer110+  an old/new/diff *side*: anchors always render as a diff, and deletions live inside111+  the hunks, so they are never reasoned about separately — see112+  [coverage-and-backfill](coverage-and-backfill.md#the-mental-model).)
docs/wiki/field-notes/html-output.md
@@ -0,0 +1,68 @@1+# Field note: the HTML output2+3+How `src/html.rs` shapes the rendered page, and the two gotchas that will mislead you4+the moment you `grep` or split that page — whether you're writing an assertion in a5+test or eyeballing a render during verification.6+7+## Shape of the page8+9+`to_html` (`src/html.rs:102`) emits `<h1>` then `document_body`; `document_body`10+(`src/html.rs:163`) walks the `Document`'s sections in order, then renders the backfill11+region (collected hunks and markers) after them. `page` (`src/html.rs:193`) wraps the12+body in the standalone chrome with the **inline** stylesheet (`STYLESHEET`,13+`src/html.rs:26`). The branch view, `to_html_branch` (`src/html.rs:113`), composes14+several documents through the same `document_body`, so a chapter is a page minus its15+`<h1>`.16+17+Callouts come in four shapes, all from `code_html` (`src/html.rs:236`) and18+`backfill_html` (`src/html.rs:277`):19+20+- **diff callout** — `<figure class="callout">`, lines colored `ins`/`del` with a21+  `@@` header line (`meta`).22+- **context callout** — code shown outside the diff: the figcaption carries a23+  `<span class="tag">unmodified</span>` (`cb.context`, `src/html.rs:236`).24+- **degraded** — `<div class="callout degraded">` with a warning, no code.25+- **marker** — `<figure class="callout marker">`, a one-line `DeltaMarker` row.26+27+The "Remaining changes" backfill heading (`<h2 class="backfill-heading">`) is emitted28+**only when there is authored code above it** (`src/html.rs:184`); a plain commit with29+no anchors is just its full diff with no heading.30+31+## Gotcha 1: code lines are packed onto one physical line32+33+`line_html` (`src/html.rs:319`) emits each code line as a single34+`<span class="line …">…</span>` with **no trailing newline**, and `code_html` /35+`backfill_html` concatenate them. So an entire callout — every `+`/`-`/context line —36+is one physical line of HTML.37+38+Consequence: `grep -c 'class="line del"'` counts **callouts that contain a deletion**,39+not deletions. It will wildly undercount. To count occurrences, count substrings:40+41+```sh42+grep -o 'class="line del"' page.html | wc -l   # actual deletions43+```44+45+## Gotcha 2: class names recur in the inline CSS46+47+The stylesheet is inlined in `<head>` and names the very classes you'd grep for —48+`backfill-heading`, `callout`, `tag`, `line del`, … (`src/html.rs:26`). So splitting the49+page into "authored vs. backfill" on the bare string `backfill-heading` lands on the CSS50+rule `h2.backfill-heading { … }` (`src/html.rs:33`) in the `<head>`, **before any51+callout** — making the authored region look empty. Split on the element instead:52+53+```sh54+# everything before this is authored; everything after is the backfill region55+csplit page.html '/<h2 class="backfill-heading">/'56+```57+58+…and remember the heading is absent when there's no authored content (Gotcha above), so59+handle the "no split point" case.60+61+## Doing it right: assert through the parsed model when you can62+63+The integration tests assert on the HTML with plain `page.contains("…")` for *presence*64+(robust to packing) and reach into the `Document` model for *structure* — see65+`tests/ranged_anchor.rs` (`html_renders_the_clip_and_the_repeated_whole_hunk`),66+`tests/context_anchor.rs` (the "unmodified" label), `tests/hunkless.rs`, and67+`tests/mode_change.rs`. Prefer asserting on `render::Document` sections for structure and68+on the page only for what's genuinely HTML-level.
src/message.rs
@@ -156,7 +156,7 @@156 src/auth/validator.rs:12-38 @20-24157 The extracted validator.158 -src/auth/middleware.rs !diff159+src/auth/middleware.rs160 The middleware now delegates.161 ";162         let m = parse(raw);@@ -185,7 +185,7 @@185 src/auth/validator.rs:12-38186 187 The middleware now just delegates.-src/auth/middleware.rs !diff188+src/auth/middleware.rs189 ";190         let m = parse(raw);191         // The first paragraph is the TL;DR; the rest of the intro leads into the
tests/context_anchor.rs
@@ -0,0 +1,139 @@1+//! Anchoring code that is *not* part of the diff. An anchor may point at a whole2+//! file the commit never touched, or at an unchanged range of a file it did, to3+//! give the reader context. Such code has no hunk, so it renders as plain4+//! lines, is flagged `context` (the emitter labels it "unmodified"), and marks5+//! nothing — the commit's real diff still appears in full via the backfill.6+7+use std::fs;8+use std::path::Path;9+10+use commit_throughline::git::Repo;11+use commit_throughline::html;12+use commit_throughline::render::{self, CodeBlock, Document, Section};13+use git2::{IndexAddOption, Repository, Signature};14+use tempfile::TempDir;15+16+fn numbered(prefix: &str, n: usize) -> String {17+    (1..=n).map(|i| format!("{prefix}{i:02}\n")).collect()18+}19+20+fn write(dir: &Path, name: &str, content: &str) {21+    fs::write(dir.join(name), content).unwrap();22+}23+24+struct Fixture {25+    _dir: TempDir,26+    repo: Repo,27+    rev: String,28+}29+30+fn build_fixture() -> Fixture {31+    let dir = TempDir::new().unwrap();32+    let git = Repository::init(dir.path()).unwrap();33+    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();34+35+    // --- v1 -------------------------------------------------------------36+    write(dir.path(), "changed.txt", &numbered("c", 20));37+    write(dir.path(), "stable.txt", &numbered("s", 5));38+    let mut index = git.index().unwrap();39+    index40+        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)41+        .unwrap();42+    index.write().unwrap();43+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();44+    let parent = git45+        .commit(Some("HEAD"), &sig, &sig, "seed", &tree, &[])46+        .unwrap();47+48+    // --- v2: change one line deep in changed.txt; stable.txt untouched --49+    let mut c: Vec<String> = numbered("c", 20).lines().map(str::to_string).collect();50+    c[9] = "CHANGED_10".to_string();51+    write(dir.path(), "changed.txt", &format!("{}\n", c.join("\n")));52+53+    let mut index = git.index().unwrap();54+    index.add_path(Path::new("changed.txt")).unwrap();55+    index.write().unwrap();56+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();57+    let parent_commit = git.find_commit(parent).unwrap();58+59+    // Reference a whole untouched file, and an unchanged range of the changed60+    // file (lines 1-3, well clear of the hunk at line 10) — both are context.61+    let message = "\62+Reference unchanged code for context63+64+Explaining a change with reference to code that did not change.65+66+stable.txt67+stable.txt is untouched by this commit; shown here only for context.68+69+changed.txt:1-370+The head of the changed file is unchanged; shown as plain context.71+";72+    let v2 = git73+        .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])74+        .unwrap();75+76+    Fixture {77+        repo: Repo::open(dir.path()).unwrap(),78+        rev: v2.to_string(),79+        _dir: dir,80+    }81+}82+83+fn code_block<'a>(doc: &'a Document, path: &str) -> Option<&'a CodeBlock> {84+    doc.sections.iter().find_map(|s| match s {85+        Section::Code(cb) if cb.path == path => Some(cb),86+        _ => None,87+    })88+}89+90+fn count_backfill(doc: &Document, path: &str) -> usize {91+    doc.sections92+        .iter()93+        .filter(|s| matches!(s, Section::Backfill { path: p, .. } if p == path))94+        .count()95+}96+97+#[test]98+fn a_file_outside_the_diff_renders_as_unmodified_context() {99+    let fx = build_fixture();100+    let doc = render::render(&fx.repo, &fx.rev).unwrap();101+102+    let cb = code_block(&doc, "stable.txt").expect("stable.txt callout");103+    assert!(!cb.degraded);104+    assert!(cb.context, "an out-of-diff file must be flagged as context");105+    // Its content is shown, plain (origin ' ', no diff markers).106+    let text: String = cb.lines.iter().map(|l| l.content.clone()).collect();107+    assert!(text.contains("s01") && text.contains("s05"));108+    assert!(cb.lines.iter().all(|l| l.origin == ' '));109+}110+111+#[test]112+fn an_unchanged_range_of_a_changed_file_is_context() {113+    let fx = build_fixture();114+    let doc = render::render(&fx.repo, &fx.rev).unwrap();115+116+    let cb = code_block(&doc, "changed.txt").expect("changed.txt callout");117+    assert!(cb.context, "an unchanged range overlaps no hunk -> context");118+    assert!(!cb.lines.iter().any(|l| l.origin == '+' || l.origin == '-'));119+}120+121+#[test]122+fn the_real_diff_still_backfills_in_full() {123+    // Neither context anchor marked a hunk, so the commit's actual change to124+    // changed.txt is preserved in the backfill.125+    let fx = build_fixture();126+    let doc = render::render(&fx.repo, &fx.rev).unwrap();127+    assert_eq!(count_backfill(&doc, "changed.txt"), 1);128+}129+130+#[test]131+fn the_page_labels_context_as_unmodified() {132+    let fx = build_fixture();133+    let doc = render::render(&fx.repo, &fx.rev).unwrap();134+    let page = html::to_html(&doc);135+    assert!(136+        page.contains("unmodified"),137+        "context callouts must be labeled"138+    );139+}
tests/grammar.rs
@@ -1,7 +1,7 @@1 //! Integration tests over the public parsing surface — the spec's worked2 //! example and the "every plain commit is valid" baseline.3 -use commit_throughline::anchor::{AnchorRef, Side};4+use commit_throughline::anchor::AnchorRef;5 use commit_throughline::message::{parse, Block};6 7 #[test]@@ -39,7 +39,10 @@39     assert_eq!(anchors.len(), 2);40     assert_eq!(anchors[0].path, "src/auth/validator.rs");41     assert_eq!(anchors[0].focus.expect("a focus range").start, 20);-    assert_eq!(anchors[1].side, Side::Diff);42+    // The trailing `!diff` is a legacy side token: the line is still an anchor43+    // for the whole file, and the selector is ignored.44+    assert_eq!(anchors[1].path, "src/auth/middleware.rs");45+    assert_eq!(anchors[1].range, None);46 }47 48 #[test]
tests/ranged_anchor.rs
@@ -0,0 +1,228 @@1+//! Ranged anchors and the whole-hunk-repeat rule. A ranged anchor shows a diff2+//! *clip* of the lines it names; if that did not cover the hunk in full, the3+//! **whole hunk is repeated** in the backfill — even though it duplicates the4+//! lines the clip already showed. This is how completeness holds at line5+//! granularity without sub-hunk splitting (the classic trigger is a brand-new6+//! file or a big rewrite, where the entire file is one hunk).7+//!8+//! The fixture adds a 12-line file and annotates only its middle, alongside a9+//! small rewrite whose range spans the whole hunk (so it is *not* repeated) and10+//! an untouched modification (backfilled verbatim).11+12+use std::fs;13+use std::path::Path;14+15+use commit_throughline::diff::Hunk;16+use commit_throughline::git::Repo;17+use commit_throughline::html;18+use commit_throughline::render::{self, Document, Section};19+use git2::{IndexAddOption, Repository, Signature};20+use tempfile::TempDir;21+22+fn numbered(prefix: &str, n: usize) -> String {23+    (1..=n).map(|i| format!("{prefix}{i:02}\n")).collect()24+}25+26+fn write(dir: &Path, name: &str, content: &str) {27+    fs::write(dir.join(name), content).unwrap();28+}29+30+struct Fixture {31+    _dir: TempDir,32+    repo: Repo,33+    rev: String,34+}35+36+fn build_fixture() -> Fixture {37+    let dir = TempDir::new().unwrap();38+    let git = Repository::init(dir.path()).unwrap();39+    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();40+41+    // --- v1: the parent -------------------------------------------------42+    write(dir.path(), "keep.txt", &numbered("k", 10));43+    write(dir.path(), "rewrite.txt", "r1\nr2\nr3\n");44+    let mut index = git.index().unwrap();45+    index46+        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)47+        .unwrap();48+    index.write().unwrap();49+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();50+    let parent = git51+        .commit(Some("HEAD"), &sig, &sig, "seed", &tree, &[])52+        .unwrap();53+54+    // --- v2: add big.txt (one 12-line hunk), rewrite rewrite.txt whole,55+    //         tweak one line of keep.txt --------------------------------56+    write(dir.path(), "big.txt", &numbered("b", 12));57+    write(dir.path(), "rewrite.txt", "R1\nR2\nR3\n");58+    let mut keep: Vec<String> = numbered("k", 10).lines().map(str::to_string).collect();59+    keep[4] = "KEEP_CHANGED".to_string();60+    write(dir.path(), "keep.txt", &format!("{}\n", keep.join("\n")));61+62+    let mut index = git.index().unwrap();63+    index64+        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)65+        .unwrap();66+    index.write().unwrap();67+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();68+    let parent_commit = git.find_commit(parent).unwrap();69+70+    // Show only the middle of big.txt (a partial range); rewrite.txt with a71+    // range that spans its whole hunk; leave keep.txt entirely to the backfill.72+    let message = "\73+Walk a new file in pieces74+75+A brand-new file shown only in part, plus a small rewrite shown whole.76+77+big.txt:5-878+The middle of the file; the whole hunk is repeated below in full.79+80+rewrite.txt:1-381+The rewrite, its range spanning the whole hunk — so it is not repeated.82+";83+    let v2 = git84+        .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])85+        .unwrap();86+87+    Fixture {88+        repo: Repo::open(dir.path()).unwrap(),89+        rev: v2.to_string(),90+        _dir: dir,91+    }92+}93+94+/// Concatenated content of every non-degraded code callout for `path`.95+fn code_text_for(doc: &Document, path: &str) -> String {96+    let mut text = String::new();97+    for section in &doc.sections {98+        if let Section::Code(cb) = section {99+            if cb.path == path && !cb.degraded {100+                for line in &cb.lines {101+                    text.push_str(&line.content);102+                    text.push('\n');103+                }104+            }105+        }106+    }107+    text108+}109+110+/// The backfilled hunks for `path`, in document order.111+fn backfill_hunks_for<'a>(doc: &'a Document, path: &str) -> Vec<&'a Hunk> {112+    doc.sections113+        .iter()114+        .filter_map(|s| match s {115+            Section::Backfill { path: p, hunk } if p == path => Some(hunk),116+            _ => None,117+        })118+        .collect()119+}120+121+fn has_code(doc: &Document, path: &str) -> bool {122+    doc.sections123+        .iter()124+        .any(|s| matches!(s, Section::Code(cb) if cb.path == path && !cb.degraded))125+}126+127+#[test]128+fn a_partial_range_shows_a_clip_and_repeats_the_whole_hunk() {129+    let fx = build_fixture();130+    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();131+    let doc = render::render(&fx.repo, &fx.rev).unwrap();132+133+    // big.txt is a brand-new file: a single hunk spanning all 12 new lines.134+    let big = diff.files.iter().find(|f| f.path == "big.txt").unwrap();135+    assert_eq!(big.hunks.len(), 1, "a new file is one hunk");136+    assert_eq!((big.hunks[0].new_start, big.hunks[0].new_lines), (1, 12));137+138+    // The callout shows only the annotated middle (lines 5..=8).139+    let shown = code_text_for(&doc, "big.txt");140+    for i in 5..=8 {141+        assert!(142+            shown.contains(&format!("b{i:02}")),143+            "callout missing b{i:02}"144+        );145+    }146+    assert!(!shown.contains("b04") && !shown.contains("b09"));147+148+    // Because the range did not span the whole hunk, the hunk is repeated in the149+    // backfill *whole* — one hunk, identical to the original, lines and all.150+    let subs = backfill_hunks_for(&doc, "big.txt");151+    assert_eq!(152+        subs.len(),153+        1,154+        "the whole hunk is repeated as a single backfill"155+    );156+    assert_eq!(subs[0], &big.hunks[0], "the repeat is byte-identical");157+}158+159+#[test]160+fn every_line_of_the_partial_file_survives() {161+    // The hard guarantee at line granularity: every line of the diff appears at162+    // least once across the callout and the backfill.163+    let fx = build_fixture();164+    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();165+    let doc = render::render(&fx.repo, &fx.rev).unwrap();166+167+    let big = diff.files.iter().find(|f| f.path == "big.txt").unwrap();168+    let mut corpus = code_text_for(&doc, "big.txt");169+    for hunk in backfill_hunks_for(&doc, "big.txt") {170+        for line in &hunk.lines {171+            corpus.push_str(&line.content);172+            corpus.push('\n');173+        }174+    }175+    for line in big.hunks.iter().flat_map(|h| &h.lines) {176+        assert!(177+            corpus.contains(&line.content),178+            "line `{}` vanished from big.txt",179+            line.content180+        );181+    }182+}183+184+#[test]185+fn a_full_span_range_is_not_repeated() {186+    let fx = build_fixture();187+    let doc = render::render(&fx.repo, &fx.rev).unwrap();188+189+    // `rewrite.txt:1-3` spans the whole hunk, so the hunk is marked full and is190+    // never backfilled — a range that covers a hunk entirely is not repeated.191+    assert!(has_code(&doc, "rewrite.txt"));192+    assert!(backfill_hunks_for(&doc, "rewrite.txt").is_empty());193+}194+195+#[test]196+fn an_untouched_hunk_is_backfilled_verbatim() {197+    let fx = build_fixture();198+    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();199+    let doc = render::render(&fx.repo, &fx.rev).unwrap();200+201+    // keep.txt has no anchor, so its hunk is backfilled byte-identical.202+    let keep = diff.files.iter().find(|f| f.path == "keep.txt").unwrap();203+    let backfill = backfill_hunks_for(&doc, "keep.txt");204+    assert_eq!(backfill.len(), keep.hunks.len());205+    assert_eq!(206+        backfill[0], &keep.hunks[0],207+        "untouched hunk must be identical"208+    );209+}210+211+#[test]212+fn html_renders_the_clip_and_the_repeated_whole_hunk() {213+    let fx = build_fixture();214+    let doc = render::render(&fx.repo, &fx.rev).unwrap();215+    let page = html::to_html(&doc);216+217+    // Every line of the partial file is present somewhere in the page.218+    for i in 1..=12 {219+        assert!(page.contains(&format!("b{i:02}")), "page missing b{i:02}");220+    }221+    // The clip emits its own `@@` header at the real offset; the repeated whole222+    // hunk emits the original header.223+    assert!(page.contains("@@ -0,0 +5,4 @@"), "missing the clip header");224+    assert!(225+        page.contains("@@ -0,0 +1,12 @@"),226+        "missing the whole-hunk header"227+    );228+}
tests/subhunk.rs
@@ -1,244 +0,0 @@-//! Sub-hunk splitting — a ranged `!new` anchor over a large hunk used to mark-//! the *whole* hunk shown, so the un-annotated remainder silently vanished from-//! the backfill (CLAUDE.md #1, at the *line* level). The classic trigger is a-//! brand-new file or a big rewrite, where the entire file is a single hunk.-//!-//! The fixture adds a 12-line file as one hunk and annotates only its middle-//! with `!new`, alongside a `!diff` rewrite (shown whole, never split) and an-//! untouched modification (backfilled verbatim). It then asserts the head and-//! tail of the split file reappear as backfilled sub-hunks, that every added-//! line survives, and that the non-split paths are unchanged.--use std::fs;-use std::path::Path;--use commit_throughline::diff::Hunk;-use commit_throughline::git::Repo;-use commit_throughline::html;-use commit_throughline::render::{self, Document, Section};-use git2::{IndexAddOption, Repository, Signature};-use tempfile::TempDir;--fn numbered(prefix: &str, n: usize) -> String {-    (1..=n).map(|i| format!("{prefix}{i:02}\n")).collect()-}--fn write(dir: &Path, name: &str, content: &str) {-    fs::write(dir.join(name), content).unwrap();-}--struct Fixture {-    _dir: TempDir,-    repo: Repo,-    rev: String,-}--fn build_fixture() -> Fixture {-    let dir = TempDir::new().unwrap();-    let git = Repository::init(dir.path()).unwrap();-    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();--    // --- v1: the parent --------------------------------------------------    write(dir.path(), "keep.txt", &numbered("k", 10));-    write(dir.path(), "rewrite.txt", "r1\nr2\nr3\n");-    let mut index = git.index().unwrap();-    index-        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)-        .unwrap();-    index.write().unwrap();-    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();-    let parent = git-        .commit(Some("HEAD"), &sig, &sig, "seed", &tree, &[])-        .unwrap();--    // --- v2: add big.txt (one 12-line hunk), rewrite rewrite.txt whole,-    //         tweak one line of keep.txt ---------------------------------    write(dir.path(), "big.txt", &numbered("b", 12));-    write(dir.path(), "rewrite.txt", "R1\nR2\nR3\n");-    let mut keep: Vec<String> = numbered("k", 10).lines().map(str::to_string).collect();-    keep[4] = "KEEP_CHANGED".to_string();-    write(dir.path(), "keep.txt", &format!("{}\n", keep.join("\n")));--    let mut index = git.index().unwrap();-    index-        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)-        .unwrap();-    index.write().unwrap();-    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();-    let parent_commit = git.find_commit(parent).unwrap();--    // Show only the middle of big.txt with `!new`; rewrite.txt whole with-    // `!diff`; leave keep.txt entirely to the backfill.-    let message = "\-Walk a new file in pieces--A brand-new file shown only in part, to exercise sub-hunk splitting.--big.txt:5-8 !new-The middle of the file; its head and tail must still be backfilled.--rewrite.txt:2 !diff-A small rewrite shown whole as a diff — never split.-";-    let v2 = git-        .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])-        .unwrap();--    Fixture {-        repo: Repo::open(dir.path()).unwrap(),-        rev: v2.to_string(),-        _dir: dir,-    }-}--/// Concatenated content of every non-degraded code callout for `path`.-fn code_text_for(doc: &Document, path: &str) -> String {-    let mut text = String::new();-    for section in &doc.sections {-        if let Section::Code(cb) = section {-            if cb.path == path && !cb.degraded {-                for line in &cb.lines {-                    text.push_str(&line.content);-                    text.push('\n');-                }-            }-        }-    }-    text-}--/// The backfilled hunks for `path`, in document order.-fn backfill_hunks_for<'a>(doc: &'a Document, path: &str) -> Vec<&'a Hunk> {-    doc.sections-        .iter()-        .filter_map(|s| match s {-            Section::Backfill { path: p, hunk } if p == path => Some(hunk),-            _ => None,-        })-        .collect()-}--fn has_code(doc: &Document, path: &str) -> bool {-    doc.sections-        .iter()-        .any(|s| matches!(s, Section::Code(cb) if cb.path == path && !cb.degraded))-}--#[test]-fn a_ranged_new_anchor_splits_its_hunk_and_backfills_the_remainder() {-    let fx = build_fixture();-    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();-    let doc = render::render(&fx.repo, &fx.rev).unwrap();--    // big.txt is a brand-new file: a single hunk spanning all 12 new lines.-    let big = diff.files.iter().find(|f| f.path == "big.txt").unwrap();-    assert_eq!(big.hunks.len(), 1, "a new file is one hunk");-    assert_eq!((big.hunks[0].new_start, big.hunks[0].new_lines), (1, 12));--    // The callout shows only the annotated middle (lines 5..=8).-    let shown = code_text_for(&doc, "big.txt");-    for i in 5..=8 {-        assert!(-            shown.contains(&format!("b{i:02}")),-            "callout missing b{i:02}"-        );-    }-    assert!(!shown.contains("b04") && !shown.contains("b09"));--    // The head and tail come back as two split sub-hunks, renumbered to their-    // real new-side positions — this is the behavior that used to be missing.-    let subs = backfill_hunks_for(&doc, "big.txt");-    assert_eq!(-        subs.len(),-        2,-        "head and tail are backfilled as two sub-hunks"-    );-    assert_eq!((subs[0].new_start, subs[0].new_lines), (1, 4));-    assert_eq!((subs[1].new_start, subs[1].new_lines), (9, 4));--    // The split sub-hunks carry exactly the un-annotated lines, none of the-    // annotated middle (no duplication across callout and backfill here).-    let backfilled: String = subs-        .iter()-        .flat_map(|h| &h.lines)-        .map(|l| l.content.clone())-        .collect::<Vec<_>>()-        .join("\n");-    for i in (1..=4).chain(9..=12) {-        assert!(-            backfilled.contains(&format!("b{i:02}")),-            "backfill missing b{i:02}"-        );-    }-    for i in 5..=8 {-        assert!(-            !backfilled.contains(&format!("b{i:02}")),-            "b{i:02} leaked into backfill"-        );-    }-}--#[test]-fn every_added_line_of_the_split_hunk_survives() {-    // The hard guarantee at line granularity: each added line of the diff-    // appears at least once across the callout and the backfilled sub-hunks.-    let fx = build_fixture();-    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();-    let doc = render::render(&fx.repo, &fx.rev).unwrap();--    let big = diff.files.iter().find(|f| f.path == "big.txt").unwrap();-    let mut corpus = code_text_for(&doc, "big.txt");-    for hunk in backfill_hunks_for(&doc, "big.txt") {-        for line in &hunk.lines {-            corpus.push_str(&line.content);-            corpus.push('\n');-        }-    }-    for line in big.hunks.iter().flat_map(|h| &h.lines) {-        if line.origin == '+' {-            assert!(-                corpus.contains(&line.content),-                "added line `{}` vanished from big.txt",-                line.content-            );-        }-    }-}--#[test]-fn diff_and_untouched_anchors_are_not_split() {-    let fx = build_fixture();-    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();-    let doc = render::render(&fx.repo, &fx.rev).unwrap();--    // `rewrite.txt:2 !diff` shows the whole hunk, so it is never backfilled —-    // a narrow range does not split a `!diff` callout.-    assert!(has_code(&doc, "rewrite.txt"));-    assert!(backfill_hunks_for(&doc, "rewrite.txt").is_empty());--    // keep.txt has no anchor, so its hunk is backfilled verbatim — the split-    // path must not perturb an untouched hunk.-    let keep = diff.files.iter().find(|f| f.path == "keep.txt").unwrap();-    let backfill = backfill_hunks_for(&doc, "keep.txt");-    assert_eq!(backfill.len(), keep.hunks.len());-    assert_eq!(-        backfill[0], &keep.hunks[0],-        "untouched hunk must be byte-identical"-    );-}--#[test]-fn html_renders_the_callout_and_both_split_sub_hunks() {-    let fx = build_fixture();-    let doc = render::render(&fx.repo, &fx.rev).unwrap();-    let page = html::to_html(&doc);--    // Every line of the split file is present somewhere in the page: the middle-    // via the callout, the head and tail via the two backfilled sub-hunks.-    for i in 1..=12 {-        assert!(page.contains(&format!("b{i:02}")), "page missing b{i:02}");-    }-    // The two split sub-hunks emit their own `@@` headers at the real offsets.-    assert!(page.contains("@@ -0,0 +1,4 @@"));-    assert!(page.contains("@@ -0,0 +9,4 @@"));-}
5/3736e5bdcEdu Ramírez

Add --open: render and open the page in one step

throughline render and branch already write a self-contained HTML file and print its path, but opening it was a manual second step. --open does it for you, shelling out to the platform opener — for both subcommands.

The flag is a plain boolean on each subcommand, long-form only since -o is already --output. Here it is on render, mirrored verbatim on branch:

src/cli.rs
@@ -27,0 +27,3 @@27+        /// After writing the page, open it with the platform's default handler.28+        #[arg(long)]29+        open: bool,

Once the page is written and its path printed, a new helper picks the opener by platform — open on macOS, xdg-open on Linux, cmd /C start on Windows — and runs it. It is best-effort: the render is the real deliverable, so a missing or failing opener warns to stderr and still exits 0 rather than failing the command. (Command is fully qualified because clap's Command enum is already imported here.)

src/main.rs
@@ -108,0 +118,28 @@118+/// Open a rendered page with the platform's default handler. Best-effort: the119+/// file is already written and its path printed, so a missing or failing opener120+/// warns but never fails the command. (`Command` here is fully qualified because121+/// the crate's clap `Command` enum is already in scope.)122+fn open_in_browser(path: &Path) {123+    let result = if cfg!(target_os = "macos") {124+        std::process::Command::new("open").arg(path).status()125+    } else if cfg!(target_os = "windows") {126+        // `start` is a cmd builtin; the empty "" is the (required) window title.127+        std::process::Command::new("cmd")128+            .args(["/C", "start", ""])129+            .arg(path)130+            .status()131+    } else {132+        std::process::Command::new("xdg-open").arg(path).status()133+    };134+    match result {135+        Ok(status) if status.success() => {}136+        Ok(status) => eprintln!(137+            "warning: opener exited with {status}; the page is at {}",138+            path.display()139+        ),140+        Err(e) => eprintln!(141+            "warning: couldn't launch the platform opener ({e}); the page is at {}",142+            path.display()143+        ),144+    }145+}

The binary is the one layer with side effects, and the integration suite links only the library — so main.rs has no test seam and --open is verified by running the binary, not by cargo test. A new wiki field note records that boundary, the best-effort opener, and the empty-PATH trick for hitting the warning branch without launching a browser; the README usage, the branch how-to, and the ROADMAP entry are updated alongside (and a stale v0.3 spec caption corrected to v0.4).

Remaining changes

README.md
@@ -86,11 +86,14 @@86 ## Usage87 88 ```sh-throughline render <commit> [-o out.html]89+throughline render <commit> [-o out.html] [--open]90 ```91 92 - `<commit>` is any revision: `HEAD`, a SHA, a tag, `HEAD~2`, …93 - Omit `-o` and it writes `throughline-<short-sha>.html` in the current directory.94+- `--open` opens the page in your default browser after writing it (`open` on macOS,95+  `xdg-open` on Linux, `start` on Windows). It is best-effort: if no opener is found96+  the path is still printed and the command succeeds.97 - `--debug` prints the parsed title / TL;DR / intro / anchors instead of HTML — handy98   for checking how a message parses.99 @@ -101,22 +104,23 @@104 with a contents list and previous/next links:105 106 ```sh-throughline branch <tip> [--base <ref>] [-o out.html]   # range is base..tip; default base: main-throughline branch <base>..<tip> [-o out.html]          # explicit range107+throughline branch <tip> [--base <ref>] [-o out.html] [--open]   # range is base..tip; default base: main108+throughline branch <base>..<tip> [-o out.html] [--open]          # explicit range109 ```110 111 - `throughline branch my-feature` renders every commit in `main..my-feature`.112 - `throughline branch v1.0..v1.1` renders an explicit range.113 - Omit `-o` and it writes `throughline-branch-<tip-sha>.html`.114+- `--open` works here too — render the branch page and open it in one step.115 - Completeness still holds **per commit** — each chapter diffs against its own116   parent; the page composes the commits in order rather than squashing them.117 118 The output is a single standalone HTML file (inline CSS, no JavaScript, no external-assets), so you can open it directly:119+assets), so you can open it directly — `--open` does exactly that for you:120 121 ```sh-throughline render HEAD -o /tmp/commit.html && open /tmp/commit.html   # macOS-# Linux: xdg-open /tmp/commit.html122+throughline render HEAD --open                          # render, then open it123+throughline render HEAD -o /tmp/commit.html && open /tmp/commit.html   # or do it by hand (macOS)124 ```125 126 ## Using it in any project (no Rust required)@@ -129,7 +133,7 @@133 134 ```sh135 git config --global alias.tl \-  '!f(){ throughline render "${1:-HEAD}" -o /tmp/tl.html && open /tmp/tl.html; }; f'136+  '!f(){ throughline render "${1:-HEAD}" --open; }; f'137 # then, in any repo:138 git tl            # HEAD139 git tl <sha>      # a specific commit@@ -189,7 +193,7 @@193   render.rs        resolve anchors -> ordered document model194   html.rs          emit a self-contained HTML page195 docs/-  SPEC.md             canonical format specification (v0.3)196+  SPEC.md             canonical format specification (v0.4)197   ARCHITECTURE.md     implementation design and module map198   ROADMAP.md          shipped work and the prioritized plan ahead199   llm-commit-guide.md instruction for LLM commit authors
docs/ROADMAP.md
@@ -163,9 +163,12 @@163   ([`docs/wiki/automation/docs-sync-hook.md`](wiki/automation/docs-sync-hook.md)).164 - [ ] **Golden/snapshot tests** for the HTML output (e.g. `insta`) so rendering165   changes are reviewable as a diff.-- [ ] **`--open` flag** — render and open the page in one step, for **both**-  `render` and `branch`. Shell out to the platform opener (`open` on macOS,-  `xdg-open` on Linux, `start` on Windows). *(Planned for a dedicated session.)*166+- [x] **`--open` flag** — render and open the page in one step, for **both**167+  `render` and `branch`. Shells out to the platform opener (`open` on macOS,168+  `xdg-open` on Linux, `start` on Windows). Best-effort: the page is written and its169+  path printed first, so a missing or failing opener warns to stderr but still exits170+  0 — `--open` stays safe in scripts and on headless boxes. *(`cli.rs` flag +171+  `main.rs` `open_in_browser` helper.)*172 - [x] **Pre-commit hook** running `fmt` / `clippy -D warnings` / `test` locally before173   every commit — the local half of the CI checks, with a non-Rust fast path and a174   `--no-verify` bypass
docs/wiki/README.md
@@ -41,6 +41,9 @@41   - [`field-notes/html-output.md`](field-notes/html-output.md)42     — how `html.rs` shapes the page, and the two gotchas (line-packing, CSS class43     names) that mislead you when you grep or split rendered output.44+  - [`field-notes/cli-binary-layer.md`](field-notes/cli-binary-layer.md)45+    — the binary (`main.rs`): where I/O lives, the best-effort `--open` opener, and46+    why `cargo test` can't reach the CLI (so how to verify it instead).47 - **How-to** — acting on this codebase.48   - [`how-to/render-a-branch.md`](how-to/render-a-branch.md) — render a range of49     commits as one navigable page (`throughline branch`); the input forms and the
docs/wiki/field-notes/cli-binary-layer.md
@@ -0,0 +1,54 @@1+# Field note: the binary layer (`main.rs`)2+3+Where the side effects live, and why `cargo test` doesn't reach them. Read this4+before adding or changing CLI behavior — output paths, flags, the `--open` opener —5+so you know what is testable and how to verify the rest.6+7+## The library returns data; the binary does I/O8+9+`commit_throughline` (the library: `render`, `html`, `git`, …) is effectively pure10+at its surface — it resolves a commit into a `Document` and lowers that into an HTML11+*string*. It writes nothing. Every side effect lives in the binary, `src/main.rs`:12+discover the repo, call the library, **write** the file, **print** the path, and —13+with `--open` — **spawn** the platform opener. `run_render` / `run_branch` are the14+two top-level flows; both end with the same three steps (write → print → maybe open).15+16+## `--open` is best-effort, by design17+18+`open_in_browser` (`src/main.rs`) shells out to the platform opener — `open` on19+macOS, `xdg-open` on Linux, `cmd /C start "" <path>` on Windows — selected with20+`cfg!(target_os = …)` (one function, every branch type-checks on every platform,21+only the matched branch runs). It is called *after* the page is written and its path22+printed, so the deliverable is already on disk. If the opener is missing or exits23+non-zero, it warns to stderr and the command still exits 0 — the render is the24+product; a viewer that won't launch must not fail it. This is the same25+"degrade, don't fail" stance the renderer takes with a bad anchor. Tracked under26+the `--open` flag in [`ROADMAP.md`](../../ROADMAP.md).27+28+**Gotcha:** `main.rs` already imports clap's `Command` (the subcommand enum), so29+`std::process::Command` is referred to **fully qualified** — a bare30+`use std::process::Command` would collide. Keep the qualification (or alias) if you31+move the helper.32+33+## The integration tests do not reach the binary34+35+`tests/` link the **library** and call its functions directly (`render::render`,36+`html::to_html`, …) over fixture repos built with `tempfile` + `git2`. They never37+spawn the `throughline` binary. So `run_render` / `run_branch` / `open_in_browser`38+are private to `main.rs` and **uncovered** by `cargo test`: output-path naming, the39+`--open` opener, and `--debug` are binary-layer behavior you verify by **running the40+binary**, not by adding a test. (This is the practical edge of ARCHITECTURE's "the41+I/O and rendering modules wrap the pure ones" — the outermost I/O wrapper, `main`,42+has no test seam at all.)43+44+Recipe — exercise the best-effort failure branch *without* launching a browser: run45+the built binary by path with an emptied `PATH`, so the process execs fine but its46+child opener lookup fails:47+48+```sh49+PATH="" target/debug/throughline render HEAD -o /tmp/x.html --open50+# prints "Rendered …", then "warning: couldn't launch the platform opener …", exits 051+```52+53+The happy path is a plain `target/debug/throughline render HEAD --open` (it really54+opens a browser tab, so do it sparingly).
docs/wiki/how-to/render-a-branch.md
@@ -20,7 +20,9 @@20 21 Both resolve to the same thing internally: the commits in `base..tip` — reachable22 from the tip but not the base — so the **base commit itself is excluded**. Output-goes to `throughline-branch-<tip-sha>.html` unless you pass `-o`.23+goes to `throughline-branch-<tip-sha>.html` unless you pass `-o`. Add `--open` to24+open the page in your default browser once it is written (best-effort — same flag as25+`render`).26 27 ## What you get28 
src/cli.rs
@@ -24,6 +24,10 @@24         #[arg(short, long)]25         output: Option<PathBuf>,26 27+        /// After writing the page, open it with the platform's default handler.28+        #[arg(long)]29+        open: bool,30+31         /// Print the parsed message structure instead of rendering HTML.32         #[arg(long)]33         debug: bool,@@ -46,5 +50,9 @@50         /// Write the HTML here instead of `throughline-branch-<sha>.html`.51         #[arg(short, long)]52         output: Option<PathBuf>,53+54+        /// After writing the page, open it with the platform's default handler.55+        #[arg(long)]56+        open: bool,57     },58 }
src/main.rs
@@ -13,23 +13,25 @@13         Command::Render {14             commit,15             output,16+            open,17             debug,18         } => {19             if debug {20                 debug_message(&commit)21             } else {-                run_render(&commit, output.as_deref())22+                run_render(&commit, output.as_deref(), open)23             }24         }25         Command::Branch {26             branch,27             base,28             output,-        } => run_branch(&branch, &base, output.as_deref()),29+            open,30+        } => run_branch(&branch, &base, output.as_deref(), open),31     }32 }33 -fn run_render(commit: &str, output: Option<&Path>) -> Result<()> {34+fn run_render(commit: &str, output: Option<&Path>, open: bool) -> Result<()> {35     let repo =36         git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;37 @@ -51,11 +53,15 @@53         doc.sections.len(),54         path.display()55     );56+57+    if open {58+        open_in_browser(&path);59+    }60     Ok(())61 }62 63 /// Render a range of commits as one navigable page (the `branch` subcommand).-fn run_branch(branch: &str, base: &str, output: Option<&Path>) -> Result<()> {64+fn run_branch(branch: &str, base: &str, output: Option<&Path>, open: bool) -> Result<()> {65     let repo =66         git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;67 @@ -102,9 +108,42 @@108         title,109         path.display()110     );111+112+    if open {113+        open_in_browser(&path);114+    }115     Ok(())116 }117 118+/// Open a rendered page with the platform's default handler. Best-effort: the119+/// file is already written and its path printed, so a missing or failing opener120+/// warns but never fails the command. (`Command` here is fully qualified because121+/// the crate's clap `Command` enum is already in scope.)122+fn open_in_browser(path: &Path) {123+    let result = if cfg!(target_os = "macos") {124+        std::process::Command::new("open").arg(path).status()125+    } else if cfg!(target_os = "windows") {126+        // `start` is a cmd builtin; the empty "" is the (required) window title.127+        std::process::Command::new("cmd")128+            .args(["/C", "start", ""])129+            .arg(path)130+            .status()131+    } else {132+        std::process::Command::new("xdg-open").arg(path).status()133+    };134+    match result {135+        Ok(status) if status.success() => {}136+        Ok(status) => eprintln!(137+            "warning: opener exited with {status}; the page is at {}",138+            path.display()139+        ),140+        Err(e) => eprintln!(141+            "warning: couldn't launch the platform opener ({e}); the page is at {}",142+            path.display()143+        ),144+    }145+}146+147 /// `--debug`: print what the message parser understands, without rendering.148 fn debug_message(commit: &str) -> Result<()> {149     let repo =
6/378c77753Edu Ramírez

Rewrite the README as a newcomer's on-ramp

Recasts the README around the core pitch — a commit's message and its diff are two halves that don't talk to each other, and Throughline unifies them — then walks a newcomer from install through the branch, worktree, and agentic-coding workflows. Adds a rendered hero image and links to a live Codeberg Pages demo built from this repo's own history.

The rewrite opens with the problem and the lightweight convention, frames the tool and the authoring guide as the two things this repo ships, resequences the old reference material (install, wiring, project layout) beneath the getting-started path, and drops the legacy !diff modifier from the worked example since the spec retired it. The hero image lands as a binary asset under docs/assets/, so it surfaces as an added-file marker in the backfill.

README.md
@@ -1,72 +1,131 @@1 # Commit Throughline2 -> A commit message is the *throughline* of a change. Render it as a complete,-> annotated diff walkthrough — a self-contained HTML page that weaves the-> author's prose through the real code of the commit, while never dropping a-> single hunk of the diff.3+**A commit is two things that barely talk to each other: a message that explains4+*why*, and a diff that shows *what*. Commit Throughline weaves them into one5+page — your prose leading into the real code it describes — out of nothing more6+than an ordinary Markdown commit message.**7 -**Status:** working proof-of-concept. The render pipeline is implemented —-`throughline render <commit>` produces a real, self-contained HTML page — and the-completeness invariant is covered by tests. See [`docs/SPEC.md`](docs/SPEC.md) for-the canonical specification, [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the-implementation design, and [`docs/ROADMAP.md`](docs/ROADMAP.md) for what's next.8+[![Live demo](https://img.shields.io/badge/live%20demo-codeberg%20pages-2185D0)](https://eduramirezh.codeberg.page/commit-throughline/)9+&nbsp;·&nbsp; [Spec](docs/SPEC.md) ·10+[Architecture](docs/ARCHITECTURE.md) ·11+[Roadmap](docs/ROADMAP.md)12 -## The idea13+![A commit from this repository, rendered as a Commit Throughline walkthrough: a TL;DR lede, then the author's prose leading into each real diff hunk.](docs/assets/example-walkthrough.png)14 -A normal diff viewer (GitHub, Forgejo) shows every change in mechanical order-with no explanation. Commit Throughline is a thin layer over that view:15+> One commit from this repo, rendered. The author's words lead *into* each diff,16+> in the order they chose — and every hunk still lands on the page.17+> **[▶ Open the live demo](https://eduramirezh.codeberg.page/commit-throughline/)**18+> to click through real examples (each one generated from this project's own19+> commits).20 -- **With no annotations**, a commit renders as exactly the complete diff you'd-  see today — nothing added, nothing hidden.-- **With annotations**, the author controls the *order*, attaches *explanations*,-  and pulls in *extra code for context* — while the renderer still guarantees-  that every hunk of the diff appears at least once.21+## Two halves of every commit22 -You write an ordinary Markdown commit message. Wherever you put a file reference-on its own line (an *anchor*), the renderer extracts the real code from Git and-shows it there, led in by the prose you wrote just above it. Everything you don't-explicitly place is appended afterward in natural diff order. The page is always a-faithful, complete view of the change.23+Open any commit — or any pull request — and you get two panes that never quite24+meet:25 -### Example26+- the **message**: the author's intent, in prose, up top;27+- the **diff**: every changed line, in mechanical order (alphabetical by file,28+  top to bottom), below.29 -A commit message like this:30+The message says *"extract the validator and make the middleware delegate."* The31+diff is four hundred lines across six files in an order nobody chose. The mapping32+between the two — *which* lines are the extraction, *which* are the delegation,33+*which* are just fallout — lives only in the reader's head. They scroll up to34+re-read the message, scroll down to find the code, and rebuild that mapping by35+hand. Every reviewer does it again. So does anyone running `git blame` two years36+later.37+38+The prose and the code are describing the *same change*, but the format keeps39+them apart and makes you the integration layer.40+41+## One page, in your order42+43+Commit Throughline unifies the two halves with a convention so light you may44+already write it by accident: **put a file reference on its own line where you'd45+point at the code.** That line is an *anchor*. The renderer pulls the real diff46+for it straight from Git and drops it in, right under the prose that leads into47+it.48+49+A message like this:50 51     Refactor the authentication pipeline52 -    Splits token validation out of the session-handling middleware so the-    two responsibilities can change independently.53+    Splits token validation out of the session-handling middleware so the two54+    responsibilities can change independently.55 56     Here is the validator I extracted; the highlighted lines are the expiry57     check that used to live inline in the middleware.58     src/auth/validator.rs:12-38 @20-2459 60     With validation gone, the middleware just delegates. Here's the change.-    src/auth/middleware.rs !diff--renders to a page with: the title as a heading; the first paragraph as a distinct-**TL;DR**; then, for each step, the prose that leads into it followed by the code —-a syntax-highlighted callout of `validator.rs` lines 12–38 (20–24 emphasized), then-the diff of `middleware.rs`; then the **backfill** — every remaining hunk of the-commit, in natural order. No hunk is ever missing.--## Guiding principles--1. **Every commit is valid.** No special syntax to opt in. A plain message-   renders as the complete diff; an annotated one renders as a walkthrough.-2. **Completeness is the hard guarantee.** Every diff hunk appears at least once.-   Annotations reorder and explain — they never restrict what is shown.-3. **Don't duplicate code in prose.** Anchors reference coordinates; the renderer-   pulls the real content from Git, so the narrative can't drift out of sync.-4. **Order is the order.** Authored content appears in message order; unplaced-   hunks follow in natural diff order.-5. **Degrade, don't fail.** An unresolvable anchor emits its prose plus a visible-   warning, and rendering carries on.--## Installing--Building needs Rust (once); **using** the tool does not — `throughline` is a-self-contained binary that operates on any Git repository.61+    src/auth/middleware.rs62+63+renders to a self-contained HTML page: the subject as a heading; the first64+paragraph as a distinct **TL;DR** lede; then, for each step, the prose that leads65+*into* it followed by the real code — a diff of `validator.rs` lines 12–3866+(20–24 emphasized), then the whole diff of `middleware.rs`. After the authored67+walkthrough comes the **backfill**: every remaining hunk of the commit — the68+updated tests, the rest of `validator.rs` — in natural order. Nothing you didn't69+mention is hidden; it is simply appended.70+71+Two properties make this safe to adopt:72+73+- **It's just Markdown.** No fenced blocks, no front matter, no opt-in syntax. An74+  anchor is recognized purely by *looking like a path on its own line*. A commit75+  message with no anchors renders as exactly the complete diff you'd see today —76+  so **every commit you've ever written is already valid**.77+- **You never lose a line.** The renderer guarantees **completeness**: every hunk78+  of the diff appears at least once, whether or not you anchored it. Annotating79+  only ever changes *order* and adds *explanation* — it can't drop, hide, or80+  misrepresent code, because the code is pulled live from Git, not copied into81+  your prose.82+83+## A new way to write changes84+85+Today the diff's order is an accident of the filesystem, and the commit message86+is a caption written *after* the fact. Once a renderer can splice real code into87+your narrative, neither has to stay that way. You can **orient the change for a88+reader**: open with the load-bearing edit instead of whatever sorts first, lead89+into each hunk with the one sentence that makes it make sense, and pull in an90+untouched function nearby when it's the context someone needs. The commit stops91+being a record you file and becomes a walkthrough you author — and because the92+guarantee is completeness, authoring is pure upside, never a way to gloss over93+what changed.94+95+That's why this repo ships **two** things, not one:96+97+| | |98+|---|---|99+| **A tool** — the `throughline` CLI | Renders any commit (or a whole branch) into a standalone HTML walkthrough. |100+| **A convention** — the authoring guide | A short, drop-in prompt/checklist for writing commits that render well — for humans, and for [coding agents](#agentic-coding) (see [`docs/llm-commit-guide.md`](docs/llm-commit-guide.md)). |101+102+The tool is useless prose-policing without messages worth rendering; the103+convention is just style advice without a renderer to reward it. Together they104+turn "write a commit message" into "explain the change."105+106+## See it in action107+108+The whole [live demo](https://eduramirezh.codeberg.page/commit-throughline/) is109+generated by `throughline` from this repository's own commit history (we110+[dogfood the format](docs/llm-commit-guide.md)). A few good entry points:111+112+- **[A single commit](https://eduramirezh.codeberg.page/commit-throughline/commit.html)** —113+  the walkthrough pictured above: prose woven through two diffs, then the backfill.114+- **[A commit with deletions](https://eduramirezh.codeberg.page/commit-throughline/commit-with-deletions.html)** —115+  a refactor that removes code, so you can see `+`/`-` together in one diff.116+- **[A branch as one page](https://eduramirezh.codeberg.page/commit-throughline/branch.html)** —117+  a short stack of commits rendered as chapters, with a contents list and118+  previous/next links.119+- **[The whole project history](https://eduramirezh.codeberg.page/commit-throughline/history.html)** —120+  every commit in this repo, end to end.121+122+Each page is a single HTML file with inline CSS and no JavaScript — exactly what123+the CLI writes locally.124+125+## Getting started126+127+Building the tool needs Rust (once). **Using** it does not: `throughline` is a128+self-contained binary that runs against any Git repository in any language.129 130 ```sh131 # Install straight from the repository into ~/.cargo/bin@@ -76,14 +135,21 @@135 cargo install --path .136 ```137 -To install somewhere already on your PATH instead — e.g. `~/.local/bin` — pass-`--root`:138+To install somewhere already on your `PATH` (e.g. `~/.local/bin`), pass `--root`:139 140 ```sh141 cargo install --root "$HOME/.local" --path .   # -> ~/.local/bin/throughline142 ```143 -## Usage144+Then render your most recent commit and open it in your browser:145+146+```sh147+cd ~/your/project          # any Git repo, any language — no Rust required148+throughline render HEAD --open149+```150+151+That's the whole loop. The page is written next to you (`throughline-<sha>.html`)152+and opened; share the file, attach it to a review, or keep rendering as you go.153 154 ```sh155 throughline render <commit> [-o out.html] [--open]@@ -91,17 +157,27 @@157 158 - `<commit>` is any revision: `HEAD`, a SHA, a tag, `HEAD~2`, …159 - Omit `-o` and it writes `throughline-<short-sha>.html` in the current directory.-- `--open` opens the page in your default browser after writing it (`open` on macOS,-  `xdg-open` on Linux, `start` on Windows). It is best-effort: if no opener is found-  the path is still printed and the command succeeds.-- `--debug` prints the parsed title / TL;DR / intro / anchors instead of HTML — handy-  for checking how a message parses.160+- `--open` opens the page after writing it (`open` on macOS, `xdg-open` on Linux,161+  `start` on Windows). Best-effort: if no opener is found the path is still162+  printed and the command still succeeds, so it's safe in scripts and on headless163+  boxes.164+- `--debug` prints the parsed title / TL;DR / intro / anchors instead of HTML —165+  handy for checking how a message parses.166+167+## Workflows168+169+### Reviewing a single commit170+171+`throughline render <commit> --open` is the unit of the whole thing — for your172+own last commit, a teammate's, or any point in history (`throughline render173+v1.2.0~3`).174 -### A whole branch as one walkthrough175+### A whole branch or pull request176 -`throughline branch` renders a *range* of commits as a single navigable page —-each commit becomes a chapter (its own complete walkthrough), in reading order,-with a contents list and previous/next links:177+`throughline branch` renders a *range* of commits as one navigable page — each178+commit a chapter (its own complete walkthrough), in reading order, with a179+contents list and previous/next links. It's the natural way to read a feature180+branch or a PR as a story instead of a pile of "files changed."181 182 ```sh183 throughline branch <tip> [--base <ref>] [-o out.html] [--open]   # range is base..tip; default base: main@@ -110,73 +186,145 @@186 187 - `throughline branch my-feature` renders every commit in `main..my-feature`.188 - `throughline branch v1.0..v1.1` renders an explicit range.-- Omit `-o` and it writes `throughline-branch-<tip-sha>.html`.-- `--open` works here too — render the branch page and open it in one step.-- Completeness still holds **per commit** — each chapter diffs against its own-  parent; the page composes the commits in order rather than squashing them.189+- Completeness holds **per commit** — each chapter diffs against its own parent;190+  the page composes the commits in order rather than squashing them. (In a repo191+  whose trunk is `master`, pass `--base master`.)192 -The output is a single standalone HTML file (inline CSS, no JavaScript, no external-assets), so you can open it directly — `--open` does exactly that for you:193+### Working in a worktree194+195+Git worktrees let you keep several branches checked out at once. `throughline`196+discovers the enclosing repository on its own, so from inside a worktree you just197+render that worktree's branch — `throughline branch HEAD --base main --open` — to198+review the work in isolation before you merge it.199+200+### Agentic coding201+202+This is where the convention earns its keep. Drop the short instruction from203+[`docs/llm-commit-guide.md`](docs/llm-commit-guide.md) into your coding agent's204+system prompt, and it will write commit messages that render as teaching205+walkthroughs. Then, instead of scrolling a wall of agent-authored diff, you read206+the branch as a narrated story:207 208 ```sh-throughline render HEAD --open                          # render, then open it-throughline render HEAD -o /tmp/commit.html && open /tmp/commit.html   # or do it by hand (macOS)209+throughline branch <the-agent's-branch> --open210 ```211 -## Using it in any project (no Rust required)212+The agent explains its own change in the order it made sense to make it, the213+renderer proves the explanation against the real diff (completeness means it214+can't quietly skip the part it's least sure about), and you review intent and215+code side by side. It pairs naturally with worktree-per-agent setups.216 -Because `throughline` discovers the enclosing Git repository on its own, you can run-it from inside *any* project — it does not need to be (or know about) Rust. A few-ways to wire it in:217+### Wiring it into any project218+219+Because `throughline` operates on the enclosing Git repo, it drops into any220+toolchain:221 222 **Git alias** — the most portable; `git tl` renders the latest commit and opens it:223 224 ```sh225 git config --global alias.tl \226   '!f(){ throughline render "${1:-HEAD}" --open; }; f'-# then, in any repo:-git tl            # HEAD227+git tl            # HEAD, in any repo228 git tl <sha>      # a specific commit229 ```230 -**`git throughline` subcommand** — Git runs any `git-throughline` on your PATH as-`git throughline`:231+**`git throughline` subcommand** — Git runs any `git-throughline` on your `PATH`232+as `git throughline`:233 234 ```sh235 ln -s "$(command -v throughline)" ~/.local/bin/git-throughline-git throughline render HEAD -o out.html236+git throughline render HEAD --open237 ```238 -**Makefile target** (C, Go, Python… — anything that uses `make`):239+**Build target / script** — wire it into whatever you already run:240 241 ```make-walkthrough:242+walkthrough:            # Makefile (C, Go, Python… anything with make)243 	throughline render HEAD -o build/walkthrough.html244 ```245 -**npm script** (a Node/JS project's `package.json`):-246 ```json247 { "scripts": { "walkthrough": "throughline render HEAD -o walkthrough.html" } }248 ```249 -**Git hook** — auto-render after every commit (`.git/hooks/post-commit`, made-executable):250+**Post-commit hook** — auto-render after every commit251+(`.git/hooks/post-commit`, made executable):252 253 ```sh254 #!/bin/sh255 throughline render HEAD -o "$(git rev-parse --git-dir)/throughline-last.html"256 ```257 -**CI** — render the head commit and publish the HTML as a build artifact; the file-is self-contained, so any static host or artifact store serves it as-is.258+**CI + static hosting** — render the head commit (or the branch) and publish the259+HTML as a build artifact or a static page. The output is one self-contained file,260+so any host serves it as-is — this project's own [live261+demo](https://eduramirezh.codeberg.page/commit-throughline/) is exactly that:262+`throughline branch` output committed to a Codeberg Pages branch.263 264 ## Writing throughline-friendly commits265 -The walkthrough is authored entirely in the commit message — see-[`docs/llm-commit-guide.md`](docs/llm-commit-guide.md) for a short instruction-you can drop into a code agent's system prompt, and [`docs/SPEC.md`](docs/SPEC.md)-for the full anchor grammar.266+The walkthrough is authored entirely in the commit message — there is nothing267+else to maintain. The rules of thumb:268+269+1. **First line is the title.** (Conventional Commits headers like `feat(auth):`270+   work fine — see [the spec](docs/SPEC.md#conventional-commits-interop).)271+2. **A one- or two-sentence TL;DR** as the first paragraph: the whole change in a272+   nutshell. It renders as a distinct lede, so make it self-contained.273+3. **Then walk the change in reading order.** For each step, write the274+   explanation *first*, then put the file reference on its own line directly275+   under it — prose *prepares* the reader for the code that follows:276+277+   ```278+   <1–3 sentences leading into the code>279+   path/to/file.ext:<start>-<end> [@<focusStart>-<focusEnd>]280+   ```281+282+4. **Never paste code into the prose.** An anchor carries only coordinates; the283+   renderer injects the real diff. Anything you don't mention is appended284+   automatically, so you're choosing *order* and adding *explanation* — never285+   selecting what to include.286+287+The anchor grammar in full:288+289+```290+<path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>]291+```292+293+- omit the range to show a file's whole change; repeat a path with narrow ranges294+  to walk it piece by piece (both styles mix);295+- `@<start>-<end>` visually emphasizes a sub-range;296+- an anchor may even point at code the commit *didn't* change — a whole untouched297+  file or an unchanged range — to give context; it renders as plain "unmodified"298+  lines, and the real diff still appears in full.299+300+For the complete grammar and semantics see [`docs/SPEC.md`](docs/SPEC.md); for the301+exact block to drop into a coding agent's system prompt, see302+[`docs/llm-commit-guide.md`](docs/llm-commit-guide.md).303+304+## The guarantees305+306+These hold no matter what you write — they're what make the format safe to adopt:307+308+1. **Every commit is valid.** No special syntax to opt in. A plain message renders309+   as the complete diff; an annotated one renders as a walkthrough.310+2. **Completeness.** Every diff hunk — and every line within it, deletions311+   included — appears at least once. Annotations reorder and explain; they never312+   restrict what is shown.313+3. **Repetition is allowed.** A hunk may appear as an authored callout *and* again314+   in the backfill. Completeness wins over tidiness; nothing is deduped away.315+4. **Don't duplicate code in prose.** Anchors carry coordinates; the renderer316+   pulls the real content from Git, so the narrative can't drift out of sync.317+5. **Order is the order.** Authored content appears in message order; unplaced318+   hunks follow in natural diff order.319+6. **Degrade, don't fail.** An unresolvable anchor emits its prose plus a visible320+   warning, and rendering carries on.321+322+## Status323+324+A working proof-of-concept. The render pipeline is implemented and the325+completeness invariant is covered by integration tests; `throughline render` and326+`throughline branch` both produce real, self-contained HTML. Conventional Commits327+interop and notation niceties are next — see [`docs/ROADMAP.md`](docs/ROADMAP.md).328 329 ## Project layout330 @@ -193,14 +341,12 @@341   render.rs        resolve anchors -> ordered document model342   html.rs          emit a self-contained HTML page343 docs/-  SPEC.md             canonical format specification (v0.4)344+  SPEC.md             canonical format specification345   ARCHITECTURE.md     implementation design and module map346   ROADMAP.md          shipped work and the prioritized plan ahead347   llm-commit-guide.md instruction for LLM commit authors348   wiki/               working knowledge base (how-tos, field-notes, automation)-tests/-  grammar.rs          parsing surface (spec's worked example)-  completeness.rs      end-to-end completeness invariant349+tests/                end-to-end completeness & grammar invariants350 ```351 352 ## Development@@ -218,12 +364,9 @@364 ./scripts/install-hooks.sh   # sets core.hooksPath; bypass a commit with --no-verify365 ```366 -See [`docs/wiki/automation/pre-commit-hook.md`](docs/wiki/automation/pre-commit-hook.md)-for what it runs, the non-Rust fast path, and how to bypass it.--Working documentation lives in the [`docs/wiki/`](docs/wiki/README.md) knowledge base;-a Claude Code `Stop` hook keeps it in step with the code after each code-changing turn-(see [`docs/wiki/automation/docs-sync-hook.md`](docs/wiki/automation/docs-sync-hook.md)).367+Working documentation lives in the [`docs/wiki/`](docs/wiki/README.md) knowledge368+base; see [`docs/wiki/automation/pre-commit-hook.md`](docs/wiki/automation/pre-commit-hook.md)369+for what the hook runs.370 371 ## License372 

Remaining changes

docs/assets/example-walkthrough.png

Binary file added

7/37f4bff8bEdu Ramírez

Document publishing the live demo

Captures the Codeberg Pages workflow as a wiki how-to so the next session doesn't rediscover the public-repo requirement the hard way.

The new page walks regenerating the demo from this repo's own history, the orphan pages-branch build in a throwaway worktree, the headless-Chrome hero-image recipe, and the gotcha that Pages serves only on a public repo.

docs/wiki/how-to/publish-the-live-demo.md
@@ -0,0 +1,91 @@1+# How-to: publish the live demo (Codeberg Pages)2+3+You want to (re)generate the demo site that shows the tool off — the walkthroughs4+served at `https://eduramirezh.codeberg.page/commit-throughline/`, built from this5+repo's *own* commits — or refresh the README hero image. Both are generated6+artifacts; this page is the recipe and the one gotcha that will waste an hour if7+you don't know it.8+9+## What is published10+11+A `pages` **orphan branch** holds only generated HTML — no source — so Codeberg12+serves it at the repo's Pages URL. Its tree:13+14+- `index.html` — a small **hand-written** landing page (links + framing). The only15+  file that is *not* tool output; preserve it when you replace the others.16+- `commit.html`, `commit-with-deletions.html`, `branch.html`, `history.html` —17+  straight `throughline` output (`src/html.rs` `to_html` / `to_html_branch`).18+19+The README hero at `docs/assets/example-walkthrough.png` is a screenshot of one of20+those renders, so it tracks the same source.21+22+## Regenerate the walkthroughs23+24+The set is chosen to show range: one woven single commit, one with deletions, a25+short branch arc, and the whole history. SHAs below are illustrative — pick fresh,26+representative ones each time:27+28+```sh29+cargo build --release30+BIN=./target/release/throughline31+$BIN render 36e5bdc          -o pages/commit.html                 # a recent commit that reads well32+$BIN render 3b36668          -o pages/commit-with-deletions.html  # a refactor that removes code (+/- together)33+$BIN branch aec2a50..HEAD    -o pages/branch.html                 # a short, coherent arc34+$BIN branch 65758f7..HEAD    -o pages/history.html                # the whole project35+```36+37+Quirk: `branch base..tip` **excludes `base`** (see38+[render-a-branch](render-a-branch.md)), so `<root>..HEAD` omits the very first39+commit. That's fine for a demo; to include the root, render it separately with40+`render`.41+42+## Rebuild and push the `pages` branch43+44+Build the orphan branch in a throwaway worktree so the main checkout is never45+touched (the [worktree gotchas](working-in-a-worktree.md) apply):46+47+```sh48+git worktree add --detach /tmp/pages-wt49+cd /tmp/pages-wt50+git checkout --orphan pages51+git rm -rf . --quiet           # clear the inherited tree; orphan keeps the working files52+cp /path/to/index.html commit.html commit-with-deletions.html branch.html history.html .53+git add -A && git commit -m "Publish the live demo as Codeberg Pages"54+git push -u origin pages55+cd - && git worktree remove /tmp/pages-wt56+```57+58+The commit is HTML-only, so the [pre-commit hook](../automation/pre-commit-hook.md)59+takes its non-Rust fast path.60+61+## It only serves on a **public** repo (the gotcha)62+63+Codeberg Pages serves a `pages` branch **only for public repositories.** On a64+private repo the push succeeds and `git ls-remote --heads origin pages` shows the65+branch, but every Pages URL returns `404` — there is no error, it just silently66+doesn't serve. Confirm visibility before chasing a propagation ghost:67+68+```sh69+curl -s -o /dev/null -w '%{http_code}\n' https://codeberg.org/eduramirezh/commit-throughline  # 404 (unauth) ⇒ private70+```71+72+Making the repo public is a visibility/access-control change in Codeberg's repo73+settings — a human step. Once public, the URL scheme is74+`https://<user>.codeberg.page/<repo>/` and it serves within ~a minute.75+76+## Refresh the README hero image77+78+Render a page, screenshot it with **headless** Chrome (no browser/extension79+overlay), and palette-reduce so it stays light in-repo:80+81+```sh82+CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"83+"$CHROME" --headless --disable-gpu --hide-scrollbars --force-device-scale-factor=2 \84+  --window-size=1000,1320 --screenshot=/tmp/hero.png file:///tmp/commit.html85+magick /tmp/hero.png -strip -colors 256 png8:docs/assets/example-walkthrough.png86+```87+88+Headless Chrome loads `file://` fine. Note the contrast if you script the89+*claude-in-chrome* tool instead: its `navigate` rewrites a `file://` URL to90+`https://file://…` and breaks it, so there you must serve the page over a local91+`python3 -m http.server` first.

The wiki index gains the page under its How-to group.

docs/wiki/README.md
@@ -50,6 +50,9 @@50     per-commit completeness semantic.51   - [`how-to/working-in-a-worktree.md`](how-to/working-in-a-worktree.md) — the two52     worktree gotchas: edit at the worktree root, and config / `main` are shared.53+  - [`how-to/publish-the-live-demo.md`](how-to/publish-the-live-demo.md) —54+    (re)generate the Codeberg Pages demo and README hero image from this repo's own55+    commits; the orphan `pages` branch, and why it serves only on a public repo.56 - **Automation** — how this repo keeps itself honest.57   - [`automation/docs-sync-hook.md`](automation/docs-sync-hook.md) — the Claude Code58     hook that prompts a docs update whenever code changes.
8/373724af4Edu Ramírez

Scope the docs-sync hook to the session's own worktree

The Stop hook nagged on /tmp scratch files this session: out-of-project edits were logged as ../-escapes, then lstrip("./") ate the escape and they counted as code. Scope detection to the session's working tree and define "code" as an allow-list, so only real in-repo Rust changes prompt a doc sync — correctly across worktrees, where each session watches its own tree and ignores the other checkouts.

The recorder now drops any edit that resolves outside the project root, so a worktree session ignores the main checkout (and vice versa) instead of logging it as a ../ path.

.claude/hooks/record_touched.py
@@ -7,8 +7,10 @@7 brought in line.8 9 Design notes:-- Paths are stored relative to the project root when possible, so the Stop hook-  can filter and display them cleanly.10+- Paths are stored relative to the project root. An edit that resolves *outside*11+  this session's tree (``/tmp`` scratch, or another checkout / worktree) is dropped12+  rather than logged as a ``..`` escape, so the Stop hook only ever sees in-tree13+  work — see ``within_root``.14 - The hook is silent and fails open: any problem (bad JSON, missing field, I/O15   error) simply means nothing is recorded. It must never block a tool call.16 @@ -42,6 +44,22 @@44     return rel.replace(os.sep, "/")45 46 47+def within_root(path: str, root: str) -> bool:48+    """True iff ``path`` resolves to somewhere inside ``root``.49+50+    ``root`` is this session's working tree (``CLAUDE_PROJECT_DIR``). In a worktree51+    session that is the worktree's own root, so an edit to the main checkout or a52+    sibling worktree resolves to a ``..`` escape and counts as *outside* — the53+    docs-sync hook only watches the tree the session is actually working in. A path54+    on another drive (Windows ``relpath`` ``ValueError``) is outside too.55+    """56+    try:57+        rel = os.path.relpath(os.path.abspath(path), os.path.abspath(root))58+    except ValueError:59+        return False60+    return rel != os.pardir and not rel.startswith(os.pardir + os.sep)61+62+63 def main() -> None:64     data = json.loads(sys.stdin.read() or "{}")65     tool_input = data.get("tool_input") or {}@@ -51,6 +69,8 @@69         return70 71     root = project_dir()72+    if not within_root(path, root):73+        return  # edit outside this session's tree (/tmp, another checkout/worktree)74     session_id = sanitize(str(data.get("session_id") or "default"))75     state_dir = os.path.join(root, ".claude", "state")76     os.makedirs(state_dir, exist_ok=True)

The gatekeeper rejects those escapes and absolute paths defensively, then allow-lists real code (*.rs and Cargo.toml) instead of treating every non-excluded file as code. It keeps the prefix exclusions so a nested worktree's .rs under .claude/worktrees/ still doesn't count, and normalizes a leading ./ by slicing, never lstrip, which would eat the dot of .claude/.

.claude/hooks/docs_sync.py
@@ -13,9 +13,11 @@13   (``stop_hook_active`` is true), the doc work has just happened: reset the14   turn's record and allow the stop. Claude Code also force-ends after 815   consecutive blocks, as a backstop.-- **Code vs. docs.** Only *code* changes trigger the prompt. Editing docs,-  Markdown, or ``.claude/`` does not — which is also why the doc-update-  continuation does not re-trigger the hook.16+- **Code vs. docs.** Only in-tree *code* changes (an allow-list: Rust sources and17+  ``Cargo.toml``) trigger the prompt. Editing docs, Markdown, ``.claude/`` config,18+  or anything outside this session's working tree does not — which is why the19+  doc-update continuation does not re-trigger the hook, and why a worktree session20+  ignores an edit that lands in the main checkout.21 - **Escape hatch.** ``THROUGHLINE_DOCSYNC=0`` disables the prompt for a turn.22 - **Fail open.** Any error allows the stop. A broken hook must never trap the23   agent.@@ -27,12 +29,16 @@29 import os30 import sys31 -# Touched paths under these prefixes / with these suffixes are NOT "code": they-# are documentation, agent config, or build output. A turn that only touches-# these does not prompt a doc sync.-EXCLUDED_PREFIXES = ("docs/", ".claude/", "target/", ".git/", ".idea/")-EXCLUDED_SUFFIXES = (".md",)-EXCLUDED_BASENAMES = (".gitignore", "Cargo.lock", "LICENSE", "LICENSE-MIT", "LICENSE-APACHE")32+# "Code" is defined positively (an allow-list), not as "everything not excluded" —33+# so a new HTML / CSV / image / text file in the repo never masquerades as code. A34+# path counts only when it is a Rust source or the Cargo manifest AND sits inside35+# this session's tree, outside the build / config / docs areas below. The prefixes36+# stay because an allowed suffix can still live somewhere that isn't this session's37+# source: a generated ``.rs`` under ``target/``, or a *nested* worktree's source38+# under ``.claude/worktrees/`` (visible only from a main-checkout session).39+NEVER_CODE_PREFIXES = ("docs/", ".claude/", "target/", ".git/", ".idea/")40+CODE_SUFFIXES = (".rs",)41+CODE_BASENAMES = ("Cargo.toml",)42 43 MAX_LISTED = 25  # cap the file list injected into the prompt44 @@ -76,14 +82,28 @@82 83 84 def is_code(rel: str) -> bool:-    p = rel.replace("\\", "/").lstrip("./")-    if p.startswith(EXCLUDED_PREFIXES):85+    """True only for an in-tree Rust source or Cargo manifest change.86+87+    Allow-list, not deny-list. A path fails to be "code" two ways:88+    - it is *outside this session's tree* — a ``..`` escape (another checkout or89+      worktree) or an absolute path (``/tmp`` scratch). The recorder already drops90+      these; this is the defensive backstop for any stale-log entry that slips91+      through.92+    - it is build output, agent config, or docs (``NEVER_CODE_PREFIXES``), or simply93+      not a ``.rs`` / ``Cargo.toml`` file.94+95+    Normalize a leading ``./`` by slicing, **never** ``lstrip("./")`` — that strips96+    *every* leading ``.``/``/`` and so eats the dot of a dotdir like ``.claude/``,97+    defeating the prefix check (it would let a nested worktree's ``.rs`` count).98+    """99+    p = rel.replace("\\", "/")100+    if os.path.isabs(p) or p == os.pardir or p.startswith(os.pardir + "/"):101         return False-    if p.endswith(EXCLUDED_SUFFIXES):102+    if p.startswith("./"):103+        p = p[2:]104+    if not p or p.startswith(NEVER_CODE_PREFIXES):105         return False-    if os.path.basename(p) in EXCLUDED_BASENAMES:-        return False-    return True106+    return p.endswith(CODE_SUFFIXES) or os.path.basename(p) in CODE_BASENAMES107 108 109 def build_reason(code_files: list) -> str:

The automation page documents the allow-list, the in-tree scoping, the worktree semantic, and the new code-only blind spot under limitations.

docs/wiki/automation/docs-sync-hook.md
@@ -31,17 +31,33 @@31 32 ## What counts as "code"33 -The prompt fires only when a *code* path was touched. These are **not** code, and a-turn that touches only them never triggers the prompt (which is also why the-doc-update continuation does not re-trigger the hook):--- anything under `docs/`, `.claude/`, `target/`, `.git/`, `.idea/`-- any `*.md` file-- `.gitignore`, `Cargo.lock`, and the `LICENSE*` files--Everything else — `src/**`, `tests/**`, `Cargo.toml`, `build.rs`, … — counts. The-rule lives in `is_code()` in `docs_sync.py`; adjust the exclusion lists there if the-boundary needs to move.34+The prompt fires only when a *code* path was touched, and "code" is an **allow-list**35+— not "everything that isn't excluded". A path counts only when **all** hold:36+37+- it is **inside this session's working tree** (`CLAUDE_PROJECT_DIR`). An edit that38+  resolves to a `..` escape or an absolute path — `/tmp` scratch, or another checkout39+  or worktree — is dropped: the recorder (`within_root()`) refuses to log it, and40+  `is_code()` rejects it again as a backstop.41+- it is **not** under `docs/`, `.claude/`, `target/`, `.git/`, or `.idea/` — docs,42+  agent config, build output, and any *nested* worktree under `.claude/worktrees/`.43+- it is a **Rust source** (`*.rs`) or the **Cargo manifest** (`Cargo.toml`).44+45+So `src/**`, `tests/**`, `build.rs`, and `Cargo.toml` count; everything else —46+Markdown, HTML, images, shell scripts, CI YAML, `Cargo.lock` — does not. The rule47+lives in `is_code()` in `docs_sync.py` (with `within_root()` in `record_touched.py`48+doing the in-tree scoping); widen `CODE_SUFFIXES` / `CODE_BASENAMES` there to make a49+new kind of source start prompting. One trap, called out in the code: normalize a50+leading `./` by slicing, never `lstrip("./")` — the latter also strips the dot of51+`.claude/` and would let a nested worktree's `.rs` slip through.52+53+### Worktrees54+55+`CLAUDE_PROJECT_DIR` is the **session's own root**, so a session running in a56+worktree watches *that worktree*. An edit that lands in the main checkout or a57+sibling worktree — the cross-checkout mistake the58+[worktree how-to](../how-to/working-in-a-worktree.md) warns about — resolves outside59+the session root and is ignored. Each session is responsible only for the tree it is60+working in.61 62 ## How it avoids looping63 @@ -83,6 +99,14 @@99   pre-existing uncommitted changes).100 - **One prompt per turn.** Code edited *during* the doc-update continuation is not101   re-checked until the next turn — an accepted trade for loop safety.102+- **Code-only.** The hook models *code → docs* drift. A doc-worthy change with **no103+  code** — publishing the live demo, adding a deploy branch, a pure process change —104+  does not prompt, by design (otherwise it would nag every turn). Read hook silence105+  as "no *code*-driven sync owed", not "nothing to document": write those docs as106+  part of the work; the [conventions](../CONVENTIONS.md) still apply.107+- **Allow-list is Rust-shaped.** Because "code" is `*.rs` + `Cargo.toml`, a non-Rust108+  source the project starts to care about (a `scripts/*.sh`, a CI workflow) will not109+  prompt until it is added to `CODE_SUFFIXES` / `CODE_BASENAMES`.110 111 ## Files112 
9/3726d0498Edu Ramírez

Auto-publish the live demo on every push to main

A pre-push hook now re-renders the Codeberg Pages demo and pushes it to the pages branch whenever you push main, best-effort so it never blocks the push. There is no client-side post-push hook, and a post-commit one would fire on unpushed commits and on every branch — a pre-push gated on main is the only correct seam, and that gate doubles as a re-entrancy guard.

The engine is a script you can also run by hand. Which commits the demo shows is an editorial choice — commits that read well — so those SHAs sit at the top as plain variables to edit.

scripts/publish-pages.sh
@@ -0,0 +23,7 @@23+# --- the curated set: which commits the demo shows --------------------------24+# These are an EDITORIAL choice — pick commits that read well, then refresh them25+# as the project grows. Everything below this block is mechanical.26+COMMIT_REV="36e5bdc"          # one woven single commit that reads well27+DELETIONS_REV="3b36668"       # a refactor that removes code (+/- shown together)28+BRANCH_RANGE="aec2a50..HEAD"  # a short, coherent arc (the base is excluded)29+HISTORY_RANGE="65758f7..HEAD" # the whole project (65758f7 is the root commit)

The hand-written index.html lives only on the pages branch, so the script bases its work on the current published tree and overwrites only the four generated files, carrying the landing page forward untouched.

scripts/publish-pages.sh
@@ -0,0 +55,13 @@55+# --- base the work on the CURRENT published tree ----------------------------56+# Prefer origin/pages so we extend what is actually live; fall back to a local57+# pages ref when offline. We work detached, so no local branch is ever moved.58+git fetch --quiet origin pages 2>/dev/null || true59+if git rev-parse --verify --quiet refs/remotes/origin/pages >/dev/null; then60+  base="origin/pages"61+elif git rev-parse --verify --quiet refs/heads/pages >/dev/null; then62+  base="pages"63+else64+  say "no 'pages' branch found locally or on origin." >&265+  say "  bootstrap it once (see the how-to), then this script can extend it." >&266+  exit 167+fi

It renders into a throwaway worktree and commits only when the output actually changed, so re-running is cheap and noise-free; the inner commit and push skip the hooks to stay fast and immune to re-entry.

scripts/publish-pages.sh
@@ -0,0 +83,18 @@83+# --- commit & push only if the render actually changed ----------------------84+git -C "$wt" add -A85+if git -C "$wt" diff --cached --quiet; then86+  say "rendered output is unchanged — nothing to publish."87+  exit 088+fi89+90+if [ "${THROUGHLINE_PUBLISH_DRY_RUN:-0}" != "0" ]; then91+  changed=$(git -C "$wt" diff --cached --name-only | tr '\n' ' ')92+  say "dry run — would publish: ${changed}(no commit, no push)."93+  exit 094+fi95+96+# HTML-only change: skip the Rust pre-commit gate and the pages pre-push gate.97+# Both would no-op, but be explicit, fast, and immune to any future re-entrancy.98+THROUGHLINE_SKIP_HOOKS=1 git -C "$wt" commit --quiet -m "Publish the live demo"99+THROUGHLINE_SKIP_HOOKS=1 git -C "$wt" push --quiet origin HEAD:refs/heads/pages100+say "published → https://eduramirezh.codeberg.page/commit-throughline/"

The hook reads the refs Git feeds it on stdin and acts only when a non-delete main is pushed to origin. That single gate is also why the script's own git push … pages can't loop back in — its ref is pages, not main.

.githooks/pre-push
@@ -0,0 +32,15 @@32+# Git feeds "<local ref> <local oid> <remote ref> <remote oid>" per pushed ref.33+# Publish when main is being created/updated — but not deleted (a delete sends34+# an all-zero local oid).35+publish=036+while read -r _local_ref local_oid remote_ref _remote_oid; do37+  case "$remote_ref" in38+    refs/heads/main)39+      case "$local_oid" in40+        *[!0]*) publish=1 ;;   # any non-zero char ⇒ a real update, not a delete41+      esac42+      ;;43+  esac44+done45+46+[ "$publish" -eq 1 ] || exit 0

If the publish fails — a render error, a dirty build, being offline — the hook prints a loud warning and still exits 0, so your push is never held hostage to the demo.

.githooks/pre-push
@@ -0,0 +47,12 @@47+48+root=$(git rev-parse --show-toplevel) || exit 049+50+echo "pre-push: main → refreshing the live demo (pages)…"51+"$root/scripts/publish-pages.sh" && exit 052+rc=$?53+54+echo >&255+echo "pre-push: WARNING — pages publish failed (exit $rc); your push continues." >&256+echo "  The live demo may now be stale. Re-run when convenient:" >&257+echo "    ./scripts/publish-pages.sh" >&258+exit 0

The how-to is now built around the one command, keeping the orphan-branch recipe only as a first-time bootstrap, and a new automation page documents the hook's trigger, re-entrancy guard, and caveats.

docs/wiki/how-to/publish-the-live-demo.md
@@ -3,8 +3,8 @@3 You want to (re)generate the demo site that shows the tool off — the walkthroughs4 served at `https://eduramirezh.codeberg.page/commit-throughline/`, built from this5 repo's *own* commits — or refresh the README hero image. Both are generated-artifacts; this page is the recipe and the one gotcha that will waste an hour if-you don't know it.6+artifacts; this page is the recipe (one command), the automation that runs it for7+you, and the one gotcha that will waste an hour if you don't know it.8 9 ## What is published10 @@ -12,26 +12,34 @@12 serves it at the repo's Pages URL. Its tree:13 14 - `index.html` — a small **hand-written** landing page (links + framing). The only-  file that is *not* tool output; preserve it when you replace the others.15+  file that is *not* tool output. The publish script **carries it forward**16+  untouched; to change it, edit it on the `pages` branch directly.17 - `commit.html`, `commit-with-deletions.html`, `branch.html`, `history.html` —18   straight `throughline` output (`src/html.rs` `to_html` / `to_html_branch`).19 20 The README hero at `docs/assets/example-walkthrough.png` is a screenshot of one of21 those renders, so it tracks the same source.22 -## Regenerate the walkthroughs23+## Publish — the one command24 -The set is chosen to show range: one woven single commit, one with deletions, a-short branch arc, and the whole history. SHAs below are illustrative — pick fresh,-representative ones each time:25+```sh26+./scripts/publish-pages.sh27+```28+29+It builds the renderer, renders the four walkthroughs, carries `index.html`30+forward, and pushes to `origin/pages` — **committing only when the render actually31+changed**, so re-running is cheap and noise-free. It works in a throwaway worktree32+(the [worktree gotchas](working-in-a-worktree.md) apply) and cleans up even on33+failure, so your main checkout is never touched.34+35+**Choosing what the demo shows** is an editorial call — pick commits that *read36+well*. The set is chosen to show range: one woven single commit, one with37+deletions, a short branch arc, and the whole history. Those SHAs live at the top of38+[`scripts/publish-pages.sh`](../../../scripts/publish-pages.sh) (the `*_REV` /39+`*_RANGE` variables); edit them there. Validate a fresh set **without publishing**:40 41 ```sh-cargo build --release-BIN=./target/release/throughline-$BIN render 36e5bdc          -o pages/commit.html                 # a recent commit that reads well-$BIN render 3b36668          -o pages/commit-with-deletions.html  # a refactor that removes code (+/- together)-$BIN branch aec2a50..HEAD    -o pages/branch.html                 # a short, coherent arc-$BIN branch 65758f7..HEAD    -o pages/history.html                # the whole project42+THROUGHLINE_PUBLISH_DRY_RUN=1 ./scripts/publish-pages.sh   # builds, renders, diffs — no push43 ```44 45 Quirk: `branch base..tip` **excludes `base`** (see@@ -39,42 +47,55 @@47 commit. That's fine for a demo; to include the root, render it separately with48 `render`.49 -## Rebuild and push the `pages` branch50+## Automatic publishing on push51+52+You don't normally run the script by hand: a **pre-push hook** runs it whenever you53+push `main`, so the demo tracks `main` on its own. It is **best-effort** — a failed54+publish prints a warning but never blocks your push. Skip it for one push with55+`git push --no-verify`. See56+[automation/pages-publish-hook.md](../automation/pages-publish-hook.md) for how it57+fires, why it can't loop, and its caveats.58+59+## Bootstrap the `pages` branch (first time only — already done)60 -Build the orphan branch in a throwaway worktree so the main checkout is never-touched (the [worktree gotchas](working-in-a-worktree.md) apply):61+The script *extends* an existing `pages` branch. It already exists, so you won't62+need this; it's here for a fresh remote or if the branch is ever lost. Build the63+orphan branch in a throwaway worktree, seeding it with a first render and a64+hand-written `index.html`:65 66 ```sh67 git worktree add --detach /tmp/pages-wt68 cd /tmp/pages-wt69 git checkout --orphan pages-git rm -rf . --quiet           # clear the inherited tree; orphan keeps the working files70+git rm -rf . --quiet            # clear the inherited tree; orphan keeps the working files71 cp /path/to/index.html commit.html commit-with-deletions.html branch.html history.html .72 git add -A && git commit -m "Publish the live demo as Codeberg Pages"73 git push -u origin pages74 cd - && git worktree remove /tmp/pages-wt75 ```76 -The commit is HTML-only, so the [pre-commit hook](../automation/pre-commit-hook.md)-takes its non-Rust fast path.77+From then on, `./scripts/publish-pages.sh` (and the hook) take over.78 -## It only serves on a **public** repo (the gotcha)79+## It serves only on a **public** repo (the gotcha)80 -Codeberg Pages serves a `pages` branch **only for public repositories.** On a-private repo the push succeeds and `git ls-remote --heads origin pages` shows the-branch, but every Pages URL returns `404` — there is no error, it just silently81+The repo is now **public**, so the URL serves. Keep this diagnostic in mind if it82+ever 404s: Codeberg Pages serves a `pages` branch **only for public repositories.**83+On a private repo the push succeeds and `git ls-remote --heads origin pages` shows84+the branch, but every Pages URL returns `404` — there is no error, it just silently85 doesn't serve. Confirm visibility before chasing a propagation ghost:86 87 ```sh-curl -s -o /dev/null -w '%{http_code}\n' https://codeberg.org/eduramirezh/commit-throughline  # 404 (unauth) ⇒ private88+# 200 ⇒ public (serves); 404 unauth ⇒ private (won't serve)89+curl -s -o /dev/null -w '%{http_code}\n' https://codeberg.org/eduramirezh/commit-throughline90 ```91 -Making the repo public is a visibility/access-control change in Codeberg's repo-settings — a human step. Once public, the URL scheme is-`https://<user>.codeberg.page/<repo>/` and it serves within ~a minute.92+Toggling visibility is a setting in Codeberg's repo settings — a human step. Once93+public, the URL scheme is `https://<user>.codeberg.page/<repo>/` and it serves94+within ~a minute.95 96 ## Refresh the README hero image97 98+This stays manual (it needs a browser and image tooling the hook doesn't run).99 Render a page, screenshot it with **headless** Chrome (no browser/extension100 overlay), and palette-reduce so it stays light in-repo:101 
docs/wiki/automation/pages-publish-hook.md
@@ -0,0 +1,101 @@1+# The pages-publish hook2+3+How the live demo stays in sync with `main` on its own. When you push `main`, a4+Git **pre-push** hook re-renders the demo and pushes it to the `pages` branch5+Codeberg serves. Read this if a push printed a publish warning, if you want to6+disable or bypass the auto-publish, or if you are changing what gets shown.7+8+This is the automation behind the [publish-the-live-demo](../how-to/publish-the-live-demo.md)9+how-to — that page is the recipe and the SHAs; this page is the trigger.10+11+## What it does12+13+The demo at `https://eduramirezh.codeberg.page/commit-throughline/` is built from14+this repo's own commits, so it goes stale the moment `main` moves — `history.html`15+(`<root>..HEAD`) and `branch.html` grow with every commit, and any change to16+`src/html.rs` re-flows *every* page. Keeping it current by hand is a chore nobody17+remembers. The hook closes that gap.18+19+[`.githooks/pre-push`](../../../.githooks/pre-push) fires on `git push`. When the20+push updates `refs/heads/main`, it runs21+[`scripts/publish-pages.sh`](../../../scripts/publish-pages.sh), which builds the22+renderer, re-renders the four walkthroughs, carries the hand-written `index.html`23+forward, and pushes the result to `origin/pages` — committing only when the render24+actually changed (`scripts/publish-pages.sh`).25+26+## When it runs — and when it gets out of the way27+28+- **Only on a push that updates `main`.** The hook reads the refs Git feeds it on29+  stdin and acts only when `refs/heads/main` is among them (and isn't a delete).30+  Feature-branch and tag pushes return instantly.31+- **Only to `origin`.** Pushing `main` to some other remote (a fork, a mirror)32+  won't republish the canonical demo.33+- **An explicit bypass.** `git push --no-verify` skips all hooks (Git's own34+  mechanism); `THROUGHLINE_SKIP_HOOKS=1 git push` skips just this one — the same35+  switch the [pre-commit hook](pre-commit-hook.md) honors.36+37+**It cannot loop.** The publish script ends with its own `git push … pages`. That38+push re-enters this very hook — but its ref is `refs/heads/pages`, not `main`, so39+the hook no-ops. The `main`-only gate *is* the re-entrancy guard; no lock or40+sentinel needed. (The script also sets `THROUGHLINE_SKIP_HOOKS=1` on its inner41+commit and push as belt-and-suspenders.)42+43+## Best-effort by design44+45+A publish failure **never blocks your push.** If the script exits non-zero — a46+render error, a dirty build, no network — the hook prints a loud warning and exits47+`0`, letting the push proceed. The demo can lag; your work is never held hostage.48+49+The trade-off is the flip side of that promise: a failure is a *warning*, not a50+stop. If a push scrolls past and you didn't notice the warning, the demo silently51+drifts until the next successful publish. When in doubt, re-run52+`./scripts/publish-pages.sh` by hand — it's idempotent and no-ops if nothing53+changed.54+55+## Caveats56+57+- **It adds a release build + four renders to every `main` push.** Cheap when58+  `target/release` is warm (a couple of seconds), slower on a cold tree. This is59+  the cost of "always in sync"; bypass with `--no-verify` when you want a quick60+  push.61+- **Publish runs *before* the push lands.** Pre-push fires before the transfer, so62+  `pages` is pushed moments before `main`. The renders read your local objects63+  (already committed), so the HTML is correct; but if the `main` push then fails,64+  `pages` briefly references commits `origin/main` doesn't have yet. The next65+  successful push self-corrects.66+- **The showcase SHAs are re-rendered, not re-chosen.** The hook keeps the *same*67+  curated commits current; picking newer representative ones is still an editorial68+  edit to the script's `*_REV` / `*_RANGE` variables.69+- **The README hero image is not automated.** It needs headless Chrome + image70+  tooling; refresh it by hand (see the how-to).71+72+## Installing73+74+Same one-time setup as the other Git hook — `core.hooksPath` must point at75+`.githooks/`:76+77+```sh78+./scripts/install-hooks.sh79+```80+81+It also re-`chmod`s the hooks, so `pre-push` is picked up alongside `pre-commit`82+(one `core.hooksPath` covers every hook and every linked worktree). If pushing83+`main` doesn't trigger a publish, confirm `git ls-files -s .githooks/pre-push`84+shows `100755` and `git config core.hooksPath` is `.githooks`.85+86+## Relationship to the other hooks87+88+This is a **Git** hook, like the [pre-commit hook](pre-commit-hook.md) — both live89+in `.githooks/` and run in your local Git. It is unrelated to the90+[docs-sync hook](docs-sync-hook.md), a **Claude Code** hook that runs in the agent91+on stop. The pre-commit hook gates a *commit* on `fmt`/`clippy`/`test`; this one92+publishes an artifact *after* you push `main`. Different lifecycles, same goal:93+keep the repo from drifting from what it claims to be.94+95+## Files96+97+| File | Role |98+|------|------|99+| [`.githooks/pre-push`](../../../.githooks/pre-push) | The trigger: detect a `main` push to `origin`, run the publish script best-effort. |100+| [`scripts/publish-pages.sh`](../../../scripts/publish-pages.sh) | The engine: build, render the curated set, carry `index.html` forward, push `pages` (idempotent). |101+| [`scripts/install-hooks.sh`](../../../scripts/install-hooks.sh) | One-time setup: point `core.hooksPath` at `.githooks` and fix exec bits. |

Finally the wiki index and the project guide point at the new hook so it is discoverable alongside the pre-commit one.

docs/wiki/README.md
@@ -60,1 +60,3 @@60     pre-commit hook that runs fmt / clippy / test before every commit.61+  - [`automation/pages-publish-hook.md`](automation/pages-publish-hook.md) — the Git62+    pre-push hook that re-renders and publishes the live demo when you push `main`.
CLAUDE.md
@@ -32,4 +32,8 @@-Install the Git pre-commit hook once per clone with `./scripts/install-hooks.sh`; it32+Install the Git hooks once per clone with `./scripts/install-hooks.sh` (it points33+`core.hooksPath` at `.githooks/`, enabling all of them). The **pre-commit** hook34 runs `fmt` / `clippy` / `test` on every Rust commit (skipped for non-Rust commits;-bypass with `--no-verify`). See35+bypass with `--no-verify`) — see36 [`docs/wiki/automation/pre-commit-hook.md`](docs/wiki/automation/pre-commit-hook.md).37+The **pre-push** hook re-renders and publishes the live demo when you push `main`,38+best-effort so it never blocks the push — see39+[`docs/wiki/automation/pages-publish-hook.md`](docs/wiki/automation/pages-publish-hook.md).

Remaining changes

.githooks/pre-push
@@ -0,0 +1,58 @@1+#!/usr/bin/env bash2+# Pre-push: keep the live-demo `pages` branch (Codeberg Pages) in sync with3+# `main`. When a push updates refs/heads/main, run scripts/publish-pages.sh to4+# re-render the demo and push it to origin/pages.5+#6+# BEST-EFFORT BY DESIGN. A publish failure never blocks your push: the hook7+# prints a loud warning and exits 0, so the demo may lag but your work is never8+# held hostage to a render error, a dirty build, or being offline.9+#10+# Fires ONLY for pushes that update main. That keeps feature-branch pushes fast11+# and, as a free bonus, stops the publish script's own `git push … pages` from12+# re-entering this hook (its ref is `pages`, not `main`).13+#14+# Enable once per clone:  ./scripts/install-hooks.sh   (sets core.hooksPath)15+# Skip for one push:      git push --no-verify16+#                  or:     THROUGHLINE_SKIP_HOOKS=1 git push17+#18+# Docs: docs/wiki/automation/pages-publish-hook.md19+20+set -uo pipefail21+22+remote_name="${1:-}"23+24+# --- escape hatch (git's own --no-verify works too) --------------------------25+if [ "${THROUGHLINE_SKIP_HOOKS:-0}" != "0" ]; then26+  exit 027+fi28+29+# Only ever publish on a push to the canonical remote.30+[ "$remote_name" = "origin" ] || exit 031+32+# Git feeds "<local ref> <local oid> <remote ref> <remote oid>" per pushed ref.33+# Publish when main is being created/updated — but not deleted (a delete sends34+# an all-zero local oid).35+publish=036+while read -r _local_ref local_oid remote_ref _remote_oid; do37+  case "$remote_ref" in38+    refs/heads/main)39+      case "$local_oid" in40+        *[!0]*) publish=1 ;;   # any non-zero char ⇒ a real update, not a delete41+      esac42+      ;;43+  esac44+done45+46+[ "$publish" -eq 1 ] || exit 047+48+root=$(git rev-parse --show-toplevel) || exit 049+50+echo "pre-push: main → refreshing the live demo (pages)…"51+"$root/scripts/publish-pages.sh" && exit 052+rc=$?53+54+echo >&255+echo "pre-push: WARNING — pages publish failed (exit $rc); your push continues." >&256+echo "  The live demo may now be stale. Re-run when convenient:" >&257+echo "    ./scripts/publish-pages.sh" >&258+exit 0
CLAUDE.md
@@ -29,10 +29,14 @@29 throughline render HEAD  # run against a commit (after build)30 ```31 -Install the Git pre-commit hook once per clone with `./scripts/install-hooks.sh`; it32+Install the Git hooks once per clone with `./scripts/install-hooks.sh` (it points33+`core.hooksPath` at `.githooks/`, enabling all of them). The **pre-commit** hook34 runs `fmt` / `clippy` / `test` on every Rust commit (skipped for non-Rust commits;-bypass with `--no-verify`). See35+bypass with `--no-verify`) — see36 [`docs/wiki/automation/pre-commit-hook.md`](docs/wiki/automation/pre-commit-hook.md).37+The **pre-push** hook re-renders and publishes the live demo when you push `main`,38+best-effort so it never blocks the push — see39+[`docs/wiki/automation/pages-publish-hook.md`](docs/wiki/automation/pages-publish-hook.md).40 41 ## Module map42 
docs/wiki/README.md
@@ -58,6 +58,8 @@58     hook that prompts a docs update whenever code changes.59   - [`automation/pre-commit-hook.md`](automation/pre-commit-hook.md) — the Git60     pre-commit hook that runs fmt / clippy / test before every commit.61+  - [`automation/pages-publish-hook.md`](automation/pages-publish-hook.md) — the Git62+    pre-push hook that re-renders and publishes the live demo when you push `main`.63 64 ## How the wiki stays current65 
scripts/publish-pages.sh
@@ -0,0 +1,100 @@1+#!/usr/bin/env bash2+# Build the live-demo site and publish it to the `pages` branch (Codeberg Pages).3+#4+# Renders a curated handful of this repo's OWN commits with `throughline`, lays5+# them beside the hand-written landing page, and pushes the result to6+# `origin/pages` — the orphan branch Codeberg serves at7+#   https://eduramirezh.codeberg.page/commit-throughline/8+#9+# Run it by hand to refresh the demo, or let the pre-push hook run it for you10+# when you push `main` (see .githooks/pre-push). Either way it is safe to re-run:11+# it extends the existing pages branch and no-ops when the render is unchanged.12+#13+# Validate a fresh set of SHAs without publishing:14+#   THROUGHLINE_PUBLISH_DRY_RUN=1 ./scripts/publish-pages.sh15+#16+# Docs: docs/wiki/how-to/publish-the-live-demo.md17+#       docs/wiki/automation/pages-publish-hook.md18+19+set -euo pipefail20+21+say() { printf 'publish-pages: %s\n' "$1"; }22+23+# --- the curated set: which commits the demo shows --------------------------24+# These are an EDITORIAL choice — pick commits that read well, then refresh them25+# as the project grows. Everything below this block is mechanical.26+COMMIT_REV="36e5bdc"          # one woven single commit that reads well27+DELETIONS_REV="3b36668"       # a refactor that removes code (+/- shown together)28+BRANCH_RANGE="aec2a50..HEAD"  # a short, coherent arc (the base is excluded)29+HISTORY_RANGE="65758f7..HEAD" # the whole project (65758f7 is the root commit)30+31+# The published tree is the four files rendered below plus index.html — a32+# hand-written landing page that is CARRIED FORWARD from the pages branch and33+# never regenerated here.34+35+# --- run from the repo root -------------------------------------------------36+root=$(git rev-parse --show-toplevel)37+cd "$root"38+39+# --- make cargo reachable (same dance as .githooks/pre-commit) --------------40+[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"41+case ":$PATH:" in42+  *":$HOME/.cargo/bin:"*) ;;43+  *) PATH="$HOME/.cargo/bin:$PATH" ;;44+esac45+if ! command -v cargo >/dev/null 2>&1; then46+  say "cargo not found on PATH — install Rust (https://rustup.rs)." >&247+  exit 148+fi49+50+# --- build the renderer -----------------------------------------------------51+say "building (cargo build --release)…"52+cargo build --release53+bin="$root/target/release/throughline"54+55+# --- base the work on the CURRENT published tree ----------------------------56+# Prefer origin/pages so we extend what is actually live; fall back to a local57+# pages ref when offline. We work detached, so no local branch is ever moved.58+git fetch --quiet origin pages 2>/dev/null || true59+if git rev-parse --verify --quiet refs/remotes/origin/pages >/dev/null; then60+  base="origin/pages"61+elif git rev-parse --verify --quiet refs/heads/pages >/dev/null; then62+  base="pages"63+else64+  say "no 'pages' branch found locally or on origin." >&265+  say "  bootstrap it once (see the how-to), then this script can extend it." >&266+  exit 167+fi68+69+# --- a throwaway worktree, removed even on failure --------------------------70+tmp=$(mktemp -d "${TMPDIR:-/tmp}/throughline-pages.XXXXXX")71+wt="$tmp/tree"72+cleanup() { git worktree remove --force "$wt" 2>/dev/null || true; rm -rf "$tmp"; }73+trap cleanup EXIT74+git worktree add --detach --quiet "$wt" "$base"75+76+# --- render the showcase into the worktree (index.html untouched) -----------77+say "rendering the walkthroughs…"78+"$bin" render "$COMMIT_REV"    -o "$wt/commit.html"79+"$bin" render "$DELETIONS_REV" -o "$wt/commit-with-deletions.html"80+"$bin" branch "$BRANCH_RANGE"  -o "$wt/branch.html"81+"$bin" branch "$HISTORY_RANGE" -o "$wt/history.html"82+83+# --- commit & push only if the render actually changed ----------------------84+git -C "$wt" add -A85+if git -C "$wt" diff --cached --quiet; then86+  say "rendered output is unchanged — nothing to publish."87+  exit 088+fi89+90+if [ "${THROUGHLINE_PUBLISH_DRY_RUN:-0}" != "0" ]; then91+  changed=$(git -C "$wt" diff --cached --name-only | tr '\n' ' ')92+  say "dry run — would publish: ${changed}(no commit, no push)."93+  exit 094+fi95+96+# HTML-only change: skip the Rust pre-commit gate and the pages pre-push gate.97+# Both would no-op, but be explicit, fast, and immune to any future re-entrancy.98+THROUGHLINE_SKIP_HOOKS=1 git -C "$wt" commit --quiet -m "Publish the live demo"99+THROUGHLINE_SKIP_HOOKS=1 git -C "$wt" push --quiet origin HEAD:refs/heads/pages100+say "published → https://eduramirezh.codeberg.page/commit-throughline/"
10/37f92f3e7Edu Ramírez

Gate commit size so a branch stays a readable sequence

A throughline branch renders one chapter per commit, so it reads best as a sequence of small, self-contained commits. This adds a pre-commit gate that aborts a sprawling commit, and documents it alongside the existing fmt/clippy/test gate.

The gate runs for every commit, before the Rust-only cargo checks, and needs no cargo. It tallies the staged change and trips on too many files, or a change that is both large and spread across several files — with a files >= 4 guard so an atomic single-file rewrite is never punished by line count. Generated and vendored noise (binaries, the lockfile, assets, generated HTML) is excluded.

.githooks/pre-commit
@@ -26,0 +31,40 @@31+# --- commit-size gate: keep commits small and self-contained -----------------32+# A `throughline branch` renders one chapter per commit, so a branch reads best33+# as a sequence of focused commits. This gate aborts a sprawling commit — too34+# many files, or large AND spread across several files — to keep that sequence35+# legible. It is deliberately lenient: a big single-file rewrite (<= 3 files)36+# never trips the line check, only genuinely spread-out changes do. Generated /37+# vendored noise (binaries, Cargo.lock, docs/assets/*, *.html) does not count.38+max_files=${THROUGHLINE_MAX_COMMIT_FILES:-12}39+max_lines=${THROUGHLINE_MAX_COMMIT_LINES:-400}40+41+size_files=042+size_lines=043+while IFS=$'\t' read -r added removed path; do44+  case "$added" in ''|*[!0-9]*) continue ;; esac   # skip binary (-) / odd lines45+  case "$removed" in ''|*[!0-9]*) continue ;; esac46+  case "$path" in47+    Cargo.lock|docs/assets/*|*.html) continue ;;   # generated / vendored noise48+  esac49+  size_files=$(( size_files + 1 ))50+  size_lines=$(( size_lines + added + removed ))51+done < <(git diff --cached --numstat)52+53+size_over=054+if [ "$max_files" -gt 0 ] && [ "$size_files" -gt "$max_files" ]; then55+  size_over=1   # too many files — likely several separable concerns56+fi57+if [ "$max_lines" -gt 0 ] && [ "$size_files" -ge 4 ] && [ "$size_lines" -gt "$max_lines" ]; then58+  size_over=1   # large AND spread across 4+ files59+fi60+61+if [ "$size_over" -eq 1 ]; then62+  echo >&263+  echo "pre-commit: 'commit size' failed — large commit ($size_files files, ~$size_lines changed lines)." >&264+  echo "  A throughline branch reads best as a sequence of small, self-contained commits —" >&265+  echo "  one logical change each, one chapter each. If these are separable concerns, split" >&266+  echo "  them into focused commits; see docs/llm-commit-guide.md." >&267+  echo "  Genuinely atomic and just big? Bypass with: git commit --no-verify" >&268+  echo "  Or relax: THROUGHLINE_MAX_COMMIT_FILES / THROUGHLINE_MAX_COMMIT_LINES (0 disables a check)." >&269+  exit 170+fi

The header now documents the two gates and the env knobs that tune or disable the size check, next to the existing bypasses.

.githooks/pre-commit
@@ -4,9 +4,14 @@-# Runs the project's quality checks on every commit: rustfmt, clippy (warnings-# are errors), and the test suite — the same set the planned CI will run (see-# docs/ROADMAP.md). This is the local complement to that CI.4+# Two gates, cheapest first:5+#   1. Commit size — abort a sprawling commit so a `throughline branch` stays a6+#      readable chapter-per-commit sequence. Runs for EVERY commit.7+#   2. Rust quality — rustfmt, clippy (warnings are errors), and the test suite,8+#      the same set the planned CI will run (docs/ROADMAP.md). Rust commits only.9+# Gate 2 is the local complement to that CI.10 #11 # Enable once per clone:   scripts/install-hooks.sh   (sets core.hooksPath)12 # Bypass for one commit:   git commit --no-verify13 #                     or:   THROUGHLINE_SKIP_HOOKS=1 git commit14+# Tune the size gate:      THROUGHLINE_MAX_COMMIT_FILES=N  (default 12; 0 disables)15+#                          THROUGHLINE_MAX_COMMIT_LINES=N  (default 400; 0 disables)16 #17 # Docs: docs/wiki/automation/pre-commit-hook.md

The wiki page gains a section with the rationale, the exact rule and why the files >= 4 guard exists, the exclusions, and how to tune or bypass it.

docs/wiki/automation/pre-commit-hook.md
@@ -28,0 +34,37 @@34+## The commit-size gate35+36+`throughline branch` renders a range of commits as one page with **one chapter per37+commit**, so a branch reads best as a sequence of small, self-contained commits. This38+gate (`.githooks/pre-commit:31-70`) keeps a commit from sprawling into several39+separable concerns that would each deserve their own chapter. It runs for **every**40+commit, before the Rust gate, and needs no `cargo`.41+42+It counts the *staged* change and aborts when either holds:43+44+- **too many files** — `files > MAX_FILES` (default **12**); or45+- **large *and* spread out** — `files >= 4` **and** `lines > MAX_LINES` (default46+  **400**, added + removed).47+48+The `files >= 4` guard is deliberate: a big single-file rewrite (≤ 3 files) is *one*49+concern and one chapter, so it never trips the line check — only genuinely spread-out50+changes do. That keeps the common atomic doc/README rewrite from being punished.51+52+**Excluded from the tally** (they balloon a commit without adding concerns): binary53+blobs (a `numstat` of `-`/`-`), `Cargo.lock`, `docs/assets/*` (images), and `*.html`54+(generated demo output). Widen the `case` at `.githooks/pre-commit:46-48` to exclude55+more.56+57+**Tuning and bypass.** Both thresholds are env-overridable, and `0` disables that58+check entirely:59+60+```sh61+THROUGHLINE_MAX_COMMIT_FILES=20 git commit   # allow more files62+THROUGHLINE_MAX_COMMIT_LINES=0  git commit   # turn off the line check63+git commit --no-verify                       # skip all hooks for one deliberately big commit64+```65+66+When a change genuinely is one atomic step that happens to be large, `--no-verify` is67+the right tool; otherwise, split it — see68+[`docs/llm-commit-guide.md`](../../llm-commit-guide.md) on shaping work as a sequence69+of small commits.70+

Remaining changes

.githooks/pre-commit
@@ -1,13 +1,18 @@1 #!/usr/bin/env bash2 # Pre-commit gate for commit-throughline.3 #-# Runs the project's quality checks on every commit: rustfmt, clippy (warnings-# are errors), and the test suite — the same set the planned CI will run (see-# docs/ROADMAP.md). This is the local complement to that CI.4+# Two gates, cheapest first:5+#   1. Commit size — abort a sprawling commit so a `throughline branch` stays a6+#      readable chapter-per-commit sequence. Runs for EVERY commit.7+#   2. Rust quality — rustfmt, clippy (warnings are errors), and the test suite,8+#      the same set the planned CI will run (docs/ROADMAP.md). Rust commits only.9+# Gate 2 is the local complement to that CI.10 #11 # Enable once per clone:   scripts/install-hooks.sh   (sets core.hooksPath)12 # Bypass for one commit:   git commit --no-verify13 #                     or:   THROUGHLINE_SKIP_HOOKS=1 git commit14+# Tune the size gate:      THROUGHLINE_MAX_COMMIT_FILES=N  (default 12; 0 disables)15+#                          THROUGHLINE_MAX_COMMIT_LINES=N  (default 400; 0 disables)16 #17 # Docs: docs/wiki/automation/pre-commit-hook.md18 @@ -23,6 +28,47 @@28 root=$(git rev-parse --show-toplevel) || exit 129 cd "$root" || exit 130 31+# --- commit-size gate: keep commits small and self-contained -----------------32+# A `throughline branch` renders one chapter per commit, so a branch reads best33+# as a sequence of focused commits. This gate aborts a sprawling commit — too34+# many files, or large AND spread across several files — to keep that sequence35+# legible. It is deliberately lenient: a big single-file rewrite (<= 3 files)36+# never trips the line check, only genuinely spread-out changes do. Generated /37+# vendored noise (binaries, Cargo.lock, docs/assets/*, *.html) does not count.38+max_files=${THROUGHLINE_MAX_COMMIT_FILES:-12}39+max_lines=${THROUGHLINE_MAX_COMMIT_LINES:-400}40+41+size_files=042+size_lines=043+while IFS=$'\t' read -r added removed path; do44+  case "$added" in ''|*[!0-9]*) continue ;; esac   # skip binary (-) / odd lines45+  case "$removed" in ''|*[!0-9]*) continue ;; esac46+  case "$path" in47+    Cargo.lock|docs/assets/*|*.html) continue ;;   # generated / vendored noise48+  esac49+  size_files=$(( size_files + 1 ))50+  size_lines=$(( size_lines + added + removed ))51+done < <(git diff --cached --numstat)52+53+size_over=054+if [ "$max_files" -gt 0 ] && [ "$size_files" -gt "$max_files" ]; then55+  size_over=1   # too many files — likely several separable concerns56+fi57+if [ "$max_lines" -gt 0 ] && [ "$size_files" -ge 4 ] && [ "$size_lines" -gt "$max_lines" ]; then58+  size_over=1   # large AND spread across 4+ files59+fi60+61+if [ "$size_over" -eq 1 ]; then62+  echo >&263+  echo "pre-commit: 'commit size' failed — large commit ($size_files files, ~$size_lines changed lines)." >&264+  echo "  A throughline branch reads best as a sequence of small, self-contained commits —" >&265+  echo "  one logical change each, one chapter each. If these are separable concerns, split" >&266+  echo "  them into focused commits; see docs/llm-commit-guide.md." >&267+  echo "  Genuinely atomic and just big? Bypass with: git commit --no-verify" >&268+  echo "  Or relax: THROUGHLINE_MAX_COMMIT_FILES / THROUGHLINE_MAX_COMMIT_LINES (0 disables a check)." >&269+  exit 170+fi71+72 # --- skip the cargo build entirely for non-Rust commits ----------------------73 # Decide from the index (what is being committed), not the dirty worktree.74 staged=$(git diff --cached --name-only)
docs/wiki/automation/pre-commit-hook.md
@@ -1,22 +1,28 @@1 # The pre-commit hook2 -How this repo enforces `fmt` / `clippy` / `test` locally, before a commit is ever-made. Every commit that touches Rust runs the same checks the planned CI will run;-this is the local half of that guarantee, and the page the hook and installer point-back to. Read it if a commit was blocked, if you want to install or bypass the hook,-or if you are changing what it runs.3+How this repo keeps commits **small** and **clean** locally, before a commit is ever4+made. The hook runs two gates: a commit-size check on *every* commit (so a5+`throughline branch` stays a readable chapter-per-commit sequence), then `fmt` /6+`clippy` / `test` on every commit that touches Rust — the same checks the planned CI7+will run. This is the local half of that guarantee, and the page the hook and8+installer point back to. Read it if a commit was blocked, if you want to install or9+bypass the hook, or if you are changing what it runs.10 11 ## What it does12 -The repo documents `cargo fmt` / `cargo clippy` / `cargo test` as the bar for every-change (see the [README](../../../README.md#development) and the-[roadmap CI item](../../ROADMAP.md)), but nothing local enforced it — a stray-formatting slip or a clippy warning could ride into a commit and only surface later.-The pre-commit hook closes that gap: it runs the three checks against the working-tree and **aborts the commit** if any fail.13+The hook ([`.githooks/pre-commit`](../../../.githooks/pre-commit)) runs two gates in14+order, both aborting the commit on failure:15 -The gate lives in [`.githooks/pre-commit`](../../../.githooks/pre-commit) and runs-three steps, cheapest first so a failure surfaces fast (`.githooks/pre-commit:46-63`):16+**1. The commit-size gate (every commit).** Keeps a commit from sprawling so the17+branch view stays legible — see [its own section](#the-commit-size-gate) below.18+19+**2. The Rust quality gate (Rust commits only).** The repo documents `cargo fmt` /20+`cargo clippy` / `cargo test` as the bar for every change (see the21+[README](../../../README.md#development) and the [roadmap CI item](../../ROADMAP.md)),22+but nothing local enforced it — a stray formatting slip or a clippy warning could ride23+into a commit and only surface later. The hook closes that gap, running three steps24+against the working tree, cheapest first so a failure surfaces fast25+(`.githooks/pre-commit:107-109`):26 27 1. `cargo fmt --check` — formatting is already clean.28 2. `cargo clippy --all-targets -- -D warnings` — no warnings, in any target.@@ -25,20 +31,57 @@31 It is deliberately the same triple the [roadmap](../../ROADMAP.md) plans to run in CI32 on Codeberg: the hook is the fast local mirror, CI the backstop no one can skip.33 34+## The commit-size gate35+36+`throughline branch` renders a range of commits as one page with **one chapter per37+commit**, so a branch reads best as a sequence of small, self-contained commits. This38+gate (`.githooks/pre-commit:31-70`) keeps a commit from sprawling into several39+separable concerns that would each deserve their own chapter. It runs for **every**40+commit, before the Rust gate, and needs no `cargo`.41+42+It counts the *staged* change and aborts when either holds:43+44+- **too many files** — `files > MAX_FILES` (default **12**); or45+- **large *and* spread out** — `files >= 4` **and** `lines > MAX_LINES` (default46+  **400**, added + removed).47+48+The `files >= 4` guard is deliberate: a big single-file rewrite (≤ 3 files) is *one*49+concern and one chapter, so it never trips the line check — only genuinely spread-out50+changes do. That keeps the common atomic doc/README rewrite from being punished.51+52+**Excluded from the tally** (they balloon a commit without adding concerns): binary53+blobs (a `numstat` of `-`/`-`), `Cargo.lock`, `docs/assets/*` (images), and `*.html`54+(generated demo output). Widen the `case` at `.githooks/pre-commit:46-48` to exclude55+more.56+57+**Tuning and bypass.** Both thresholds are env-overridable, and `0` disables that58+check entirely:59+60+```sh61+THROUGHLINE_MAX_COMMIT_FILES=20 git commit   # allow more files62+THROUGHLINE_MAX_COMMIT_LINES=0  git commit   # turn off the line check63+git commit --no-verify                       # skip all hooks for one deliberately big commit64+```65+66+When a change genuinely is one atomic step that happens to be large, `--no-verify` is67+the right tool; otherwise, split it — see68+[`docs/llm-commit-guide.md`](../../llm-commit-guide.md) on shaping work as a sequence69+of small commits.70+71 ## When it runs — and when it gets out of the way72 73 `core.hooksPath` points Git at `.githooks/` (see [Installing](#installing)), so the-hook fires on every `git commit`. Two early exits keep it from taxing commits that-can't benefit:74+hook fires on every `git commit`. The size gate above always runs first; two early75+exits then keep the *cargo* gate from taxing commits that can't benefit:76 -- **Non-Rust commits skip the cargo build entirely.** The hook reads the *staged*-  paths and, if none are Rust-relevant, exits `0` without invoking cargo-  (`.githooks/pre-commit:26-32`). "Rust-relevant" means a `*.rs` file,77+- **Non-Rust commits skip the cargo build entirely.** After the size gate, the hook78+  reads the *staged* paths and, if none are Rust-relevant, exits `0` without invoking79+  cargo (`.githooks/pre-commit:72-78`). "Rust-relevant" means a `*.rs` file,80   `Cargo.toml`/`Cargo.lock`, or a `rust-toolchain*`. A docs- or config-only commit —-  common in this repo — is instant.81+  common in this repo — is instant (but still size-checked).82 - **An explicit bypass.** `git commit --no-verify` skips all hooks (Git's own83   mechanism); `THROUGHLINE_SKIP_HOOKS=1 git commit` skips just this one-  (`.githooks/pre-commit:16-20`). Use them for a deliberate work-in-progress commit —84+  (`.githooks/pre-commit:22-25`). Use them for a deliberate work-in-progress commit —85   CI still has the final say.86 87 Note the asymmetry: the relevance check reads the **index** (what is being@@ -67,7 +110,7 @@110 - **`cargo` must be reachable.** GUI Git clients and other non-login shells often111   start without `~/.cargo/bin` on `PATH`. The hook defends against this — it sources112   `~/.cargo/env` and prepends `~/.cargo/bin` before looking for cargo-  (`.githooks/pre-commit:34-44`). If cargo is still missing it prints how to install113+  (`.githooks/pre-commit:80-90`). If cargo is still missing it prints how to install114   Rust or bypass, and aborts rather than silently passing.115 - **The committed hook must be executable.** Git ignores a hook file that lacks the116   execute bit — with no warning. The file is tracked mode `755`, and the installer@@ -90,6 +133,6 @@133 134 | File | Role |135 |------|------|-| [`.githooks/pre-commit`](../../../.githooks/pre-commit) | The gate: skip-if-no-Rust, ensure cargo, run fmt/clippy/test. |136+| [`.githooks/pre-commit`](../../../.githooks/pre-commit) | Both gates: commit-size check (every commit), then skip-if-no-Rust, ensure cargo, run fmt/clippy/test. |137 | [`scripts/install-hooks.sh`](../../../scripts/install-hooks.sh) | One-time setup: point `core.hooksPath` at `.githooks` and fix the exec bit. |138 | `.git/config` (`core.hooksPath`) | Per-clone, shared across worktrees; not version-controlled. |
11/37dedd029Edu Ramírez

Teach commit decomposition, not just message authoring

The format had guidance for writing one commit's message but nothing on commit shape. This adds the missing half — author the work as a sequence of small, self-contained commits — to the agent guide, the project conventions, and the README.

The drop-in agent prompt now opens by shaping the commits — one logical change each, each step its own commit, prefer several small over one sprawling — before the unchanged message rules.

docs/llm-commit-guide.md
@@ -8,5 +8,14 @@-Write commit messages so they render as a rich diff walkthrough that TEACHES the change.-Plain Markdown. Lead the reader into each piece of code; don't caption it after the fact.-8+Author the change as a SEQUENCE of small, self-contained commits, and write each commit's9+message so it renders as a rich diff walkthrough that TEACHES the change. Plain Markdown.10+11+Shape the commits, not just the messages:12+- One logical change per commit — each builds and passes tests on its own.13+- When a task has several steps, land each step as its own commit, in the order a reader14+  should follow. A `throughline branch` then renders the work as one chapter per commit.15+- Prefer several small commits over one sprawling commit: smaller commits review better16+  and render as a clearer, chapter-per-step walkthrough.17+18+Write each commit's message — lead the reader into each piece of code; don't caption it19+after the fact:20 - First line: the title.21 - Then a one- or two-sentence TL;DR — the whole change in a nutshell. It renders as a

Its "why" note explains the payoff: completeness holds per commit, so a stack of focused commits renders as a branch with one self-contained chapter per step.

docs/llm-commit-guide.md
@@ -47,0 +56,4 @@56+Small commits compound the effect. Completeness holds **per commit**, so a stack of57+focused commits renders as a `throughline branch` with one self-contained chapter per58+step — a change you can read top to bottom — instead of one giant chapter no order can59+rescue. Splitting the work is therefore part of authoring it, not a chore done after.

The project conventions gain a sibling to "Dogfood the format": one logical change per commit, landed as a sequence, with the new size gate as the backstop.

CLAUDE.md
@@ -102,0 +103,7 @@103+- **Small, self-contained commits.** One logical change per commit — each should104+  build and pass tests on its own. When a task is large, land it as a *sequence* of105+  focused commits, not one sprawling commit, so `throughline branch HEAD --base main`106+  reads as a chapter-per-step walkthrough (completeness holds per commit). The107+  pre-commit hook aborts an over-large commit as a backstop — split it, or bypass a108+  deliberately big one with `--no-verify`. See109+  [`docs/llm-commit-guide.md`](docs/llm-commit-guide.md).

In the README, the branch view's payoff now names what it rewards — small, self-contained commits, split from a big change into focused steps.

README.md
@@ -193,0 +193,6 @@193+Because each commit becomes its own chapter, the branch view rewards **small,194+self-contained commits**: split a big change into a sequence of focused commits —195+one logical step each — and the page reads as a story rather than one overwhelming196+chapter. See [Writing throughline-friendly197+commits](#writing-throughline-friendly-commits) below.198+

And the authoring checklist gains a rule for it, pointing back at the branch view.

README.md
@@ -286,0 +293,5 @@293+5. **Keep each commit small and self-contained.** The walkthrough is authored per294+   commit, so a big change reads best as a *sequence* of focused commits — one295+   logical step each — rendered together with `throughline branch` ([above](#a-whole-branch-or-pull-request)).296+   The pre-commit hook nudges you here, aborting an over-large commit (bypass a297+   deliberately big one with `--no-verify`).

Remaining changes

CLAUDE.md
@@ -31,8 +31,9 @@31 32 Install the Git hooks once per clone with `./scripts/install-hooks.sh` (it points33 `core.hooksPath` at `.githooks/`, enabling all of them). The **pre-commit** hook-runs `fmt` / `clippy` / `test` on every Rust commit (skipped for non-Rust commits;-bypass with `--no-verify`) — see34+runs `fmt` / `clippy` / `test` on every Rust commit (skipped for non-Rust commits)35+and aborts an over-large commit on *any* commit, to keep changes small and36+self-contained (bypass with `--no-verify`) — see37 [`docs/wiki/automation/pre-commit-hook.md`](docs/wiki/automation/pre-commit-hook.md).38 The **pre-push** hook re-renders and publishes the live demo when you push `main`,39 best-effort so it never blocks the push — see@@ -99,6 +100,13 @@100   title; a one- or two-sentence TL;DR; then, for each change, the prose that *leads101   into* it followed by the `path:lines` reference on its own line. Prose prepares the102   reader for the code that follows — explanation first, then the code it describes.103+- **Small, self-contained commits.** One logical change per commit — each should104+  build and pass tests on its own. When a task is large, land it as a *sequence* of105+  focused commits, not one sprawling commit, so `throughline branch HEAD --base main`106+  reads as a chapter-per-step walkthrough (completeness holds per commit). The107+  pre-commit hook aborts an over-large commit as a backstop — split it, or bypass a108+  deliberately big one with `--no-verify`. See109+  [`docs/llm-commit-guide.md`](docs/llm-commit-guide.md).110 - **Commit to the current branch.** Commit work to whatever branch the session is111   checked out on — do *not* auto-create a branch off `main`. If the session starts112   on `main`, commit to `main`; if it starts in a worktree or feature branch, keep
README.md
@@ -190,6 +190,12 @@190   the page composes the commits in order rather than squashing them. (In a repo191   whose trunk is `master`, pass `--base master`.)192 193+Because each commit becomes its own chapter, the branch view rewards **small,194+self-contained commits**: split a big change into a sequence of focused commits —195+one logical step each — and the page reads as a story rather than one overwhelming196+chapter. See [Writing throughline-friendly197+commits](#writing-throughline-friendly-commits) below.198+199 ### Working in a worktree200 201 Git worktrees let you keep several branches checked out at once. `throughline`@@ -201,9 +207,10 @@207 208 This is where the convention earns its keep. Drop the short instruction from209 [`docs/llm-commit-guide.md`](docs/llm-commit-guide.md) into your coding agent's-system prompt, and it will write commit messages that render as teaching-walkthroughs. Then, instead of scrolling a wall of agent-authored diff, you read-the branch as a narrated story:210+system prompt, and it will both **shape its work as a stack of small,211+self-contained commits** — one logical step each — and write each message so it212+renders as a teaching walkthrough. Then, instead of scrolling a wall of213+agent-authored diff, you read the branch as a narrated story:214 215 ```sh216 throughline branch <the-agent's-branch> --open@@ -283,6 +290,11 @@290    renderer injects the real diff. Anything you don't mention is appended291    automatically, so you're choosing *order* and adding *explanation* — never292    selecting what to include.293+5. **Keep each commit small and self-contained.** The walkthrough is authored per294+   commit, so a big change reads best as a *sequence* of focused commits — one295+   logical step each — rendered together with `throughline branch` ([above](#a-whole-branch-or-pull-request)).296+   The pre-commit hook nudges you here, aborting an over-large commit (bypass a297+   deliberately big one with `--no-verify`).298 299 The anchor grammar in full:300 @@ -357,8 +369,9 @@369 cargo clippy   # lint370 ```371 -A committed Git pre-commit hook runs all three on every commit that touches Rust.-Enable it once per clone:372+A committed Git pre-commit hook runs all three on every commit that touches Rust,373+and aborts an over-large commit on any commit (keeping changes small and374+self-contained). Enable it once per clone:375 376 ```sh377 ./scripts/install-hooks.sh   # sets core.hooksPath; bypass a commit with --no-verify
docs/llm-commit-guide.md
@@ -5,9 +5,18 @@5 below is self-contained — copy it verbatim.6 7 ```-Write commit messages so they render as a rich diff walkthrough that TEACHES the change.-Plain Markdown. Lead the reader into each piece of code; don't caption it after the fact.-8+Author the change as a SEQUENCE of small, self-contained commits, and write each commit's9+message so it renders as a rich diff walkthrough that TEACHES the change. Plain Markdown.10+11+Shape the commits, not just the messages:12+- One logical change per commit — each builds and passes tests on its own.13+- When a task has several steps, land each step as its own commit, in the order a reader14+  should follow. A `throughline branch` then renders the work as one chapter per commit.15+- Prefer several small commits over one sprawling commit: smaller commits review better16+  and render as a clearer, chapter-per-step walkthrough.17+18+Write each commit's message — lead the reader into each piece of code; don't caption it19+after the fact:20 - First line: the title.21 - Then a one- or two-sentence TL;DR — the whole change in a nutshell. It renders as a22   distinct summary at the top, so make it self-contained.@@ -44,4 +53,9 @@53 callout. The reader meets the explanation first and the code second, so the page54 teaches the change instead of captioning it.55 56+Small commits compound the effect. Completeness holds **per commit**, so a stack of57+focused commits renders as a `throughline branch` with one self-contained chapter per58+step — a change you can read top to bottom — instead of one giant chapter no order can59+rescue. Splitting the work is therefore part of authoring it, not a chore done after.60+61 See [`SPEC.md`](SPEC.md) for the full anchor grammar and semantics.
12/376e70fa6Edu Ramírez

Reframe a throughline as one source projected onto many surfaces

A throughline is a projection of a single source — the commit message plus the diff — onto a surface, not an HTML format. This reframes the docs around that idea, records the planned Markdown/PR-description projection as staged work, and names the solo, no-forge review loop the tool already serves.

The README's thesis was "two halves of a commit become one page." Lift it a level: the page is just one projection of the message-plus-diff source — which is what makes a pull-request description (not the commit, which renders almost no Markdown) the one native surface that could host an inline throughline next.

README.md
@@ -106,0 +106,21 @@106+## One source, many surfaces107+108+The HTML page is a *projection*, not the point. The source is always the same —109+your commit message plus the diff Git already has — and the renderer paints it110+onto a surface. Today that surface is the self-contained HTML page above. But a111+commit message, a diff, and a **pull-request description** are three places the112+*same change* gets described independently, and they drift. Treat the113+message-plus-diff as the single source and the rest become derived views of it.114+115+That's the next surface on the116+[roadmap](docs/ROADMAP.md#markdown-output-and-the-pr-surface): a **Markdown**117+projection of the same walkthrough. It targets the one native forge surface that118+can display a throughline at all — a commit message renders almost no Markdown,119+but a pull-request description renders all of it, so the PR body (not the commit)120+is where an inline throughline can actually live.121+122+**You don't need pull requests to get the payoff.** Working solo, the HTML page123+*is* the deliverable — render a commit or a branch and read it. See [Agentic124+coding](#agentic-coding) below for that loop: no forge, no remote, just125+`throughline` and your browser.126+

Most of the payoff today is local and solo, so spell that out where the agent-review workflow already lives: a local loop with no forge or remote, plus the planned terminal path — --format=markdown piped into mdcat/glow — for readers who would rather stay out of the browser.

README.md
@@ -224,0 +245,8 @@245+This is a *local* loop — no PR, no remote, nothing published; just the agent's246+branch, `throughline`, and your browser. For solo work that review-by-walkthrough247+*is* the workflow, and it's the case the tool serves best today. Prefer to stay in248+the terminal? A planned `--format=markdown` output249+([roadmap](docs/ROADMAP.md#markdown-output-and-the-pr-surface)) will pipe the same250+walkthrough into a Markdown pager such as [`mdcat`](https://github.com/swsnr/mdcat)251+or `glow`.252+

The unbuilt work needs an honest home, so replace the vague "export formats beyond HTML" line with a staged plan: the offline Markdown emitter first, then a non-destructive throughline pr helper that owns a managed region, with CI regeneration deliberately deferred.

docs/ROADMAP.md
@@ -158,0 +158,33 @@158+## Markdown output and the PR surface159+160+A throughline is a *projection* of a single source — the commit message plus the161+diff Git already has — onto a surface. Today the only surface is a self-contained162+HTML page. A **Markdown** projection unlocks every surface that renders Markdown163+natively, the most valuable being a **pull-request description**: the one native164+forge surface that can actually display a throughline (a commit message renders165+almost no Markdown; a PR body renders all of it). Staged so each step stands on its166+own:167+168+- [ ] **`--format=markdown`** — emit the walkthrough as Markdown with the resolved169+  hunks inlined as fenced `diff` code blocks, reusing the existing170+  resolve/coverage/backfill pipeline (a sibling emitter to171+  [`html.rs`](../src/html.rs)). Pure and offline — no network, no auth — and useful172+  on its own: pipe it into a Markdown pager (`mdcat`, `glow`) for a terminal173+  walkthrough, or drop it into a gist or wiki. Two questions to settle: **size**174+  (fold the backfill into `<details>`, or cap it and link to the full HTML page) and175+  **completeness** (keep the guarantee; make the backfill collapsible rather than176+  relax it).177+- [ ] **`throughline pr`** — a thin helper over `--format=markdown` that writes the178+  walkthrough into a pull-request description **non-destructively**: it owns a179+  managed region (`<!-- throughline:start --> … <!-- throughline:end -->`) and180+  updates only that, leaving the author's own prose, issue links, and checklists181+  intact. Shells out to the forge CLI (`gh`, `fj`/`tea`) so auth and API differences182+  stay out of the Rust core. Author-invoked — a snapshot at the moment you run it.183+- [ ] **CI regeneration** *(deferred)* — a workflow that re-renders the PR body on184+  every push so the projection can't drift from the diff. Deferred deliberately: it185+  takes on forge coupling, maintenance, and the size edge cases above. Ship the two186+  steps above first; add this only on demand.187+188+(This is where the old "export formats beyond HTML" line went: the format that189+matters first is Markdown, because of where it can land.)190+

Finally, keep the spec from contradicting the roadmap. The out-of-scope note now reads as "planned, not abandoned" and points at that section, while the format itself stays surface-agnostic.

docs/SPEC.md
@@ -291,1 +291,4 @@-- Merging or syncing rendered walkthroughs across branches; export formats other than HTML.291+- Merging or syncing rendered walkthroughs across branches. (Output beyond the PoC's292+  HTML — notably a **Markdown** projection for PR descriptions and other293+  Markdown-native surfaces — is *planned*, not abandoned; see294+  [`ROADMAP.md`](ROADMAP.md#markdown-output-and-the-pr-surface).)

Remaining changes

README.md
@@ -103,6 +103,27 @@103 convention is just style advice without a renderer to reward it. Together they104 turn "write a commit message" into "explain the change."105 106+## One source, many surfaces107+108+The HTML page is a *projection*, not the point. The source is always the same —109+your commit message plus the diff Git already has — and the renderer paints it110+onto a surface. Today that surface is the self-contained HTML page above. But a111+commit message, a diff, and a **pull-request description** are three places the112+*same change* gets described independently, and they drift. Treat the113+message-plus-diff as the single source and the rest become derived views of it.114+115+That's the next surface on the116+[roadmap](docs/ROADMAP.md#markdown-output-and-the-pr-surface): a **Markdown**117+projection of the same walkthrough. It targets the one native forge surface that118+can display a throughline at all — a commit message renders almost no Markdown,119+but a pull-request description renders all of it, so the PR body (not the commit)120+is where an inline throughline can actually live.121+122+**You don't need pull requests to get the payoff.** Working solo, the HTML page123+*is* the deliverable — render a commit or a branch and read it. See [Agentic124+coding](#agentic-coding) below for that loop: no forge, no remote, just125+`throughline` and your browser.126+127 ## See it in action128 129 The whole [live demo](https://eduramirezh.codeberg.page/commit-throughline/) is@@ -221,6 +242,14 @@242 can't quietly skip the part it's least sure about), and you review intent and243 code side by side. It pairs naturally with worktree-per-agent setups.244 245+This is a *local* loop — no PR, no remote, nothing published; just the agent's246+branch, `throughline`, and your browser. For solo work that review-by-walkthrough247+*is* the workflow, and it's the case the tool serves best today. Prefer to stay in248+the terminal? A planned `--format=markdown` output249+([roadmap](docs/ROADMAP.md#markdown-output-and-the-pr-surface)) will pipe the same250+walkthrough into a Markdown pager such as [`mdcat`](https://github.com/swsnr/mdcat)251+or `glow`.252+253 ### Wiring it into any project254 255 Because `throughline` operates on the enclosing Git repo, it drops into any
docs/ROADMAP.md
@@ -155,6 +155,39 @@155 `@focus` has no forge-standard fragment spelling, so it remains a Throughline156 extension regardless.157 158+## Markdown output and the PR surface159+160+A throughline is a *projection* of a single source — the commit message plus the161+diff Git already has — onto a surface. Today the only surface is a self-contained162+HTML page. A **Markdown** projection unlocks every surface that renders Markdown163+natively, the most valuable being a **pull-request description**: the one native164+forge surface that can actually display a throughline (a commit message renders165+almost no Markdown; a PR body renders all of it). Staged so each step stands on its166+own:167+168+- [ ] **`--format=markdown`** — emit the walkthrough as Markdown with the resolved169+  hunks inlined as fenced `diff` code blocks, reusing the existing170+  resolve/coverage/backfill pipeline (a sibling emitter to171+  [`html.rs`](../src/html.rs)). Pure and offline — no network, no auth — and useful172+  on its own: pipe it into a Markdown pager (`mdcat`, `glow`) for a terminal173+  walkthrough, or drop it into a gist or wiki. Two questions to settle: **size**174+  (fold the backfill into `<details>`, or cap it and link to the full HTML page) and175+  **completeness** (keep the guarantee; make the backfill collapsible rather than176+  relax it).177+- [ ] **`throughline pr`** — a thin helper over `--format=markdown` that writes the178+  walkthrough into a pull-request description **non-destructively**: it owns a179+  managed region (`<!-- throughline:start --> … <!-- throughline:end -->`) and180+  updates only that, leaving the author's own prose, issue links, and checklists181+  intact. Shells out to the forge CLI (`gh`, `fj`/`tea`) so auth and API differences182+  stay out of the Rust core. Author-invoked — a snapshot at the moment you run it.183+- [ ] **CI regeneration** *(deferred)* — a workflow that re-renders the PR body on184+  every push so the projection can't drift from the diff. Deferred deliberately: it185+  takes on forge coupling, maintenance, and the size edge cases above. Ship the two186+  steps above first; add this only on demand.187+188+(This is where the old "export formats beyond HTML" line went: the format that189+matters first is Markdown, because of where it can land.)190+191 ## Confidence & developer experience192 193 - [x] **Living docs** — a [`docs/wiki/`](wiki/README.md) knowledge base for developers@@ -192,4 +225,5 @@225   emit both the git operation and the combined narrative. Turns "prepare a branch226   for merge" into "compose the change's story."227 - [ ] **Content-based anchoring** instead of line numbers (v2; survives rebases).-- [ ] **Export formats** beyond HTML.228+- [ ] **More export formats** (PDF, …) beyond the HTML and229+  [Markdown](#markdown-output-and-the-pr-surface) projections.
docs/SPEC.md
@@ -288,7 +288,10 @@288 289 - Interactive / slide-by-slide presentation UI (later, as CSS/JS over the HTML).290 - Content-based anchoring instead of line numbers (v2; fragile across rebases but fine here).-- Merging or syncing rendered walkthroughs across branches; export formats other than HTML.291+- Merging or syncing rendered walkthroughs across branches. (Output beyond the PoC's292+  HTML — notably a **Markdown** projection for PR descriptions and other293+  Markdown-native surfaces — is *planned*, not abandoned; see294+  [`ROADMAP.md`](ROADMAP.md#markdown-output-and-the-pr-surface).)295 296 ## Instruction for the LLM297 
13/37b619835Edu Ramírez

Add a Markdown emitter mirroring the HTML renderer

A sibling to the HTML emitter that projects the same Document onto Markdown — for a pager, a gist, or (later) a PR body. It inherits completeness: every hunk still appears, the backfill merely folded into a collapsible block.

The emitter walks the same Document sections as html.rs, but the destination renders Markdown itself, so prose passes through verbatim (never a Markdown library) and each diff hunk becomes a fenced diff block. A fence-length guard widens the fence past the longest backtick run so a line that is itself a code fence can't close the block early, and an @focus range — which a fenced block can't highlight — becomes a one-line caption.

src/markdown.rs
@@ -0,0 +1,386 @@1+//! Emit a Markdown rendering from a [`crate::render::Document`], a sibling to2+//! [`crate::html`]. The destination (a pager, gist, wiki, or PR body) renders3+//! Markdown itself, so prose passes through **verbatim** — it is never run through4+//! a Markdown library the way `html.rs` does. Diff hunks become fenced ```diff5+//! blocks; the backfill is folded into a `<details>` block but kept fully present,6+//! so the completeness guarantee still holds (it is only collapsed, never dropped).7+//!8+//! This module is kept structurally parallel to `html.rs` (`document_body`, the9+//! `Trailing` enum, the backfill grouping in `backfill_md`) so the two emitters can10+//! be read side by side. Keep them in sync when one changes.11+12+use std::path::Path;13+14+use crate::diff::{DeltaMarker, Hunk};15+use crate::render::{hunk_to_code_lines, CodeBlock, CodeLine, Document, Section};16+17+/// A trailing (backfill-region) item: a not-yet-shown hunk or a delta-level18+/// marker. Collected in natural order, then rendered together after the authored19+/// blocks. (Mirrors `html::Trailing`.)20+enum Trailing<'a> {21+    Hunk {22+        path: &'a str,23+        hunk: &'a Hunk,24+    },25+    Marker {26+        path: &'a str,27+        marker: &'a DeltaMarker,28+    },29+}30+31+/// Render `doc` to a Markdown walkthrough: an `#` title, then the body.32+pub fn to_markdown(doc: &Document) -> String {33+    let mut blocks = vec![heading(1, &doc.title)];34+    blocks.extend(document_body(doc));35+    let mut out = join_blocks(blocks);36+    out.push('\n');37+    out38+}39+40+/// The body of one document as a list of self-contained blocks (each with no41+/// trailing newline): every authored section in order, then the backfill region.42+/// Emits no title heading — the caller owns it (so a branch chapter can supply its43+/// own `##`). Mirrors `html::document_body`.44+fn document_body(doc: &Document) -> Vec<String> {45+    let mut blocks = Vec::new();46+47+    // The backfill region is always the trailing run; collect its items so they can48+    // be grouped by file, then rendered after the authored blocks (natural order49+    // preserved).50+    let mut backfill: Vec<Trailing> = Vec::new();51+    for section in &doc.sections {52+        match section {53+            Section::Tldr(md) => blocks.push(tldr_md(md)),54+            Section::Prose(md) => blocks.push(prose_md(md)),55+            Section::Code(cb) => blocks.push(code_md(cb)),56+            Section::Backfill { path, hunk } => backfill.push(Trailing::Hunk { path, hunk }),57+            Section::Marker { path, marker } => backfill.push(Trailing::Marker { path, marker }),58+        }59+    }60+61+    if !backfill.is_empty() {62+        // Fold the backfill into a <details> only when there is authored code above63+        // it; a plain commit is simply its full diff, with no wrapper or heading.64+        let collapsible = doc.sections.iter().any(|s| matches!(s, Section::Code(_)));65+        blocks.push(backfill_md(&backfill, collapsible));66+    }67+68+    blocks69+}70+71+/// The leading TL;DR, rendered as a blockquote lede (content kept verbatim).72+fn tldr_md(md: &str) -> String {73+    blockquote(md)74+}75+76+/// A prose lead-in or intro — already Markdown, emitted verbatim.77+fn prose_md(md: &str) -> String {78+    md.to_string()79+}80+81+/// A resolved (or degraded) code callout: a degraded warning, a plain "unmodified"82+/// context block, or a fenced `diff` block. Mirrors `html::code_html`.83+fn code_md(cb: &CodeBlock) -> String {84+    if cb.degraded {85+        let detail = cb86+            .note87+            .as_ref()88+            .map(|n| format!(": {n}"))89+            .unwrap_or_default();90+        return blockquote(&format!(91+            "\u{26a0} could not resolve `{}`{}",92+            cb.path, detail93+        ));94+    }95+96+    // Context (a whole unchanged file or range) is not a diff, so flag it97+    // "unmodified" and highlight it as the file's language rather than as a diff.98+    if cb.context {99+        let cap = format!("{} _(unmodified)_", caption(&cb.path));100+        let block = fenced(lang_for(&cb.path), &context_lines(&cb.lines));101+        return join_blocks(vec![cap, block]);102+    }103+104+    let mut parts = vec![caption(&cb.path)];105+    if let Some(fc) = focus_caption(&cb.lines) {106+        parts.push(fc);107+    }108+    parts.push(fenced("diff", &diff_lines(&cb.lines)));109+    join_blocks(parts)110+}111+112+/// Render the collected backfill items, grouping consecutive hunks of one file into113+/// a single ```diff block. A marker closes any open group and renders as a one-line114+/// note. When `collapsible`, the whole region is folded into a `<details>` block115+/// (the backfill stays fully present — it is only collapsed). Mirrors116+/// `html::backfill_html`; keep the grouping in sync.117+fn backfill_md(items: &[Trailing], collapsible: bool) -> String {118+    let mut blocks: Vec<String> = Vec::new();119+    let mut current: Option<&str> = None; // path of the open hunk group, if any120+    let mut group: Vec<String> = Vec::new();121+    let close = |blocks: &mut Vec<String>, current: &mut Option<&str>, group: &mut Vec<String>| {122+        if let Some(path) = current.take() {123+            blocks.push(join_blocks(vec![caption(path), fenced("diff", group)]));124+            group.clear();125+        }126+    };127+    for item in items {128+        match item {129+            Trailing::Hunk { path, hunk } => {130+                if current != Some(path) {131+                    close(&mut blocks, &mut current, &mut group);132+                    current = Some(path);133+                }134+                group.extend(diff_lines(&hunk_to_code_lines(hunk, None)));135+            }136+            Trailing::Marker { path, marker } => {137+                close(&mut blocks, &mut current, &mut group);138+                blocks.push(format!("{} \u{2014} {}", caption(path), marker));139+            }140+        }141+    }142+    close(&mut blocks, &mut current, &mut group);143+144+    let inner = join_blocks(blocks);145+    if collapsible {146+        // Strict blank-line discipline: a blank line after </summary> and before147+        // </details> so GitHub/Forgejo parse the fenced code inside as Markdown.148+        format!("<details>\n<summary>Remaining changes</summary>\n\n{inner}\n\n</details>")149+    } else {150+        inner151+    }152+}153+154+/// Turn display lines into unified-diff text: the `@@` header verbatim, and every155+/// other line prefixed with its sign (`+`/`-`/` `). Emitted unescaped so the156+/// destination's `diff` highlighting works.157+fn diff_lines(lines: &[CodeLine]) -> Vec<String> {158+    lines159+        .iter()160+        .map(|l| match l.origin {161+            '@' => l.content.clone(),162+            '+' => format!("+{}", l.content),163+            '-' => format!("-{}", l.content),164+            _ => format!(" {}", l.content),165+        })166+        .collect()167+}168+169+/// The raw content of context lines (no diff signs).170+fn context_lines(lines: &[CodeLine]) -> Vec<String> {171+    lines.iter().map(|l| l.content.clone()).collect()172+}173+174+/// A one-line caption naming the anchor's `@focus` lines, or `None` when no shown175+/// line is focused (deletions are never focused, so a focus that lands only on176+/// removed lines has nothing to cite). Focused new-side numbers are grouped per177+/// hunk run (split on `@` headers), so a whole-file anchor spanning hunks lists178+/// each run, e.g. `10–12, 80–82`.179+fn focus_caption(lines: &[CodeLine]) -> Option<String> {180+    let mut runs: Vec<(u32, u32)> = Vec::new();181+    let mut cur: Option<(u32, u32)> = None;182+    for line in lines {183+        if line.origin == '@' {184+            runs.extend(cur.take());185+            continue;186+        }187+        if line.focus {188+            if let Some(n) = line.number {189+                cur = Some(match cur {190+                    Some((lo, hi)) => (lo.min(n), hi.max(n)),191+                    None => (n, n),192+                });193+            }194+        }195+    }196+    runs.extend(cur.take());197+198+    if runs.is_empty() {199+        return None;200+    }201+    let parts: Vec<String> = runs202+        .iter()203+        .map(|(lo, hi)| {204+            if lo == hi {205+                lo.to_string()206+            } else {207+                format!("{lo}\u{2013}{hi}")208+            }209+        })210+        .collect();211+    let noun = if runs.len() == 1 && runs[0].0 == runs[0].1 {212+        "line"213+    } else {214+        "lines"215+    };216+    Some(format!("_Focus: {noun} {}_", parts.join(", ")))217+}218+219+/// Wrap `content` lines in a fenced code block carrying `info`, choosing a fence220+/// long enough that no content line can close it early. CommonMark lets a closing221+/// fence be indented up to three spaces, so a context line that is just backticks222+/// (e.g. ` \`\`\`` in a diff, or a raw fence in an unmodified block) would terminate223+/// a three-backtick block; using one more backtick than the longest run anywhere in224+/// the content prevents that. The closing fence carries no info string (one that225+/// did would not be recognized as a closer).226+fn fenced(info: &str, lines: &[String]) -> String {227+    let longest = lines.iter().map(|l| max_backtick_run(l)).max().unwrap_or(0);228+    let fence = "`".repeat(longest.max(2) + 1);229+    let mut out = String::new();230+    out.push_str(&fence);231+    out.push_str(info);232+    out.push('\n');233+    for line in lines {234+        out.push_str(line);235+        out.push('\n');236+    }237+    out.push_str(&fence);238+    out239+}240+241+/// The length of the longest run of consecutive backtick characters in `line`.242+fn max_backtick_run(line: &str) -> usize {243+    let mut longest = 0;244+    let mut run = 0;245+    for c in line.chars() {246+        if c == '`' {247+            run += 1;248+            longest = longest.max(run);249+        } else {250+            run = 0;251+        }252+    }253+    longest254+}255+256+/// A code-fence info string (language) guessed from a path's extension, or `""`257+/// when unknown. Returns a fixed constant, never the raw extension.258+fn lang_for(path: &str) -> &'static str {259+    let ext = Path::new(path)260+        .extension()261+        .and_then(|e| e.to_str())262+        .unwrap_or("");263+    match ext {264+        "rs" => "rust",265+        "py" => "python",266+        "js" | "mjs" | "cjs" => "javascript",267+        "ts" => "typescript",268+        "go" => "go",269+        "rb" => "ruby",270+        "java" => "java",271+        "c" | "h" => "c",272+        "cc" | "cpp" | "cxx" | "hpp" => "cpp",273+        "sh" | "bash" => "bash",274+        "md" | "markdown" => "markdown",275+        "toml" => "toml",276+        "yaml" | "yml" => "yaml",277+        "json" => "json",278+        "html" => "html",279+        "css" => "css",280+        _ => "",281+    }282+}283+284+/// A block caption naming a file: its path in a bold code span.285+fn caption(path: &str) -> String {286+    format!("**`{path}`**")287+}288+289+/// An ATX heading at `level`, with inline Markdown in the text neutralized.290+fn heading(level: usize, text: &str) -> String {291+    format!("{} {}", "#".repeat(level), escape_md_inline(text))292+}293+294+/// Quote `text` as a Markdown blockquote, prefixing every line with `> `.295+fn blockquote(text: &str) -> String {296+    text.lines()297+        .map(|l| {298+            if l.is_empty() {299+                ">".to_string()300+            } else {301+                format!("> {l}")302+            }303+        })304+        .collect::<Vec<_>>()305+        .join("\n")306+}307+308+/// Join self-contained blocks with one blank line between them, dropping empties309+/// (so callers can push conditionally). Blocks carry no trailing newline; the top310+/// level adds the single final one.311+fn join_blocks(blocks: Vec<String>) -> String {312+    blocks313+        .into_iter()314+        .filter(|b| !b.is_empty())315+        .collect::<Vec<_>>()316+        .join("\n\n")317+}318+319+/// Backslash-escape characters that would otherwise be read as inline Markdown in a320+/// heading or other inline position. The commit title is the subject line, not321+/// Markdown (the body keeps its verbatim treatment), so it is escaped the way322+/// `html::escape` neutralizes it for HTML.323+fn escape_md_inline(text: &str) -> String {324+    let mut out = String::with_capacity(text.len());325+    for c in text.chars() {326+        if matches!(c, '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>') {327+            out.push('\\');328+        }329+        out.push(c);330+    }331+    out332+}333+334+#[cfg(test)]335+mod tests {336+    use super::*;337+338+    #[test]339+    fn fence_uses_three_backticks_without_embedded_fences() {340+        let out = fenced("diff", &["+a".to_string(), "-b".to_string()]);341+        assert!(out.starts_with("```diff\n"), "got: {out}");342+        assert!(out.ends_with("\n```"), "got: {out}");343+    }344+345+    #[test]346+    fn fence_grows_past_an_embedded_fence() {347+        // A content line of three backticks would close a three-backtick block348+        // early (CommonMark allows an indented closing fence), so the guard must349+        // open and close with four.350+        let out = fenced("diff", &[" ```".to_string(), " code".to_string()]);351+        assert!(out.starts_with("````diff\n"), "got: {out}");352+        assert!(out.ends_with("\n````"), "got: {out}");353+    }354+355+    #[test]356+    fn fence_grows_past_the_longest_run() {357+        let out = fenced("", &["before".to_string(), "`````".to_string()]);358+        assert!(out.starts_with("``````\n"), "six-backtick open, got: {out}");359+    }360+361+    #[test]362+    fn focus_caption_groups_runs_and_omits_when_empty() {363+        let line = |origin: char, focus: bool, number: Option<u32>| CodeLine {364+            origin,365+            content: "x".to_string(),366+            focus,367+            number,368+        };369+        // Two hunk runs, each with a contiguous focus block.370+        let lines = vec![371+            line('@', false, None),372+            line('+', true, Some(10)),373+            line('+', true, Some(11)),374+            line(' ', false, Some(12)),375+            line('@', false, None),376+            line('+', true, Some(80)),377+        ];378+        assert_eq!(379+            focus_caption(&lines),380+            Some("_Focus: lines 10\u{2013}11, 80_".to_string())381+        );382+        // No focused line -> no caption.383+        let plain = vec![line('+', false, Some(1))];384+        assert_eq!(focus_caption(&plain), None);385+    }386+}

Register the module so the library exposes the new to_markdown entry point.

src/lib.rs
@@ -10,6 +10,7 @@10 pub mod diff;11 pub mod git;12 pub mod html;13+pub mod markdown;14 pub mod message;15 pub mod render;16 

Pin the guarantee on the rendered Markdown itself: every added and removed line appears, the backfill is a collapsible <details>, and an anchorless commit is a bare diff with no wrapper.

tests/markdown.rs
@@ -0,0 +1,253 @@1+//! The Markdown emitter (`markdown::to_markdown`) over the same `Document` the2+//! HTML emitter consumes. The headline guarantee is **completeness**: every diff3+//! hunk — every added *and* deleted line — appears at least once in the rendered4+//! Markdown, whether in an authored callout or the backfill. A small structural5+//! test pins the shape (H1 title, fenced `diff` blocks, the collapsible backfill,6+//! the TL;DR lede).7+//!8+//! The fixture mirrors `tests/completeness.rs`: a non-root commit that modifies9+//! (with deletions), adds, and deletes files, annotating only some of them.10+11+use std::fs;12+use std::path::Path;13+14+use commit_throughline::git::Repo;15+use commit_throughline::{markdown, render};16+use git2::{IndexAddOption, Repository, Signature};17+use tempfile::TempDir;18+19+/// Build 20 numbered lines like `a01\n…a20\n`.20+fn numbered(prefix: &str, n: usize) -> String {21+    (1..=n).map(|i| format!("{prefix}{i:02}\n")).collect()22+}23+24+/// Replace 1-indexed lines in a `\n`-terminated body.25+fn replace_lines(base: &str, repl: &[(usize, &str)]) -> String {26+    let mut lines: Vec<String> = base.lines().map(str::to_string).collect();27+    for (ln, text) in repl {28+        lines[ln - 1] = (*text).to_string();29+    }30+    let mut out = lines.join("\n");31+    out.push('\n');32+    out33+}34+35+fn write(dir: &Path, name: &str, content: &str) {36+    fs::write(dir.join(name), content).unwrap();37+}38+39+/// A fixture repo plus the revision of its second (annotated) commit.40+struct Fixture {41+    _dir: TempDir,42+    repo: Repo,43+    rev: String,44+}45+46+fn build_fixture() -> Fixture {47+    let dir = TempDir::new().unwrap();48+    let git = Repository::init(dir.path()).unwrap();49+    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();50+51+    // --- v1: the parent commit -------------------------------------------52+    let a_v1 = numbered("a", 20);53+    let f_v1 = numbered("f", 20);54+    write(dir.path(), "a.txt", &a_v1);55+    write(dir.path(), "b.txt", "bravo one\nbravo two\n");56+    write(dir.path(), "c.txt", "charlie to be deleted\n");57+    write(58+        dir.path(),59+        "e.txt",60+        "echo one\necho two\necho GONE\necho four\n",61+    );62+    write(dir.path(), "f.txt", &f_v1);63+64+    let mut index = git.index().unwrap();65+    index66+        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)67+        .unwrap();68+    index.write().unwrap();69+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();70+    let parent = git71+        .commit(Some("HEAD"), &sig, &sig, "seed the fixture", &tree, &[])72+        .unwrap();73+74+    // --- v2: modify a.txt & f.txt (two hunks each), rewrite b.txt, delete75+    //         c.txt, add d.txt ---------------------------------------------76+    let a_v2 = replace_lines(&a_v1, &[(3, "ALPHA_NEW_3"), (18, "ALPHA_NEW_18")]);77+    let f_v2 = replace_lines(&f_v1, &[(3, "FOXTROT_NEW_3"), (18, "FOXTROT_NEW_18")]);78+    write(dir.path(), "a.txt", &a_v2);79+    write(dir.path(), "b.txt", "BRAVO_NEW one\nBRAVO_NEW two\n");80+    // e.txt is modified with a real deletion: "echo GONE" is removed.81+    write(dir.path(), "e.txt", "echo one\nECHO_NEW_two\necho four\n");82+    write(dir.path(), "f.txt", &f_v2);83+    write(dir.path(), "d.txt", "delta brand new file\n");84+    fs::remove_file(dir.path().join("c.txt")).unwrap();85+86+    let mut index = git.index().unwrap();87+    for p in ["a.txt", "b.txt", "e.txt", "f.txt", "d.txt"] {88+        index.add_path(Path::new(p)).unwrap();89+    }90+    index.remove_path(Path::new("c.txt")).unwrap();91+    index.write().unwrap();92+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();93+    let parent_commit = git.find_commit(parent).unwrap();94+95+    // Annotate b.txt (whole file), e.txt (whole file, has a deletion), and one line96+    // of f.txt; a.txt, c.txt's deletion, and d.txt are left for the backfill.97+    let message = "\98+Exercise the renderer99+100+A small commit that touches several files to exercise coverage and backfill.101+102+b.txt103+The whole change to b, shown as a diff.104+105+e.txt106+The whole change to e — its removed line must appear in this diff callout.107+108+f.txt:3109+A single line of f; the rest of its hunk and its second hunk are backfilled.110+";111+    let v2 = git112+        .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])113+        .unwrap();114+115+    Fixture {116+        repo: Repo::open(dir.path()).unwrap(),117+        rev: v2.to_string(),118+        _dir: dir,119+    }120+}121+122+#[test]123+fn every_diff_line_appears_in_the_markdown() {124+    let fx = build_fixture();125+    let diff = fx.repo.diff_against_parent(&fx.rev).unwrap();126+    let doc = render::render(&fx.repo, &fx.rev).unwrap();127+    let md = markdown::to_markdown(&doc);128+129+    assert!(!diff.files.is_empty(), "the fixture must have a diff");130+131+    // The completeness invariant, read straight off the rendered Markdown: because132+    // the backfill re-emits every uncovered hunk whole (deletions included), every133+    // added and removed line's content is a substring of the output.134+    for file in &diff.files {135+        for (hi, hunk) in file.hunks.iter().enumerate() {136+            for line in &hunk.lines {137+                if matches!(line.origin, '+' | '-') && !line.content.is_empty() {138+                    assert!(139+                        md.contains(&line.content),140+                        "completeness violated: hunk {hi} of `{}` drops line {:?}",141+                        file.path,142+                        line.content,143+                    );144+                }145+            }146+        }147+    }148+149+    // Spot-check representative lines across the kinds of change.150+    for needle in [151+        "echo GONE",             // a deletion (e.txt), shown in its callout152+        "ECHO_NEW_two",          // its replacement153+        "ALPHA_NEW_3",           // a.txt, unannotated -> backfill154+        "FOXTROT_NEW_18",        // f.txt second hunk -> backfill155+        "delta brand new file",  // d.txt addition -> backfill156+        "charlie to be deleted", // c.txt deletion -> backfill157+    ] {158+        assert!(md.contains(needle), "expected `{needle}` in the Markdown");159+    }160+}161+162+#[test]163+fn markdown_has_the_expected_shape() {164+    let fx = build_fixture();165+    let doc = render::render(&fx.repo, &fx.rev).unwrap();166+    let md = markdown::to_markdown(&doc);167+168+    // H1 title.169+    assert!(170+        md.starts_with("# Exercise the renderer\n"),171+        "should open with the title as an H1"172+    );173+    // The TL;DR is a blockquote lede.174+    assert!(175+        md.contains("> A small commit that touches several files"),176+        "the TL;DR should render as a blockquote"177+    );178+    // Diff callouts are fenced `diff` blocks.179+    assert!(md.contains("```diff\n"), "expected fenced diff blocks");180+    // The unified-diff hunk header rides inside the fence.181+    assert!(md.contains("@@ "), "expected a hunk header");182+    // The backfill is folded into a collapsible <details> (a.txt etc. are183+    // unannotated, so there IS a backfill, and there IS authored code above it).184+    assert!(185+        md.contains("<details>\n<summary>Remaining changes</summary>\n\n"),186+        "the backfill should be a collapsible <details> with the required blank line"187+    );188+    assert!(189+        md.contains("\n\n</details>\n"),190+        "the </details> needs a blank line before it"191+    );192+    // A file caption names the path in a bold code span.193+    assert!(194+        md.contains("**`a.txt`**"),195+        "expected a backfilled file caption"196+    );197+}198+199+#[test]200+fn an_anchorless_commit_is_a_bare_diff_with_no_details() {201+    // A message with no anchors renders as the complete diff (fenced blocks), with202+    // no commentary and no <details> wrapper.203+    let dir = TempDir::new().unwrap();204+    let git = Repository::init(dir.path()).unwrap();205+    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();206+207+    write(dir.path(), "only.txt", "one\ntwo\n");208+    let mut index = git.index().unwrap();209+    index210+        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)211+        .unwrap();212+    index.write().unwrap();213+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();214+    let parent = git215+        .commit(Some("HEAD"), &sig, &sig, "seed", &tree, &[])216+        .unwrap();217+218+    write(dir.path(), "only.txt", "one\nTWO_CHANGED\nthree\n");219+    let mut index = git.index().unwrap();220+    index.add_path(Path::new("only.txt")).unwrap();221+    index.write().unwrap();222+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();223+    let parent_commit = git.find_commit(parent).unwrap();224+    let rev = git225+        .commit(226+            Some("HEAD"),227+            &sig,228+            &sig,229+            "Plain commit, no anchors\n",230+            &tree,231+            &[&parent_commit],232+        )233+        .unwrap()234+        .to_string();235+236+    let repo = Repo::open(dir.path()).unwrap();237+    let doc = render::render(&repo, &rev).unwrap();238+    let md = markdown::to_markdown(&doc);239+240+    assert!(241+        md.contains("```diff\n"),242+        "the full diff should still render"243+    );244+    assert!(md.contains("TWO_CHANGED"), "the change must appear");245+    assert!(246+        !md.contains("<details>"),247+        "a plain commit has no collapsible backfill wrapper, got:\n{md}"248+    );249+    assert!(250+        !md.contains("Remaining changes"),251+        "a plain commit has no backfill heading"252+    );253+}
14/37ccdffdeEdu Ramírez

Wire --format=markdown into the render command

Expose the Markdown emitter through throughline render --format markdown. HTML stays the default, and the default output filename now follows the chosen format (throughline-<sha>.md).

Add a Format value-enum (html default, markdown) that also knows the file extension each format writes, so the flag and the default filename share one source of truth.

src/cli.rs
@@ -3,7 +3,7 @@3 4 use std::path::PathBuf;5 -use clap::{Parser, Subcommand};6+use clap::{Parser, Subcommand, ValueEnum};7 8 /// Render a commit message as a complete, annotated diff walkthrough.9 #[derive(Debug, Parser)]@@ -13,22 +13,47 @@13     pub command: Command,14 }15 16+/// Output surface. Both formats are projections of the same `Document`: HTML is a17+/// self-contained page; Markdown is for a pager (`mdcat`/`glow`), a gist/wiki, or a18+/// pull-request body.19+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]20+pub enum Format {21+    /// A self-contained HTML page (the default).22+    Html,23+    /// Markdown, with diff hunks inlined as fenced `diff` code blocks.24+    Markdown,25+}26+27+impl Format {28+    /// The default output-file extension for this format.29+    pub fn extension(self) -> &'static str {30+        match self {31+            Format::Html => "html",32+            Format::Markdown => "md",33+        }34+    }35+}36+37 #[derive(Debug, Subcommand)]38 pub enum Command {-    /// Render a commit to a self-contained HTML page.39+    /// Render a commit to a self-contained HTML page (or Markdown).40     Render {41         /// The commit to render (e.g. `HEAD`, a SHA, or a tag).42         commit: String,43 -        /// Write the HTML here instead of the default location.44+        /// Write the output here instead of the default location.45         #[arg(short, long)]46         output: Option<PathBuf>,47 48+        /// Output format: a self-contained HTML page, or Markdown.49+        #[arg(long, value_enum, default_value = "html")]50+        format: Format,51+52         /// After writing the page, open it with the platform's default handler.53         #[arg(long)]54         open: bool,55 -        /// Print the parsed message structure instead of rendering HTML.56+        /// Print the parsed message structure instead of rendering output.57         #[arg(long)]58         debug: bool,59     },

Pick the emitter from the chosen format and let its extension drive the default output path, leaving the best-effort --open untouched.

src/main.rs
@@ -3,9 +3,9 @@3 use anyhow::{Context, Result};4 use clap::Parser;5 -use commit_throughline::cli::{Cli, Command};6+use commit_throughline::cli::{Cli, Command, Format};7 use commit_throughline::message::{self, Block};-use commit_throughline::{git, html, render};8+use commit_throughline::{git, html, markdown, render};9 10 fn main() -> Result<()> {11     let cli = Cli::parse();@@ -13,13 +13,14 @@13         Command::Render {14             commit,15             output,16+            format,17             open,18             debug,19         } => {20             if debug {21                 debug_message(&commit)22             } else {-                run_render(&commit, output.as_deref(), open)23+                run_render(&commit, output.as_deref(), format, open)24             }25         }26         Command::Branch {@@ -31,18 +32,21 @@32     }33 }34 -fn run_render(commit: &str, output: Option<&Path>, open: bool) -> Result<()> {35+fn run_render(commit: &str, output: Option<&Path>, format: Format, open: bool) -> Result<()> {36     let repo =37         git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;38 39     let doc = render::render(&repo, commit).with_context(|| format!("rendering `{commit}`"))?;-    let page = html::to_html(&doc);40+    let page = match format {41+        Format::Html => html::to_html(&doc),42+        Format::Markdown => markdown::to_markdown(&doc),43+    };44 45     let path = match output {46         Some(p) => p.to_path_buf(),47         None => {48             let short = repo.short_id(commit).unwrap_or_else(|_| "out".to_string());-            PathBuf::from(format!("throughline-{short}.html"))49+            PathBuf::from(format!("throughline-{short}.{}", format.extension()))50         }51     };52     std::fs::write(&path, page).with_context(|| format!("writing `{}`", path.display()))?;

Record the new module and the flag in the module map.

CLAUDE.md
@@ -43,7 +43,7 @@43 44 | File | Responsibility |45 |------|----------------|-| `src/cli.rs` | `throughline render <commit>` / `throughline branch <range>` argument parsing. |46+| `src/cli.rs` | `throughline render <commit>` / `throughline branch <range>` argument parsing, including `--format {html,markdown}`. |47 | `src/message.rs` | Split a raw message into title, TL;DR (first paragraph), intro (lead-in to the first anchor), and an ordered list of prose/anchor blocks. |48 | `src/anchor.rs` | The anchor grammar: recognize a path-shaped line and parse `path[:range] [@focus]` (a legacy trailing `!side` is accepted and ignored). |49 | `src/conventional.rs` | Conventional Commits header (`type(scope): …`) and footer-trailer passthrough. |@@ -51,6 +51,7 @@51 | `src/diff.rs` | Hunk / file-diff model, per-hunk coverage tracking, range clipping (`clip_to_new_range`), and backfill collection. |52 | `src/render.rs` | Resolve anchors against Git, track coverage, build the ordered `Document` model. |53 | `src/html.rs` | Emit a single self-contained HTML file with inline CSS. |54+| `src/markdown.rs` | Emit the same `Document` as Markdown: prose verbatim, hunks as fenced `diff` blocks, the backfill folded into `<details>` (a sibling of `html.rs`). |55 56 ## Invariants — do not break these57 
15/3792dc318Edu Ramírez

Render a branch as Markdown, sharing the Chapter model

Extend --format=markdown to throughline branch: a range becomes one Markdown document — an H1 range title, a contents list, and one ## chapter per commit in reading order, each keeping its own completeness. The shared Chapter type moves to the model so neither emitter owns the other's types.

A Chapter is a Document plus chapter metadata — a render-level model, not an HTML detail. Move it beside Document so both emitters consume it as siblings, rather than markdown.rs reaching into html.rs for a type (html.rs now imports it back).

src/render.rs
@@ -59,13 +59,28 @@59 }60 61 /// The fully ordered model of a rendered commit. This is the single contract-/// between the rendering logic and any output emitter (e.g. [`crate::html`]).62+/// between the rendering logic and any output emitter (e.g. [`crate::html`] or63+/// [`crate::markdown`]).64 #[derive(Debug, Clone)]65 pub struct Document {66     pub title: String,67     pub sections: Vec<Section>,68 }69 70+/// One commit rendered as a chapter of a branch walkthrough: the per-commit71+/// [`Document`] plus the display metadata for its chapter header. Like72+/// [`Document`], it is a render-level model shared by every emitter, not specific73+/// to any one output format.74+#[derive(Debug, Clone)]75+pub struct Chapter {76+    /// Abbreviated object id, shown in the header and contents.77+    pub short: String,78+    /// Author name, shown in the chapter byline (may be empty).79+    pub author: String,80+    /// The commit's own rendered document (complete against its parent).81+    pub doc: Document,82+}83+84 /// Build the [`Document`] for `rev`:85 ///86 /// 1. parse the message into title / TL;DR / intro / blocks,

Compose the chapters into one page: the range title, a plain numbered contents list (no in-page links — forges slugify headings differently, so a hand-built anchor would break on one), and each commit's body under its own heading with a position/sha/author line.

src/markdown.rs
@@ -12,7 +12,7 @@12 use std::path::Path;13 14 use crate::diff::{DeltaMarker, Hunk};-use crate::render::{hunk_to_code_lines, CodeBlock, CodeLine, Document, Section};15+use crate::render::{hunk_to_code_lines, Chapter, CodeBlock, CodeLine, Document, Section};16 17 /// A trailing (backfill-region) item: a not-yet-shown hunk or a delta-level18 /// marker. Collected in natural order, then rendered together after the authored@@ -37,6 +37,50 @@37     out38 }39 40+/// Render a range of commits as one Markdown document: an `#` title, a contents41+/// list, then each commit's walkthrough under its own `##` heading, in reading42+/// order. Each chapter keeps its own completeness guarantee (it is a per-commit43+/// [`Document`]); the page composes them rather than squashing. Mirrors44+/// [`crate::html::to_html_branch`].45+pub fn to_markdown_branch(title: &str, chapters: &[Chapter]) -> String {46+    let total = chapters.len();47+    let mut blocks = vec![heading(1, title)];48+49+    // Contents: a plain numbered list, deliberately without in-page links. GitHub50+    // and Forgejo slugify headings differently, so a hand-built `#anchor` would51+    // break on one of them; the `##` chapter headings still give an outline.52+    if !chapters.is_empty() {53+        let toc: Vec<String> = chapters54+            .iter()55+            .enumerate()56+            .map(|(i, ch)| {57+                format!(58+                    "{}. {} \u{2014} `{}`",59+                    i + 1,60+                    escape_md_inline(&ch.doc.title),61+                    ch.short62+                )63+            })64+            .collect();65+        blocks.push(toc.join("\n"));66+    }67+68+    for (i, ch) in chapters.iter().enumerate() {69+        blocks.push(heading(2, &ch.doc.title));70+        let mut meta = format!("`{}/{total}` \u{b7} `{}`", i + 1, ch.short);71+        if !ch.author.is_empty() {72+            meta.push_str(" \u{b7} ");73+            meta.push_str(&escape_md_inline(&ch.author));74+        }75+        blocks.push(meta);76+        blocks.extend(document_body(&ch.doc));77+    }78+79+    let mut out = join_blocks(blocks);80+    out.push('\n');81+    out82+}83+84 /// The body of one document as a list of self-contained blocks (each with no85 /// trailing newline): every authored section in order, then the backfill region.86 /// Emits no title heading — the caller owns it (so a branch chapter can supply its

Offer the format on the branch command too, mirroring the render command.

src/cli.rs
@@ -72,10 +72,14 @@72         #[arg(long, default_value = "main")]73         base: String,74 -        /// Write the HTML here instead of `throughline-branch-<sha>.html`.75+        /// Write the output here instead of `throughline-branch-<sha>.<ext>`.76         #[arg(short, long)]77         output: Option<PathBuf>,78 79+        /// Output format: a self-contained HTML page, or Markdown.80+        #[arg(long, value_enum, default_value = "html")]81+        format: Format,82+83         /// After writing the page, open it with the platform's default handler.84         #[arg(long)]85         open: bool,

Cover the branch projection: chapters compose in reading order and each commit's own change survives into its chapter.

tests/markdown.rs
@@ -251,3 +251,75 @@251         "a plain commit has no backfill heading"252     );253 }254+255+#[test]256+fn branch_markdown_composes_chapters_in_order() {257+    // A range renders as an H1 title, a contents list, then one chapter per commit258+    // in reading order — each complete (its unique line appears in its body).259+    let dir = TempDir::new().unwrap();260+    let git = Repository::init(dir.path()).unwrap();261+    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();262+    let commit_all = |msg: &str, parents: &[git2::Oid]| -> git2::Oid {263+        let mut index = git.index().unwrap();264+        index265+            .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)266+            .unwrap();267+        index.write().unwrap();268+        let tree = git.find_tree(index.write_tree().unwrap()).unwrap();269+        let parents: Vec<git2::Commit> = parents270+            .iter()271+            .map(|p| git.find_commit(*p).unwrap())272+            .collect();273+        let refs: Vec<&git2::Commit> = parents.iter().collect();274+        git.commit(Some("HEAD"), &sig, &sig, msg, &tree, &refs)275+            .unwrap()276+    };277+278+    write(dir.path(), "a.txt", "alpha one\n");279+    let root = commit_all("Seed\n\nStart.\n", &[]);280+    write(dir.path(), "b.txt", "bravo_unique_marker\n");281+    let first = commit_all("Add bravo\n\nIntroduce bravo.\n", &[root]);282+    write(dir.path(), "a.txt", "alpha one\nalpha_extra_marker\n");283+    let second = commit_all("Extend alpha\n\nGrow alpha.\n", &[first]);284+285+    let repo = Repo::open(dir.path()).unwrap();286+    let commits = repo287+        .range_commits(&root.to_string(), &second.to_string())288+        .unwrap();289+    let chapters: Vec<render::Chapter> = commits290+        .iter()291+        .map(|c| render::Chapter {292+            short: c.short.clone(),293+            author: c.author.clone(),294+            doc: render::render(&repo, &c.id).unwrap(),295+        })296+        .collect();297+    let md = markdown::to_markdown_branch("base..tip", &chapters);298+299+    // H1 range title.300+    assert!(301+        md.starts_with("# base..tip\n"),302+        "expected the range as an H1"303+    );304+    // Contents list names both chapters (base..tip excludes the root commit).305+    assert!(306+        md.contains("1. Add bravo "),307+        "TOC should list the first chapter"308+    );309+    assert!(310+        md.contains("2. Extend alpha "),311+        "TOC should list the second chapter"312+    );313+    // Each commit becomes a chapter heading, in reading order.314+    let h_first = md.find("## Add bravo").expect("first chapter heading");315+    let h_second = md.find("## Extend alpha").expect("second chapter heading");316+    assert!(h_first < h_second, "chapters must be in reading order");317+    // Completeness across chapters: each commit's unique line is present.318+    assert!(md.contains("bravo_unique_marker"), "first chapter's change");319+    assert!(md.contains("alpha_extra_marker"), "second chapter's change");320+    // The chapter metadata line carries the position.321+    assert!(322+        md.contains("`1/2` \u{b7} `"),323+        "expected a chapter metadata line"324+    );325+}

Remaining changes

src/html.rs
@@ -5,7 +5,7 @@5 use pulldown_cmark::{html as md_html, Options, Parser};6 7 use crate::diff::{DeltaMarker, Hunk};-use crate::render::{hunk_to_code_lines, CodeBlock, CodeLine, Document, Section};8+use crate::render::{hunk_to_code_lines, Chapter, CodeBlock, CodeLine, Document, Section};9 10 /// A trailing (backfill-region) item: either a not-yet-shown hunk or a11 /// delta-level marker (a hunk-less delta, or a mode change beside content).@@ -87,17 +87,6 @@87 nav.chapter-nav a:hover { text-decoration: underline; }88 "#;89 -/// One commit rendered as a chapter of a branch walkthrough: the per-commit-/// [`Document`] plus the display metadata for its chapter header.-pub struct Chapter {-    /// Abbreviated object id, shown in the header and contents.-    pub short: String,-    /// Author name, shown in the chapter byline (may be empty).-    pub author: String,-    /// The commit's own rendered document (complete against its parent).-    pub doc: Document,-}-90 /// Render `doc` to a complete, standalone HTML document.91 pub fn to_html(doc: &Document) -> String {92     let mut body = String::new();
src/main.rs
@@ -27,8 +27,9 @@27             branch,28             base,29             output,30+            format,31             open,-        } => run_branch(&branch, &base, output.as_deref(), open),32+        } => run_branch(&branch, &base, output.as_deref(), format, open),33     }34 }35 @@ -65,7 +66,13 @@66 }67 68 /// Render a range of commits as one navigable page (the `branch` subcommand).-fn run_branch(branch: &str, base: &str, output: Option<&Path>, open: bool) -> Result<()> {69+fn run_branch(70+    branch: &str,71+    base: &str,72+    output: Option<&Path>,73+    format: Format,74+    open: bool,75+) -> Result<()> {76     let repo =77         git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;78 @@ -87,7 +94,7 @@94     for c in &commits {95         let doc = render::render(&repo, &c.id)96             .with_context(|| format!("rendering commit `{}`", c.short))?;-        chapters.push(html::Chapter {97+        chapters.push(render::Chapter {98             short: c.short.clone(),99             author: c.author.clone(),100             doc,@@ -95,13 +102,16 @@102     }103 104     let title = format!("{base}..{tip}");-    let page = html::to_html_branch(&title, &chapters);105+    let page = match format {106+        Format::Html => html::to_html_branch(&title, &chapters),107+        Format::Markdown => markdown::to_markdown_branch(&title, &chapters),108+    };109 110     let path = match output {111         Some(p) => p.to_path_buf(),112         None => {113             let short = repo.short_id(&tip).unwrap_or_else(|_| "branch".to_string());-            PathBuf::from(format!("throughline-branch-{short}.html"))114+            PathBuf::from(format!("throughline-branch-{short}.{}", format.extension()))115         }116     };117     std::fs::write(&path, page).with_context(|| format!("writing `{}`", path.display()))?;
tests/branch.rs
@@ -99,9 +99,9 @@99 100 fn render_branch(fx: &Fixture) -> String {101     let commits = fx.repo.range_commits(&fx.root, &fx.second).unwrap();-    let chapters: Vec<html::Chapter> = commits102+    let chapters: Vec<render::Chapter> = commits103         .iter()-        .map(|c| html::Chapter {104+        .map(|c| render::Chapter {105             short: c.short.clone(),106             author: c.author.clone(),107             doc: render::render(&fx.repo, &c.id).unwrap(),
16/37fa444dfEdu Ramírez

Document the Markdown output across the canonical docs

Tick the shipped --format=markdown box and teach the spec, architecture, and wiki about the second emitter: how it renders, the @focus and fence-length choices, and where its gotchas live.

Mark the first PR-surface step done, recording how the two open questions (size, completeness) were settled — fold the backfill into <details>, keep it whole.

docs/ROADMAP.md
@@ -165,15 +165,16 @@165 almost no Markdown; a PR body renders all of it). Staged so each step stands on its166 own:167 -- [ ] **`--format=markdown`** — emit the walkthrough as Markdown with the resolved168+- [x] **`--format=markdown`** — emit the walkthrough as Markdown with the resolved169   hunks inlined as fenced `diff` code blocks, reusing the existing170   resolve/coverage/backfill pipeline (a sibling emitter to-  [`html.rs`](../src/html.rs)). Pure and offline — no network, no auth — and useful-  on its own: pipe it into a Markdown pager (`mdcat`, `glow`) for a terminal-  walkthrough, or drop it into a gist or wiki. Two questions to settle: **size**-  (fold the backfill into `<details>`, or cap it and link to the full HTML page) and-  **completeness** (keep the guarantee; make the backfill collapsible rather than-  relax it).171+  [`html.rs`](../src/html.rs), [`markdown.rs`](../src/markdown.rs)). Pure and offline172+  — no network, no auth — and useful on its own: pipe it into a Markdown pager173+  (`mdcat`, `glow`) for a terminal walkthrough, or drop it into a gist or wiki. Both174+  open questions were settled as the parenthetical suggested: **size** — the backfill175+  is folded into a `<details>` block; **completeness** — kept, the backfill stays176+  fully present and is only collapsed, never relaxed. On both `render` and `branch`;177+  see [`docs/wiki/field-notes/markdown-output.md`](wiki/field-notes/markdown-output.md).178 - [ ] **`throughline pr`** — a thin helper over `--format=markdown` that writes the179   walkthrough into a pull-request description **non-destructively**: it owns a180   managed region (`<!-- throughline:start --> … <!-- throughline:end -->`) and

Generalize "Renderer behavior" from a single HTML file to one Document projected onto HTML or Markdown, and spell out the Markdown-specific rules: verbatim prose, fenced diff blocks, the fence-length guard, and the @focus caption.

docs/SPEC.md
@@ -253,8 +253,12 @@253 254 ## Renderer behavior (PoC)255 -Reference implementation in Rust. Output is a single self-contained HTML file, opened-locally in a browser.256+Reference implementation in Rust. The resolved walkthrough is one model (the257+`Document`) projected onto an output format chosen with `--format`: a single258+self-contained **HTML** file (the default, opened locally in a browser), or259+**Markdown** (`--format=markdown`) for a Markdown pager, a gist or wiki, or a260+pull-request body. Both formats are emitters over the same model, so the guarantees261+below hold identically for each.262 263 ### Pipeline264 @@ -273,9 +277,22 @@277      spans the hunk's whole new side); a clip that did not cover its hunk marks nothing.278 4. Backfill: emit every hunk not shown in full, **whole**, in natural diff order — even279    one an anchor showed only a clip of (repetition guarantees every line appears).-5. Emit HTML: render prose with a Markdown library; for each anchor emit a code block —-   a diff (focus lines marked; `+`/`-` colored) or plain context labeled "unmodified";-   then emit the backfill hunks as a normal diff. Inline all CSS so the file is standalone.280+5. Emit the chosen format from the shared model:281+   - **HTML** (default): render prose with a Markdown library; for each anchor emit a282+     code block — a diff (focus lines marked; `+`/`-` colored) or plain context283+     labeled "unmodified"; then emit the backfill hunks as a normal diff. Inline all284+     CSS so the file is standalone.285+   - **Markdown** (`--format=markdown`): emit prose *verbatim* (the destination286+     renders Markdown, so it is never run through a Markdown library), each diff hunk287+     as a fenced `diff` block, context as a fenced block labeled "unmodified",288+     and the backfill folded into a `<details>` block — kept fully present, only289+     collapsed. Two Markdown-specific rules: a fenced block opens and closes with one290+     more backtick than the longest backtick run in its content, so a content line291+     that is itself a code fence cannot close the block early; and an anchor's292+     `@focus` range — which a fenced block cannot highlight — is named in a one-line293+     caption above the block (omitted when no shown line falls in the focus, since a294+     deletion carries no new-side line number to cite). The `branch` view composes one295+     `##` chapter per commit under a plain (unlinked) contents list.296 297 ### Degraded anchors298 @@ -288,10 +305,10 @@305 306 - Interactive / slide-by-slide presentation UI (later, as CSS/JS over the HTML).307 - Content-based anchoring instead of line numbers (v2; fragile across rebases but fine here).-- Merging or syncing rendered walkthroughs across branches. (Output beyond the PoC's-  HTML — notably a **Markdown** projection for PR descriptions and other-  Markdown-native surfaces — is *planned*, not abandoned; see-  [`ROADMAP.md`](ROADMAP.md#markdown-output-and-the-pr-surface).)308+- Merging or syncing rendered walkthroughs across branches. (The **Markdown**309+  projection for Markdown-native surfaces now ships as `--format=markdown`; writing it310+  into a pull-request body — `throughline pr` — and CI regeneration remain *planned*;311+  see [`ROADMAP.md`](ROADMAP.md#markdown-output-and-the-pr-surface).)312 313 ## Instruction for the LLM314 

Capture the working knowledge in its own field note, the sibling of the HTML one: the document shape, the variable-length fences, the blank lines <details> needs, and the baked-in choices (focus, title escaping, unlinked contents list).

docs/wiki/field-notes/markdown-output.md
@@ -0,0 +1,107 @@1+# Field note: the Markdown output2+3+How `src/markdown.rs` shapes the rendered Markdown, and the two gotchas (variable4+fence length, blank lines inside `<details>`) that bite when you grep the output or5+wonder why a fenced block "leaked" on a forge. Read it before changing the emitter6+or writing an assertion against its output. It is the sibling of7+[`html-output.md`](html-output.md); the two emitters are deliberately parallel.8+9+## Shape of the document10+11+`to_markdown` (`src/markdown.rs:32`) emits an `#` title then `document_body`12+(`src/markdown.rs:88`), which walks the `Document`'s sections in order and collects13+the trailing backfill (hunks and markers, via the `Trailing` enum at14+`src/markdown.rs:20`) into one region rendered after them — the same control flow as15+`html::document_body`. `to_markdown_branch` (`src/markdown.rs:45`) composes several16+documents: an `#` range title, a plain numbered contents list, then each commit under17+its own `##` heading plus a `position · sha · author` line.18+19+Everything is built as a list of self-contained blocks joined by one blank line20+(`join_blocks`, `src/markdown.rs:355`); blocks carry no trailing newline, and the top21+level adds the single final one. The two key principles:22+23+- **Prose is emitted verbatim**, never through a Markdown library — the destination24+  renders Markdown itself (contrast `html.rs`, which calls `pulldown-cmark`). The25+  TL;DR is the one exception in *form*: it is wrapped as a blockquote lede, content26+  unchanged.27+- **All code comes from the Git-sourced model**, reusing `render::hunk_to_code_lines`28+  for backfill hunks exactly as `html.rs` does.29+30+Callouts come in four shapes, from `code_md` (`src/markdown.rs:127`) and `backfill_md`31+(`src/markdown.rs:161`):32+33+- **diff callout** — a `` **`path`** `` caption above a fenced `diff` block;34+  lines carry their `+`/`-`/` ` sign and the `@@` header rides inside.35+- **context callout** — a caption tagged `_(unmodified)_` above a fenced block in the36+  file's language (guessed from the extension).37+- **degraded** — a `> ⚠ could not resolve …` blockquote, no fence.38+- **marker** — a one-line `` **`path`** — Binary file added `` note.39+40+The backfill is folded into a `<details><summary>Remaining changes</summary>` block41+**only when there is authored code above it** (`src/markdown.rs:161`,42+`backfill_md(..., collapsible)`); a plain anchorless commit is just its full diff,43+bare. Folding is presentation only — every uncovered hunk is still emitted whole, so44+completeness holds (guarded by `every_diff_line_appears_in_the_markdown`,45+`tests/markdown.rs:123`; the bare-diff case by46+`an_anchorless_commit_is_a_bare_diff_with_no_details`, `tests/markdown.rs:200`).47+48+## Gotcha 1: fences can be longer than three backticks49+50+`fenced` (`src/markdown.rs:270`) does **not** always open with three backticks. It51+scans the content for the longest run of consecutive backticks (`max_backtick_run`,52+`src/markdown.rs:286`) and opens/closes with one more than that. This is required:53+CommonMark lets a closing fence be indented up to three spaces, so a context line that54+is just `` ``` `` — which a diff block emits as `` ``` `` (a leading space for the diff55+sign) and an unmodified block emits raw — would close a three-backtick block early.56+This repo's own Markdown diffs hit it constantly.57+58+Consequence for grepping: to find diff blocks, do **not** match the literal three59+backticks. Match a run:60+61+```sh62+grep -nE '^`{3,}diff' page.md     # diff blocks, whatever their fence length63+```64+65+The closing fence carries **no** info string (one that did would not be recognized as66+a closer, and the block would run to end-of-file). Guarded by the fence tests at67+`src/markdown.rs:383`–`404`.68+69+## Gotcha 2: Markdown inside `<details>` needs blank lines70+71+A fenced block inside `<details>` renders on GitHub and Forgejo **only** with strict72+blank-line separation, because of how Markdown parsers treat raw-HTML blocks.73+`backfill_md` (`src/markdown.rs:161`) emits exactly: a blank line after74+`</summary>`, blank lines around every inner block, and a blank line before75+`</details>`. If you change that wrapper, keep the blanks — without the one after76+`</summary>` the fences render as literal text (you see the backticks). The structural77+test pins the exact shape (`markdown_has_the_expected_shape`, `tests/markdown.rs:163`).78+79+Note the cost: in a *terminal* pager (`mdcat`/`glow`) the `<details>` tags show as80+raw HTML — the trade-off is deliberate, since the surface that matters (a PR body,81+gist, or wiki) collapses them.82+83+## Choices baked in: focus, titles, contents84+85+- **`@focus`** can't be a sub-range highlight in a fenced block, so `focus_caption`86+  (`src/markdown.rs:223`) names the focused new-side lines in a one-line caption above87+  the block, grouped per hunk run. A focus that lands only on deletions yields **no**88+  caption — deletions carry no new-side number to cite (a `-` line is never focused).89+  Guarded by `focus_caption_groups_runs_and_omits_when_empty` (`src/markdown.rs:406`).90+- **Titles are escaped, prose is not.** The commit title (and chapter titles, author)91+  pass through `escape_md_inline` (`src/markdown.rs:367`) so a `*` or `` ` `` in a92+  subject line doesn't become formatting in a heading — the Markdown analogue of93+  `html::escape`. The message *body* stays verbatim, because it genuinely is Markdown.94+- **The branch contents list has no in-page links.** GitHub and Forgejo slugify95+  headings differently, so a hand-built `#anchor` would resolve on one and break on96+  the other; the `##` chapter headings still give an outline. See97+  `branch_markdown_composes_chapters_in_order` (`tests/markdown.rs:256`).98+99+## Doing it right: assert on the string and the model100+101+Because the emitter writes raw, unescaped diff content, completeness is checkable102+directly on the rendered string: every `+`/`-` line's content is a substring of the103+output (`tests/markdown.rs:123`). Use that for *presence*; reach into the104+`render::Document` model for *structure* (which sections exist, what is backfilled),105+exactly as the HTML tests do — see [`html-output.md`](html-output.md). Keep the106+shared control flow (`document_body`, the backfill grouping) in sync between the two107+emitters when you touch one.

Map the new emitter into the architecture — a second consumer of the shared Document (and Chapter), in the pipeline diagram and the module table.

docs/ARCHITECTURE.md
@@ -6,8 +6,8 @@6 7 ## Overview8 -The renderer is a straight-line pipeline from a commit revision to a single HTML-string:9+The renderer is a straight-line pipeline from a commit revision to an ordered10+`Document`, which an emitter then lowers to HTML or Markdown:11 12 ```13 revision@@ -23,9 +23,11 @@23    │                                            │  backfill unshown hunks in natural order24    ▼                                            ▼25                                           render::Document (ordered Sections)---                                          html::to_html ─────► self-contained HTML26+                                                 │  --format picks one emitter27+                                  ┌──────────────┴──────────────┐28+                                  ▼                              ▼29+                          html::to_html                  markdown::to_markdown30+                          self-contained HTML            Markdown (fenced diffs)31 ```32 33 Nothing in the pipeline may drop a hunk. The `Coverage` tracker and the backfill@@ -48,6 +50,7 @@50 | `diff` | The diff data model (`Diff` → `FileDiff` → `Hunk` → `DiffLine`) and the per-hunk `Coverage` tracker. |51 | `render` | Orchestration. Resolves anchors against `git`, marks coverage, backfills the rest, and produces the ordered `Document`. |52 | `html` | Lower a `Document` into a standalone HTML page with inlined CSS (`to_html`); compose several `Document`s into one branch walkthrough (`to_html_branch`). |53+| `markdown` | Lower the same `Document` into Markdown — prose verbatim, hunks as fenced `diff` blocks, the backfill folded into `<details>` (`to_markdown` / `to_markdown_branch`). A sibling of `html`. |54 55 The two **pure** modules (`anchor`, `message`) define the format and are the most56 heavily unit-tested; the I/O and rendering modules wrap them.@@ -82,9 +85,11 @@85     known, so the emitter stays a dumb formatter. `origin` is `'+'`/`'-'`/`' '`86     or `'@'` for a hunk header.87 -`Document` is the single contract between the rendering logic and the HTML-emitter. Adding an output format (later) means writing another consumer of-`Document`, not touching the pipeline.88+`Document` is the single contract between the rendering logic and the emitters.89+This is why a second output format — `markdown`, alongside `html` — is just90+another consumer of `Document` (and `Chapter`, the branch-level pairing of a91+`Document` with its commit metadata, which lives in `render` for the same reason);92+neither emitter touches the pipeline, and both inherit completeness for free.93 94 ## The render algorithm95 @@ -167,6 +172,8 @@172    sections-plus-backfill body) and `page` (the standalone chrome), so a chapter173    renders identically to a standalone page minus the `<h1>`. Navigation is plain174    in-page anchors — still no JavaScript, still one self-contained file.175+   `markdown::to_markdown_branch` mirrors this for Markdown: the same per-commit176+   `Document`s under `##` chapter headings beneath a plain contents list.177 178 The key semantic: **completeness holds per commit, not over the branch.** Each179 chapter shows every hunk of its own commit; the page does not squash the range@@ -221,11 +228,12 @@228 Implemented: the full pipeline. The CLI surface, the `anchor` grammar and229 `message::parse`, `git::diff_against_parent` / `blob_lines`, the `render`230 orchestration (always-diff anchor resolution, per-hunk coverage, whole-hunk-backfill, and delta-level markers), and the `html` emitter — with the-completeness invariant covered by integration tests (`tests/completeness.rs`,231+backfill, and delta-level markers), and the `html` and `markdown` emitters — with232+the completeness invariant covered by integration tests (`tests/completeness.rs`,233 `tests/hunkless.rs`, `tests/mode_change.rs`, `tests/ranged_anchor.rs`,-`tests/context_anchor.rs`). The `branch` view composes a commit range into one-navigable page on top of that pipeline (`tests/branch.rs`).234+`tests/context_anchor.rs`, `tests/markdown.rs`). The `branch` view composes a commit235+range into one navigable page (HTML or Markdown) on top of that pipeline236+(`tests/branch.rs`).237 238 Stubbed: `conventional::Header::parse` (returns `None`) and footer-trailer239 splitting in `message::parse` (`trailers` is empty); both fall through harmlessly@@ -235,4 +243,5 @@243 (hunk-less deltas, mode-beside-content markers done; the always-diff redesign244 closed the deletion holes), Conventional Commits interop, notation interop245 (accept the `#L12-L38` fragment and Markdown-link anchor forms), and the spec's-later items (interactive UI, content-based anchoring, non-HTML export).246+later items (interactive UI, content-based anchoring, the `throughline pr` surface247+over the now-shipped Markdown output, and further export formats).

Fix a stray code fence in the module docs so rustdoc stops swallowing the prose after it.

src/markdown.rs
@@ -1,7 +1,7 @@1 //! Emit a Markdown rendering from a [`crate::render::Document`], a sibling to2 //! [`crate::html`]. The destination (a pager, gist, wiki, or PR body) renders3 //! Markdown itself, so prose passes through **verbatim** — it is never run through-//! a Markdown library the way `html.rs` does. Diff hunks become fenced ```diff4+//! a Markdown library the way `html.rs` does. Diff hunks become fenced `diff`5 //! blocks; the backfill is folded into a `<details>` block but kept fully present,6 //! so the completeness guarantee still holds (it is only collapsed, never dropped).7 //!@@ -154,7 +154,7 @@154 }155 156 /// Render the collected backfill items, grouping consecutive hunks of one file into-/// a single ```diff block. A marker closes any open group and renders as a one-line157+/// a single `diff` block. A marker closes any open group and renders as a one-line158 /// note. When `collapsible`, the whole region is folded into a `<details>` block159 /// (the backfill stays fully present — it is only collapsed). Mirrors160 /// `html::backfill_html`; keep the grouping in sync.

Remaining changes

README.md
@@ -245,10 +245,10 @@245 This is a *local* loop — no PR, no remote, nothing published; just the agent's246 branch, `throughline`, and your browser. For solo work that review-by-walkthrough247 *is* the workflow, and it's the case the tool serves best today. Prefer to stay in-the terminal? A planned `--format=markdown` output-([roadmap](docs/ROADMAP.md#markdown-output-and-the-pr-surface)) will pipe the same-walkthrough into a Markdown pager such as [`mdcat`](https://github.com/swsnr/mdcat)-or `glow`.248+the terminal? `--format=markdown`249+([details](docs/ROADMAP.md#markdown-output-and-the-pr-surface)) writes the same250+walkthrough as Markdown — page it with [`mdcat`](https://github.com/swsnr/mdcat) or251+`glow`, or drop it into a gist or wiki.252 253 ### Wiring it into any project254 
docs/wiki/README.md
@@ -41,6 +41,9 @@41   - [`field-notes/html-output.md`](field-notes/html-output.md)42     — how `html.rs` shapes the page, and the two gotchas (line-packing, CSS class43     names) that mislead you when you grep or split rendered output.44+  - [`field-notes/markdown-output.md`](field-notes/markdown-output.md)45+    — how `markdown.rs` shapes the Markdown (sibling of `html.rs`), and its two46+    gotchas: variable fence length and the blank lines `<details>` needs.47   - [`field-notes/cli-binary-layer.md`](field-notes/cli-binary-layer.md)48     — the binary (`main.rs`): where I/O lives, the best-effort `--open` opener, and49     why `cargo test` can't reach the CLI (so how to verify it instead).
17/37ed1a64cEdu Ramírez

Sync the CLI docs with the Markdown output

Three docs still described HTML-only output after --format shipped: the binary-layer field note, the branch how-to, and the README usage synopsis. Bring them in line.

The binary now selects an emitter and names the default file by the format's extension — fold that into the I/O field note, which had said the library lowers a Document into "an HTML string" and listed only the HTML-era binary behaviors.

docs/wiki/field-notes/cli-binary-layer.md
@@ -6,12 +6,14 @@6 7 ## The library returns data; the binary does I/O8 -`commit_throughline` (the library: `render`, `html`, `git`, …) is effectively pure-at its surface — it resolves a commit into a `Document` and lowers that into an HTML-*string*. It writes nothing. Every side effect lives in the binary, `src/main.rs`:-discover the repo, call the library, **write** the file, **print** the path, and —-with `--open` — **spawn** the platform opener. `run_render` / `run_branch` are the-two top-level flows; both end with the same three steps (write → print → maybe open).9+`commit_throughline` (the library: `render`, `html`, `markdown`, `git`, …) is10+effectively pure at its surface — it resolves a commit into a `Document` and lowers11+that into a *string* (HTML, or Markdown when `--format markdown` is passed). It writes12+nothing. Every side effect lives in the binary, `src/main.rs`: discover the repo,13+**pick the emitter** from `--format`, **write** the file (named by the format's14+extension — `.html`/`.md` — when `-o` is omitted), **print** the path, and — with15+`--open` — **spawn** the platform opener. `run_render` / `run_branch` are the two16+top-level flows; both end with the same steps (select → write → print → maybe open).17 18 ## `--open` is best-effort, by design19 @@ -36,8 +38,8 @@38 `html::to_html`, …) over fixture repos built with `tempfile` + `git2`. They never39 spawn the `throughline` binary. So `run_render` / `run_branch` / `open_in_browser`40 are private to `main.rs` and **uncovered** by `cargo test`: output-path naming, the-`--open` opener, and `--debug` are binary-layer behavior you verify by **running the-binary**, not by adding a test. (This is the practical edge of ARCHITECTURE's "the41+`--format` emitter selection, the `--open` opener, and `--debug` are binary-layer42+behavior you verify by **running the binary**, not by adding a test. (This is the practical edge of ARCHITECTURE's "the43 I/O and rendering modules wrap the pure ones" — the outermost I/O wrapper, `main`,44 has no test seam at all.)45 

The branch how-to hard-coded a .html output and an HTML-only "what you get"; note that --format markdown composes the same chapters as Markdown, cross-linking the field note.

docs/wiki/how-to/render-a-branch.md
@@ -20,9 +20,10 @@20 21 Both resolve to the same thing internally: the commits in `base..tip` — reachable22 from the tip but not the base — so the **base commit itself is excluded**. Output-goes to `throughline-branch-<tip-sha>.html` unless you pass `-o`. Add `--open` to-open the page in your default browser once it is written (best-effort — same flag as-`render`).23+goes to `throughline-branch-<tip-sha>.<ext>` unless you pass `-o`, where the extension24+follows `--format` (`.html` by default, `.md` for `--format markdown`). Add `--open`25+to open the page in your default browser once it is written (best-effort — same flag26+as `render`).27 28 ## What you get29 @@ -35,6 +36,10 @@36   the author),37 - **previous / next / contents** links between chapters.38 39+With `--format markdown` you get the same composition as Markdown instead — one `##`40+chapter per commit beneath a plain contents list — for a pager, gist, or PR body. See41+[`field-notes/markdown-output.md`](../field-notes/markdown-output.md).42+43 ## The one semantic to keep straight44 45 Completeness holds **per commit, not over the branch.** Each chapter shows every

The README usage synopsis omitted --format; add it to both commands and let the default-filename note follow the format.

README.md
@@ -173,11 +173,14 @@173 and opened; share the file, attach it to a review, or keep rendering as you go.174 175 ```sh-throughline render <commit> [-o out.html] [--open]176+throughline render <commit> [-o out.html] [--format html|markdown] [--open]177 ```178 179 - `<commit>` is any revision: `HEAD`, a SHA, a tag, `HEAD~2`, …-- Omit `-o` and it writes `throughline-<short-sha>.html` in the current directory.180+- `--format` picks the output (default `html`; `markdown` for a pager, gist, or PR181+  body).182+- Omit `-o` and it writes `throughline-<short-sha>.<ext>` in the current directory,183+  the extension following `--format` (`.html` / `.md`).184 - `--open` opens the page after writing it (`open` on macOS, `xdg-open` on Linux,185   `start` on Windows). Best-effort: if no opener is found the path is still186   printed and the command still succeeds, so it's safe in scripts and on headless@@ -201,8 +204,8 @@204 branch or a PR as a story instead of a pile of "files changed."205 206 ```sh-throughline branch <tip> [--base <ref>] [-o out.html] [--open]   # range is base..tip; default base: main-throughline branch <base>..<tip> [-o out.html] [--open]          # explicit range207+throughline branch <tip> [--base <ref>] [-o out.html] [--format html|markdown] [--open]   # range is base..tip; default base: main208+throughline branch <base>..<tip> [-o out.html] [--format html|markdown] [--open]          # explicit range209 ```210 211 - `throughline branch my-feature` renders every commit in `main..my-feature`.
18/372b08c13Edu Ramírez

Document the canonical Claude Code auto-render hook

The README shipped every wiring recipe except the Claude Code one, so setup generators improvised it with $CLAUDE_PROJECT_DIR — which renders the main checkout's HEAD in a worktree-per-agent loop, breaking the exact setup the "Agentic coding" section sells.

Here is the recipe: it resolves the tree from cwd (the rule the worktree how-to already mandates), fails open so it never blocks a turn, and renders the branch as chapters when ahead of the trunk.

README.md
@@ -297,0 +299,46 @@299+**Claude Code Stop hook** — the agentic-loop-native version of the above: render300+automatically when the agent finishes a turn, so you read the walkthrough rather301+than the raw diff. Save as `.claude/hooks/render-throughline.sh` (make it302+executable):303+304+```sh305+#!/usr/bin/env sh306+# Render the current branch (or just the latest commit) as a throughline.307+# Resolve the tree from cwd, NOT $CLAUDE_PROJECT_DIR: in a worktree-per-agent308+# loop the work is in whichever worktree the agent is in, which that variable309+# may not point at. Fail-open — any problem exits 0 and never blocks the turn.310+set -u311+root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0312+cd "$root" || exit 0313+tl=$(command -v throughline 2>/dev/null || echo "$HOME/.local/bin/throughline")314+[ -x "$tl" ] || exit 0315+316+dir="$(git rev-parse --git-dir)/throughline"; out="$dir/walkthrough.html"317+mkdir -p "$dir"318+319+head=$(git rev-parse HEAD 2>/dev/null) || exit 0          # skip unless HEAD moved320+[ "$(cat "$dir/.last-head" 2>/dev/null)" = "$head" ] && exit 0321+322+base=323+for b in main master; do324+  git rev-parse --verify --quiet "$b" >/dev/null && { base=$b; break; }325+done326+if [ -n "$base" ] && [ "$(git rev-list --count "$base..HEAD" 2>/dev/null || echo 0)" -gt 0 ]327+then "$tl" branch HEAD --base "$base" -o "$out" 2>/dev/null   # ahead of trunk → chapters328+else "$tl" render HEAD -o "$out" 2>/dev/null                  # on the trunk → latest commit329+fi330+printf '%s' "$head" > "$dir/.last-head"331+```332+333+Register it — `$CLAUDE_PROJECT_DIR` only *locates the script*; the script derives334+the working tree from cwd, so it renders whichever worktree the agent is in.335+`Stop` is a list: append, don't overwrite existing hooks.336+337+```json338+{ "hooks": { "Stop": [ { "hooks": [339+  { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/render-throughline.sh\"" }340+] } ] } }341+```342+343+The page lands inside `.git/` (never committed); keep it open in a browser tab and344+refresh after a turn.

And the agentic-coding section now points to it, so the auto-render loop is discoverable from where worktree-per-agent setups are introduced.

README.md
@@ -246,1 +246,3 @@-code side by side. It pairs naturally with worktree-per-agent setups.246+code side by side. It pairs naturally with worktree-per-agent setups — make the247+render automatic with the [Claude Code Stop hook](#wiring-it-into-any-project)248+below.

Remaining changes

README.md
@@ -243,7 +243,9 @@243 The agent explains its own change in the order it made sense to make it, the244 renderer proves the explanation against the real diff (completeness means it245 can't quietly skip the part it's least sure about), and you review intent and-code side by side. It pairs naturally with worktree-per-agent setups.246+code side by side. It pairs naturally with worktree-per-agent setups — make the247+render automatic with the [Claude Code Stop hook](#wiring-it-into-any-project)248+below.249 250 This is a *local* loop — no PR, no remote, nothing published; just the agent's251 branch, `throughline`, and your browser. For solo work that review-by-walkthrough@@ -294,6 +296,53 @@296 throughline render HEAD -o "$(git rev-parse --git-dir)/throughline-last.html"297 ```298 299+**Claude Code Stop hook** — the agentic-loop-native version of the above: render300+automatically when the agent finishes a turn, so you read the walkthrough rather301+than the raw diff. Save as `.claude/hooks/render-throughline.sh` (make it302+executable):303+304+```sh305+#!/usr/bin/env sh306+# Render the current branch (or just the latest commit) as a throughline.307+# Resolve the tree from cwd, NOT $CLAUDE_PROJECT_DIR: in a worktree-per-agent308+# loop the work is in whichever worktree the agent is in, which that variable309+# may not point at. Fail-open — any problem exits 0 and never blocks the turn.310+set -u311+root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0312+cd "$root" || exit 0313+tl=$(command -v throughline 2>/dev/null || echo "$HOME/.local/bin/throughline")314+[ -x "$tl" ] || exit 0315+316+dir="$(git rev-parse --git-dir)/throughline"; out="$dir/walkthrough.html"317+mkdir -p "$dir"318+319+head=$(git rev-parse HEAD 2>/dev/null) || exit 0          # skip unless HEAD moved320+[ "$(cat "$dir/.last-head" 2>/dev/null)" = "$head" ] && exit 0321+322+base=323+for b in main master; do324+  git rev-parse --verify --quiet "$b" >/dev/null && { base=$b; break; }325+done326+if [ -n "$base" ] && [ "$(git rev-list --count "$base..HEAD" 2>/dev/null || echo 0)" -gt 0 ]327+then "$tl" branch HEAD --base "$base" -o "$out" 2>/dev/null   # ahead of trunk → chapters328+else "$tl" render HEAD -o "$out" 2>/dev/null                  # on the trunk → latest commit329+fi330+printf '%s' "$head" > "$dir/.last-head"331+```332+333+Register it — `$CLAUDE_PROJECT_DIR` only *locates the script*; the script derives334+the working tree from cwd, so it renders whichever worktree the agent is in.335+`Stop` is a list: append, don't overwrite existing hooks.336+337+```json338+{ "hooks": { "Stop": [ { "hooks": [339+  { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/render-throughline.sh\"" }340+] } ] } }341+```342+343+The page lands inside `.git/` (never committed); keep it open in a browser tab and344+refresh after a turn.345+346 **CI + static hosting** — render the head commit (or the branch) and publish the347 HTML as a build artifact or a static page. The output is one self-contained file,348 so any host serves it as-is — this project's own [live
19/378d6e842Edu Ramírez

Dogfood the auto-render loop with a Stop hook

This repo now runs the very agentic-coding loop the README sells: a non-blocking Claude Code Stop hook renders each turn's work as a walkthrough, alongside the existing docs-sync gate.

The hook resolves the working tree from cwd — so a worktree session renders its own branch, not the main checkout's — prefers a locally-built binary, skips when HEAD hasn't moved, and fails open throughout; a preview must never trap a turn.

.claude/hooks/render-throughline.sh
@@ -0,0 +1,51 @@1+#!/usr/bin/env sh2+# Stop hook — render the current work as a Commit Throughline walkthrough, so a3+# turn's commits can be read as a narrated diff instead of scrolled raw. This is4+# the repo dogfooding its own agentic-coding loop (README "Agentic coding").5+#6+# It is a non-blocking SIDE EFFECT, unlike the docs_sync Stop gate it runs beside:7+# any problem just exits 0 — it never blocks a stop or re-prompts a turn.8+#9+# Docs: docs/wiki/automation/auto-render-hook.md10+set -u11+12+# Opt out for a turn (mirrors docs_sync's THROUGHLINE_DOCSYNC=0).13+[ "${THROUGHLINE_RENDER:-1}" = "0" ] && exit 014+15+# Resolve the tree from cwd, NOT $CLAUDE_PROJECT_DIR: a worktree session must16+# render ITS OWN branch, not the main checkout's. (Same rule as17+# docs/wiki/how-to/working-in-a-worktree.md.)18+root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 019+cd "$root" || exit 020+21+# Prefer a locally-built binary — freshest while developing the renderer, and the22+# pre-commit `cargo test` keeps target/debug current — then any installed one.23+tl=24+for cand in \25+  "$root/target/debug/throughline" \26+  "$root/target/release/throughline" \27+  "$(command -v throughline 2>/dev/null)" \28+  "$HOME/.local/bin/throughline"29+do30+  [ -n "$cand" ] && [ -x "$cand" ] && { tl=$cand; break; }31+done32+[ -n "$tl" ] || exit 033+34+dir="$(git rev-parse --git-dir)/throughline"; out="$dir/walkthrough.html"35+mkdir -p "$dir"36+37+# Skip unless HEAD moved since the last render (no churn on no-commit turns).38+head=$(git rev-parse HEAD 2>/dev/null) || exit 039+[ "$(cat "$dir/.last-head" 2>/dev/null)" = "$head" ] && exit 040+41+# Ahead of the trunk → render the branch as chapters; otherwise the latest commit.42+base=43+for b in main master; do44+  git rev-parse --verify --quiet "$b" >/dev/null && { base=$b; break; }45+done46+if [ -n "$base" ] && [ "$(git rev-list --count "$base..HEAD" 2>/dev/null || echo 0)" -gt 0 ]47+then "$tl" branch HEAD --base "$base" -o "$out" 2>/dev/null48+else "$tl" render HEAD -o "$out" 2>/dev/null49+fi50+printf '%s' "$head" > "$dir/.last-head"51+exit 0

It registers as a second Stop entry beside the docs-sync gate. The two are independent: that one blocks to force doc updates, this one is a silent side effect.

.claude/settings.json
@@ -1,5 +1,5 @@1 {-  "$comment": "Project-shared Claude Code settings. The docs-sync hooks keep documentation in step with code; see docs/wiki/automation/docs-sync-hook.md.",2+  "$comment": "Project-shared Claude Code settings. docs-sync hooks keep documentation in step with code (docs/wiki/automation/docs-sync-hook.md); the Stop render hook previews each turn's work as a walkthrough (docs/wiki/automation/auto-render-hook.md).",3   "hooks": {4     "PostToolUse": [5       {@@ -20,6 +20,14 @@20             "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/docs_sync.py\""21           }22         ]23+      },24+      {25+        "hooks": [26+          {27+            "type": "command",28+            "command": "sh \"$CLAUDE_PROJECT_DIR/.claude/hooks/render-throughline.sh\""29+          }30+        ]31       }32     ]33   }

The wiki documents what it renders, the build-vs-working-tree caveat that bites only because we develop the renderer here, and the three ways to switch it off.

docs/wiki/automation/auto-render-hook.md
@@ -0,0 +1,90 @@1+# The auto-render hook2+3+How this repo renders each turn's work as a Commit Throughline walkthrough, so the4+commits an agent just made can be read as a narrated diff instead of scrolled raw.5+This is the repo dogfooding its own6+[agentic-coding loop](../../../README.md#agentic-coding); this page documents the7+hook and how to switch it off.8+9+## What it does10+11+A Claude Code [`Stop` hook](https://code.claude.com/docs/en/hooks)12+(`.claude/hooks/render-throughline.sh`) runs when a turn ends and writes a13+self-contained HTML walkthrough of the current work to14+`<git-dir>/throughline/walkthrough.html` — a path inside `.git/`, so it is never15+tracked. Keep that file open in a browser tab and refresh after a turn.16+17+It renders the **branch as chapters** (`throughline branch HEAD --base <trunk>`)18+when HEAD is ahead of the trunk, and the **latest commit**19+(`throughline render HEAD`) otherwise — so it does the right thing whether you work20+on a feature branch or commit straight to the trunk. The trunk is detected as21+`main`, then `master`.22+23+Unlike the [docs-sync gate](docs-sync-hook.md) it runs beside, this hook is a24+**non-blocking side effect**: it never emits a `block` decision and never25+re-prompts. Any problem — no repo, no binary, a render error — simply exits 0. A26+preview is a convenience; it must never trap a turn or fail a stop.27+28+## How it works29+30+- **Tree from cwd, not `$CLAUDE_PROJECT_DIR`.** The script resolves the working31+  tree with `git rev-parse --show-toplevel`, so a session in a worktree renders32+  *that worktree's* branch, not the main checkout's. `$CLAUDE_PROJECT_DIR` only33+  *locates the script* in [`settings.json`](../../../.claude/settings.json); it is34+  the wrong input for *which tree to render* (it can point at the main checkout).35+  This is the same "resolve from the worktree root" rule the36+  [worktree how-to](../how-to/working-in-a-worktree.md) mandates.37+- **Local build first.** It picks the first executable of38+  `target/debug` → `target/release` → a `throughline` on `PATH` → `~/.local/bin`.39+  Preferring the local build means the preview tracks your work while you develop40+  the renderer — and because the [pre-commit hook](pre-commit-hook.md) runs41+  `cargo test` (which rebuilds `target/debug`), that binary is fresh right after any42+  Rust commit.43+- **Re-renders only when HEAD moved.** It stamps the rendered SHA in44+  `<git-dir>/throughline/.last-head` and exits early when it is unchanged, so a45+  no-commit turn does not re-render.46+47+## The one caveat: it renders the *built* binary, not your working tree48+49+Everywhere else the hook renders your commits and that is the whole value. Here you50+often edit the renderer itself — and the hook runs the *built* binary, so the page51+reflects your last build, not uncommitted source. Read it as a preview of the52+*commits*, not as a live test of unbuilt renderer changes; rebuild (`cargo build`,53+or just commit — the pre-commit `cargo test` rebuilds) to see those.54+55+## Turning it off56+57+- **For one turn:** set `THROUGHLINE_RENDER=0` in the environment Claude Code runs58+  in. The hook sees it and exits without rendering.59+- **For yourself only:** override the `Stop` hook in `.claude/settings.local.json`60+  (git-ignored, personal).61+- **For the whole project:** remove the render entry from the `Stop` array in62+  `.claude/settings.json`.63+64+## Requirements65+66+- **A `throughline` binary** reachable via the fallback chain above. Without one the67+  hook is a silent no-op (fail-open), so a fresh clone that has not built or68+  installed the tool simply renders nothing.69+- **`sh`** (POSIX shell). The hook is plain `sh`, no Bash-isms.70+71+## Files72+73+| File | Role |74+|------|------|75+| `.claude/settings.json` | Registers the hook as a `Stop` entry (committed, project-shared). |76+| `.claude/hooks/render-throughline.sh` | The hook: resolve tree, pick binary, render, stamp. |77+| `<git-dir>/throughline/walkthrough.html` | The rendered page (untracked — it lives inside `.git/`). |78+| `<git-dir>/throughline/.last-head` | The last-rendered SHA (the skip-guard's state). |79+80+## Verifying it works81+82+Run it by hand from the repo (or a worktree) root:83+84+```sh85+sh .claude/hooks/render-throughline.sh && open "$(git rev-parse --git-dir)/throughline/walkthrough.html"86+```87+88+You should get a walkthrough of the latest commit — or of the branch, if you are89+ahead of the trunk. Or run Claude Code with `--debug` and confirm the `Stop` command90+runs after a turn that committed.

And it joins the automation section of the wiki map so the next reader finds it.

docs/wiki/README.md
@@ -59,6 +59,8 @@59 - **Automation** — how this repo keeps itself honest.60   - [`automation/docs-sync-hook.md`](automation/docs-sync-hook.md) — the Claude Code61     hook that prompts a docs update whenever code changes.62+  - [`automation/auto-render-hook.md`](automation/auto-render-hook.md) — the Claude63+    Code Stop hook that previews each turn's work as a walkthrough (dogfoods the loop).64   - [`automation/pre-commit-hook.md`](automation/pre-commit-hook.md) — the Git65     pre-commit hook that runs fmt / clippy / test before every commit.66   - [`automation/pages-publish-hook.md`](automation/pages-publish-hook.md) — the Git
20/3799373a0Edu Ramírez

Document how squashing and rebasing affect narration

Completeness holds for any commit you render, but a message authored against per-commit diffs stops narrating faithfully once those commits are squashed. This documents that boundary in the canonical spec.

A new "Squashing & rebasing" section, placed right after the worked example, separates the two guarantees under a history rewrite: completeness never breaks (the renderer always pulls the full diff, so no code is lost), while faithful narration can — an anchor's range, written against the old diff, may miss in the combined one. It then names the faithful paths: render the original commit or range, re-author against the squashed diff, or lean on the one lossless case, a single commit rebased onto its target.

docs/SPEC.md
@@ -201,6 +201,44 @@201 order, shown plainly. The prose always precedes the code it describes, and no hunk is202 missing.203 204+## Squashing & rebasing205+206+An anchor is a coordinate — a path and a line range — against the diff of a207+**specific commit**, and a message is authored against the diff it ships with. The208+narration is faithful only when the diff it renders over is the one it was written209+for, and history rewrites can break that pairing:210+211+- **Squash-merge** (GitHub/GitLab "Squash and merge", `git merge --squash`)212+  collapses a branch's commits into one whose **combined** diff differs from the213+  per-commit diffs the messages were authored against: hunks merge, line numbers214+  shift, and a later commit's edit can erase an earlier one's.215+- **Rebase** replays commits onto a new base. Moving a commit unchanged preserves216+  its diff; resolving a conflict edits the diff in place.217+218+**Completeness never breaks.** The renderer always pulls the *full* diff of219+whatever commit it is given, so every hunk — and every line — still appears; you220+can render a squashed commit and lose no code. What a rewrite can break is221+**faithful narration**: an anchor's range, written against the old diff, may land222+on the wrong lines in the new one, fall back to plain context, or degrade to a223+warning (which, per *degrade, don't fail*, still emits the prose). The words224+survive; the code they lead into may no longer be what they point at.225+226+To read a change as its author narrated it, render it against a diff the message227+matches:228+229+- **Render the original commit or range.** The per-commit branch view230+  (`throughline branch base..tip`) renders each commit against its own parent, so231+  every chapter's message still matches its diff — the faithful way to read a232+  feature branch whether or not it is later squashed.233+- **Re-author against the squashed diff.** For a single page over the squashed234+  commit, write the squash or cover-letter message with anchors against the235+  *combined* diff. (Composing one narrative over a branch's squashed diff236+  automatically is a [planned](ROADMAP.md) extension — the roadmap's "branch-level237+  narrative".)238+- **A single commit rebased onto its target is the one lossless case.** Replayed239+  with no conflict edits, it keeps the same diff, so its message narrates it240+  faithfully wherever it lands.241+242 ## Conventional Commits interop243 244 Commit Throughline and [Conventional Commits](https://www.conventionalcommits.org) target
21/37c6ded32Edu Ramírez

Add a squash-merge recipe to "Wiring it into any project"

The wiring recipes cover post-commit and CI but skip the squash-on-merge reality every team hits. Add the recipe: render the branch before squashing, or reuse its message only when it is a single commit.

The new entry follows "CI + static hosting" and names the trap — the squash collapses a branch into one commit whose combined diff no longer matches the per-commit messages — then gives the two faithful options and links to the spec's new Squashing & rebasing section for the reasoning.

README.md
@@ -349,6 +349,17 @@349 demo](https://eduramirezh.codeberg.page/commit-throughline/) is exactly that:350 `throughline branch` output committed to a Codeberg Pages branch.351 352+**Squash-merge** — the "Squash and merge" button (GitHub/GitLab) lands a branch as353+a single commit whose *combined* diff differs from the per-commit diffs its354+messages were authored against, so the squashed commit no longer narrates its own355+change. Keep the walkthrough faithful one of two ways: render the branch *before*356+it is squashed — `throughline branch <feature> --base main` reads each original357+commit against its own parent — or, for one page over the squashed commit, **reuse358+the branch message only when the branch was a single commit** (the squash leaves359+its diff unchanged); otherwise **re-author** the squash message with anchors360+against the combined diff. The spec's [Squashing &361+rebasing](docs/SPEC.md#squashing--rebasing) explains why.362+363 ## Writing throughline-friendly commits364 365 The walkthrough is authored entirely in the commit message — there is nothing
22/37c112408Edu Ramírez

Recognize root dotfiles and extensionless files as anchors

A path on its own line for a repository-root file with no slash and no extension — .gitignore, Makefile, Dockerfile, LICENSE — fell through the path recognizer to prose, so its hunk only ever surfaced uncovered in the backfill and the author's coordinate was lost. Teach the recognizer two more context-free shapes so these lines anchor like any other.

The recognizer keeps its slash and extension checks and gains two more clauses. It still decides on token shape alone and never consults the diff, so a root file is matched by shape and name, not by being present in the commit — the parse stays deterministic.

src/anchor.rs
@@ -129,5 +129,10 @@-/// The recognition heuristic: a token "looks like a path" if it contains a `/`-/// or ends in a recognizable `.extension`.129+/// The recognition heuristic: a token "looks like a path". A repository-root file130+/// has no `/` to give it away, so beyond the obvious "contains a `/` or ends in a131+/// `.extension`" we also recognize two context-free shapes that are paths but not132+/// prose: a **dotfile** (`.gitignore`) and a **well-known extensionless filename**133+/// (`Makefile`, `LICENSE`). The check never consults the diff — per the spec, the134+/// parse must not change meaning based on what changed — so a root file is matched135+/// by *shape and name*, not by being present in the commit.136 fn looks_like_path(token: &str) -> bool {-    token.contains('/') || has_extension(token)137+    token.contains('/') || has_extension(token) || is_dotfile(token) || is_known_filename(token)138 }

A dotfile is a leading dot followed by an alphanumeric, so a prose ellipsis (...) or a parent-dir token (..) is never mistaken for one; the curated, case-insensitive allowlist covers the distinctive extensionless root files — the *file build drivers and the all-caps legal and doc files.

src/anchor.rs
@@ -147,0 +152,57 @@152+/// True if `token` is a dotfile: a leading `.` immediately followed by an ASCII153+/// alphanumeric, like `.gitignore` or `.env`. (`has_extension` rejects these — the154+/// only `.` is at index 0 — so a single-dot dotfile would otherwise fall through to155+/// prose.) Requiring an alphanumeric after the dot keeps a prose ellipsis (`...`)156+/// or a parent-dir token (`..`) from being read as an anchor.157+fn is_dotfile(token: &str) -> bool {158+    let mut chars = token.chars();159+    chars.next() == Some('.') && chars.next().is_some_and(|c| c.is_ascii_alphanumeric())160+}161+162+/// Well-known extensionless filenames that conventionally live at the repository163+/// root, where there is no `/` to mark them as paths. Matched case-insensitively164+/// against the whole token. The list is deliberately curated to *distinctive*165+/// names (the `*file` build files, the all-caps legal/doc files) so that a166+/// one-word prose line — `Done`, `Fixed` — never becomes an anchor; extend it as167+/// new conventions appear. A name here that names no file in the commit still168+/// degrades visibly (prose + warning) rather than silently reverting to prose, so169+/// erring toward recognition keeps the author's coordinate.170+const KNOWN_FILENAMES: &[&str] = &[171+    // Build, packaging & CI drivers.172+    "makefile",173+    "gnumakefile",174+    "dockerfile",175+    "containerfile",176+    "jenkinsfile",177+    "vagrantfile",178+    "procfile",179+    "brewfile",180+    "justfile",181+    "rakefile",182+    "gemfile",183+    "podfile",184+    "berksfile",185+    "guardfile",186+    "capfile",187+    // Project metadata, legal & top-level docs.188+    "license",189+    "licence",190+    "copying",191+    "copyright",192+    "notice",193+    "patents",194+    "authors",195+    "contributors",196+    "maintainers",197+    "codeowners",198+    "readme",199+    "changelog",200+    "install",201+];202+203+/// True if `token` is one of the [`KNOWN_FILENAMES`], compared case-insensitively.204+fn is_known_filename(token: &str) -> bool {205+    KNOWN_FILENAMES206+        .iter()207+        .any(|name| token.eq_ignore_ascii_case(name))208+}

The unit tests pin the new positives and, just as important, the negatives that keep the "rarely collides with prose" promise: a bare unknown word and a lone ellipsis stay prose.

src/anchor.rs
@@ -202,1 +265,65 @@265     #[test]266+    fn root_extensionless_filenames() {267+        // A repository-root build/doc file has no `/` and no extension; it is268+        // recognized by name so its anchor carries a coordinate.269+        for name in [270+            "Makefile",271+            "Dockerfile",272+            "LICENSE",273+            "Rakefile",274+            "CODEOWNERS",275+        ] {276+            let a = anchor(name);277+            assert_eq!(a.path, name);278+            assert_eq!(a.range, None);279+        }280+    }281+282+    #[test]283+    fn root_dotfiles() {284+        // A single-dot dotfile (`has_extension` rejects it) is recognized by shape.285+        for name in [".gitignore", ".env", ".dockerignore", ".gitattributes"] {286+            let a = anchor(name);287+            assert_eq!(a.path, name);288+            assert_eq!(a.range, None);289+        }290+    }291+292+    #[test]293+    fn known_filename_is_case_insensitive() {294+        assert_eq!(anchor("makefile").path, "makefile");295+        assert_eq!(anchor("DOCKERFILE").path, "DOCKERFILE");296+        // The path is preserved verbatim so it still resolves against Git as typed.297+        assert_eq!(anchor("License").path, "License");298+    }299+300+    #[test]301+    fn root_file_with_range_and_focus() {302+        let a = anchor("Makefile:5-10 @7-8");303+        assert_eq!(a.path, "Makefile");304+        assert_eq!(a.range, Some(LineRange { start: 5, end: 10 }));305+        assert_eq!(a.focus, Some(LineRange { start: 7, end: 8 }));306+307+        let b = anchor(".gitignore:2");308+        assert_eq!(b.path, ".gitignore");309+        assert_eq!(b.range, Some(LineRange { start: 2, end: 2 }));310+    }311+312+    #[test]313+    fn bare_unknown_word_is_prose() {314+        // A single word that is neither a dotfile nor a known filename stays prose,315+        // so the recognizer keeps its "rarely collides with prose" promise.316+        assert!(AnchorRef::parse_line("Done").is_none());317+        assert!(AnchorRef::parse_line("Refactored").is_none());318+        assert!(AnchorRef::parse_line("Notes").is_none());319+    }320+321+    #[test]322+    fn ellipsis_and_parent_dir_are_not_dotfiles() {323+        // A dotfile needs an alphanumeric right after the dot; prose ellipses and324+        // bare `..`/`.` do not qualify.325+        assert!(AnchorRef::parse_line("...").is_none());326+        assert!(AnchorRef::parse_line("..").is_none());327+        assert!(AnchorRef::parse_line(".").is_none());328+    }329+

The real guarantee is end to end: a root anchor must resolve to a covered callout rather than only a backfill, while an un-anchored root file still completes via the backfill.

tests/root_file_anchor.rs
@@ -0,0 +1,171 @@1+//! Anchoring repository-root files that have no `/` and no extension.2+//!3+//! A root build/doc file (`Makefile`, `LICENSE`) or a single-dot dotfile4+//! (`.gitignore`) used to fall through the path recognizer and be read as prose:5+//! the anchor line became a caption and its hunk was only ever appended by the6+//! backfill, *uncovered* — completeness held, but the author's chosen position and7+//! coordinate were lost. These tests pin the fix end to end: such a line now8+//! resolves to a covered code callout, and a whole-file anchor marks the hunk full9+//! so it is not also backfilled. The un-anchored root file still backfills, proving10+//! recognition did not quietly swallow it.11+12+use std::fs;13+use std::path::Path;14+15+use commit_throughline::diff::Hunk;16+use commit_throughline::git::Repo;17+use commit_throughline::render::{self, Document, Section};18+use git2::{IndexAddOption, Repository, Signature};19+use tempfile::TempDir;20+21+fn write(dir: &Path, name: &str, content: &str) {22+    fs::write(dir.join(name), content).unwrap();23+}24+25+fn commit_all(git: &Repository, sig: &Signature, msg: &str, parents: &[git2::Oid]) -> git2::Oid {26+    let mut index = git.index().unwrap();27+    index28+        .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)29+        .unwrap();30+    // `add_all` skips ignored paths; add the dotfile explicitly so a fixture that31+    // ships a `.gitignore` still stages its sibling root files deterministically.32+    index.add_path(Path::new(".gitignore")).ok();33+    index.write().unwrap();34+    let tree = git.find_tree(index.write_tree().unwrap()).unwrap();35+    let parent_commits: Vec<git2::Commit> = parents36+        .iter()37+        .map(|p| git.find_commit(*p).unwrap())38+        .collect();39+    let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();40+    git.commit(Some("HEAD"), sig, sig, msg, &tree, &parent_refs)41+        .unwrap()42+}43+44+struct Fixture {45+    _dir: TempDir,46+    repo: Repo,47+    rev: String,48+}49+50+fn build_fixture() -> Fixture {51+    let dir = TempDir::new().unwrap();52+    let git = Repository::init(dir.path()).unwrap();53+    let sig = Signature::now("Fixture", "fixture@example.com").unwrap();54+55+    // --- parent: three root files with no `/` and no extension ----------56+    write(dir.path(), "Makefile", "all:\n\tcargo build\n");57+    write(dir.path(), ".gitignore", "target/\n");58+    write(dir.path(), "LICENSE", "MIT License\nline two\n");59+    let parent = commit_all(&git, &sig, "seed", &[]);60+61+    // --- child: modify all three ----------------------------------------62+    write(dir.path(), "Makefile", "all:\n\tcargo build --release\n");63+    write(dir.path(), ".gitignore", "target/\n*.tmp\n");64+    write(dir.path(), "LICENSE", "MIT License\nline two changed\n");65+66+    // Anchor Makefile and .gitignore whole; leave LICENSE to the backfill.67+    let message = "\68+Tighten the root build and ignore files69+70+Root files with no slash and no extension, anchored by name.71+72+The release build is the default target now.73+Makefile74+75+Ignore scratch files too.76+.gitignore77+";78+    let child = commit_all(&git, &sig, message, &[parent]);79+80+    Fixture {81+        repo: Repo::open(dir.path()).unwrap(),82+        rev: child.to_string(),83+        _dir: dir,84+    }85+}86+87+fn has_code(doc: &Document, path: &str) -> bool {88+    doc.sections89+        .iter()90+        .any(|s| matches!(s, Section::Code(cb) if cb.path == path && !cb.degraded))91+}92+93+fn backfill_hunks_for<'a>(doc: &'a Document, path: &str) -> Vec<&'a Hunk> {94+    doc.sections95+        .iter()96+        .filter_map(|s| match s {97+            Section::Backfill { path: p, hunk } if p == path => Some(hunk),98+            _ => None,99+        })100+        .collect()101+}102+103+#[test]104+fn root_extensionless_anchor_resolves_to_a_covered_callout() {105+    let fx = build_fixture();106+    let doc = render::render(&fx.repo, &fx.rev).unwrap();107+108+    // The `Makefile` line is honored as an anchor: its change shows as a real code109+    // callout, not as prose. A whole-file anchor covers the hunk in full, so it is110+    // not repeated by the backfill — the coordinate was kept, not lost.111+    assert!(112+        has_code(&doc, "Makefile"),113+        "Makefile anchor produced no code"114+    );115+    assert!(116+        backfill_hunks_for(&doc, "Makefile").is_empty(),117+        "a whole-file anchor must mark the hunk full, leaving nothing to backfill"118+    );119+}120+121+#[test]122+fn root_dotfile_anchor_resolves_to_a_covered_callout() {123+    let fx = build_fixture();124+    let doc = render::render(&fx.repo, &fx.rev).unwrap();125+126+    // `.gitignore` — a single-dot dotfile — is recognized by shape, same story.127+    assert!(128+        has_code(&doc, ".gitignore"),129+        ".gitignore anchor produced no code"130+    );131+    assert!(132+        backfill_hunks_for(&doc, ".gitignore").is_empty(),133+        "the whole-file dotfile anchor must leave nothing to backfill"134+    );135+}136+137+#[test]138+fn an_un_anchored_root_file_still_backfills() {139+    let fx = build_fixture();140+    let doc = render::render(&fx.repo, &fx.rev).unwrap();141+142+    // LICENSE was never anchored: it has no authored callout but still appears in143+    // the backfill, so completeness holds and recognition did not swallow it.144+    assert!(!has_code(&doc, "LICENSE"));145+    assert_eq!(146+        backfill_hunks_for(&doc, "LICENSE").len(),147+        1,148+        "the un-anchored root file is completed by the backfill"149+    );150+}151+152+#[test]153+fn the_anchored_callout_precedes_the_backfill() {154+    let fx = build_fixture();155+    let doc = render::render(&fx.repo, &fx.rev).unwrap();156+157+    // Order is the order: the authored Makefile callout comes before the trailing158+    // LICENSE backfill. (If the anchor had degraded to prose, there would be no159+    // Makefile code section to find here.)160+    let make_code = doc161+        .sections162+        .iter()163+        .position(|s| matches!(s, Section::Code(cb) if cb.path == "Makefile"))164+        .expect("a Makefile callout");165+    let license_backfill = doc166+        .sections167+        .iter()168+        .position(|s| matches!(s, Section::Backfill { path, .. } if path == "LICENSE"))169+        .expect("a LICENSE backfill");170+    assert!(make_code < license_backfill);171+}

Remaining changes

src/anchor.rs
@@ -126,10 +126,15 @@126     }127 }128 -/// The recognition heuristic: a token "looks like a path" if it contains a `/`-/// or ends in a recognizable `.extension`.129+/// The recognition heuristic: a token "looks like a path". A repository-root file130+/// has no `/` to give it away, so beyond the obvious "contains a `/` or ends in a131+/// `.extension`" we also recognize two context-free shapes that are paths but not132+/// prose: a **dotfile** (`.gitignore`) and a **well-known extensionless filename**133+/// (`Makefile`, `LICENSE`). The check never consults the diff — per the spec, the134+/// parse must not change meaning based on what changed — so a root file is matched135+/// by *shape and name*, not by being present in the commit.136 fn looks_like_path(token: &str) -> bool {-    token.contains('/') || has_extension(token)137+    token.contains('/') || has_extension(token) || is_dotfile(token) || is_known_filename(token)138 }139 140 /// True if `token` ends in `.<ext>` where `<ext>` is a non-empty run of ASCII@@ -144,6 +149,64 @@149     }150 }151 152+/// True if `token` is a dotfile: a leading `.` immediately followed by an ASCII153+/// alphanumeric, like `.gitignore` or `.env`. (`has_extension` rejects these — the154+/// only `.` is at index 0 — so a single-dot dotfile would otherwise fall through to155+/// prose.) Requiring an alphanumeric after the dot keeps a prose ellipsis (`...`)156+/// or a parent-dir token (`..`) from being read as an anchor.157+fn is_dotfile(token: &str) -> bool {158+    let mut chars = token.chars();159+    chars.next() == Some('.') && chars.next().is_some_and(|c| c.is_ascii_alphanumeric())160+}161+162+/// Well-known extensionless filenames that conventionally live at the repository163+/// root, where there is no `/` to mark them as paths. Matched case-insensitively164+/// against the whole token. The list is deliberately curated to *distinctive*165+/// names (the `*file` build files, the all-caps legal/doc files) so that a166+/// one-word prose line — `Done`, `Fixed` — never becomes an anchor; extend it as167+/// new conventions appear. A name here that names no file in the commit still168+/// degrades visibly (prose + warning) rather than silently reverting to prose, so169+/// erring toward recognition keeps the author's coordinate.170+const KNOWN_FILENAMES: &[&str] = &[171+    // Build, packaging & CI drivers.172+    "makefile",173+    "gnumakefile",174+    "dockerfile",175+    "containerfile",176+    "jenkinsfile",177+    "vagrantfile",178+    "procfile",179+    "brewfile",180+    "justfile",181+    "rakefile",182+    "gemfile",183+    "podfile",184+    "berksfile",185+    "guardfile",186+    "capfile",187+    // Project metadata, legal & top-level docs.188+    "license",189+    "licence",190+    "copying",191+    "copyright",192+    "notice",193+    "patents",194+    "authors",195+    "contributors",196+    "maintainers",197+    "codeowners",198+    "readme",199+    "changelog",200+    "install",201+];202+203+/// True if `token` is one of the [`KNOWN_FILENAMES`], compared case-insensitively.204+fn is_known_filename(token: &str) -> bool {205+    KNOWN_FILENAMES206+        .iter()207+        .any(|name| token.eq_ignore_ascii_case(name))208+}209+210 #[cfg(test)]211 mod tests {212     use super::*;@@ -200,6 +263,71 @@263     }264 265     #[test]266+    fn root_extensionless_filenames() {267+        // A repository-root build/doc file has no `/` and no extension; it is268+        // recognized by name so its anchor carries a coordinate.269+        for name in [270+            "Makefile",271+            "Dockerfile",272+            "LICENSE",273+            "Rakefile",274+            "CODEOWNERS",275+        ] {276+            let a = anchor(name);277+            assert_eq!(a.path, name);278+            assert_eq!(a.range, None);279+        }280+    }281+282+    #[test]283+    fn root_dotfiles() {284+        // A single-dot dotfile (`has_extension` rejects it) is recognized by shape.285+        for name in [".gitignore", ".env", ".dockerignore", ".gitattributes"] {286+            let a = anchor(name);287+            assert_eq!(a.path, name);288+            assert_eq!(a.range, None);289+        }290+    }291+292+    #[test]293+    fn known_filename_is_case_insensitive() {294+        assert_eq!(anchor("makefile").path, "makefile");295+        assert_eq!(anchor("DOCKERFILE").path, "DOCKERFILE");296+        // The path is preserved verbatim so it still resolves against Git as typed.297+        assert_eq!(anchor("License").path, "License");298+    }299+300+    #[test]301+    fn root_file_with_range_and_focus() {302+        let a = anchor("Makefile:5-10 @7-8");303+        assert_eq!(a.path, "Makefile");304+        assert_eq!(a.range, Some(LineRange { start: 5, end: 10 }));305+        assert_eq!(a.focus, Some(LineRange { start: 7, end: 8 }));306+307+        let b = anchor(".gitignore:2");308+        assert_eq!(b.path, ".gitignore");309+        assert_eq!(b.range, Some(LineRange { start: 2, end: 2 }));310+    }311+312+    #[test]313+    fn bare_unknown_word_is_prose() {314+        // A single word that is neither a dotfile nor a known filename stays prose,315+        // so the recognizer keeps its "rarely collides with prose" promise.316+        assert!(AnchorRef::parse_line("Done").is_none());317+        assert!(AnchorRef::parse_line("Refactored").is_none());318+        assert!(AnchorRef::parse_line("Notes").is_none());319+    }320+321+    #[test]322+    fn ellipsis_and_parent_dir_are_not_dotfiles() {323+        // A dotfile needs an alphanumeric right after the dot; prose ellipses and324+        // bare `..`/`.` do not qualify.325+        assert!(AnchorRef::parse_line("...").is_none());326+        assert!(AnchorRef::parse_line("..").is_none());327+        assert!(AnchorRef::parse_line(".").is_none());328+    }329+330+    #[test]331     fn prose_is_not_an_anchor() {332         assert!(AnchorRef::parse_line("This refactors the validator.").is_none());333         assert!(AnchorRef::parse_line("See the validator for details.").is_none());@@ -229,6 +357,9 @@357             "src/auth/validator.rs:50",358             "src/auth/middleware.rs",359             "docs/api.md:10-20 @14-16",360+            "Makefile",361+            "Makefile:5-10 @7-8",362+            ".gitignore",363         ] {364             let a = anchor(line);365             assert_eq!(a.to_string(), line, "round-trip failed for {line:?}");
23/37a2a656bEdu Ramírez

Document the broadened anchor recognizer

Bring the spec and wiki in line with the recognizer now accepting root dotfiles and extensionless files, and capture the design and its tradeoff as working knowledge.

The recognition rule now enumerates all four path shapes with a root-file example, and the disambiguation note explains the broadened-but-strict pattern and why recognition never reads the diff.

docs/SPEC.md
@@ -74,9 +74,20 @@74 75 ### Recognition rule76 -A line is an anchor if, after trimming, it begins with a token that **looks like a path**-— it contains a `/` or ends in a recognizable `.extension` — optionally followed by a line-specifier and a focus range, and nothing else of substance on the line.77+A line is an anchor if, after trimming, it begins with a token that **looks like a path**,78+optionally followed by a line specifier and a focus range, and nothing else of substance on79+the line. A token looks like a path when **any** of these hold:80+81+- it contains a `/`; or82+- it ends in a recognizable `.extension`; or83+- it is a **dotfile** — a leading `.` followed by a name (`.gitignore`, `.env`); or84+- it is a **well-known extensionless filename** — a curated set of root files such as85+  `Makefile`, `Dockerfile`, `LICENSE`, `README`, `CHANGELOG`, matched case-insensitively.86+87+The last two exist because a repository-root file has no `/` to give it away and may have no88+extension; without them, `Makefile` on its own line would read as prose and its hunk would89+only ever appear, uncovered, in the backfill — losing the author's coordinate. Recognition90+is by *shape and name only*; it never consults the diff (see disambiguation below).91 92 ```93 <path>[:<start>[-<end>]] [@<focusStart>-<focusEnd>]@@ -94,6 +105,8 @@105 src/auth/validator.rs:50106 src/auth/middleware.rs107 docs/api.md:10-20 @14-16108+Makefile109+.gitignore:2110 ```111 112 > **Legacy side modifier.** Earlier versions accepted a trailing `!new`/`!old`/`!diff`@@ -103,11 +116,14 @@116 117 ### Disambiguation against prose118 -The path-shaped pattern is strict (must contain `/` or a `.ext`) so it rarely collides-with prose. A matching line is an anchor even if the file isn't in the diff — it then-shows file content if present, or a degraded warning if not. The parse never changes-meaning based on diff contents. To write a literal path-shaped line in prose, put it in a-Markdown code span (`` `like/this.rs` ``); that is not an anchor.119+The path-shaped pattern is kept strict so it rarely collides with prose: a slash, a real120+extension, a dotfile shape, or a name on a *curated* list of distinctive root files — so an121+ordinary one-word line (`Done`, `Fixed`) is never mistaken for an anchor. A matching line is122+an anchor even if the file isn't in the diff — it then shows file content if present, or a123+degraded warning if not. The parse never changes meaning based on diff contents: a root file124+is recognized by its name and shape, not by being present in the commit, so the same line125+parses identically whether or not that file changed. To write a literal path-shaped line in126+prose, put it in a Markdown code span (`` `like/this.rs` ``); that is not an anchor.127 128 ## Coverage and backfill129 

A new field note records what makes a line an anchor, the gotcha that hid the bug (completeness masked the lost coordinate), the context-free invariant, and the allowlist tradeoff — including the considered, rejected "range implies path" alternative.

docs/wiki/field-notes/anchor-recognition.md
@@ -0,0 +1,89 @@1+# What makes a line an anchor2+3+How `AnchorRef::parse_line` decides a line is a code reference rather than prose,4+why that decision is made on *shape and name alone* (never the diff), and the5+root-file gotcha that the heuristic exists to avoid. Read this before touching6+`src/anchor.rs` or loosening what counts as a path. The normative rule lives in7+[`SPEC.md`](../../SPEC.md) ("Recognition rule"); this is the working knowledge8+behind it.9+10+## The heuristic11+12+A line is an anchor when, after trimming, its first token *looks like a path* and13+the rest of the line is only an optional `:range` and `@focus` (a legacy `!side`14+token is tolerated and ignored). The whole decision is in `looks_like_path`15+(`src/anchor.rs:136`): a token qualifies if **any** of four shapes hold.16+17+| Shape | Example | Recognized by |18+|-------|---------|---------------|19+| Contains a slash | `src/auth/mod.rs` | the `/` clause of `looks_like_path` (`src/anchor.rs:136`) |20+| Real extension | `README.md`, `a.tar.gz` | `has_extension` (`src/anchor.rs:142`) |21+| Dotfile | `.gitignore`, `.env` | `is_dotfile` (`src/anchor.rs:157`) |22+| Known root filename | `Makefile`, `LICENSE` | `is_known_filename` (`src/anchor.rs:204`) |23+24+The first two are the obvious cases. The last two exist for one reason: a file at25+the **repository root** has no `/`, and many such files (`Makefile`, `Dockerfile`,26+`LICENSE`, `.gitignore`) have no extension either. Without these shapes the line27+falls through to prose.28+29+## The gotcha this prevents30+31+Before the dotfile and known-filename shapes existed, an anchor like32+33+```34+Makefile35+```36+37+on its own line was read as a one-word *prose* paragraph. The renderer still hit38+its completeness guarantee — the Makefile hunk showed up in the backfill — so it39+was easy to miss. But the author's coordinate was silently dropped: no callout at40+the chosen position, no lead-in attached, the hunk demoted to the trailing41+"everything else" pile. **Completeness hid the bug; the position was the loss.**42+Guarded end to end by `tests/root_file_anchor.rs` (a root anchor must resolve to a43+covered callout, not a backfill) and at the unit level by `root_extensionless_filenames`,44+`root_dotfiles`, and `known_filename_is_case_insensitive` in `src/anchor.rs`.45+46+## Two design rules worth keeping47+48+**Recognition never consults the diff.** `looks_like_path` is a pure function of49+the token — it does *not* check whether the file is in the commit. This is a spec50+invariant ("The parse never changes meaning based on diff contents"): the same line51+parses identically whether or not that file changed, so a typo'd or stale anchor52+degrades *visibly* (prose + warning) instead of flipping between anchor and prose53+depending on the diff. That is also why a root file can't simply be recognized "if54+it's one of the changed paths" — tempting, but it would couple the parser to the55+diff and break the invariant.56+57+**Erring toward recognition is the safe direction.** A name we recognize that turns58+out to match no file still degrades visibly (see [`reading-model.md`](reading-model.md)59+for the lead-in/prose split, and `SPEC.md` "Degraded anchors"). A name we *fail* to60+recognize reverts to prose and is lost without a trace — exactly the bug above. So61+the heuristic leans inclusive, bounded by the prose-collision risk below.62+63+## The allowlist and its tradeoff64+65+`KNOWN_FILENAMES` (`src/anchor.rs:170`) is a curated, case-insensitive list — the66+`*file` build drivers (`Makefile`, `Dockerfile`, `Rakefile`, …) and the all-caps67+legal/doc files (`LICENSE`, `README`, `CHANGELOG`, `CODEOWNERS`, …). It is68+deliberately *not* "any bare word": that would make a one-word prose line (`Done`,69+`Fixed`) into an anchor and break the spec's "rarely collides with prose" promise70+(pinned by `bare_unknown_word_is_prose`). The cost is that the list is finite and71+opinionated — a project's bespoke extensionless file (say `RUNME`) won't be72+recognized whole. **To add one, append to `KNOWN_FILENAMES`**; keep entries73+distinctive enough that they'd never stand alone as an English sentence.74+75+A considered, unimplemented alternative: treat an explicit `:range` as sufficient on76+its own (so `RUNME:5-10` would anchor any token). It is a strong signal — prose77+rarely writes `word:5-10` — but it widens the recognizer beyond the reported need,78+so it was left out. Revisit it if arbitrary extensionless files become common.79+80+## Boundaries81+82+- The dotfile shape requires an alphanumeric **right after** the leading dot, so a83+  prose ellipsis (`...`) or a `..` parent token is not mistaken for a dotfile84+  (`ellipsis_and_parent_dir_are_not_dotfiles`).85+- A path-shaped first token followed by ordinary words is still prose86+  (`src/parser.rs is where it lives`) — the trailing-token check in `parse_line`87+  (`src/anchor.rs:68`) rejects it.88+- The path is stored **verbatim** (original case), so case-insensitive *recognition*89+  never changes how the anchor resolves against Git.

The wiki map gains the page so the next session can find it; the sibling reading-model note links to it for "what counts as an anchor."

docs/wiki/README.md
@@ -30,6 +30,10 @@30   - [`CONVENTIONS.md`](CONVENTIONS.md) — how to write and maintain a wiki page.31     Read this before adding one.32 - **Field notes** — deep knowledge of one area, written for whoever touches it next.33+  - [`field-notes/anchor-recognition.md`](field-notes/anchor-recognition.md)34+    — what makes a line an anchor: the path heuristic, the root-file gotcha (why35+    `Makefile` and `.gitignore` need their own shapes), and the curated allowlist36+    and its tradeoff.37   - [`field-notes/git-lowering.md`](field-notes/git-lowering.md)38     — how a commit's diff becomes the model: binary/hunk-less detection, where39     modes live, why renames aren't detected, how to write a fixture, and walking a

Remaining changes

docs/wiki/field-notes/reading-model.md
@@ -59,3 +59,6 @@59   class (`tldr_html` in `src/html.rs`) — inline code, emphasis, and links all work in it.60 - An anchor with no lead-in above it is fine: it renders as a bare callout. Lead-ins are61   explanatory, never structural, so completeness is unaffected by their presence or order.62+- What makes a line an *anchor* in the first place — the path heuristic, and why root files63+  like `Makefile` or `.gitignore` need their own shapes — is a separate concern, covered in64+  [anchor-recognition](anchor-recognition.md).
24/37c525d4bEdu Ramírez

Add the managed-region splice for `throughline pr`

A new pure module that writes a rendered walkthrough into a pull-request body inside an HTML-comment region — replacing that region in place or, when it is absent, appending it — so re-running never disturbs the author's own description.

The region is delimited by two HTML-comment markers, invisible in every Markdown renderer yet a stable string to find on the next run, and wrap frames a rendered walkthrough between them with blank lines so the Markdown inside still parses.

src/pr.rs
@@ -0,0 +12,15 @@12+/// Opening marker of the managed region. An HTML comment, so it stays invisible13+/// in every Markdown renderer (GitHub, Forgejo, a pager) while still being a14+/// stable string to find on the next run.15+pub const START_MARKER: &str = "<!-- throughline:start -->";16+17+/// Closing marker of the managed region.18+pub const END_MARKER: &str = "<!-- throughline:end -->";19+20+/// Wrap a rendered walkthrough in the managed-region markers, ready to splice21+/// into a PR body. A blank line separates each marker from the content so the22+/// Markdown inside still parses as its own blocks (a comment flush against a23+/// heading would not start a new block on some renderers).24+pub fn wrap(body: &str) -> String {25+    format!("{START_MARKER}\n\n{}\n\n{END_MARKER}", body.trim_end())26+}

splice is the entry point: when a well-formed region already exists it swaps just that span and keeps everything around it verbatim — so the same render is idempotent and a new render updates only the region — while with no region it appends the wrapped block after the author's text.

src/pr.rs
@@ -0,0 +28,26 @@28+/// Splice a freshly rendered walkthrough into an existing PR body, returning the29+/// new body.30+///31+/// - If a well-formed managed region (a [`START_MARKER`] followed later by an32+///   [`END_MARKER`]) is present, its whole span — markers included — is replaced33+///   with a freshly wrapped block, and everything before and after is preserved34+///   verbatim. Re-running is therefore **idempotent**: the same render yields the35+///   same body, and a new render swaps only the region.36+/// - Otherwise the wrapped block is **appended** after the existing body37+///   (separated by a blank line), leaving the author's text in place. A lone or38+///   reversed marker counts as "no region" and also appends — a half-recognized39+///   span is never edited.40+pub fn splice(existing: &str, body: &str) -> String {41+    let block = wrap(body);42+    match region_span(existing) {43+        Some((before, after)) => format!("{before}{block}{after}"),44+        None => {45+            let base = existing.trim_end();46+            if base.is_empty() {47+                block48+            } else {49+                format!("{base}\n\n{block}")50+            }51+        }52+    }53+}

Recognition is deliberately strict: the end marker is matched only after the start, so a lone or reversed marker is treated as "no region" and appends rather than letting a half-recognized span be edited.

src/pr.rs
@@ -0,0 +55,11 @@55+/// Locate a well-formed managed region in `body`: the text *before* the region56+/// and the text *after* it (markers excluded from both), or `None` when there is57+/// no well-formed region. [`END_MARKER`] is matched only *after* [`START_MARKER`],58+/// so a reversed or lone marker is not a region.59+fn region_span(body: &str) -> Option<(&str, &str)> {60+    let start = body.find(START_MARKER)?;61+    let after_start = start + START_MARKER.len();62+    let end_rel = body[after_start..].find(END_MARKER)?;63+    let end = after_start + end_rel + END_MARKER.len();64+    Some((&body[..start], &body[end..]))65+}

The module is registered in the library; the forge I/O that will read and write the body stays out of it — that half lands in the binary next.

src/lib.rs
@@ -12,6 +12,7 @@12 pub mod html;13 pub mod markdown;14 pub mod message;15+pub mod pr;16 pub mod render;17 18 /// The library's result type.

The module map gains a pr row so the new pure/I-O split is documented beside the others (the architecture doc gets the matching row in the backfill).

CLAUDE.md
@@ -52,6 +52,7 @@52 | `src/render.rs` | Resolve anchors against Git, track coverage, build the ordered `Document` model. |53 | `src/html.rs` | Emit a single self-contained HTML file with inline CSS. |54 | `src/markdown.rs` | Emit the same `Document` as Markdown: prose verbatim, hunks as fenced `diff` blocks, the backfill folded into `<details>` (a sibling of `html.rs`). |55+| `src/pr.rs` | Splice a rendered Markdown walkthrough into a pull-request body inside a managed region (`<!-- throughline:start/end -->`), non-destructively — the pure half of `throughline pr` (the `fj` forge I/O lives in `main.rs`). |56 57 ## Invariants — do not break these58 

Remaining changes

docs/ARCHITECTURE.md
@@ -51,6 +51,7 @@51 | `render` | Orchestration. Resolves anchors against `git`, marks coverage, backfills the rest, and produces the ordered `Document`. |52 | `html` | Lower a `Document` into a standalone HTML page with inlined CSS (`to_html`); compose several `Document`s into one branch walkthrough (`to_html_branch`). |53 | `markdown` | Lower the same `Document` into Markdown — prose verbatim, hunks as fenced `diff` blocks, the backfill folded into `<details>` (`to_markdown` / `to_markdown_branch`). A sibling of `html`. |54+| `pr` | Splice a rendered Markdown walkthrough into a pull-request body inside a managed region, non-destructively. Pure (`wrap` / `splice`); the forge I/O that reads and writes the body lives in the binary. |55 56 The two **pure** modules (`anchor`, `message`) define the format and are the most57 heavily unit-tested; the I/O and rendering modules wrap them.
src/pr.rs
@@ -0,0 +1,141 @@1+//! Splice a rendered throughline into a pull-request body **non-destructively**.2+//!3+//! `throughline pr` owns a single *managed region* delimited by HTML-comment4+//! markers; everything outside it — the author's own description, issue links,5+//! checklists — is left untouched. This module is the **pure** half of that:6+//! given the PR's current body and a freshly rendered Markdown walkthrough, it7+//! produces the new body. The forge I/O (reading and writing the body) lives in8+//! the binary layer (`main.rs`), the same library-is-pure / binary-does-I/O split9+//! the rest of the crate follows (see10+//! `docs/wiki/field-notes/cli-binary-layer.md`).11+12+/// Opening marker of the managed region. An HTML comment, so it stays invisible13+/// in every Markdown renderer (GitHub, Forgejo, a pager) while still being a14+/// stable string to find on the next run.15+pub const START_MARKER: &str = "<!-- throughline:start -->";16+17+/// Closing marker of the managed region.18+pub const END_MARKER: &str = "<!-- throughline:end -->";19+20+/// Wrap a rendered walkthrough in the managed-region markers, ready to splice21+/// into a PR body. A blank line separates each marker from the content so the22+/// Markdown inside still parses as its own blocks (a comment flush against a23+/// heading would not start a new block on some renderers).24+pub fn wrap(body: &str) -> String {25+    format!("{START_MARKER}\n\n{}\n\n{END_MARKER}", body.trim_end())26+}27+28+/// Splice a freshly rendered walkthrough into an existing PR body, returning the29+/// new body.30+///31+/// - If a well-formed managed region (a [`START_MARKER`] followed later by an32+///   [`END_MARKER`]) is present, its whole span — markers included — is replaced33+///   with a freshly wrapped block, and everything before and after is preserved34+///   verbatim. Re-running is therefore **idempotent**: the same render yields the35+///   same body, and a new render swaps only the region.36+/// - Otherwise the wrapped block is **appended** after the existing body37+///   (separated by a blank line), leaving the author's text in place. A lone or38+///   reversed marker counts as "no region" and also appends — a half-recognized39+///   span is never edited.40+pub fn splice(existing: &str, body: &str) -> String {41+    let block = wrap(body);42+    match region_span(existing) {43+        Some((before, after)) => format!("{before}{block}{after}"),44+        None => {45+            let base = existing.trim_end();46+            if base.is_empty() {47+                block48+            } else {49+                format!("{base}\n\n{block}")50+            }51+        }52+    }53+}54+55+/// Locate a well-formed managed region in `body`: the text *before* the region56+/// and the text *after* it (markers excluded from both), or `None` when there is57+/// no well-formed region. [`END_MARKER`] is matched only *after* [`START_MARKER`],58+/// so a reversed or lone marker is not a region.59+fn region_span(body: &str) -> Option<(&str, &str)> {60+    let start = body.find(START_MARKER)?;61+    let after_start = start + START_MARKER.len();62+    let end_rel = body[after_start..].find(END_MARKER)?;63+    let end = after_start + end_rel + END_MARKER.len();64+    Some((&body[..start], &body[end..]))65+}66+67+#[cfg(test)]68+mod tests {69+    use super::*;70+71+    #[test]72+    fn wrap_surrounds_body_with_blank_lines() {73+        let out = wrap("hello");74+        assert!(out.starts_with(START_MARKER), "got: {out}");75+        assert!(out.ends_with(END_MARKER), "got: {out}");76+        assert!(out.contains("\n\nhello\n\n"), "got: {out}");77+    }78+79+    #[test]80+    fn splice_appends_after_existing_prose() {81+        let out = splice("My PR description.", "WALK");82+        assert!(out.starts_with("My PR description.\n\n"), "got: {out}");83+        assert!(out.contains(START_MARKER));84+        assert!(out.trim_end().ends_with(END_MARKER), "got: {out}");85+    }86+87+    #[test]88+    fn splice_into_empty_is_just_the_block() {89+        assert_eq!(splice("", "WALK"), wrap("WALK"));90+        assert_eq!(splice("   \n\n", "WALK"), wrap("WALK"));91+    }92+93+    #[test]94+    fn splice_replaces_region_and_preserves_both_surroundings() {95+        let body = format!("Above.\n\n{}\n\nBelow.", wrap("OLD"));96+        let out = splice(&body, "NEW");97+        assert!(out.contains("Above."), "kept text above: {out}");98+        assert!(out.contains("Below."), "kept text below: {out}");99+        assert!(100+            out.contains("NEW") && !out.contains("OLD"),101+            "swapped: {out}"102+        );103+        assert_eq!(out.matches(START_MARKER).count(), 1, "one region: {out}");104+        assert_eq!(out.matches(END_MARKER).count(), 1, "one region: {out}");105+    }106+107+    #[test]108+    fn splice_is_idempotent() {109+        let once = splice("Desc.", "WALK");110+        let twice = splice(&once, "WALK");111+        assert_eq!(once, twice);112+    }113+114+    #[test]115+    fn re_splice_updates_only_the_region() {116+        let v1 = splice("Desc.", "v1");117+        let v2 = splice(&v1, "v2");118+        assert!(v2.contains("v2") && !v2.contains("v1"), "got: {v2}");119+        assert!(v2.starts_with("Desc.\n\n"), "kept author prose: {v2}");120+        assert_eq!(v2.matches(START_MARKER).count(), 1, "still one region");121+    }122+123+    #[test]124+    fn lone_start_marker_is_not_a_region_so_it_appends() {125+        let existing = format!("Intro {START_MARKER} but no end");126+        let out = splice(&existing, "WALK");127+        assert!(out.contains("but no end"), "kept original text: {out}");128+        // the stray start plus the freshly wrapped block.129+        assert_eq!(out.matches(START_MARKER).count(), 2);130+        assert_eq!(out.matches(END_MARKER).count(), 1);131+    }132+133+    #[test]134+    fn reversed_markers_are_not_a_region() {135+        let existing = format!("{END_MARKER} x {START_MARKER}");136+        let out = splice(&existing, "WALK");137+        // No end *after* the start, so it appends rather than editing the span.138+        assert_eq!(out.matches(START_MARKER).count(), 2);139+        assert_eq!(out.matches(END_MARKER).count(), 2);140+    }141+}
25/37b2b04a7Edu Ramírez

Render the current branch as a PR-ready Markdown body

A new throughline pr subcommand renders base..HEAD as the per-commit walkthrough, wraps it in the managed region, and emits it to standard output or a file — the body you'd post into a pull request, before any forge write.

The command joins render and branch on the CLI; its help spells out that it renders the current branch (base..HEAD, base defaulting to main) as Markdown, the one format a PR description renders in full.

src/cli.rs
@@ -1,5 +1,6 @@-//! Command-line surface: `throughline render <commit> [-o out.html]` and-//! `throughline branch <tip|base..tip> [-o out.html]`.1+//! Command-line surface: `throughline render <commit> [-o out.html]`,2+//! `throughline branch <tip|base..tip> [-o out.html]`, and `throughline pr`3+//! (render the current branch as a PR-ready Markdown body).4 5 use std::path::PathBuf;6 @@ -84,4 +85,19 @@85         #[arg(long)]86         open: bool,87     },88+89+    /// Render the current branch as a PR-ready Markdown walkthrough.90+    ///91+    /// Renders `base..HEAD` as a per-commit walkthrough wrapped in a managed92+    /// region (ready to splice into a pull-request description), emitted to93+    /// standard output or `-o <file>`. Markdown only — a PR body is Markdown.94+    Pr {95+        /// Base to take the commit range from (`base..HEAD`); mirrors `branch`.96+        #[arg(long, default_value = "main")]97+        base: String,98+99+        /// Write the rendered body to this file instead of standard output.100+        #[arg(short, long)]101+        output: Option<PathBuf>,102+    },103 }

A second consumer of the range-to-chapters walk would have duplicated branch's body, so that spine is lifted into a shared helper — list the commits, guard the empty range once, and render each to its own chapter document.

src/main.rs
@@ -68,0 +69,22 @@69+/// Render every commit in `base..tip` to its chapter document, oldest first —70+/// the shared spine of the `branch` and `pr` views. Errors if the range is empty.71+fn render_range(repo: &git::Repo, base: &str, tip: &str) -> Result<Vec<render::Chapter>> {72+    let commits = repo73+        .range_commits(base, tip)74+        .with_context(|| format!("listing commits in `{base}..{tip}`"))?;75+    if commits.is_empty() {76+        anyhow::bail!("no commits in `{base}..{tip}` — is the range empty or reversed?");77+    }78+    // Each commit renders to its own complete document; the caller composes them.79+    let mut chapters = Vec::with_capacity(commits.len());80+    for c in &commits {81+        let doc = render::render(repo, &c.id)82+            .with_context(|| format!("rendering commit `{}`", c.short))?;83+        chapters.push(render::Chapter {84+            short: c.short.clone(),85+            author: c.author.clone(),86+            doc,87+        });88+    }89+    Ok(chapters)90+}

run_pr builds on it: render base..HEAD, lower the chapters to a branch walkthrough, and pr::wrap the result. With -o it writes a file and prints the path; otherwise it prints the body straight to stdout for piping or review.

src/main.rs
@@ -132,0 +139,30 @@139+/// Render the current branch (`base..HEAD`) as a PR-ready Markdown body: the140+/// per-commit walkthrough wrapped in the managed region, emitted to a file141+/// (`-o`) or standard output. Splicing this into the live PR description is the142+/// next step; for now it produces the body you'd post.143+fn run_pr(base: &str, output: Option<&Path>) -> Result<()> {144+    let repo =145+        git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;146+147+    // A PR is a branch: render `base..HEAD` as the per-commit walkthrough.148+    let tip = "HEAD";149+    let chapters = render_range(&repo, base, tip)?;150+151+    let title = format!("{base}..{tip}");152+    let walkthrough = markdown::to_markdown_branch(&title, &chapters);153+    let body = pr::wrap(&walkthrough);154+155+    match output {156+        Some(path) => {157+            std::fs::write(path, &body).with_context(|| format!("writing `{}`", path.display()))?;158+            println!(159+                "Wrote PR body ({} commit(s)) to {}",160+                chapters.len(),161+                path.display()162+            );163+        }164+        // Default: print the body so it can be piped or eyeballed.165+        None => println!("{body}"),166+    }167+    Ok(())168+}

The subcommand is dispatched from main, and branch now calls the same helper (its inline loop replaced by the one-line call in the backfill below).

src/main.rs
@@ -33,0 +33,1 @@33+        Command::Pr { base, output } => run_pr(&base, output.as_deref()),

Remaining changes

src/main.rs
@@ -5,7 +5,7 @@5 6 use commit_throughline::cli::{Cli, Command, Format};7 use commit_throughline::message::{self, Block};-use commit_throughline::{git, html, markdown, render};8+use commit_throughline::{git, html, markdown, pr, render};9 10 fn main() -> Result<()> {11     let cli = Cli::parse();@@ -30,6 +30,7 @@30             format,31             open,32         } => run_branch(&branch, &base, output.as_deref(), format, open),33+        Command::Pr { base, output } => run_pr(&base, output.as_deref()),34     }35 }36 @@ -65,6 +66,29 @@66     Ok(())67 }68 69+/// Render every commit in `base..tip` to its chapter document, oldest first —70+/// the shared spine of the `branch` and `pr` views. Errors if the range is empty.71+fn render_range(repo: &git::Repo, base: &str, tip: &str) -> Result<Vec<render::Chapter>> {72+    let commits = repo73+        .range_commits(base, tip)74+        .with_context(|| format!("listing commits in `{base}..{tip}`"))?;75+    if commits.is_empty() {76+        anyhow::bail!("no commits in `{base}..{tip}` — is the range empty or reversed?");77+    }78+    // Each commit renders to its own complete document; the caller composes them.79+    let mut chapters = Vec::with_capacity(commits.len());80+    for c in &commits {81+        let doc = render::render(repo, &c.id)82+            .with_context(|| format!("rendering commit `{}`", c.short))?;83+        chapters.push(render::Chapter {84+            short: c.short.clone(),85+            author: c.author.clone(),86+            doc,87+        });88+    }89+    Ok(chapters)90+}91+92 /// Render a range of commits as one navigable page (the `branch` subcommand).93 fn run_branch(94     branch: &str,@@ -82,24 +106,7 @@106         None => (base.to_string(), branch.to_string()),107     };108 -    let commits = repo-        .range_commits(&base, &tip)-        .with_context(|| format!("listing commits in `{base}..{tip}`"))?;-    if commits.is_empty() {-        anyhow::bail!("no commits in `{base}..{tip}` — is the range empty or reversed?");-    }--    // Each commit renders to its own complete document; the page composes them.-    let mut chapters = Vec::with_capacity(commits.len());-    for c in &commits {-        let doc = render::render(&repo, &c.id)-            .with_context(|| format!("rendering commit `{}`", c.short))?;-        chapters.push(render::Chapter {-            short: c.short.clone(),-            author: c.author.clone(),-            doc,-        });-    }109+    let chapters = render_range(&repo, &base, &tip)?;110 111     let title = format!("{base}..{tip}");112     let page = match format {@@ -129,6 +136,37 @@136     Ok(())137 }138 139+/// Render the current branch (`base..HEAD`) as a PR-ready Markdown body: the140+/// per-commit walkthrough wrapped in the managed region, emitted to a file141+/// (`-o`) or standard output. Splicing this into the live PR description is the142+/// next step; for now it produces the body you'd post.143+fn run_pr(base: &str, output: Option<&Path>) -> Result<()> {144+    let repo =145+        git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;146+147+    // A PR is a branch: render `base..HEAD` as the per-commit walkthrough.148+    let tip = "HEAD";149+    let chapters = render_range(&repo, base, tip)?;150+151+    let title = format!("{base}..{tip}");152+    let walkthrough = markdown::to_markdown_branch(&title, &chapters);153+    let body = pr::wrap(&walkthrough);154+155+    match output {156+        Some(path) => {157+            std::fs::write(path, &body).with_context(|| format!("writing `{}`", path.display()))?;158+            println!(159+                "Wrote PR body ({} commit(s)) to {}",160+                chapters.len(),161+                path.display()162+            );163+        }164+        // Default: print the body so it can be piped or eyeballed.165+        None => println!("{body}"),166+    }167+    Ok(())168+}169+170 /// Open a rendered page with the platform's default handler. Best-effort: the171 /// file is already written and its path printed, so a missing or failing opener172 /// warns but never fails the command. (`Command` here is fully qualified because
26/372c7113bEdu Ramírez

Splice the walkthrough into a live Forgejo pull request

throughline pr --pr N now reads pull request N's current body over Codeberg's REST API, splices the walkthrough into its managed region so the author's own prose survives, and writes it back — while --dry-run or -o preview the result without touching the PR.

The subcommand grows the flags for it: --pr <N> targets a pull request, --dry-run previews, and --base turns optional — given a PR, it defaults to that PR's own base branch.

src/cli.rs
@@ -86,17 +86,29 @@86         open: bool,87     },88 -    /// Render the current branch as a PR-ready Markdown walkthrough.89+    /// Write the current branch's walkthrough into its pull request, or print it.90     ///-    /// Renders `base..HEAD` as a per-commit walkthrough wrapped in a managed-    /// region (ready to splice into a pull-request description), emitted to-    /// standard output or `-o <file>`. Markdown only — a PR body is Markdown.91+    /// Renders `base..HEAD` as a per-commit Markdown walkthrough. With `--pr <N>`92+    /// it splices that into pull request N's description inside a managed region —93+    /// reading the current body and writing it back via the forge's REST API, so94+    /// the author's own prose is preserved. Without `--pr` it just emits the95+    /// wrapped body to standard output (or `-o <file>`). Markdown only.96     Pr {-        /// Base to take the commit range from (`base..HEAD`); mirrors `branch`.-        #[arg(long, default_value = "main")]-        base: String,97+        /// Pull request number to update. Omit to print the body instead of98+        /// writing it to a PR.99+        #[arg(long)]100+        pr: Option<u64>,101+102+        /// Base of the commit range (`base..HEAD`). Defaults to the PR's own base103+        /// branch when `--pr` is given, otherwise `main`.104+        #[arg(long)]105+        base: Option<String>,106+107+        /// Print the spliced body instead of writing it to the PR (with `--pr`).108+        #[arg(long)]109+        dry_run: bool,110 -        /// Write the rendered body to this file instead of standard output.111+        /// Write the body to this file instead of standard output / the PR.112         #[arg(short, long)]113         output: Option<PathBuf>,114     },

Reaching a forge starts from the origin remote, so the repo learns to hand back its URL.

src/git.rs
@@ -52,6 +52,16 @@52         Ok(short.as_str().unwrap_or_default().to_string())53     }54 55+    /// The configured URL of the named remote (e.g. `origin`). Used to locate the56+    /// forge a pull request lives on (`throughline pr`); the URL is parsed into a57+    /// host + `owner/repo` slug by [`crate::pr::parse_remote`].58+    pub fn remote_url(&self, name: &str) -> Result<String> {59+        let remote = self.inner.find_remote(name)?;60+        // git2 0.21's `url()` errors (rather than returning `None`) when the URL61+        // is absent or not valid UTF-8 — either way there is no usable URL.62+        Ok(remote.url()?.to_string())63+    }64+65     /// The commits in `base..tip` — reachable from `tip` but not `base` — in66     /// reading order (oldest first), each with display metadata.67     ///

That URL is parsed — pure, and unit-tested across the ssh, scp, and https spellings — into a host and owner/repo, which build the Forgejo REST base and flag a GitHub remote the Forgejo path can't serve.

src/pr.rs
@@ -64,6 +64,75 @@64     Some((&body[..start], &body[end..]))65 }66 67+/// A forge repository identified from a git remote URL: the host and the68+/// `owner/repo` slug — enough to build a Forgejo/Gitea REST URL. The actual HTTP69+/// (reading and writing a PR body) lives in the binary; this is just the pure70+/// addressing.71+#[derive(Debug, Clone, PartialEq, Eq)]72+pub struct ForgeRepo {73+    pub host: String,74+    pub owner: String,75+    pub repo: String,76+}77+78+impl ForgeRepo {79+    /// The Forgejo/Gitea REST base for this repo:80+    /// `https://<host>/api/v1/repos/<owner>/<repo>`. A pull request `N` is then81+    /// `<api_base>/pulls/N`.82+    pub fn api_base(&self) -> String {83+        format!(84+            "https://{}/api/v1/repos/{}/{}",85+            self.host, self.owner, self.repo86+        )87+    }88+89+    /// Whether the remote points at github.com — which the Forgejo REST path does90+    /// not serve (a `gh` adapter is planned), so the binary can refuse with a91+    /// clear message rather than aiming Forgejo calls at GitHub.92+    pub fn is_github(&self) -> bool {93+        self.host.eq_ignore_ascii_case("github.com")94+    }95+}96+97+/// Parse a git remote URL into a [`ForgeRepo`]. Handles the common spellings:98+/// `https://host[:port]/owner/repo[.git]`, `ssh://[user@]host[:port]/owner/repo[.git]`,99+/// and the scp-like `[user@]host:owner/repo[.git]`. Returns `None` when it does100+/// not look like a `host` + `owner/repo` URL.101+pub fn parse_remote(url: &str) -> Option<ForgeRepo> {102+    let url = url.trim();103+    let (authority, path) = if let Some(rest) = url104+        .strip_prefix("https://")105+        .or_else(|| url.strip_prefix("http://"))106+        .or_else(|| url.strip_prefix("ssh://"))107+        .or_else(|| url.strip_prefix("git://"))108+    {109+        // [user@]host[:port]/owner/repo...110+        rest.split_once('/')?111+    } else {112+        // scp-like: [user@]host:owner/repo...113+        url.split_once(':')?114+    };115+116+    // Drop any `user@` prefix and `:port` suffix to leave the bare host.117+    let host = authority.rsplit('@').next().unwrap_or(authority);118+    let host = host.split(':').next().unwrap_or(host);119+120+    let path = path.trim_start_matches('/').trim_end_matches('/');121+    let path = path.strip_suffix(".git").unwrap_or(path);122+    let mut segs = path.split('/').filter(|s| !s.is_empty());123+    let owner = segs.next()?;124+    let repo = segs.next()?;125+126+    if host.is_empty() || owner.is_empty() || repo.is_empty() {127+        return None;128+    }129+    Some(ForgeRepo {130+        host: host.to_string(),131+        owner: owner.to_string(),132+        repo: repo.to_string(),133+    })134+}135+136 #[cfg(test)]137 mod tests {138     use super::*;@@ -138,4 +207,40 @@207         assert_eq!(out.matches(START_MARKER).count(), 2);208         assert_eq!(out.matches(END_MARKER).count(), 2);209     }210+211+    #[test]212+    fn parse_remote_handles_ssh_scp_and_https() {213+        let want = ForgeRepo {214+            host: "codeberg.org".into(),215+            owner: "eduramirezh".into(),216+            repo: "commit-throughline".into(),217+        };218+        for url in [219+            "ssh://git@codeberg.org/eduramirezh/commit-throughline.git",220+            "git@codeberg.org:eduramirezh/commit-throughline.git",221+            "https://codeberg.org/eduramirezh/commit-throughline",222+            "https://codeberg.org/eduramirezh/commit-throughline.git/",223+            "ssh://git@codeberg.org:22/eduramirezh/commit-throughline.git",224+        ] {225+            assert_eq!(parse_remote(url).as_ref(), Some(&want), "url: {url}");226+        }227+    }228+229+    #[test]230+    fn parse_remote_builds_the_api_base() {231+        let forge =232+            parse_remote("https://codeberg.org/eduramirezh/commit-throughline.git").unwrap();233+        assert_eq!(234+            forge.api_base(),235+            "https://codeberg.org/api/v1/repos/eduramirezh/commit-throughline"236+        );237+        assert!(!forge.is_github());238+    }239+240+    #[test]241+    fn parse_remote_flags_github_and_rejects_nonsense() {242+        assert!(parse_remote("git@github.com:o/r.git").unwrap().is_github());243+        assert_eq!(parse_remote("not a url"), None);244+        assert_eq!(parse_remote("https://host.tld/onlyowner"), None);245+    }246 }

run_pr ties it together: with no --pr it emits the wrapped body as before; with one it resolves the forge (refusing GitHub), reads the live body and base, splices the walkthrough in, then either prints it (--dry-run/-o) or writes the PR back.

src/main.rs
@@ -144,7 +152,49 @@152+fn run_pr(pr: Option<u64>, base: Option<&str>, dry_run: bool, output: Option<&Path>) -> Result<()> {153     let repo =154         git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;155 -    // A PR is a branch: render `base..HEAD` as the per-commit walkthrough.-    let tip = "HEAD";-    let chapters = render_range(&repo, base, tip)?;156+    let Some(index) = pr else {157+        // Offline: no PR named, so just emit the wrapped walkthrough.158+        let walkthrough = pr_walkthrough(&repo, base.unwrap_or("main"))?;159+        return emit_body(&pr::wrap(&walkthrough), output, None);160+    };161+162+    // Forge mode: locate the repo on its `origin` remote, refuse a non-Forgejo host.163+    let url = repo164+        .remote_url("origin")165+        .context("reading the `origin` remote (needed to find the pull request)")?;166+    let forge =167+        pr::parse_remote(&url).with_context(|| format!("understanding the remote URL `{url}`"))?;168+    if forge.is_github() {169+        anyhow::bail!(170+            "`{}` is a GitHub remote; `throughline pr` currently targets Forgejo/Gitea \171+             (a `gh` adapter is planned)",172+            forge.host173+        );174+    }175+    let api_base = forge.api_base();176+    let token = forge_token()?;177+178+    // Read the live body to splice into, and fall back to the PR's own base branch.179+    let (current_body, pr_base) = read_pr(&api_base, &token, index)?;180+    let base = base181+        .map(str::to_string)182+        .or_else(|| (!pr_base.is_empty()).then_some(pr_base))183+        .unwrap_or_else(|| "main".to_string());184+185+    let walkthrough = pr_walkthrough(&repo, &base)?;186+    let new_body = pr::splice(&current_body, &walkthrough);187+188+    // `--dry-run` or `-o` want the spliced result without touching the PR.189+    if dry_run || output.is_some() {190+        let note = format!("dry run — pull request #{index} not modified");191+        return emit_body(&new_body, output, dry_run.then_some(note.as_str()));192+    }193 194+    write_pr_body(&api_base, &token, index, &new_body)?;195+    println!(196+        "Updated pull request #{index} on {}/{} ({})",197+        forge.owner, forge.repo, forge.host198+    );199+    Ok(())200+}

The REST itself is a thin curl shell: one helper sends an authenticated request over stdin and splits the status from the response, so an HTTP stack stays out of the crate — the same way git2 is built without network transport. The token comes from a FORGEJO_TOKEN-style env var, and serde_json (the one new dependency) parses and builds the bodies safely.

src/main.rs
@@ -170,0 +290,45 @@290+fn curl_api(method: &str, url: &str, token: &str, payload: Option<&str>) -> Result<(u16, String)> {291+    use std::io::Write;292+    use std::process::{Command, Stdio};293+294+    let mut cmd = Command::new("curl");295+    cmd.arg("-sS")296+        .args(["-X", method])297+        // Append the status after the body, newline-separated, so it can be split298+        // back off (the body itself may or may not end with a newline).299+        .args(["-w", "\n%{http_code}"])300+        .args(["-H", &format!("Authorization: token {token}")])301+        .args(["-H", "Accept: application/json"])302+        .stdout(Stdio::piped())303+        .stderr(Stdio::piped());304+    if payload.is_some() {305+        cmd.args(["-H", "Content-Type: application/json"])306+            .args(["--data-binary", "@-"])307+            .stdin(Stdio::piped());308+    }309+    cmd.arg(url);310+311+    let mut child = cmd.spawn().context("launching curl (is it installed?)")?;312+    if let Some(body) = payload {313+        child314+            .stdin315+            .take()316+            .expect("stdin is piped when there is a payload")317+            .write_all(body.as_bytes())318+            .context("sending the request body to curl")?;319+    }320+    let out = child.wait_with_output().context("running curl")?;321+    if !out.status.success() {322+        anyhow::bail!(323+            "curl failed: {}",324+            String::from_utf8_lossy(&out.stderr).trim()325+        );326+    }327+328+    let stdout = String::from_utf8_lossy(&out.stdout);329+    let (body, code) = stdout.rsplit_once('\n').unwrap_or(("", stdout.trim()));330+    let status = code.trim().parse().unwrap_or(0);331+    Ok((status, body.to_string()))332+}333+334+/// Pull a human-readable error out of a forge JSON error response (its `message`

Remaining changes

Cargo.lock
@@ -136,6 +136,7 @@136  "clap",137  "git2",138  "pulldown-cmark",139+ "serde_json",140  "tempfile",141  "thiserror",142 ]@@ -208,6 +209,12 @@209 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"210 211 [[package]]212+name = "itoa"213+version = "1.0.18"214+source = "registry+https://github.com/rust-lang/crates.io-index"215+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"216+217+[[package]]218 name = "jobserver"219 version = "0.1.34"220 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -340,6 +347,48 @@347 ]348 349 [[package]]350+name = "serde"351+version = "1.0.228"352+source = "registry+https://github.com/rust-lang/crates.io-index"353+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"354+dependencies = [355+ "serde_core",356+]357+358+[[package]]359+name = "serde_core"360+version = "1.0.228"361+source = "registry+https://github.com/rust-lang/crates.io-index"362+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"363+dependencies = [364+ "serde_derive",365+]366+367+[[package]]368+name = "serde_derive"369+version = "1.0.228"370+source = "registry+https://github.com/rust-lang/crates.io-index"371+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"372+dependencies = [373+ "proc-macro2",374+ "quote",375+ "syn",376+]377+378+[[package]]379+name = "serde_json"380+version = "1.0.150"381+source = "registry+https://github.com/rust-lang/crates.io-index"382+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"383+dependencies = [384+ "itoa",385+ "memchr",386+ "serde",387+ "serde_core",388+ "zmij",389+]390+391+[[package]]392 name = "shlex"393 version = "2.0.1"394 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -454,3 +503,9 @@503 version = "0.57.1"504 source = "registry+https://github.com/rust-lang/crates.io-index"505 checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"506+507+[[package]]508+name = "zmij"509+version = "1.0.21"510+source = "registry+https://github.com/rust-lang/crates.io-index"511+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml
@@ -22,6 +22,7 @@22 clap = { version = "4.6.1", features = ["derive"] }23 git2 = { version = "0.21.0", default-features = false }24 pulldown-cmark = "0.13.4"25+serde_json = "1.0.150"26 thiserror = "2.0.18"27 28 [dev-dependencies]
src/main.rs
@@ -30,7 +30,12 @@30             format,31             open,32         } => run_branch(&branch, &base, output.as_deref(), format, open),-        Command::Pr { base, output } => run_pr(&base, output.as_deref()),33+        Command::Pr {34+            pr,35+            base,36+            dry_run,37+            output,38+        } => run_pr(pr, base.as_deref(), dry_run, output.as_deref()),39     }40 }41 @@ -136,37 +141,210 @@141     Ok(())142 }143 -/// Render the current branch (`base..HEAD`) as a PR-ready Markdown body: the-/// per-commit walkthrough wrapped in the managed region, emitted to a file-/// (`-o`) or standard output. Splicing this into the live PR description is the-/// next step; for now it produces the body you'd post.-fn run_pr(base: &str, output: Option<&Path>) -> Result<()> {144+/// Render the current branch (`base..HEAD`) as a PR-ready Markdown body and145+/// either splice it into a live pull request (`--pr N`) or just emit it.146+///147+/// Forge mode (`--pr N`) reads the PR's current body and base via the Forgejo148+/// REST API, splices the walkthrough into the managed region — preserving the149+/// author's own prose — and writes it back, unless `--dry-run` / `-o` ask for150+/// the result without touching the PR. Offline (no `--pr`) emits the wrapped151+/// body to a file or standard output.152+fn run_pr(pr: Option<u64>, base: Option<&str>, dry_run: bool, output: Option<&Path>) -> Result<()> {153     let repo =154         git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;155 -    // A PR is a branch: render `base..HEAD` as the per-commit walkthrough.-    let tip = "HEAD";-    let chapters = render_range(&repo, base, tip)?;156+    let Some(index) = pr else {157+        // Offline: no PR named, so just emit the wrapped walkthrough.158+        let walkthrough = pr_walkthrough(&repo, base.unwrap_or("main"))?;159+        return emit_body(&pr::wrap(&walkthrough), output, None);160+    };161+162+    // Forge mode: locate the repo on its `origin` remote, refuse a non-Forgejo host.163+    let url = repo164+        .remote_url("origin")165+        .context("reading the `origin` remote (needed to find the pull request)")?;166+    let forge =167+        pr::parse_remote(&url).with_context(|| format!("understanding the remote URL `{url}`"))?;168+    if forge.is_github() {169+        anyhow::bail!(170+            "`{}` is a GitHub remote; `throughline pr` currently targets Forgejo/Gitea \171+             (a `gh` adapter is planned)",172+            forge.host173+        );174+    }175+    let api_base = forge.api_base();176+    let token = forge_token()?;177+178+    // Read the live body to splice into, and fall back to the PR's own base branch.179+    let (current_body, pr_base) = read_pr(&api_base, &token, index)?;180+    let base = base181+        .map(str::to_string)182+        .or_else(|| (!pr_base.is_empty()).then_some(pr_base))183+        .unwrap_or_else(|| "main".to_string());184+185+    let walkthrough = pr_walkthrough(&repo, &base)?;186+    let new_body = pr::splice(&current_body, &walkthrough);187+188+    // `--dry-run` or `-o` want the spliced result without touching the PR.189+    if dry_run || output.is_some() {190+        let note = format!("dry run — pull request #{index} not modified");191+        return emit_body(&new_body, output, dry_run.then_some(note.as_str()));192+    }193 194+    write_pr_body(&api_base, &token, index, &new_body)?;195+    println!(196+        "Updated pull request #{index} on {}/{} ({})",197+        forge.owner, forge.repo, forge.host198+    );199+    Ok(())200+}201+202+/// The per-commit Markdown walkthrough of `base..HEAD` (unwrapped), shared by the203+/// offline and forge paths of `run_pr`.204+fn pr_walkthrough(repo: &git::Repo, base: &str) -> Result<String> {205+    let tip = "HEAD";206+    let chapters = render_range(repo, base, tip)?;207     let title = format!("{base}..{tip}");-    let walkthrough = markdown::to_markdown_branch(&title, &chapters);-    let body = pr::wrap(&walkthrough);208+    Ok(markdown::to_markdown_branch(&title, &chapters))209+}210 211+/// Emit a finished PR body: write it to `output` (a file) or print it to stdout.212+/// `note`, when set, is written to stderr afterward (e.g. a dry-run notice), so213+/// stdout stays exactly the body for piping.214+fn emit_body(body: &str, output: Option<&Path>, note: Option<&str>) -> Result<()> {215     match output {216         Some(path) => {-            std::fs::write(path, &body).with_context(|| format!("writing `{}`", path.display()))?;-            println!(-                "Wrote PR body ({} commit(s)) to {}",-                chapters.len(),-                path.display()-            );217+            std::fs::write(path, body).with_context(|| format!("writing `{}`", path.display()))?;218+            println!("Wrote PR body to {}", path.display());219         }-        // Default: print the body so it can be piped or eyeballed.220         None => println!("{body}"),221     }222+    if let Some(note) = note {223+        eprintln!("{note}");224+    }225+    Ok(())226+}227+228+/// The forge API token, from the first of a few conventional env vars that is229+/// set. The REST calls are authenticated with it; without it we can neither read230+/// nor write a pull request.231+fn forge_token() -> Result<String> {232+    for var in ["FORGEJO_TOKEN", "CODEBERG_TOKEN", "GITEA_TOKEN"] {233+        if let Ok(v) = std::env::var(var) {234+            if !v.trim().is_empty() {235+                return Ok(v);236+            }237+        }238+    }239+    anyhow::bail!(240+        "no forge token in the environment — set FORGEJO_TOKEN (or CODEBERG_TOKEN / \241+         GITEA_TOKEN) to an access token with read/write on the repository"242+    )243+}244+245+/// Read pull request `index`'s current body and base branch via the REST API.246+fn read_pr(api_base: &str, token: &str, index: u64) -> Result<(String, String)> {247+    let url = format!("{api_base}/pulls/{index}");248+    let (status, json) = curl_api("GET", &url, token, None)?;249+    if status != 200 {250+        anyhow::bail!(251+            "reading pull request #{index} failed (HTTP {status}): {}",252+            forge_message(&json)253+        );254+    }255+    let pr: serde_json::Value =256+        serde_json::from_str(&json).context("parsing the pull request JSON")?;257+    let body = pr258+        .get("body")259+        .and_then(|b| b.as_str())260+        .unwrap_or("")261+        .to_string();262+    let base = pr263+        .get("base")264+        .and_then(|b| b.get("ref"))265+        .and_then(|r| r.as_str())266+        .unwrap_or("")267+        .to_string();268+    Ok((body, base))269+}270+271+/// Set pull request `index`'s body via the REST API.272+fn write_pr_body(api_base: &str, token: &str, index: u64, body: &str) -> Result<()> {273+    let url = format!("{api_base}/pulls/{index}");274+    let payload = serde_json::json!({ "body": body }).to_string();275+    let (status, json) = curl_api("PATCH", &url, token, Some(&payload))?;276+    if status != 200 && status != 201 {277+        anyhow::bail!(278+            "updating pull request #{index} failed (HTTP {status}): {}",279+            forge_message(&json)280+        );281+    }282     Ok(())283 }284 285+/// One authenticated Forgejo/Gitea REST call via `curl`, returning the HTTP286+/// status and the response body. Shelling `curl` keeps an HTTP transport out of287+/// the dependency tree (mirroring `git2`'s no-network build); a `payload` is sent288+/// as the request body over stdin (`--data-binary @-`) so a large diff never goes289+/// through argv.290+fn curl_api(method: &str, url: &str, token: &str, payload: Option<&str>) -> Result<(u16, String)> {291+    use std::io::Write;292+    use std::process::{Command, Stdio};293+294+    let mut cmd = Command::new("curl");295+    cmd.arg("-sS")296+        .args(["-X", method])297+        // Append the status after the body, newline-separated, so it can be split298+        // back off (the body itself may or may not end with a newline).299+        .args(["-w", "\n%{http_code}"])300+        .args(["-H", &format!("Authorization: token {token}")])301+        .args(["-H", "Accept: application/json"])302+        .stdout(Stdio::piped())303+        .stderr(Stdio::piped());304+    if payload.is_some() {305+        cmd.args(["-H", "Content-Type: application/json"])306+            .args(["--data-binary", "@-"])307+            .stdin(Stdio::piped());308+    }309+    cmd.arg(url);310+311+    let mut child = cmd.spawn().context("launching curl (is it installed?)")?;312+    if let Some(body) = payload {313+        child314+            .stdin315+            .take()316+            .expect("stdin is piped when there is a payload")317+            .write_all(body.as_bytes())318+            .context("sending the request body to curl")?;319+    }320+    let out = child.wait_with_output().context("running curl")?;321+    if !out.status.success() {322+        anyhow::bail!(323+            "curl failed: {}",324+            String::from_utf8_lossy(&out.stderr).trim()325+        );326+    }327+328+    let stdout = String::from_utf8_lossy(&out.stdout);329+    let (body, code) = stdout.rsplit_once('\n').unwrap_or(("", stdout.trim()));330+    let status = code.trim().parse().unwrap_or(0);331+    Ok((status, body.to_string()))332+}333+334+/// Pull a human-readable error out of a forge JSON error response (its `message`335+/// field), falling back to a trimmed snippet of the raw response.336+fn forge_message(json: &str) -> String {337+    serde_json::from_str::<serde_json::Value>(json)338+        .ok()339+        .and_then(|v| {340+            v.get("message")341+                .and_then(|m| m.as_str())342+                .map(str::to_string)343+        })344+        .filter(|m| !m.is_empty())345+        .unwrap_or_else(|| json.trim().chars().take(200).collect())346+}347+348 /// Open a rendered page with the platform's default handler. Best-effort: the349 /// file is already written and its path printed, so a missing or failing opener350 /// warns but never fails the command. (`Command` here is fully qualified because
27/376b72a3dEdu Ramírez

Document `throughline pr` across the spec, README, and wiki

Marks the PR-body feature shipped and writes its working knowledge: a how-to for using it and a field-note on why it talks to Forgejo over REST instead of the fj CLI.

The roadmap entry flips to done and records the pivot honestly — the forge CLI was the plan, but fj can't read back the raw body, so the non-destructive splice reads over the Forgejo/Gitea REST API; a gh adapter is the next forge.

docs/ROADMAP.md
@@ -175,12 +175,19 @@175   is folded into a `<details>` block; **completeness** — kept, the backfill stays176   fully present and is only collapsed, never relaxed. On both `render` and `branch`;177   see [`docs/wiki/field-notes/markdown-output.md`](wiki/field-notes/markdown-output.md).-- [ ] **`throughline pr`** — a thin helper over `--format=markdown` that writes the-  walkthrough into a pull-request description **non-destructively**: it owns a-  managed region (`<!-- throughline:start --> … <!-- throughline:end -->`) and-  updates only that, leaving the author's own prose, issue links, and checklists-  intact. Shells out to the forge CLI (`gh`, `fj`/`tea`) so auth and API differences-  stay out of the Rust core. Author-invoked — a snapshot at the moment you run it.178+- [x] **`throughline pr`** — writes the branch walkthrough into a pull-request179+  description **non-destructively**: it owns a managed region180+  (`<!-- throughline:start --> … <!-- throughline:end -->`) and replaces only that,181+  leaving the author's own prose, issue links, and checklists intact. `--pr <N>`182+  reads the live body and base over the **Forgejo/Gitea REST API**, splices, and183+  writes it back; `--dry-run` / `-o` preview without touching the PR. (`curl` for184+  transport + `serde_json` for the JSON + a `FORGEJO_TOKEN`-style env var for auth,185+  so an HTTP stack stays out of the crate, like `git2`'s no-network build.) The186+  forge *CLI* was the original plan, but Forgejo's `fj` can't read back the **raw**187+  body — it renders it — so the non-destructive splice needs the REST read; a188+  **`gh`/GitHub** adapter behind the same `pr::parse_remote` seam is the next forge.189+  See [`how-to/update-a-pr.md`](wiki/how-to/update-a-pr.md) and190+  [`field-notes/pr-forge-layer.md`](wiki/field-notes/pr-forge-layer.md).191 - [ ] **CI regeneration** *(deferred)* — a workflow that re-renders the PR body on192   every push so the projection can't drift from the diff. Deferred deliberately: it193   takes on forge coupling, maintenance, and the size edge cases above. Ship the two

The README gains a recipe in the branch/PR workflow: a dry-run preview, the --pr N write, the managed region that keeps your own prose, and the reason it survives a squash-merge — the body lives outside the rewritten history.

README.md
@@ -220,6 +220,23 @@220 chapter. See [Writing throughline-friendly221 commits](#writing-throughline-friendly-commits) below.222 223+Once that branch is a **pull request**, `throughline pr` writes the same per-commit224+walkthrough straight into the PR description, so reviewers read the story on the225+forge:226+227+```sh228+throughline pr --dry-run     # preview the body it would post (no PR touched)229+throughline pr --pr 42       # splice it into pull request #42's description230+```231+232+It owns a managed region (`<!-- throughline:start … end -->`) and replaces only233+that, leaving your own prose and checklists intact, so it's safe to re-run as the234+branch grows. Today it targets **Forgejo/Gitea** (e.g. Codeberg) over the REST API235+— set a `FORGEJO_TOKEN` first; see [Update a236+PR](docs/wiki/how-to/update-a-pr.md). And because the walkthrough lives in the PR237+body, a later **squash-merge** can't invalidate it — the body sits outside the238+rewritten history.239+240 ### Working in a worktree241 242 Git worktrees let you keep several branches checked out at once. `throughline`

The how-to is the task page: the token, the flags, prerequisites, and why no fj/tea is needed.

docs/wiki/how-to/update-a-pr.md
@@ -0,0 +1,81 @@1+# How-to: write the walkthrough into a pull request2+3+You have a branch open as a pull request and want its description to *be* the4+narrated walkthrough — each commit a chapter — so reviewers read the story on the5+forge instead of a pile of "files changed." This is the `throughline pr`6+subcommand. For the page it produces, see7+[`field-notes/markdown-output.md`](../field-notes/markdown-output.md); for how it8+talks to the forge, see [`field-notes/pr-forge-layer.md`](../field-notes/pr-forge-layer.md).9+10+## The quick version11+12+```sh13+throughline pr --dry-run        # print the body it would post; touches nothing14+throughline pr --pr 42          # splice the walkthrough into pull request #4215+throughline pr --pr 42 -o body.md   # write the spliced body to a file, not the PR16+```17+18+`throughline pr` renders `base..HEAD` as the per-commit walkthrough (the same19+composition as `throughline branch --format markdown`), wraps it in a **managed20+region**, and — with `--pr <N>` — reads PR N's current description, replaces just21+that region, and writes it back. Everything outside the region (your own summary,22+issue links, checklists) is left untouched, so re-running as the branch grows only23+refreshes the walkthrough.24+25+## Prerequisites (Forgejo / Gitea)26+27+Today the forge path targets **Forgejo/Gitea** (Codeberg is Forgejo) over its REST28+API. You need:29+30+- **A token.** Create a personal access token on the forge with read/write on the31+  repository, and export it: `export FORGEJO_TOKEN=…` (`CODEBERG_TOKEN` and32+  `GITEA_TOKEN` are also accepted). Without it, the command stops before any33+  network call with a message telling you to set one.34+- **`curl`** on your `PATH` (it ships with macOS and most Linux). No `fj`/`tea` CLI35+  is required — and deliberately so: see the field note for why the read goes over36+  REST, not through `fj`.37+- **An open PR** whose head is the branch you're on. Pass its number with `--pr`.38+39+## What each flag does40+41+- `--pr <N>` — the pull request to update. **Omit it** and `throughline pr` just42+  prints the wrapped body to stdout (or `-o <file>`) — an offline preview that43+  needs no token or network.44+- `--base <ref>` — the base of the range (`base..HEAD`). Omitted, it defaults to45+  **the PR's own base branch** (read from the forge); offline, it falls back to46+  `main`.47+- `--dry-run` — do everything except the write: print the *spliced* body (your48+  existing description with the region updated) so you can eyeball it. Standard49+  output stays exactly the body; the "not modified" note goes to stderr.50+- `-o <file>` — write the body to a file instead of the PR (also implies no write).51+52+## Why this survives a squash-merge53+54+A PR is a branch, and the walkthrough is composed **per commit** — faithful to the55+work as authored. Because it lives in the **PR body**, not in a commit message, a56+later **squash-merge** to trunk (which rewrites the commits into one) can't57+invalidate it: the body sits outside the rewritten history. This is the robust58+home for a branch narrative that a squash would otherwise break — see the spec's59+[Squashing & rebasing](../../SPEC.md#squashing--rebasing).60+61+## Gotchas62+63+- **Markdown only.** A PR body renders Markdown, so `pr` has no `--format`; it64+  always emits Markdown (HTML is for `render`/`branch`).65+- **GitHub remotes are refused** (for now). The host is parsed from `origin`; a66+  `github.com` remote exits with a clear message — a `gh` adapter is planned. Any67+  Forgejo/Gitea host works.68+- **Re-running is idempotent.** The managed region is matched and replaced, never69+  appended twice; a fresh render swaps only the region. Editing your prose *outside*70+  the markers is safe and preserved.71+- **Empty/reversed ranges** (a tip already merged into the base) produce no commits72+  and exit with an error rather than posting an empty body.73+74+## Where the code lives75+76+- `src/pr.rs` — pure: `wrap` / `splice` (the managed region) and `parse_remote`77+  (remote URL → host + `owner/repo`, with `api_base` and the GitHub check). Unit-78+  tested.79+- `src/main.rs` — `run_pr` (offline vs. forge), and the REST I/O: `read_pr` /80+  `write_pr_body` over a `curl_api` helper, `forge_token` for the env-var token.81+- `src/git.rs` — `remote_url` exposes the `origin` URL `parse_remote` consumes.

The field-note is the why: the pure splice versus binary-layer REST, the fj finding that forced REST, and the curl + serde_json + token design — including how a future gh adapter slots in behind the same parse_remote seam.

docs/wiki/field-notes/pr-forge-layer.md
@@ -0,0 +1,92 @@1+# Field note: the PR forge layer (`throughline pr`)2+3+How the walkthrough gets into a pull-request body, why it talks to the forge over4+**REST instead of a CLI**, and which half is pure (and tested) versus binary-layer5+I/O. Read this before changing `pr.rs`, `run_pr`, or adding a second forge.6+7+## The shape: a pure splice + binary-layer REST8+9+`throughline pr` keeps the crate's library-is-pure / binary-does-I/O split (see10+[`cli-binary-layer.md`](cli-binary-layer.md)):11+12+- **`src/pr.rs` is pure.** `wrap` frames a rendered walkthrough between two13+  HTML-comment markers (`<!-- throughline:start -->` / `<!-- throughline:end -->`);14+  `splice(existing, body)` replaces that region in an existing body **in place**, or15+  appends it when absent. `parse_remote` turns a git remote URL into a16+  `ForgeRepo { host, owner, repo }`, with `api_base()` and `is_github()`. All of it17+  is unit-tested — no network.18+- **`src/main.rs` does the I/O.** `run_pr` orchestrates; `read_pr` / `write_pr_body`19+  are the REST calls over a single `curl_api` helper; `forge_token` reads the token.20+  Like the rest of the binary layer, `cargo test` doesn't reach these — verify them21+  by running the command against a real PR (or `--dry-run`).22+23+The managed region is what makes the write **non-destructive**: only the span24+between the markers is ours. `splice` is idempotent — a re-render swaps just the25+region, and a lone or reversed marker is treated as "no region" (it appends rather26+than editing a half-recognized span), so we never corrupt a body we don't fully27+recognize.28+29+## Why REST, not the forge CLI30+31+The roadmap originally imagined shelling out to the forge CLI (`gh`, `fj`/`tea`) so32+"auth and API differences stay out of the Rust core." That holds for GitHub's `gh`33+(`gh pr view --json body` reads the **raw** body), but **not for Forgejo**, and the34+reason is specific and worth recording so nobody re-litigates it:35+36+- `fj pr edit body <NEW_BODY>` **writes** the body fine (positional arg; no37+  `--body-from-file` in 0.5.0).38+- `fj pr view body` **cannot return the raw body.** Its source prints a localized39+  title/header line and then the body run through a Markdown *renderer*40+  (`crate::markdown(body)`), not the raw text. A non-destructive splice needs the41+  exact bytes to preserve everything outside our region, and a rendered view can't42+  round-trip.43+44+So the Forgejo path reads (and writes) the **raw** `body` field over the REST API,45+where it is exact. `tea` (Gitea) is worse for this — no single-PR body read at all.46+Net: **Forgejo → REST; GitHub → `gh` (a future adapter behind the same47+`parse_remote` seam).**48+49+## The REST design50+51+- **Transport is `curl`, parsing is `serde_json`.** Shelling `curl` keeps an HTTP52+  stack out of the dependency tree — the same reason `git2` is built53+  `default-features = false` (no network transport). `serde_json` (the one runtime54+  dep `pr` added) parses the GET response and builds the PATCH payload, so body text55+  with quotes/newlines/unicode is escaped correctly rather than by hand.56+- **`curl_api(method, url, token, payload)`** sends `-w "\n%{http_code}"` and splits57+  the status back off the end of stdout (the body may or may not end in a newline,58+  so split on the *last* one). A `payload` is fed over stdin (`--data-binary @-`),59+  not argv, so a large diff never risks `ARG_MAX`.60+- **Endpoints** (Forgejo/Gitea v1): `GET …/repos/{owner}/{repo}/pulls/{N}` returns61+  `body` and `base.ref`; `PATCH` the same URL with `{"body": …}` sets it. Auth is62+  `Authorization: token <TOKEN>`.63+- **Token** comes from `FORGEJO_TOKEN` → `CODEBERG_TOKEN` → `GITEA_TOKEN` (first set64+  wins). Missing → a clear error *before* any network call.65+66+## Resolution details67+68+- **`parse_remote`** handles `https://`, `ssh://`, `git://`, and scp-like69+  `git@host:owner/repo` spellings, dropping a `user@` prefix, a `:port`, a trailing70+  `.git`, and slashes. It's pure and table-tested; a host it can't read → a degraded71+  error, not a panic.72+- **`is_github()`** gates the GitHub guard in `run_pr`: a `github.com` remote exits73+  with "a `gh` adapter is planned" rather than aiming Forgejo calls at GitHub.74+- **Base auto-detect:** with `--pr` and no `--base`, the base is the PR's own75+  `base.ref` from the GET; offline it falls back to `main`.76+- **`--dry-run` / `-o`** route through the same splice but skip the PATCH — the77+  preview is the *spliced* body (your description with the region updated), so what78+  you see is exactly what would be posted.79+80+## Gotchas81+82+- **Verify by running, not by `cargo test`.** Only `pr.rs` (splice, `parse_remote`)83+  is unit-tested; the `curl`/REST glue is binary-layer. `--dry-run` is the safe way84+  to exercise read+splice end-to-end before a real write.85+- **Codeberg returns transient `504`s.** A failed call may just be the gateway —86+  retry before assuming a bug. `curl_api` surfaces the HTTP status and the forge's87+  `message` field on non-200s.88+- **Token scope.** A read-only token reads but fails the PATCH with a 403/404; the89+  error carries the forge's message.90+- **Adding `gh`/GitHub:** keep `parse_remote` as the seam — branch in `run_pr` on91+  the host, add a `gh`-based `read_pr`/`write_pr_body` (it *can* use the CLI), and92+  drop the GitHub guard.

The spec's out-of-scope note now says the PR body ships (CI regeneration still planned), and the wiki map links both new pages — both fold in via the backfill.

docs/wiki/README.md
@@ -51,10 +51,16 @@51   - [`field-notes/cli-binary-layer.md`](field-notes/cli-binary-layer.md)52     — the binary (`main.rs`): where I/O lives, the best-effort `--open` opener, and53     why `cargo test` can't reach the CLI (so how to verify it instead).54+  - [`field-notes/pr-forge-layer.md`](field-notes/pr-forge-layer.md)55+    — how `throughline pr` writes a PR body: the managed-region splice, why it talks56+    to Forgejo over REST (`fj` can't read the raw body), and the `curl`/token I/O.57 - **How-to** — acting on this codebase.58   - [`how-to/render-a-branch.md`](how-to/render-a-branch.md) — render a range of59     commits as one navigable page (`throughline branch`); the input forms and the60     per-commit completeness semantic.61+  - [`how-to/update-a-pr.md`](how-to/update-a-pr.md) — write the branch walkthrough62+    into a Forgejo/Gitea pull-request body (`throughline pr`); the token, the63+    managed region, and `--dry-run`.64   - [`how-to/working-in-a-worktree.md`](how-to/working-in-a-worktree.md) — the two65     worktree gotchas: edit at the worktree root, and config / `main` are shared.66   - [`how-to/publish-the-live-demo.md`](how-to/publish-the-live-demo.md) —

Remaining changes

docs/SPEC.md
@@ -360,9 +360,10 @@360 - Interactive / slide-by-slide presentation UI (later, as CSS/JS over the HTML).361 - Content-based anchoring instead of line numbers (v2; fragile across rebases but fine here).362 - Merging or syncing rendered walkthroughs across branches. (The **Markdown**-  projection for Markdown-native surfaces now ships as `--format=markdown`; writing it-  into a pull-request body — `throughline pr` — and CI regeneration remain *planned*;-  see [`ROADMAP.md`](ROADMAP.md#markdown-output-and-the-pr-surface).)363+  projection for Markdown-native surfaces ships as `--format=markdown`, and364+  `throughline pr` now writes it into a Forgejo/Gitea pull-request body365+  non-destructively; **CI regeneration** of that body remains *planned*; see366+  [`ROADMAP.md`](ROADMAP.md#markdown-output-and-the-pr-surface).)367 368 ## Instruction for the LLM369 
28/372c75eb9Edu Ramírez

Sync architecture and field-notes to the PR surface

Catches the docs the throughline pr work outpaced: the architecture doc still listed it as a later item and never described its flow, and two field-notes had gone stale around the edges. Nothing now calls the shipped feature "planned."

The architecture doc gains a structural "PR surface" section — a third composition over the pipeline, with the pure/REST split and a cross-link to the deep field-note — plus updated cli/pr rows, a pr testing bullet, and a status that no longer files the command under future work.

docs/ARCHITECTURE.md
@@ -42,7 +42,7 @@42 43 | Module | Role |44 |--------|------|-| `cli` | Parse `throughline render <commit>` and `throughline branch <range>` (clap, derive). |45+| `cli` | Parse `throughline render <commit>`, `throughline branch <range>`, and `throughline pr` (clap, derive). |46 | `git` | Thin wrapper over `git2`: open/discover the repo, read a commit's raw message, compute the diff against the first parent, read a file's lines at the commit (for context outside the diff). |47 | `anchor` | The anchor grammar — recognition (is this line a code reference?) and parsing into `AnchorRef { path, range, focus }`. Pure, no I/O. |48 | `message` | Split a raw message into `title`, `tldr` (the first paragraph), `intro` (the lead-in to the first anchor), and an ordered `Vec<Block>` of prose/anchor blocks. Pure, no I/O. |@@ -51,7 +51,7 @@51 | `render` | Orchestration. Resolves anchors against `git`, marks coverage, backfills the rest, and produces the ordered `Document`. |52 | `html` | Lower a `Document` into a standalone HTML page with inlined CSS (`to_html`); compose several `Document`s into one branch walkthrough (`to_html_branch`). |53 | `markdown` | Lower the same `Document` into Markdown — prose verbatim, hunks as fenced `diff` blocks, the backfill folded into `<details>` (`to_markdown` / `to_markdown_branch`). A sibling of `html`. |-| `pr` | Splice a rendered Markdown walkthrough into a pull-request body inside a managed region, non-destructively. Pure (`wrap` / `splice`); the forge I/O that reads and writes the body lives in the binary. |54+| `pr` | Pull-request body splicing: `wrap` / `splice` for the managed region and `parse_remote` for the forge address (host + `owner/repo`). Pure; the REST I/O that reads and writes the body lives in the binary. |55 56 The two **pure** modules (`anchor`, `message`) define the format and are the most57 heavily unit-tested; the I/O and rendering modules wrap them.@@ -183,6 +183,29 @@183 [`ROADMAP.md`](ROADMAP.md).) Guarded by `tests/branch.rs`, which asserts the184 chapter order and that each commit's change lands in its own chapter.185 186+## The PR surface187+188+`throughline pr` is a third composition over the same pipeline — like the branch189+view it renders, it does not re-resolve. It builds the per-commit branch190+walkthrough as Markdown (`base..HEAD`, base defaulting to the PR's own base), then191+**splices** it into a pull request's description inside a managed region192+(`<!-- throughline:start --> … <!-- throughline:end -->`), replacing only that193+region so the author's own prose is preserved. The split mirrors the rest of the194+crate:195+196+- **Pure (`src/pr.rs`):** `wrap` / `splice` (the managed region, idempotent) and197+  `parse_remote` (a remote URL → host + `owner/repo`, with `api_base` and a GitHub198+  check). Unit-tested.199+- **I/O (`src/main.rs`):** `run_pr` orchestrates; the body is read and written over200+  the **Forgejo/Gitea REST API** (`curl` transport, `serde_json`, a token env var),201+  *not* a forge CLI — because Forgejo's `fj` can't read back the **raw** body. The202+  full rationale and the `gh`-adapter seam are in203+  [`wiki/field-notes/pr-forge-layer.md`](wiki/field-notes/pr-forge-layer.md).204+205+Because the walkthrough lives in the PR *body*, a later squash-merge can't206+invalidate it — the body sits outside the rewritten history (the spec's207+[Squashing & rebasing](SPEC.md#squashing--rebasing)).208+209 ## Conventional Commits210 211 `conventional::Header::parse` reads the subject line; footer trailers are split@@ -214,6 +237,11 @@237 - **`diff`** unit-tests the pure pieces: hunk/range overlap, coverage marking,238   `DeltaMarker` phrasing, `Hunk::contained_in_new_range`, and239   `Hunk::clip_to_new_range`.240+- **`pr`** unit-tests the pure pieces: the managed-region `splice`241+  (append / replace / idempotency) and `parse_remote` across URL spellings. The242+  forge REST path is binary-layer (no `cargo test` seam) — verify it by running243+  `throughline pr` (or `--dry-run`); see244+  [`wiki/field-notes/pr-forge-layer.md`](wiki/field-notes/pr-forge-layer.md).245 - **Integration tests** (`tests/`) build a small fixture repository and assert246   end-to-end properties — above all the **completeness invariant**: for any247   commit and any set of anchors, every hunk (and every added *and deleted* line)@@ -234,7 +262,9 @@262 `tests/hunkless.rs`, `tests/mode_change.rs`, `tests/ranged_anchor.rs`,263 `tests/context_anchor.rs`, `tests/markdown.rs`). The `branch` view composes a commit264 range into one navigable page (HTML or Markdown) on top of that pipeline-(`tests/branch.rs`).265+(`tests/branch.rs`), and `throughline pr` splices the Markdown walkthrough into a266+Forgejo/Gitea pull-request body over REST — the pure `pr` pieces in `src/pr.rs` are267+unit-tested; the forge I/O is binary-layer (see [The PR surface](#the-pr-surface)).268 269 Stubbed: `conventional::Header::parse` (returns `None`) and footer-trailer270 splitting in `message::parse` (`trailers` is empty); both fall through harmlessly@@ -244,5 +274,5 @@274 (hunk-less deltas, mode-beside-content markers done; the always-diff redesign275 closed the deletion holes), Conventional Commits interop, notation interop276 (accept the `#L12-L38` fragment and Markdown-link anchor forms), and the spec's-later items (interactive UI, content-based anchoring, the `throughline pr` surface-over the now-shipped Markdown output, and further export formats).277+later items (interactive UI, content-based anchoring, CI regeneration of the278+now-shipped `throughline pr` body, and further export formats).

The binary-layer note listed run_render/run_branch/open_in_browser as the flows cargo test can't reach; run_pr and the forge REST helpers belong on that list too, with --dry-run as the way to exercise them.

docs/wiki/field-notes/cli-binary-layer.md
@@ -36,12 +36,16 @@36 37 `tests/` link the **library** and call its functions directly (`render::render`,38 `html::to_html`, …) over fixture repos built with `tempfile` + `git2`. They never-spawn the `throughline` binary. So `run_render` / `run_branch` / `open_in_browser`-are private to `main.rs` and **uncovered** by `cargo test`: output-path naming, the-`--format` emitter selection, the `--open` opener, and `--debug` are binary-layer-behavior you verify by **running the binary**, not by adding a test. (This is the practical edge of ARCHITECTURE's "the-I/O and rendering modules wrap the pure ones" — the outermost I/O wrapper, `main`,-has no test seam at all.)39+spawn the `throughline` binary. So `run_render` / `run_branch` / `run_pr` /40+`open_in_browser` are private to `main.rs` and **uncovered** by `cargo test`:41+output-path naming, the `--format` emitter selection, the `--open` opener,42+`--debug`, and the whole `throughline pr` **forge REST path** (`curl_api` /43+`read_pr` / `write_pr_body`, over the network) are binary-layer behavior you verify44+by **running the binary**, not by adding a test — for the forge path use45+`throughline pr --dry-run`, and see46+[`pr-forge-layer.md`](pr-forge-layer.md). (This is the practical edge of47+ARCHITECTURE's "the I/O and rendering modules wrap the pure ones" — the outermost48+I/O wrapper, `main`, has no test seam at all.)49 50 Recipe — exercise the best-effort failure branch *without* launching a browser: run51 the built binary by path with an emptied `PATH`, so the process execs fine but its

The git-lowering note picks up the one gotcha this task turned up: git2 0.21's Remote::url() returns Result, not Option, so the new remote_url getter takes it with ? — a ported ok_or_else won't compile.

docs/wiki/field-notes/git-lowering.md
@@ -110,3 +110,9 @@110   an old/new/diff *side*: anchors always render as a diff, and deletions live inside111   the hunks, so they are never reasoned about separately — see112   [coverage-and-backfill](coverage-and-backfill.md#the-mental-model).)113+- **git2 0.21's `Remote::url()` returns `Result`, not `Option`.** `Repo::remote_url`114+  (`src/git.rs:58`) — the only non-lowering getter here, exposing the `origin` URL115+  for `throughline pr`'s forge addressing116+  ([`pr-forge-layer.md`](pr-forge-layer.md)) — must take the URL with `?`. Older117+  git2 returned `Option<&str>`, so a ported `ok_or_else` won't compile; 0.21 errors118+  (rather than returning `None`) on a missing or non-UTF-8 URL.

The project guide's cli.rs row also names pr now (it folds in via the backfill).

CLAUDE.md
@@ -43,7 +43,7 @@43 44 | File | Responsibility |45 |------|----------------|-| `src/cli.rs` | `throughline render <commit>` / `throughline branch <range>` argument parsing, including `--format {html,markdown}`. |46+| `src/cli.rs` | `throughline render <commit>` / `throughline branch <range>` / `throughline pr` argument parsing, including `--format {html,markdown}`. |47 | `src/message.rs` | Split a raw message into title, TL;DR (first paragraph), intro (lead-in to the first anchor), and an ordered list of prose/anchor blocks. |48 | `src/anchor.rs` | The anchor grammar: recognize a path-shaped line and parse `path[:range] [@focus]` (a legacy trailing `!side` is accepted and ignored). |49 | `src/conventional.rs` | Conventional Commits header (`type(scope): …`) and footer-trailer passthrough. |
29/37008abf6Edu Ramírez

Default the `branch` tip to HEAD when omitted

A bare throughline branch now renders main..HEAD — the branch you are standing on — mirroring throughline pr, which never needed a tip argument.

The tip stays positional but becomes optional. It is an Option<String> rather than a clap default_value so the binary can still tell "omitted" apart from an explicit HEAD — the planned interactive branch picker hangs off exactly that distinction.

src/cli.rs
@@ -65,6 +65,7 @@65     /// order, with a contents list and previous/next links.66     Branch {67         /// The branch tip to render (a branch name, SHA, or tag), or an explicit-        /// `base..tip` range. A bare tip is diffed against `--base`.-        branch: String,68+        /// `base..tip` range. A bare tip is diffed against `--base`. Omitted69+        /// entirely, it defaults to `HEAD` — the branch you are standing on.70+        branch: Option<String>,71 

run_branch resolves the omitted tip to HEAD up front, before the existing base..tip split, so the range walk, page title, and output filename all flow through unchanged.

src/main.rs
@@ -29,7 +29,7 @@29             output,30             format,31             open,-        } => run_branch(&branch, &base, output.as_deref(), format, open),32+        } => run_branch(branch.as_deref(), &base, output.as_deref(), format, open),33         Command::Pr {34             pr,35             base,@@ -96,7 +96,7 @@96 97 /// Render a range of commits as one navigable page (the `branch` subcommand).98 fn run_branch(-    branch: &str,99+    branch: Option<&str>,100     base: &str,101     output: Option<&Path>,102     format: Format,@@ -105,10 +105,12 @@105     let repo =106         git::Repo::discover(std::env::current_dir()?).context("opening the Git repository")?;107 -    // Accept either an explicit `base..tip` range or a bare tip ref (+ `--base`).-    let (base, tip) = match branch.split_once("..") {108+    // Accept an explicit `base..tip` range or a bare tip ref (+ `--base`);109+    // omitted entirely, the tip is `HEAD` — the branch you are standing on.110+    let spec = branch.unwrap_or("HEAD");111+    let (base, tip) = match spec.split_once("..") {112         Some((b, t)) => (b.to_string(), t.to_string()),-        None => (base.to_string(), branch.to_string()),113+        None => (base.to_string(), spec.to_string()),114     };115 116     let chapters = render_range(&repo, &base, &tip)?;

Two parse tests pin the new surface: the bare invocation parses with no tip while --base keeps its main default, and an explicit range string still lands in branch untouched.

src/cli.rs
@@ -116,0 +117,29 @@117+118+#[cfg(test)]119+mod tests {120+    use super::*;121+122+    /// A bare `throughline branch` is valid: the tip is omitted (the binary123+    /// defaults it to `HEAD`) and `--base` keeps its own default.124+    #[test]125+    fn branch_tip_is_optional() {126+        let cli = Cli::try_parse_from(["throughline", "branch"]).unwrap();127+        match cli.command {128+            Command::Branch { branch, base, .. } => {129+                assert_eq!(branch, None);130+                assert_eq!(base, "main");131+            }132+            other => panic!("expected Branch, parsed {other:?}"),133+        }134+    }135+136+    /// An explicit tip or range still lands in `branch` untouched.137+    #[test]138+    fn branch_tip_parses_when_given() {139+        let cli = Cli::try_parse_from(["throughline", "branch", "v1.0..v1.1"]).unwrap();140+        match cli.command {141+            Command::Branch { branch, .. } => assert_eq!(branch.as_deref(), Some("v1.0..v1.1")),142+            other => panic!("expected Branch, parsed {other:?}"),143+        }144+    }145+}

The README and the branch how-to now lead with the bare form, and the module maps note the HEAD default.

docs/wiki/how-to/render-a-branch.md
@@ -9,6 +9,10 @@-## The two input forms9+## The three input forms10 11 ```sh12+# Nothing at all — the tip defaults to `HEAD`, the branch you are standing on:13+throughline branch14+throughline branch --base develop15+16 # A bare tip — the range is `base..tip`, with base defaulting to `main`:17 throughline branch my-feature18 throughline branch my-feature --base develop

Remaining changes

CLAUDE.md
@@ -43,7 +43,7 @@43 44 | File | Responsibility |45 |------|----------------|-| `src/cli.rs` | `throughline render <commit>` / `throughline branch <range>` / `throughline pr` argument parsing, including `--format {html,markdown}`. |46+| `src/cli.rs` | `throughline render <commit>` / `throughline branch [<range>]` (tip defaults to `HEAD`) / `throughline pr` argument parsing, including `--format {html,markdown}`. |47 | `src/message.rs` | Split a raw message into title, TL;DR (first paragraph), intro (lead-in to the first anchor), and an ordered list of prose/anchor blocks. |48 | `src/anchor.rs` | The anchor grammar: recognize a path-shaped line and parse `path[:range] [@focus]` (a legacy trailing `!side` is accepted and ignored). |49 | `src/conventional.rs` | Conventional Commits header (`type(scope): …`) and footer-trailer passthrough. |
README.md
@@ -204,10 +204,11 @@204 branch or a PR as a story instead of a pile of "files changed."205 206 ```sh-throughline branch <tip> [--base <ref>] [-o out.html] [--format html|markdown] [--open]   # range is base..tip; default base: main-throughline branch <base>..<tip> [-o out.html] [--format html|markdown] [--open]          # explicit range207+throughline branch [<tip>] [--base <ref>] [-o out.html] [--format html|markdown] [--open]  # range is base..tip; default tip: HEAD, base: main208+throughline branch <base>..<tip> [-o out.html] [--format html|markdown] [--open]           # explicit range209 ```210 211+- `throughline branch` (bare) renders `main..HEAD` — the branch you're standing on.212 - `throughline branch my-feature` renders every commit in `main..my-feature`.213 - `throughline branch v1.0..v1.1` renders an explicit range.214 - Completeness holds **per commit** — each chapter diffs against its own parent;@@ -241,8 +242,8 @@242 243 Git worktrees let you keep several branches checked out at once. `throughline`244 discovers the enclosing repository on its own, so from inside a worktree you just-render that worktree's branch — `throughline branch HEAD --base main --open` — to-review the work in isolation before you merge it.245+render that worktree's branch — a bare `throughline branch --open` (the tip246+defaults to `HEAD`) — to review the work in isolation before you merge it.247 248 ### Agentic coding249 
docs/ARCHITECTURE.md
@@ -42,7 +42,7 @@42 43 | Module | Role |44 |--------|------|-| `cli` | Parse `throughline render <commit>`, `throughline branch <range>`, and `throughline pr` (clap, derive). |45+| `cli` | Parse `throughline render <commit>`, `throughline branch [<range>]` (the tip defaults to `HEAD`), and `throughline pr` (clap, derive). |46 | `git` | Thin wrapper over `git2`: open/discover the repo, read a commit's raw message, compute the diff against the first parent, read a file's lines at the commit (for context outside the diff). |47 | `anchor` | The anchor grammar — recognition (is this line a code reference?) and parsing into `AnchorRef { path, range, focus }`. Pure, no I/O. |48 | `message` | Split a raw message into `title`, `tldr` (the first paragraph), `intro` (the lead-in to the first anchor), and an ordered `Vec<Block>` of prose/anchor blocks. Pure, no I/O. |
docs/wiki/how-to/render-a-branch.md
@@ -6,9 +6,13 @@6 [reading model](../field-notes/reading-model.md); for the internals see the7 [branch view](../../ARCHITECTURE.md#the-branch-view) in the architecture doc.8 -## The two input forms9+## The three input forms10 11 ```sh12+# Nothing at all — the tip defaults to `HEAD`, the branch you are standing on:13+throughline branch14+throughline branch --base develop15+16 # A bare tip — the range is `base..tip`, with base defaulting to `main`:17 throughline branch my-feature18 throughline branch my-feature --base develop
src/cli.rs
@@ -65,8 +65,9 @@65     /// order, with a contents list and previous/next links.66     Branch {67         /// The branch tip to render (a branch name, SHA, or tag), or an explicit-        /// `base..tip` range. A bare tip is diffed against `--base`.-        branch: String,68+        /// `base..tip` range. A bare tip is diffed against `--base`. Omitted69+        /// entirely, it defaults to `HEAD` — the branch you are standing on.70+        branch: Option<String>,71 72         /// Base to take the commit range from when `branch` is a bare tip73         /// (`base..tip`). Ignored when `branch` is already a `..` range.@@ -113,3 +114,32 @@114         output: Option<PathBuf>,115     },116 }117+118+#[cfg(test)]119+mod tests {120+    use super::*;121+122+    /// A bare `throughline branch` is valid: the tip is omitted (the binary123+    /// defaults it to `HEAD`) and `--base` keeps its own default.124+    #[test]125+    fn branch_tip_is_optional() {126+        let cli = Cli::try_parse_from(["throughline", "branch"]).unwrap();127+        match cli.command {128+            Command::Branch { branch, base, .. } => {129+                assert_eq!(branch, None);130+                assert_eq!(base, "main");131+            }132+            other => panic!("expected Branch, parsed {other:?}"),133+        }134+    }135+136+    /// An explicit tip or range still lands in `branch` untouched.137+    #[test]138+    fn branch_tip_parses_when_given() {139+        let cli = Cli::try_parse_from(["throughline", "branch", "v1.0..v1.1"]).unwrap();140+        match cli.command {141+            Command::Branch { branch, .. } => assert_eq!(branch.as_deref(), Some("v1.0..v1.1")),142+            other => panic!("expected Branch, parsed {other:?}"),143+        }144+    }145+}
30/37f79fdc9Edu Ramírez

Open a branch picker when `main..HEAD` comes up empty

A bare throughline branch on the base itself used to die on the empty range. On a terminal it now opens a picker — every other local branch, freshest first, worktree branches labeled with their path — so you can render a worktree's work from the main checkout without naming it.

The candidate list is a git-layer query. Branch refs are shared across every worktree of a repository, which is exactly why the picker works: the branch you want is visible from the main checkout even while checked out elsewhere. Each candidate carries its name, tip commit time, and the linked worktree that has it checked out.

src/git.rs
@@ -13,0 +14,11 @@14+/// A local branch, with what the interactive picker needs: a recency key for15+/// ordering and the linked worktree (if any) where the branch is checked out.16+pub struct LocalBranch {17+    /// Short branch name (e.g. `fix/clip-range`).18+    pub name: String,19+    /// Commit time of the branch tip, seconds since epoch — the sort key.20+    pub when: i64,21+    /// Working-tree path, when the branch is checked out in a linked worktree.22+    pub worktree: Option<PathBuf>,23+}24+

local_branches walks the linked worktrees first to map branch → path (skipping detached HEADs and anything that fails to introspect — the badge is best-effort), then lists local branches and sorts them most recently committed first, ties broken by name.

src/git.rs
@@ -91,0 +103,51 @@103+    /// Local branches, most recently committed first (ties broken by name),104+    /// each badged with the linked worktree that has it checked out, if any.105+    ///106+    /// This is the interactive picker's candidate list. Branch refs are shared107+    /// across every worktree of a repository, so the listing sees a branch even108+    /// when it is checked out elsewhere — which is the point: the picker runs109+    /// from the main checkout precisely to render work done in a worktree.110+    /// Best-effort throughout: a branch or worktree that fails to introspect is111+    /// skipped (or left unbadged) rather than failing the whole listing.112+    pub fn local_branches(&self) -> Result<Vec<LocalBranch>> {113+        // Branch name → working-tree path, for every linked worktree that has a114+        // branch (not a detached HEAD) checked out.115+        let mut checked_out: HashMap<String, PathBuf> = HashMap::new();116+        for entry in self.inner.worktrees()?.iter() {117+            // 0.21's `StringArray` items are `Result<Option<&str>>` (error /118+            // non-UTF-8 / name); only a clean name is worth looking up.119+            let Ok(Some(name)) = entry else { continue };120+            let Ok(wt) = self.inner.find_worktree(name) else {121+                continue;122+            };123+            let Ok(wt_repo) = git2::Repository::open_from_worktree(&wt) else {124+                continue;125+            };126+            let Ok(head) = wt_repo.head() else { continue };127+            if head.is_branch() {128+                if let Ok(branch) = head.shorthand() {129+                    checked_out.insert(branch.to_string(), wt.path().to_path_buf());130+                }131+            }132+        }133+134+        let mut out = Vec::new();135+        for entry in self.inner.branches(Some(git2::BranchType::Local))? {136+            let Ok((branch, _)) = entry else { continue };137+            let Ok(Some(name)) = branch.name() else {138+                continue;139+            };140+            let name = name.to_string();141+            let Ok(tip) = branch.get().peel_to_commit() else {142+                continue;143+            };144+            out.push(LocalBranch {145+                worktree: checked_out.remove(&name),146+                when: tip.time().seconds(),147+                name,148+            });149+        }150+        out.sort_by(|a, b| b.when.cmp(&a.when).then_with(|| a.name.cmp(&b.name)));151+        Ok(out)152+    }153+

run_branch reroutes to the picker only when all three guards agree: the tip was omitted (not an explicit HEAD), the probe says the default range is empty, and stdin and stderr are real TTYs. Scripts, hooks, and explicit arguments keep today's hard error, so automation stays deterministic.

src/main.rs
@@ -111,5 +112,22 @@-    let (base, tip) = match spec.split_once("..") {112+    let (base, mut tip) = match spec.split_once("..") {113         Some((b, t)) => (b.to_string(), t.to_string()),114         None => (base.to_string(), spec.to_string()),115     };116 117+    // A bare `throughline branch` run *on the base itself* defaults to the118+    // empty range `base..HEAD` — a dead end. On a terminal, turn that dead end119+    // into a branch picker instead of an error; everywhere else (scripts,120+    // hooks, an explicit tip) keep the hard error, so automation stays121+    // deterministic. The probe swallows resolution errors on purpose:122+    // `render_range` below reports them with full context.123+    if branch.is_none()124+        && repo125+            .range_commits(&base, &tip)126+            .map(|c| c.is_empty())127+            .unwrap_or(false)128+        && std::io::stdin().is_terminal()129+        && std::io::stderr().is_terminal()130+    {131+        tip = pick_branch(&repo, &base)?;132+    }133+

The prompt itself is inquire's filterable select: Enter renders the chosen branch against --base, Esc backs out cleanly, and each row shows its worktree path with $HOME shortened to ~.

src/main.rs
@@ -146,0 +164,51 @@164+/// Choose a tip interactively when the defaulted range came up empty: every165+/// local branch except `base`, most recently committed first, badged with the166+/// worktree where it is checked out. Returns the chosen branch name; fails when167+/// there is nothing to offer or the user backs out (Esc / Ctrl-C).168+fn pick_branch(repo: &git::Repo, base: &str) -> Result<String> {169+    let choices: Vec<BranchChoice> = repo170+        .local_branches()171+        .context("listing local branches")?172+        .into_iter()173+        .filter(|b| b.name != base)174+        .map(BranchChoice)175+        .collect();176+    if choices.is_empty() {177+        anyhow::bail!("`{base}..HEAD` is empty, and there is no other local branch to render");178+    }179+180+    let prompt = format!("`{base}..HEAD` is empty — render which branch against `{base}`?");181+    match inquire::Select::new(&prompt, choices).prompt() {182+        Ok(choice) => Ok(choice.0.name),183+        Err(inquire::InquireError::OperationCanceled)184+        | Err(inquire::InquireError::OperationInterrupted) => {185+            anyhow::bail!("no branch selected")186+        }187+        Err(e) => Err(e).context("showing the branch picker"),188+    }189+}190+191+/// One picker row: the branch name, plus the worktree it is checked out in —192+/// the usual reason the branch isn't `HEAD` right now.193+struct BranchChoice(git::LocalBranch);194+195+impl std::fmt::Display for BranchChoice {196+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {197+        match &self.0.worktree {198+            Some(path) => write!(f, "{}  (worktree: {})", self.0.name, tildify(path)),199+            None => f.write_str(&self.0.name),200+        }201+    }202+}203+204+/// Abbreviate `$HOME` to `~` for display — picker rows, not paths to reuse.205+fn tildify(path: &Path) -> String {206+    let s = path.display().to_string();207+    match std::env::var("HOME") {208+        Ok(home) if !home.is_empty() && s.starts_with(&home) => {209+            format!("~{}", &s[home.len()..])210+        }211+        _ => s,212+    }213+}214+

An integration test pins the contract with fixed commit times (no clock races): recency order, a worktree badge on the checked-out branch, no badge elsewhere.

tests/local_branches.rs
@@ -0,0 +1,67 @@1+//! `Repo::local_branches` — the interactive picker's candidate list.2+//!3+//! The guarantees under test: branches come back most-recently-committed4+//! first, a branch checked out in a linked worktree carries that worktree's5+//! path, and other branches stay unbadged. Commit times are pinned explicitly6+//! (not `now`) so the ordering assertions can never race the clock.7+8+use commit_throughline::git::Repo;9+use git2::{BranchType, Oid, Repository, Signature, Time, WorktreeAddOptions};10+use tempfile::TempDir;11+12+/// Commit an empty tree to `refname` at the given epoch second, so each13+/// branch tip has a distinct, controlled commit time.14+fn commit_at(git: &Repository, refname: &str, secs: i64, parents: &[Oid]) -> Oid {15+    let sig = Signature::new("Fixture", "fixture@example.com", &Time::new(secs, 0)).unwrap();16+    let tree = git17+        .find_tree(git.index().unwrap().write_tree().unwrap())18+        .unwrap();19+    let parent_commits: Vec<git2::Commit> = parents20+        .iter()21+        .map(|p| git.find_commit(*p).unwrap())22+        .collect();23+    let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect();24+    git.commit(Some(refname), &sig, &sig, refname, &tree, &parent_refs)25+        .unwrap()26+}27+28+#[test]29+fn local_branches_sort_by_recency_and_badge_worktrees() {30+    let dir = TempDir::new().unwrap();31+    let repo_path = dir.path().join("repo");32+    let git = Repository::init(&repo_path).unwrap();33+34+    // The default branch at t=100, then two children committed straight to35+    // their refs (HEAD stays on the default branch): stale@200, fresh@300.36+    let root = commit_at(&git, "HEAD", 100, &[]);37+    commit_at(&git, "refs/heads/stale", 200, &[root]);38+    commit_at(&git, "refs/heads/fresh", 300, &[root]);39+40+    // Check `fresh` out in a linked worktree, the way a worktree-per-branch41+    // workflow would have it.42+    let fresh_ref = git43+        .find_branch("fresh", BranchType::Local)44+        .unwrap()45+        .into_reference();46+    let mut opts = WorktreeAddOptions::new();47+    opts.reference(Some(&fresh_ref));48+    git.worktree("wt-fresh", &dir.path().join("wt-fresh"), Some(&opts))49+        .unwrap();50+51+    let branches = Repo::open(&repo_path).unwrap().local_branches().unwrap();52+53+    // Most recently committed first: fresh (300), stale (200), the root (100).54+    let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();55+    let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();56+    assert_eq!(branches.len(), 3);57+    assert!(pos("fresh") < pos("stale"));58+    assert!(branches[2].when == 100, "the root branch sorts last");59+60+    // Only the branch living in the linked worktree is badged with its path;61+    // the main checkout's own branch is not a *linked* worktree.62+    let fresh = &branches[pos("fresh")];63+    let badge = fresh.worktree.as_ref().expect("fresh is checked out");64+    assert!(badge.ends_with("wt-fresh"), "got {}", badge.display());65+    assert!(branches[pos("stale")].worktree.is_none());66+    assert!(branches[2].worktree.is_none());67+}

The README, the branch how-to, and the architecture notes document the picker and the worktree-review-in-reverse flow it exists for.

docs/wiki/how-to/render-a-branch.md
@@ -22,6 +22,23 @@22 throughline branch v1.0..v1.123 ```24 25+## The interactive picker26+27+A bare `throughline branch` run **on the base itself** would resolve to the28+empty range `main..HEAD`. On a terminal (stdin *and* stderr are TTYs) that dead29+end becomes a picker instead of an error: every other local branch, most30+recently committed first, with branches checked out in a linked worktree31+labeled `(worktree: <path>)`. Type to filter, Enter to render the selection32+against `--base`, Esc to back out.33+34+This is the worktree-review flow in reverse: branch refs are shared across35+worktrees and rendering reads only the object database, so from the main36+checkout you can render a branch that lives in a worktree without `cd`-ing into37+it. The picker only changes the *tip* — `--base` still applies — and it never38+fires for scripts: an explicit tip, or any non-TTY context (hooks, CI, pipes),39+keeps the hard empty-range error. Picking a branch that is already merged into40+the base just hits that same error afterwards.41+42 Both resolve to the same thing internally: the commits in `base..tip` — reachable43 from the tip but not the base — so the **base commit itself is excluded**. Output44 goes to `throughline-branch-<tip-sha>.<ext>` unless you pass `-o`, where the extension@@ -68,14 +85,19 @@85   difference `base...tip` (three dots) is not supported — use `base..tip`.86 - **Empty or reversed ranges** (e.g. `tip..base`, or a tip already merged into the87   base) produce no commits and exit with an error rather than writing an empty-  page.88+  page — with one exception: a *bare* `throughline branch` whose default89+  `base..HEAD` is empty opens the interactive picker when run on a terminal90+  (see above).91 92 ## Where the code lives93 94 - `src/git.rs` — `range_commits(base, tip)` walks the range oldest-first-  (`Sort::TOPOLOGICAL | REVERSE`) and returns a `RangeCommit` per commit.95+  (`Sort::TOPOLOGICAL | REVERSE`) and returns a `RangeCommit` per commit;96+  `local_branches()` lists the picker's candidates (recency-sorted, worktree97+  paths attached), pinned by `tests/local_branches.rs`.98 - `src/html.rs` — `to_html_branch` composes the per-commit `Document`s into99   chapters; it shares the private `document_body` / `page` helpers with `to_html`,100   so a chapter is exactly a standalone page minus its `<h1>`.101 - `src/main.rs` — `run_branch` parses the range form, renders each commit, and-  writes the page.102+  writes the page; `pick_branch` is the `inquire` prompt the empty bare case103+  falls into.

Remaining changes

CLAUDE.md
@@ -18,6 +18,8 @@18 - **pulldown-cmark** — Markdown rendering (render/html layer; added when those19   stages land).20 - **anyhow** (binary) + **thiserror** (library) — error handling.21+- **inquire** (binary only) — the interactive branch picker behind a bare22+  `throughline branch` on the base.23 24 ## Build / test / run25 @@ -47,7 +49,7 @@49 | `src/message.rs` | Split a raw message into title, TL;DR (first paragraph), intro (lead-in to the first anchor), and an ordered list of prose/anchor blocks. |50 | `src/anchor.rs` | The anchor grammar: recognize a path-shaped line and parse `path[:range] [@focus]` (a legacy trailing `!side` is accepted and ignored). |51 | `src/conventional.rs` | Conventional Commits header (`type(scope): …`) and footer-trailer passthrough. |-| `src/git.rs` | Open the repo; resolve a commit and its parent; compute the full diff; read a file's lines at the commit (for context outside the diff). |52+| `src/git.rs` | Open the repo; resolve a commit and its parent; compute the full diff; read a file's lines at the commit (for context outside the diff); list local branches by recency with worktree badges (for the `branch` picker). |53 | `src/diff.rs` | Hunk / file-diff model, per-hunk coverage tracking, range clipping (`clip_to_new_range`), and backfill collection. |54 | `src/render.rs` | Resolve anchors against Git, track coverage, build the ordered `Document` model. |55 | `src/html.rs` | Emit a single self-contained HTML file with inline CSS. |
Cargo.lock
@@ -135,6 +135,7 @@135  "anyhow",136  "clap",137  "git2",138+ "inquire",139  "pulldown-cmark",140  "serde_json",141  "tempfile",@@ -142,6 +143,79 @@143 ]144 145 [[package]]146+name = "convert_case"147+version = "0.10.0"148+source = "registry+https://github.com/rust-lang/crates.io-index"149+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"150+dependencies = [151+ "unicode-segmentation",152+]153+154+[[package]]155+name = "crossterm"156+version = "0.29.0"157+source = "registry+https://github.com/rust-lang/crates.io-index"158+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"159+dependencies = [160+ "bitflags",161+ "crossterm_winapi",162+ "derive_more",163+ "document-features",164+ "mio",165+ "parking_lot",166+ "rustix",167+ "signal-hook",168+ "signal-hook-mio",169+ "winapi",170+]171+172+[[package]]173+name = "crossterm_winapi"174+version = "0.9.1"175+source = "registry+https://github.com/rust-lang/crates.io-index"176+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"177+dependencies = [178+ "winapi",179+]180+181+[[package]]182+name = "derive_more"183+version = "2.1.1"184+source = "registry+https://github.com/rust-lang/crates.io-index"185+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"186+dependencies = [187+ "derive_more-impl",188+]189+190+[[package]]191+name = "derive_more-impl"192+version = "2.1.1"193+source = "registry+https://github.com/rust-lang/crates.io-index"194+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"195+dependencies = [196+ "convert_case",197+ "proc-macro2",198+ "quote",199+ "rustc_version",200+ "syn",201+]202+203+[[package]]204+name = "document-features"205+version = "0.2.12"206+source = "registry+https://github.com/rust-lang/crates.io-index"207+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"208+dependencies = [209+ "litrs",210+]211+212+[[package]]213+name = "dyn-clone"214+version = "1.0.20"215+source = "registry+https://github.com/rust-lang/crates.io-index"216+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"217+218+[[package]]219 name = "errno"220 version = "0.3.14"221 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -164,6 +238,15 @@238 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"239 240 [[package]]241+name = "fuzzy-matcher"242+version = "0.3.7"243+source = "registry+https://github.com/rust-lang/crates.io-index"244+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"245+dependencies = [246+ "thread_local",247+]248+249+[[package]]250 name = "getopts"251 version = "0.2.24"252 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -203,6 +286,20 @@286 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"287 288 [[package]]289+name = "inquire"290+version = "0.9.4"291+source = "registry+https://github.com/rust-lang/crates.io-index"292+checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"293+dependencies = [294+ "bitflags",295+ "crossterm",296+ "dyn-clone",297+ "fuzzy-matcher",298+ "unicode-segmentation",299+ "unicode-width",300+]301+302+[[package]]303 name = "is_terminal_polyfill"304 version = "1.70.2"305 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -261,6 +358,21 @@358 checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"359 360 [[package]]361+name = "litrs"362+version = "1.0.0"363+source = "registry+https://github.com/rust-lang/crates.io-index"364+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"365+366+[[package]]367+name = "lock_api"368+version = "0.4.14"369+source = "registry+https://github.com/rust-lang/crates.io-index"370+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"371+dependencies = [372+ "scopeguard",373+]374+375+[[package]]376 name = "log"377 version = "0.4.30"378 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -273,6 +385,18 @@385 checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"386 387 [[package]]388+name = "mio"389+version = "1.2.1"390+source = "registry+https://github.com/rust-lang/crates.io-index"391+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"392+dependencies = [393+ "libc",394+ "log",395+ "wasi",396+ "windows-sys",397+]398+399+[[package]]400 name = "once_cell"401 version = "1.21.4"402 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -285,6 +409,29 @@409 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"410 411 [[package]]412+name = "parking_lot"413+version = "0.12.5"414+source = "registry+https://github.com/rust-lang/crates.io-index"415+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"416+dependencies = [417+ "lock_api",418+ "parking_lot_core",419+]420+421+[[package]]422+name = "parking_lot_core"423+version = "0.9.12"424+source = "registry+https://github.com/rust-lang/crates.io-index"425+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"426+dependencies = [427+ "cfg-if",428+ "libc",429+ "redox_syscall",430+ "smallvec",431+ "windows-link",432+]433+434+[[package]]435 name = "pkg-config"436 version = "0.3.33"437 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -334,6 +481,24 @@481 checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"482 483 [[package]]484+name = "redox_syscall"485+version = "0.5.18"486+source = "registry+https://github.com/rust-lang/crates.io-index"487+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"488+dependencies = [489+ "bitflags",490+]491+492+[[package]]493+name = "rustc_version"494+version = "0.4.1"495+source = "registry+https://github.com/rust-lang/crates.io-index"496+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"497+dependencies = [498+ "semver",499+]500+501+[[package]]502 name = "rustix"503 version = "1.1.4"504 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -347,6 +512,18 @@512 ]513 514 [[package]]515+name = "scopeguard"516+version = "1.2.0"517+source = "registry+https://github.com/rust-lang/crates.io-index"518+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"519+520+[[package]]521+name = "semver"522+version = "1.0.28"523+source = "registry+https://github.com/rust-lang/crates.io-index"524+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"525+526+[[package]]527 name = "serde"528 version = "1.0.228"529 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -395,6 +572,43 @@572 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"573 574 [[package]]575+name = "signal-hook"576+version = "0.3.18"577+source = "registry+https://github.com/rust-lang/crates.io-index"578+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"579+dependencies = [580+ "libc",581+ "signal-hook-registry",582+]583+584+[[package]]585+name = "signal-hook-mio"586+version = "0.2.5"587+source = "registry+https://github.com/rust-lang/crates.io-index"588+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"589+dependencies = [590+ "libc",591+ "mio",592+ "signal-hook",593+]594+595+[[package]]596+name = "signal-hook-registry"597+version = "1.4.8"598+source = "registry+https://github.com/rust-lang/crates.io-index"599+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"600+dependencies = [601+ "errno",602+ "libc",603+]604+605+[[package]]606+name = "smallvec"607+version = "1.15.1"608+source = "registry+https://github.com/rust-lang/crates.io-index"609+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"610+611+[[package]]612 name = "strsim"613 version = "0.11.1"614 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -445,6 +659,15 @@659 ]660 661 [[package]]662+name = "thread_local"663+version = "1.1.9"664+source = "registry+https://github.com/rust-lang/crates.io-index"665+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"666+dependencies = [667+ "cfg-if",668+]669+670+[[package]]671 name = "unicase"672 version = "2.9.0"673 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -457,6 +680,12 @@680 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"681 682 [[package]]683+name = "unicode-segmentation"684+version = "1.13.3"685+source = "registry+https://github.com/rust-lang/crates.io-index"686+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"687+688+[[package]]689 name = "unicode-width"690 version = "0.2.2"691 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -475,6 +704,12 @@704 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"705 706 [[package]]707+name = "wasi"708+version = "0.11.1+wasi-snapshot-preview1"709+source = "registry+https://github.com/rust-lang/crates.io-index"710+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"711+712+[[package]]713 name = "wasip2"714 version = "1.0.3+wasi-0.2.9"715 source = "registry+https://github.com/rust-lang/crates.io-index"@@ -484,6 +719,28 @@719 ]720 721 [[package]]722+name = "winapi"723+version = "0.3.9"724+source = "registry+https://github.com/rust-lang/crates.io-index"725+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"726+dependencies = [727+ "winapi-i686-pc-windows-gnu",728+ "winapi-x86_64-pc-windows-gnu",729+]730+731+[[package]]732+name = "winapi-i686-pc-windows-gnu"733+version = "0.4.0"734+source = "registry+https://github.com/rust-lang/crates.io-index"735+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"736+737+[[package]]738+name = "winapi-x86_64-pc-windows-gnu"739+version = "0.4.0"740+source = "registry+https://github.com/rust-lang/crates.io-index"741+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"742+743+[[package]]744 name = "windows-link"745 version = "0.2.1"746 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml
@@ -21,6 +21,7 @@21 anyhow = "1.0.102"22 clap = { version = "4.6.1", features = ["derive"] }23 git2 = { version = "0.21.0", default-features = false }24+inquire = "0.9.4"25 pulldown-cmark = "0.13.4"26 serde_json = "1.0.150"27 thiserror = "2.0.18"
README.md
@@ -209,6 +209,10 @@209 ```210 211 - `throughline branch` (bare) renders `main..HEAD` — the branch you're standing on.212+- Bare on `main` itself (so `main..HEAD` is empty), it opens an **interactive213+  picker** instead: every other local branch, most recently committed first,214+  branches checked out in a worktree labeled with their path. Terminal only —215+  scripts and hooks still get the hard error.216 - `throughline branch my-feature` renders every commit in `main..my-feature`.217 - `throughline branch v1.0..v1.1` renders an explicit range.218 - Completeness holds **per commit** — each chapter diffs against its own parent;@@ -245,6 +249,12 @@249 render that worktree's branch — a bare `throughline branch --open` (the tip250 defaults to `HEAD`) — to review the work in isolation before you merge it.251 252+It also works from the other side. Branch refs are shared across worktrees, so253+from the *main* checkout a bare `throughline branch` notices `main..HEAD` is254+empty and offers the picker: your worktree branches, freshest first, each255+labeled with its worktree path. Pick one to render that worktree's work without256+leaving the main directory.257+258 ### Agentic coding259 260 This is where the convention earns its keep. Drop the short instruction from
docs/ARCHITECTURE.md
@@ -43,7 +43,7 @@43 | Module | Role |44 |--------|------|45 | `cli` | Parse `throughline render <commit>`, `throughline branch [<range>]` (the tip defaults to `HEAD`), and `throughline pr` (clap, derive). |-| `git` | Thin wrapper over `git2`: open/discover the repo, read a commit's raw message, compute the diff against the first parent, read a file's lines at the commit (for context outside the diff). |46+| `git` | Thin wrapper over `git2`: open/discover the repo, read a commit's raw message, compute the diff against the first parent, read a file's lines at the commit (for context outside the diff), list local branches by recency with worktree badges (for the picker). |47 | `anchor` | The anchor grammar — recognition (is this line a code reference?) and parsing into `AnchorRef { path, range, focus }`. Pure, no I/O. |48 | `message` | Split a raw message into `title`, `tldr` (the first paragraph), `intro` (the lead-in to the first anchor), and an ordered `Vec<Block>` of prose/anchor blocks. Pure, no I/O. |49 | `conventional` | Conventional Commits interop: parse the `type(scope)!: subject` header and pass footer trailers through untouched. |@@ -183,6 +183,18 @@183 [`ROADMAP.md`](ROADMAP.md).) Guarded by `tests/branch.rs`, which asserts the184 chapter order and that each commit's change lands in its own chapter.185 186+One ergonomic affordance sits in front of the pipeline: when the tip was187+*omitted* (it defaults to `HEAD`) and `base..HEAD` turns out empty — bare188+`throughline branch` run on the base itself — the binary offers an interactive189+picker instead of the empty-range error, but only on a terminal (stdin and190+stderr TTYs). Candidates come from `git::Repo::local_branches()`: every local191+branch except the base, most recently committed first, badged with the linked192+worktree that has it checked out (branch refs are shared across worktrees, so193+this is how you render a worktree's branch from the main checkout). Scripts and194+hooks never see the prompt — any non-TTY context, or an explicit tip, keeps the195+hard error. The prompt UI (`inquire`) lives in the binary, the candidate query196+in `git` — same pure/IO split as `pr`.197+198 ## The PR surface199 200 `throughline pr` is a third composition over the same pipeline — like the branch
src/git.rs
@@ -1,6 +1,7 @@1 //! Git access: resolve commits, compute diffs, and read blob content.2 -use std::path::Path;3+use std::collections::HashMap;4+use std::path::{Path, PathBuf};5 6 use crate::diff::{DeltaMarker, DeltaStatus, Diff, DiffLine, FileDiff, Hunk};7 use crate::{Error, Result};@@ -10,6 +11,17 @@11     inner: git2::Repository,12 }13 14+/// A local branch, with what the interactive picker needs: a recency key for15+/// ordering and the linked worktree (if any) where the branch is checked out.16+pub struct LocalBranch {17+    /// Short branch name (e.g. `fix/clip-range`).18+    pub name: String,19+    /// Commit time of the branch tip, seconds since epoch — the sort key.20+    pub when: i64,21+    /// Working-tree path, when the branch is checked out in a linked worktree.22+    pub worktree: Option<PathBuf>,23+}24+25 /// One commit in a branch range, with just enough metadata to render it as a26 /// chapter: a revision to feed back into [`Repo::diff_against_parent`] /27 /// `render`, plus its abbreviated id and author for the chapter byline.@@ -88,6 +100,57 @@100         Ok(out)101     }102 103+    /// Local branches, most recently committed first (ties broken by name),104+    /// each badged with the linked worktree that has it checked out, if any.105+    ///106+    /// This is the interactive picker's candidate list. Branch refs are shared107+    /// across every worktree of a repository, so the listing sees a branch even108+    /// when it is checked out elsewhere — which is the point: the picker runs109+    /// from the main checkout precisely to render work done in a worktree.110+    /// Best-effort throughout: a branch or worktree that fails to introspect is111+    /// skipped (or left unbadged) rather than failing the whole listing.112+    pub fn local_branches(&self) -> Result<Vec<LocalBranch>> {113+        // Branch name → working-tree path, for every linked worktree that has a114+        // branch (not a detached HEAD) checked out.115+        let mut checked_out: HashMap<String, PathBuf> = HashMap::new();116+        for entry in self.inner.worktrees()?.iter() {117+            // 0.21's `StringArray` items are `Result<Option<&str>>` (error /118+            // non-UTF-8 / name); only a clean name is worth looking up.119+            let Ok(Some(name)) = entry else { continue };120+            let Ok(wt) = self.inner.find_worktree(name) else {121+                continue;122+            };123+            let Ok(wt_repo) = git2::Repository::open_from_worktree(&wt) else {124+                continue;125+            };126+            let Ok(head) = wt_repo.head() else { continue };127+            if head.is_branch() {128+                if let Ok(branch) = head.shorthand() {129+                    checked_out.insert(branch.to_string(), wt.path().to_path_buf());130+                }131+            }132+        }133+134+        let mut out = Vec::new();135+        for entry in self.inner.branches(Some(git2::BranchType::Local))? {136+            let Ok((branch, _)) = entry else { continue };137+            let Ok(Some(name)) = branch.name() else {138+                continue;139+            };140+            let name = name.to_string();141+            let Ok(tip) = branch.get().peel_to_commit() else {142+                continue;143+            };144+            out.push(LocalBranch {145+                worktree: checked_out.remove(&name),146+                when: tip.time().seconds(),147+                name,148+            });149+        }150+        out.sort_by(|a, b| b.when.cmp(&a.when).then_with(|| a.name.cmp(&b.name)));151+        Ok(out)152+    }153+154     /// The full diff of `rev` against its first parent (or the empty tree for a155     /// root commit), lowered into [`Diff`] in natural order: files alphabetical156     /// by path, hunks top-to-bottom within each file.
src/main.rs
@@ -1,3 +1,4 @@1+use std::io::IsTerminal;2 use std::path::{Path, PathBuf};3 4 use anyhow::{Context, Result};@@ -108,11 +109,28 @@109     // Accept an explicit `base..tip` range or a bare tip ref (+ `--base`);110     // omitted entirely, the tip is `HEAD` — the branch you are standing on.111     let spec = branch.unwrap_or("HEAD");-    let (base, tip) = match spec.split_once("..") {112+    let (base, mut tip) = match spec.split_once("..") {113         Some((b, t)) => (b.to_string(), t.to_string()),114         None => (base.to_string(), spec.to_string()),115     };116 117+    // A bare `throughline branch` run *on the base itself* defaults to the118+    // empty range `base..HEAD` — a dead end. On a terminal, turn that dead end119+    // into a branch picker instead of an error; everywhere else (scripts,120+    // hooks, an explicit tip) keep the hard error, so automation stays121+    // deterministic. The probe swallows resolution errors on purpose:122+    // `render_range` below reports them with full context.123+    if branch.is_none()124+        && repo125+            .range_commits(&base, &tip)126+            .map(|c| c.is_empty())127+            .unwrap_or(false)128+        && std::io::stdin().is_terminal()129+        && std::io::stderr().is_terminal()130+    {131+        tip = pick_branch(&repo, &base)?;132+    }133+134     let chapters = render_range(&repo, &base, &tip)?;135 136     let title = format!("{base}..{tip}");@@ -143,6 +161,57 @@161     Ok(())162 }163 164+/// Choose a tip interactively when the defaulted range came up empty: every165+/// local branch except `base`, most recently committed first, badged with the166+/// worktree where it is checked out. Returns the chosen branch name; fails when167+/// there is nothing to offer or the user backs out (Esc / Ctrl-C).168+fn pick_branch(repo: &git::Repo, base: &str) -> Result<String> {169+    let choices: Vec<BranchChoice> = repo170+        .local_branches()171+        .context("listing local branches")?172+        .into_iter()173+        .filter(|b| b.name != base)174+        .map(BranchChoice)175+        .collect();176+    if choices.is_empty() {177+        anyhow::bail!("`{base}..HEAD` is empty, and there is no other local branch to render");178+    }179+180+    let prompt = format!("`{base}..HEAD` is empty — render which branch against `{base}`?");181+    match inquire::Select::new(&prompt, choices).prompt() {182+        Ok(choice) => Ok(choice.0.name),183+        Err(inquire::InquireError::OperationCanceled)184+        | Err(inquire::InquireError::OperationInterrupted) => {185+            anyhow::bail!("no branch selected")186+        }187+        Err(e) => Err(e).context("showing the branch picker"),188+    }189+}190+191+/// One picker row: the branch name, plus the worktree it is checked out in —192+/// the usual reason the branch isn't `HEAD` right now.193+struct BranchChoice(git::LocalBranch);194+195+impl std::fmt::Display for BranchChoice {196+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {197+        match &self.0.worktree {198+            Some(path) => write!(f, "{}  (worktree: {})", self.0.name, tildify(path)),199+            None => f.write_str(&self.0.name),200+        }201+    }202+}203+204+/// Abbreviate `$HOME` to `~` for display — picker rows, not paths to reuse.205+fn tildify(path: &Path) -> String {206+    let s = path.display().to_string();207+    match std::env::var("HOME") {208+        Ok(home) if !home.is_empty() && s.starts_with(&home) => {209+            format!("~{}", &s[home.len()..])210+        }211+        _ => s,212+    }213+}214+215 /// Render the current branch (`base..HEAD`) as a PR-ready Markdown body and216 /// either splice it into a live pull request (`--pr N`) or just emit it.217 ///
31/3724399eaEdu Ramírez

Sync the git-lowering field notes with the picker query

Docs-only follow-up to the branch picker: the field notes gain the new local_branches query, and the page's stale src/git.rs line references move to where the code lives now that the file grew.

The new section records the two git2 shapes worth remembering: worktrees are looked up by name (find_worktreeWorktree::open_from_repository goes the other direction), and the listing is best-effort throughout, so the picker can never make throughline branch less reliable.

docs/wiki/field-notes/git-lowering.md
@@ -98,0 +99,21 @@99+## Listing branches for the picker100+101+`local_branches` (`src/git.rs:112`) feeds the `branch` picker: every local102+branch with its tip commit time (the recency sort key) and, when a linked103+worktree has the branch checked out, that worktree's path. Two shapes to know:104+105+- **Worktrees are looked up by name.** `repo.worktrees()` yields names;106+  `repo.find_worktree(name)` opens each. (`Worktree::open_from_repository` is107+  the *reverse* direction — it takes a `Repository` already opened inside a108+  worktree — and is not what you want here.) The checked-out branch comes from109+  opening the worktree as a repository (`Repository::open_from_worktree`) and110+  reading its `head()`; a detached HEAD is skipped via `head.is_branch()`.111+- **Everything is best-effort.** A worktree or branch that fails to introspect112+  is skipped (or merely loses its badge); the listing itself only fails if the113+  repo can't enumerate refs at all. The picker is an ergonomic affordance — it114+  must never make `throughline branch` *less* reliable.115+116+Sorted most-recently-committed first, ties by name; pinned by117+`tests/local_branches.rs`, which fixes commit times explicitly so the ordering118+assertions cannot race the clock. The prompt UI lives in the binary119+(`pick_branch` in `src/main.rs`), keeping this module free of terminal I/O.

The 0.21 gotcha widens from Remote::url() to the general Result-ification, including StringArray's doubly wrapped Result<Option<&str>> items — the trap a lone .flatten() falls into.

docs/wiki/field-notes/git-lowering.md
@@ -113,6 +136,9 @@-- **git2 0.21's `Remote::url()` returns `Result`, not `Option`.** `Repo::remote_url`-  (`src/git.rs:58`) — the only non-lowering getter here, exposing the `origin` URL-  for `throughline pr`'s forge addressing-  ([`pr-forge-layer.md`](pr-forge-layer.md)) — must take the URL with `?`. Older-  git2 returned `Option<&str>`, so a ported `ok_or_else` won't compile; 0.21 errors-  (rather than returning `None`) on a missing or non-UTF-8 URL.136+- **git2 0.21 returns `Result` where older versions returned `Option`.** The137+  Result-ification bites anywhere a getter can meet non-UTF-8: `Remote::url()`138+  (`Repo::remote_url`, `src/git.rs:70`) and `Reference::shorthand()` (the139+  worktree-HEAD read in `local_branches`) both yield `Result<&str>` now, so a140+  ported `ok_or_else` / `if let Some(…)` won't compile. `StringArray`'s iterator141+  goes further: items are **doubly wrapped** `Result<Option<&str>>`, so a single142+  `.flatten()` only peels the `Result` and hands you `Option<&str>` — match143+  `Ok(Some(name))` (see the worktree loop in `local_branches`,144+  `src/git.rs:112`).

The wiki index entry for render-a-branch now mentions the HEAD default and the picker.

docs/wiki/README.md
@@ -56,8 +56,9 @@56     to Forgejo over REST (`fj` can't read the raw body), and the `curl`/token I/O.57 - **How-to** — acting on this codebase.58   - [`how-to/render-a-branch.md`](how-to/render-a-branch.md) — render a range of-    commits as one navigable page (`throughline branch`); the input forms and the-    per-commit completeness semantic.59+    commits as one navigable page (`throughline branch`); the input forms (the tip60+    defaults to `HEAD`), the interactive branch picker, and the per-commit61+    completeness semantic.62   - [`how-to/update-a-pr.md`](how-to/update-a-pr.md) — write the branch walkthrough63     into a Forgejo/Gitea pull-request body (`throughline pr`); the token, the64     managed region, and `--dry-run`.

Remaining changes

docs/wiki/field-notes/git-lowering.md
@@ -4,16 +4,17 @@4 `Diff` → `FileDiff` → `Hunk` model, and the parts that aren't obvious from5 reading it cold: binary and hunk-less detection, where file modes live, and why6 renames are not detected. Read this before touching `src/git.rs` or writing a-completeness fixture; the commit-*range* walk behind the branch view-(`range_commits`) is covered in the last section. The companion page,7+completeness fixture; the branch view's two queries — the commit-*range* walk8+(`range_commits`) and the picker's branch listing (`local_branches`) — get their9+own sections near the end. The companion page,10 [coverage-and-backfill](coverage-and-backfill.md), picks up where this one ends —11 what happens *after* the diff is in the model.12 13 ## The pipeline14 -`diff_against_parent` (`src/git.rs:47`) resolves the commit, takes its first15+`diff_against_parent` (`src/git.rs:157`) resolves the commit, takes its first16 parent's tree (or the empty tree for a root commit), and diffs the two with-`diff_tree_to_tree(old, new, None)` (`src/git.rs:58`). It then walks each delta17+`diff_tree_to_tree(old, new, None)` (`src/git.rs:168`). It then walks each delta18 and lowers it via `git2::Patch`, which gives structured per-hunk / per-line19 access so we never re-parse diff text. Files are sorted alphabetically so the20 natural-order invariant doesn't depend on libgit2's ordering. The structural@@ -23,28 +24,28 @@24 25 1. **Binary deltas have no patch at all.** `git2::Patch::from_diff` returns26    `None` for a binary delta (and the `BINARY` flag may also be set) — see-   `is_binary` at `src/git.rs:77`. A binary delta therefore lowers to **zero27+   `is_binary` at `src/git.rs:187`. A binary delta therefore lowers to **zero28    hunks**.29 2. **A *textual* delta can also be hunk-less.** A mode-only change or an30    empty-file add/delete produces no hunk either. So "no hunks" is not the same-   as "binary"; the code tests `hunks.is_empty()` (`src/git.rs:116`) and only31+   as "binary"; the code tests `hunks.is_empty()` (`src/git.rs:226`) and only32    then asks *why*.33 3. **File modes live on both sides of every delta.** `delta.old_file().mode()`34    and `delta.new_file().mode()` are populated even when content hunks exist; a35    `0` mode means that side is absent (an add or a delete). This is the fact that36    lets a mode change ride *alongside* content hunks — `mode_change`-   (`src/git.rs:186`) reads both sides regardless of whether the delta also has37+   (`src/git.rs:286`) reads both sides regardless of whether the delta also has38    hunks.39 40 ## The marker decision — where completeness widens to the delta41 42 A delta carries a `DeltaMarker` for any fact no hunk represents. The branch is at-`src/git.rs:116`:43+`src/git.rs:226`:44 45 | Delta shape | Marker | Source |46 |-------------|--------|--------|-| hunk-less (binary / mode-only / empty) | `delta_marker` — binary wins, else mode, else empty | `src/git.rs:168` |-| has hunks | `mode_change` only — a `Mode` marker iff the mode changed, else `None` | `src/git.rs:186` |47+| hunk-less (binary / mode-only / empty) | `delta_marker` — binary wins, else mode, else empty | `src/git.rs:268` |48+| has hunks | `mode_change` only — a `Mode` marker iff the mode changed, else `None` | `src/git.rs:286` |49 50 `delta_marker` delegates its mode case to the same `mode_change` helper, so the51 two paths can never disagree about what counts as a mode change. Either way the@@ -77,24 +78,46 @@78 79 ## Walking a commit range (the branch view)80 -`range_commits` (`src/git.rs:63`) lists the commits for `throughline branch`, and81+`range_commits` (`src/git.rs:84`) lists the commits for `throughline branch`, and82 two `git2` details are easy to get wrong:83 -- **`push_range("base..tip")` excludes `base`** (`src/git.rs:68`): it yields the84+- **`push_range("base..tip")` excludes `base`** (`src/git.rs:89`): it yields the85   commits reachable from `tip` but not `base` — exactly the commits a branch adds.86   The base is the *cutoff*, never itself a chapter.87 - **The default walk order is newest-first**, but chapters must read oldest-first,-  so the walk sets `Sort::TOPOLOGICAL | REVERSE` (`src/git.rs:67`) — topological so88+  so the walk sets `Sort::TOPOLOGICAL | REVERSE` (`src/git.rs:88`) — topological so89   a parent always precedes its child even when commit timestamps don't.90 91 Each listed commit then renders through `diff_against_parent` like any other: the92 range view *composes* per-commit documents, it does not squash, so completeness-still holds per commit. `RangeCommit` (`src/git.rs:17`) carries only the metadata a93+still holds per commit. `RangeCommit` (`src/git.rs:28`) carries only the metadata a94 chapter header needs. The user-facing shape is in the how-to95 [render-a-branch](../how-to/render-a-branch.md) and the structure in96 [`ARCHITECTURE.md`](../../ARCHITECTURE.md#the-branch-view); guarded by97 `tests/branch.rs`.98 99+## Listing branches for the picker100+101+`local_branches` (`src/git.rs:112`) feeds the `branch` picker: every local102+branch with its tip commit time (the recency sort key) and, when a linked103+worktree has the branch checked out, that worktree's path. Two shapes to know:104+105+- **Worktrees are looked up by name.** `repo.worktrees()` yields names;106+  `repo.find_worktree(name)` opens each. (`Worktree::open_from_repository` is107+  the *reverse* direction — it takes a `Repository` already opened inside a108+  worktree — and is not what you want here.) The checked-out branch comes from109+  opening the worktree as a repository (`Repository::open_from_worktree`) and110+  reading its `head()`; a detached HEAD is skipped via `head.is_branch()`.111+- **Everything is best-effort.** A worktree or branch that fails to introspect112+  is skipped (or merely loses its badge); the listing itself only fails if the113+  repo can't enumerate refs at all. The picker is an ergonomic affordance — it114+  must never make `throughline branch` *less* reliable.115+116+Sorted most-recently-committed first, ties by name; pinned by117+`tests/local_branches.rs`, which fixes commit times explicitly so the ordering118+assertions cannot race the clock. The prompt UI lives in the binary119+(`pick_branch` in `src/main.rs`), keeping this module free of terminal I/O.120+121 ## Gotchas122 123 - **Line endings are normalized on the way in.** The lowering pops a trailing@@ -110,9 +133,12 @@133   an old/new/diff *side*: anchors always render as a diff, and deletions live inside134   the hunks, so they are never reasoned about separately — see135   [coverage-and-backfill](coverage-and-backfill.md#the-mental-model).)-- **git2 0.21's `Remote::url()` returns `Result`, not `Option`.** `Repo::remote_url`-  (`src/git.rs:58`) — the only non-lowering getter here, exposing the `origin` URL-  for `throughline pr`'s forge addressing-  ([`pr-forge-layer.md`](pr-forge-layer.md)) — must take the URL with `?`. Older-  git2 returned `Option<&str>`, so a ported `ok_or_else` won't compile; 0.21 errors-  (rather than returning `None`) on a missing or non-UTF-8 URL.136+- **git2 0.21 returns `Result` where older versions returned `Option`.** The137+  Result-ification bites anywhere a getter can meet non-UTF-8: `Remote::url()`138+  (`Repo::remote_url`, `src/git.rs:70`) and `Reference::shorthand()` (the139+  worktree-HEAD read in `local_branches`) both yield `Result<&str>` now, so a140+  ported `ok_or_else` / `if let Some(…)` won't compile. `StringArray`'s iterator141+  goes further: items are **doubly wrapped** `Result<Option<&str>>`, so a single142+  `.flatten()` only peels the `Result` and hands you `Option<&str>` — match143+  `Ok(Some(name))` (see the worktree loop in `local_branches`,144+  `src/git.rs:112`).
32/373ee5b15Edu Ramírez

Record the picker in the roadmap and the TTY verification recipe

Docs retrospective for the branch picker: the roadmap now reflects what shipped and what was deliberately deferred, and the wiki gains the how-to that this task had to invent — exercising TTY-gated behavior without a terminal.

The branch-view section gains the shipped ergonomics — the HEAD default and the empty-range picker — as a done item with its guards.

docs/ROADMAP.md
@@ -115,0 +115,9 @@115+- [x] **Tip ergonomics: `HEAD` default + branch picker.** The tip argument is116+  optional — bare `throughline branch` renders `main..HEAD`, mirroring117+  `throughline pr` — and when that default range is *empty* (you are standing on118+  the base) a terminal gets an interactive picker instead of the error: every119+  other local branch, most recently committed first, badged with the linked120+  worktree that has it checked out. TTY-gated, so scripts and hooks keep the121+  hard error. *(`cli.rs` / `main.rs` + `git::local_branches`; guarded by122+  `tests/local_branches.rs` and the `cli.rs` parse tests; see the123+  [how-to](wiki/how-to/render-a-branch.md#the-interactive-picker).)*

Shell completion is recorded as deferred rather than forgotten: the picker covers the on-the-base case, completion would be the mid-command-line complement, and the clap_complete route is sketched for whoever picks it up.

docs/ROADMAP.md
@@ -220,0 +229,6 @@229+- [ ] **Shell completion** via `clap_complete`: a `throughline completions <shell>`230+  subcommand covers flags/subcommands for free; completing *branch names* for231+  `branch` needs either the (still unstable) dynamic engine or hand-tuned scripts232+  shelling to `git for-each-ref --sort=-committerdate`. Deliberately deferred — the233+  [picker](wiki/how-to/render-a-branch.md#the-interactive-picker) covers the234+  on-the-base case; completion is the complement for mid-command-line recall.

The new how-to is the retrospective's deliverable. The picker can never fire in an agent session or script (the TTY gate sees a pipe), so the page gives both halves: assert the closed gate with ordinary pipes, and open it under expect's pseudo-terminal — scratch repo, prompt matching amid ANSI escapes, the silent-timeout trap, and the Esc back-out path.

docs/wiki/how-to/verify-interactive-cli.md
@@ -0,0 +1,79 @@1+# How-to: verify TTY-gated behavior from a non-interactive session2+3+The branch picker only fires when stdin **and** stderr are real TTYs4+(`src/main.rs:128-129`), which means a plain shell pipe — including every5+command an agent session runs — can *never* reach it. This page is the recipe6+for exercising both sides of that gate without a human at a terminal: the7+non-TTY half with ordinary pipes, the TTY half by faking a terminal with8+`expect`. Use it whenever you touch the picker (`pick_branch`,9+`src/main.rs:168`) or add another interactive affordance.10+11+## The non-TTY half: just run it12+13+Any normal invocation from a script or agent shell is already non-interactive,14+so asserting the gate's *closed* position needs nothing special:15+16+```sh17+throughline branch            # on the base, piped: must exit 1 with the18+                              # empty-range error, never hang on a prompt19+```20+21+If this ever blocks waiting for input, the TTY gate is broken — that is the22+regression to fear most, because it would hang hooks and CI.23+24+## The TTY half: a pty via `expect`25+26+`/usr/bin/expect` (preinstalled on macOS) runs the binary under a27+pseudo-terminal, so stdin/stderr *are* TTYs and the gate opens. Build a scratch28+repo with a worktree branch, then drive the prompt:29+30+```sh31+S=$(mktemp -d)32+cd "$S" && git init -q -b main repo && cd repo33+git config user.email t@t.t && git config user.name T34+git commit -q --allow-empty -m "root"35+git worktree add -q -b feature ../wt-feature36+( cd ../wt-feature && echo hi > f.txt && git add f.txt && git commit -q -m "Add f" )37+38+/usr/bin/expect <<'EOF'39+set timeout 1540+spawn throughline branch41+expect {42+  "render which branch" { send "\r" }43+  timeout { puts "TIMEOUT waiting for picker"; exit 1 }44+}45+expect {46+  -re "Rendered .* commit" { }47+  timeout { puts "TIMEOUT waiting for render"; exit 1 }48+}49+expect eof50+EOF51+```52+53+Enter selects the highlighted (first = most recent) candidate — here the54+`feature` worktree branch — and the run ends with the normal55+`Rendered 1 commit(s) of main..feature …` line.56+57+## Gotchas58+59+- **Match a short, distinctive substring, not a whole line.** The prompt60+  arrives wrapped in ANSI style/cursor escapes and may be re-rendered several61+  times; `"render which branch"` is stable, a full-line match is not.62+- **Always pair `expect` with a `timeout` arm that exits non-zero.** The63+  default is to time out *silently and succeed*, which turns a hung prompt64+  into a green check.65+- **The prompt goes to stderr** (`inquire`'s render target), but under66+  `spawn`'s pty stdout and stderr arrive interleaved on the same stream —67+  expect sees both, no redirection needed.68+- **To test the back-out path**, `send "\x1b"` (Esc) and expect the69+  `no branch selected` error with exit 1.70+71+## What is and isn't covered by automated tests72+73+The *candidate list* — recency order, worktree badges — is real code with a74+real test (`git::local_branches`, `src/git.rs:112`, pinned by75+`tests/local_branches.rs`). The prompt loop itself is `inquire`'s and has no76+automated test in this repo; this recipe is the manual verification, and the77+natural starting point if it ever graduates into a scripted check (the CI item78+in [`ROADMAP.md`](../../ROADMAP.md)). The user-facing behavior is described in79+[render-a-branch](render-a-branch.md#the-interactive-picker).

The worktree how-to closes the loop its 'Finishing' section left open: review the branch from either side — bare throughline branch inside the worktree, or the picker from the main checkout.

docs/wiki/how-to/working-in-a-worktree.md
@@ -43,0 +44,6 @@44+To review the work before merging, dogfood the branch view from either side:45+inside the worktree a bare `throughline branch` renders `main..HEAD`, and from46+the *main* checkout the same bare command opens the branch picker — worktree47+branches listed freshest-first with their paths — since branch refs are shared48+across worktrees. See49+[render-a-branch](render-a-branch.md#the-interactive-picker).

Remaining changes

docs/ROADMAP.md
@@ -112,6 +112,15 @@112   composes, it does not squash — and it is still one self-contained file with no113   JavaScript. *(`git.rs` + `html.rs` + `cli.rs` / `main.rs`; guarded by114   `tests/branch.rs`.)*115+- [x] **Tip ergonomics: `HEAD` default + branch picker.** The tip argument is116+  optional — bare `throughline branch` renders `main..HEAD`, mirroring117+  `throughline pr` — and when that default range is *empty* (you are standing on118+  the base) a terminal gets an interactive picker instead of the error: every119+  other local branch, most recently committed first, badged with the linked120+  worktree that has it checked out. TTY-gated, so scripts and hooks keep the121+  hard error. *(`cli.rs` / `main.rs` + `git::local_branches`; guarded by122+  `tests/local_branches.rs` and the `cli.rs` parse tests; see the123+  [how-to](wiki/how-to/render-a-branch.md#the-interactive-picker).)*124 125 ## Next — Conventional Commits interop126 @@ -217,6 +226,12 @@226 - [ ] **CI** on the codeberg remote running the same `fmt` / `clippy -D warnings` /227   `test` as the [pre-commit hook](wiki/automation/pre-commit-hook.md) — the backstop228   no one can `--no-verify`.229+- [ ] **Shell completion** via `clap_complete`: a `throughline completions <shell>`230+  subcommand covers flags/subcommands for free; completing *branch names* for231+  `branch` needs either the (still unstable) dynamic engine or hand-tuned scripts232+  shelling to `git for-each-ref --sort=-committerdate`. Deliberately deferred — the233+  [picker](wiki/how-to/render-a-branch.md#the-interactive-picker) covers the234+  on-the-base case; completion is the complement for mid-command-line recall.235 236 ## Later — polish & product237 
docs/wiki/README.md
@@ -59,6 +59,9 @@59     commits as one navigable page (`throughline branch`); the input forms (the tip60     defaults to `HEAD`), the interactive branch picker, and the per-commit61     completeness semantic.62+  - [`how-to/verify-interactive-cli.md`](how-to/verify-interactive-cli.md) — exercise63+    TTY-gated behavior (the branch picker) from a non-interactive session: the64+    non-TTY half with pipes, the TTY half under `expect`.65   - [`how-to/update-a-pr.md`](how-to/update-a-pr.md) — write the branch walkthrough66     into a Forgejo/Gitea pull-request body (`throughline pr`); the token, the67     managed region, and `--dry-run`.
docs/wiki/how-to/working-in-a-worktree.md
@@ -40,3 +40,10 @@40 Commit to the worktree's own branch — the repo convention (see41 [`CLAUDE.md`](../../../CLAUDE.md)); the author merges to `main` later. Don't commit to42 `main` from a worktree.43+44+To review the work before merging, dogfood the branch view from either side:45+inside the worktree a bare `throughline branch` renders `main..HEAD`, and from46+the *main* checkout the same bare command opens the branch picker — worktree47+branches listed freshest-first with their paths — since branch refs are shared48+across worktrees. See49+[render-a-branch](render-a-branch.md#the-interactive-picker).
33/3789bcf27Edu Ramírez

Tell the truth about shipped surfaces in the README

The README disagreed with itself: "One source, many surfaces" still introduced the Markdown projection as the roadmap's next step, while three other sections document --format markdown and throughline pr as working features. A newcomer reading top to bottom could not tell what exists.

The section now presents both shipped projections — Markdown output and the PR splice — and keeps only CI re-rendering as future work.

README.md
@@ -110,11 +110,14 @@-onto a surface. Today that surface is the self-contained HTML page above. But a110+onto a surface. The first surface is the self-contained HTML page above. But a111 commit message, a diff, and a **pull-request description** are three places the112 *same change* gets described independently, and they drift. Treat the113 message-plus-diff as the single source and the rest become derived views of it.114 -That's the next surface on the-[roadmap](docs/ROADMAP.md#markdown-output-and-the-pr-surface): a **Markdown**-projection of the same walkthrough. It targets the one native forge surface that115+Two more surfaces ship today. `--format markdown` emits the same walkthrough as116+**Markdown** — for a pager, a gist, a wiki. And `throughline pr` splices that117+Markdown into the **pull-request description**: the one native forge surface that118 can display a throughline at all — a commit message renders almost no Markdown,-but a pull-request description renders all of it, so the PR body (not the commit)-is where an inline throughline can actually live.119+but a PR body renders all of it, so the PR body (not the commit) is where an120+inline throughline lives on the forge. See [A whole branch or pull121+request](#a-whole-branch-or-pull-request) for both; what's left on the122+[roadmap](docs/ROADMAP.md#markdown-output-and-the-pr-surface) is re-rendering the123+PR body from CI so it can't drift.

The Conventional Commits parenthetical read as if typed interop existed; it now says a header passes through as the title today, and links the roadmap item that will type it.

README.md
@@ -396,2 +399,4 @@-1. **First line is the title.** (Conventional Commits headers like `feat(auth):`-   work fine — see [the spec](docs/SPEC.md#conventional-commits-interop).)399+1. **First line is the title.** (A Conventional Commits header like `feat(auth):`400+   passes through as the title today; typed interop — badges, breaking-change401+   callouts — is [next on the roadmap](docs/ROADMAP.md#next--conventional-commits-interop).402+   See [the spec](docs/SPEC.md#conventional-commits-interop).)

Status now names everything that ships — HTML and Markdown output plus the PR splice — so the maturity line matches the feature list above it.

README.md
@@ -456,4 +461,6 @@461 A working proof-of-concept. The render pipeline is implemented and the462 completeness invariant is covered by integration tests; `throughline render` and-`throughline branch` both produce real, self-contained HTML. Conventional Commits-interop and notation niceties are next — see [`docs/ROADMAP.md`](docs/ROADMAP.md).463+`throughline branch` produce self-contained HTML or Markdown (`--format`), and464+`throughline pr` updates a Forgejo/Gitea pull-request description. Conventional465+Commits interop and notation niceties are next — see466+[`docs/ROADMAP.md`](docs/ROADMAP.md).

Remaining changes

README.md
@@ -107,17 +107,20 @@107 108 The HTML page is a *projection*, not the point. The source is always the same —109 your commit message plus the diff Git already has — and the renderer paints it-onto a surface. Today that surface is the self-contained HTML page above. But a110+onto a surface. The first surface is the self-contained HTML page above. But a111 commit message, a diff, and a **pull-request description** are three places the112 *same change* gets described independently, and they drift. Treat the113 message-plus-diff as the single source and the rest become derived views of it.114 -That's the next surface on the-[roadmap](docs/ROADMAP.md#markdown-output-and-the-pr-surface): a **Markdown**-projection of the same walkthrough. It targets the one native forge surface that115+Two more surfaces ship today. `--format markdown` emits the same walkthrough as116+**Markdown** — for a pager, a gist, a wiki. And `throughline pr` splices that117+Markdown into the **pull-request description**: the one native forge surface that118 can display a throughline at all — a commit message renders almost no Markdown,-but a pull-request description renders all of it, so the PR body (not the commit)-is where an inline throughline can actually live.119+but a PR body renders all of it, so the PR body (not the commit) is where an120+inline throughline lives on the forge. See [A whole branch or pull121+request](#a-whole-branch-or-pull-request) for both; what's left on the122+[roadmap](docs/ROADMAP.md#markdown-output-and-the-pr-surface) is re-rendering the123+PR body from CI so it can't drift.124 125 **You don't need pull requests to get the payoff.** Working solo, the HTML page126 *is* the deliverable — render a commit or a branch and read it. See [Agentic@@ -393,8 +396,10 @@396 The walkthrough is authored entirely in the commit message — there is nothing397 else to maintain. The rules of thumb:398 -1. **First line is the title.** (Conventional Commits headers like `feat(auth):`-   work fine — see [the spec](docs/SPEC.md#conventional-commits-interop).)399+1. **First line is the title.** (A Conventional Commits header like `feat(auth):`400+   passes through as the title today; typed interop — badges, breaking-change401+   callouts — is [next on the roadmap](docs/ROADMAP.md#next--conventional-commits-interop).402+   See [the spec](docs/SPEC.md#conventional-commits-interop).)403 2. **A one- or two-sentence TL;DR** as the first paragraph: the whole change in a404    nutshell. It renders as a distinct lede, so make it self-contained.405 3. **Then walk the change in reading order.** For each step, write the@@ -455,8 +460,10 @@460 461 A working proof-of-concept. The render pipeline is implemented and the462 completeness invariant is covered by integration tests; `throughline render` and-`throughline branch` both produce real, self-contained HTML. Conventional Commits-interop and notation niceties are next — see [`docs/ROADMAP.md`](docs/ROADMAP.md).463+`throughline branch` produce self-contained HTML or Markdown (`--format`), and464+`throughline pr` updates a Forgejo/Gitea pull-request description. Conventional465+Commits interop and notation niceties are next — see466+[`docs/ROADMAP.md`](docs/ROADMAP.md).467 468 ## Project layout469 
34/3790ac364Edu Ramírez

Put a two-command quick start under the hero

Getting started sat ~150 lines in, after four conceptual sections — a command-first skimmer had to scroll past the whole pitch to learn how to try the tool. The full install/usage detail stays where it was; the hero now ends with the shortest path to a rendered page.

Right under the demo blockquote: install, render, open — two commands, with a pointer into Getting started for the rest.

README.md
@@ -21,0 +21,8 @@21+Try it on the repo you're in — any language; only *building* the tool needs Rust22+([details](#getting-started)):23+24+```sh25+cargo install --git https://codeberg.org/eduramirezh/commit-throughline26+throughline render HEAD --open   # your last commit, as a page, in your browser27+```28+

Remaining changes

README.md
@@ -18,6 +18,14 @@18 > to click through real examples (each one generated from this project's own19 > commits).20 21+Try it on the repo you're in — any language; only *building* the tool needs Rust22+([details](#getting-started)):23+24+```sh25+cargo install --git https://codeberg.org/eduramirezh/commit-throughline26+throughline render HEAD --open   # your last commit, as a page, in your browser27+```28+29 ## Two halves of every commit30 31 Open any commit — or any pull request — and you get two panes that never quite
35/37d0c4eb3Edu Ramírez

Surface the agent-review loop in the opening

The strongest concrete use case — reviewing an agent's branch as a narrated, completeness-checked story — was buried mid-page under Workflows, mentioned only in passing before that. Newcomers deciding whether the tool is for them now meet it above the fold.

One paragraph after the quick start: instruct the agent once, read its branch as a story the renderer proves against the real diff, follow the link for the full loop.

README.md
@@ -29,1 +30,7 @@30+one short authoring instruction ([`docs/llm-commit-guide.md`](docs/llm-commit-guide.md)),31+then read its branch as a narrated story instead of a wall of diff — the32+walkthrough is proven against the real code, so it can't quietly skip the part33+the agent was least sure about. The full loop is in [Agentic34+coding](#agentic-coding).35+36 ## Two halves of every commit

Remaining changes

README.md
@@ -26,6 +26,13 @@26 throughline render HEAD --open   # your last commit, as a page, in your browser27 ```28 29+**Reviewing a coding agent's work** is where this shines today: give the agent30+one short authoring instruction ([`docs/llm-commit-guide.md`](docs/llm-commit-guide.md)),31+then read its branch as a narrated story instead of a wall of diff — the32+walkthrough is proven against the real code, so it can't quietly skip the part33+the agent was least sure about. The full loop is in [Agentic34+coding](#agentic-coding).35+36 ## Two halves of every commit37 38 Open any commit — or any pull request — and you get two panes that never quite
36/377d22d8cEdu Ramírez

Give every demo page a way back

The four published walkthroughs are verbatim CLI output, so a visitor who lands on one directly — a shared link, a search hit — sees no project name and has no path back to the demo index or the repo. The publisher now appends a one-line footer to each rendered page; the CLI's own output stays untouched.

The splice: one footer line before the closing

, styled with the CSS variables the renderer already defines so it follows light/dark. Code content can't false-match — the renderer HTML-escapes it, so the document's own is the only raw one.

scripts/publish-pages.sh
@@ -83,0 +83,13 @@83+# --- append a demo-only footer: a way back from any page ---------------------84+# The four pages are verbatim CLI output — that is the demo's point — but a85+# visitor who lands on one directly (shared link, search hit) gets no project86+# name and no way back. Append one footer line before the closing </body>.87+# index.html and the README disclose the addition. The inline style leans on88+# the CSS variables the renderer defines on :root, so it tracks the page's89+# light/dark scheme. Diff content can't collide with the match: the renderer90+# HTML-escapes code lines, so the document's own </body> is the only raw one.91+demo_footer='<footer style="margin-top:3rem;border-top:1px solid var(--rule);padding-top:1rem;color:var(--muted);font-size:0.92rem;">Rendered by <a style="color:var(--accent);text-decoration:none;" href="https://codeberg.org/eduramirezh/commit-throughline">Commit Throughline</a> from this repository&#x2019;s own history &middot; <a style="color:var(--accent);text-decoration:none;" href="./">all demos</a></footer>'92+for page in commit commit-with-deletions branch history; do93+  DEMO_FOOTER="$demo_footer" perl -0777 -pi -e \94+    's{</body>}{$ENV{DEMO_FOOTER}\n</body>}' "$wt/$page.html"95+done

The README's "exactly what the CLI writes locally" claim stays honest by disclosing the addition.

README.md
@@ -161,2 +161,3 @@161 Each page is a single HTML file with inline CSS and no JavaScript — exactly what-the CLI writes locally.162+the CLI writes locally, plus a one-line footer the publish script appends so a163+page you land on cold still leads back to the project.

Both pages that describe the published tree now mention the footer and why it exists.

docs/wiki/how-to/publish-the-live-demo.md
@@ -15,7 +15,12 @@15   file that is *not* tool output. The publish script **carries it forward**16   untouched; to change it, edit it on the `pages` branch directly.17 - `commit.html`, `commit-with-deletions.html`, `branch.html`, `history.html` —-  straight `throughline` output (`src/html.rs` `to_html` / `to_html_branch`).18+  straight `throughline` output (`src/html.rs` `to_html` / `to_html_branch`), plus19+  **one appended footer line** (project link + a way back to `index.html`). The20+  footer is the only demo-side edit — the publish script splices it in before21+  `</body>` after rendering — because a visitor who lands on a page directly22+  would otherwise have no path to the project. The CLI's own output stays23+  footer-free; both `index.html` and the README disclose the addition.24 25 The README hero at `docs/assets/example-walkthrough.png` is a screenshot of one of26 those renders, so it tracks the same source.@@ -26,9 +31,10 @@31 ./scripts/publish-pages.sh32 ```33 -It builds the renderer, renders the four walkthroughs, carries `index.html`-forward, and pushes to `origin/pages` — **committing only when the render actually-changed**, so re-running is cheap and noise-free. It works in a throwaway worktree34+It builds the renderer, renders the four walkthroughs, appends the demo footer35+to each, carries `index.html` forward, and pushes to `origin/pages` —36+**committing only when the render actually changed**, so re-running is cheap and37+noise-free. It works in a throwaway worktree38 (the [worktree gotchas](working-in-a-worktree.md) apply) and cleans up even on39 failure, so your main checkout is never touched.40 
docs/wiki/automation/pages-publish-hook.md
@@ -19,9 +19,10 @@19 [`.githooks/pre-push`](../../../.githooks/pre-push) fires on `git push`. When the20 push updates `refs/heads/main`, it runs21 [`scripts/publish-pages.sh`](../../../scripts/publish-pages.sh), which builds the-renderer, re-renders the four walkthroughs, carries the hand-written `index.html`-forward, and pushes the result to `origin/pages` — committing only when the render-actually changed (`scripts/publish-pages.sh`).22+renderer, re-renders the four walkthroughs, appends the demo-only footer to each23+(see [the how-to](../how-to/publish-the-live-demo.md#what-is-published)), carries24+the hand-written `index.html` forward, and pushes the result to `origin/pages` —25+committing only when the render actually changed (`scripts/publish-pages.sh`).26 27 ## When it runs — and when it gets out of the way28 

Remaining changes

README.md
@@ -159,7 +159,8 @@159   every commit in this repo, end to end.160 161 Each page is a single HTML file with inline CSS and no JavaScript — exactly what-the CLI writes locally.162+the CLI writes locally, plus a one-line footer the publish script appends so a163+page you land on cold still leads back to the project.164 165 ## Getting started166 
scripts/publish-pages.sh
@@ -80,6 +80,20 @@80 "$bin" branch "$BRANCH_RANGE"  -o "$wt/branch.html"81 "$bin" branch "$HISTORY_RANGE" -o "$wt/history.html"82 83+# --- append a demo-only footer: a way back from any page ---------------------84+# The four pages are verbatim CLI output — that is the demo's point — but a85+# visitor who lands on one directly (shared link, search hit) gets no project86+# name and no way back. Append one footer line before the closing </body>.87+# index.html and the README disclose the addition. The inline style leans on88+# the CSS variables the renderer defines on :root, so it tracks the page's89+# light/dark scheme. Diff content can't collide with the match: the renderer90+# HTML-escapes code lines, so the document's own </body> is the only raw one.91+demo_footer='<footer style="margin-top:3rem;border-top:1px solid var(--rule);padding-top:1rem;color:var(--muted);font-size:0.92rem;">Rendered by <a style="color:var(--accent);text-decoration:none;" href="https://codeberg.org/eduramirezh/commit-throughline">Commit Throughline</a> from this repository&#x2019;s own history &middot; <a style="color:var(--accent);text-decoration:none;" href="./">all demos</a></footer>'92+for page in commit commit-with-deletions branch history; do93+  DEMO_FOOTER="$demo_footer" perl -0777 -pi -e \94+    's{</body>}{$ENV{DEMO_FOOTER}\n</body>}' "$wt/$page.html"95+done96+97 # --- commit & push only if the render actually changed ----------------------98 git -C "$wt" add -A99 if git -C "$wt" diff --cached --quiet; then
37/376cb2e7cEdu Ramírez

Prepare the crate for a crates.io release

cargo install --git plus a full source build is the only install path — the narrowest point of the funnel for a tool whose pitch is "any repo, any language". Publishing needs a human login, so this lands everything up to that step: registry metadata, package hygiene, and the release procedure. cargo publish --dry-run verifies: 55 files, 129 KiB compressed.

Cargo.toml gains the homepage (the live demo) and an exclude list that keeps repo-only material — the ~370 KB hero image, hooks, publish scripts — out of the package while tests and the text docs stay in.

Cargo.toml
@@ -5,6 +5,10 @@5 description = "Render a commit message as a complete, annotated diff walkthrough."6 license = "MIT OR Apache-2.0"7 repository = "https://codeberg.org/eduramirezh/commit-throughline"8+homepage = "https://eduramirezh.codeberg.page/commit-throughline/"9 readme = "README.md"10 keywords = ["git", "commit", "diff", "markdown"]11 categories = ["command-line-utilities", "development-tools"]12+# Keep repo-only material out of the published package (the hero image alone13+# is ~370 KB); everything a from-source build or `cargo test` needs stays in.14+exclude = ["docs/assets/", ".githooks/", ".claude/", "scripts/"]

The release procedure lives in the wiki: token, dry-run, publish, and the two README install lines to flip once the registry copy exists.

docs/wiki/how-to/release-to-crates-io.md
@@ -0,0 +1,61 @@1+# How-to: release to crates.io2+3+You want `cargo install commit-throughline` to work — publishing the crate so4+installing no longer requires `--git` and a full clone. The package metadata is5+already in place; this page is the release procedure, the versioning rule, and6+the README lines to flip after the first publish.7+8+## What is already prepared9+10+- `Cargo.toml` carries the registry metadata — description, dual license,11+  `repository`, `homepage` (the live demo), keywords, categories — and an12+  `exclude` list that keeps repo-only material (the hero image, the Git hooks,13+  the publish scripts) out of the package while leaving everything a14+  from-source build or `cargo test` needs in.15+- The crate name `commit-throughline` was unclaimed as of 2026-06 (the binary16+  it installs is `throughline`, per `[[bin]]`).17+18+## The procedure19+20+1. **Log in once per machine.** Create an API token at21+   `https://crates.io/settings/tokens` and run `cargo login` (it prompts for22+   the token; in a Claude Code session, run it yourself with `! cargo login`).23+24+2. **Verify the package.** A dry run builds the crate from the packaged25+   sources exactly as crates.io will, and the list flags anything the26+   `exclude` rules miss:27+28+   ```sh29+   cargo publish --dry-run30+   cargo package --list   # expect: src, tests, docs (text only), licenses, README31+   ```32+33+3. **Publish:**34+35+   ```sh36+   cargo publish37+   ```38+39+4. **Flip the README install lines.** The quick start under the hero and the40+   first command of Getting started lead with the `--git` form because the41+   registry copy didn't exist; once it does, lead with42+   `cargo install commit-throughline` and keep the `--git` form as the43+   from-source alternative. (Search the README for `cargo install --git` —44+   two sites.)45+46+## Versioning47+48+`version` in `Cargo.toml` becomes the released version — bump it for every49+publish. crates.io rejects re-publishing an existing version; a published50+version can be *yanked* but never replaced or deleted, so the dry run comes51+first. Pre-1.0 convention: feature release bumps minor (`0.1.0` → `0.2.0`),52+fix release bumps patch.53+54+## Deliberately not done55+56+- **Prebuilt binaries / `cargo binstall`** — wants a forge release with57+  per-target artifacts and a cross-compile setup; revisit on demand. Until58+  then `cargo install` compiles from source (needs Rust, takes a minute or59+  two — `git2` builds vendored libgit2).60+- **docs.rs** — builds the library docs automatically on publish; nothing to61+  configure.

The wiki map and the roadmap both record the step so it is discoverable and tracked.

docs/wiki/README.md
@@ -73,0 +73,3 @@73+  - [`how-to/release-to-crates-io.md`](how-to/release-to-crates-io.md) — publish74+    the crate so `cargo install commit-throughline` works: what's prepared, the75+    publish procedure, and the README lines to flip after the first release.
docs/ROADMAP.md
@@ -235,0 +235,5 @@235+- [ ] **Distribution** — publish to crates.io so `cargo install236+  commit-throughline` works without a clone. The package metadata and exclusions237+  are in place and the publish is a one-command human step — the procedure is238+  [`wiki/how-to/release-to-crates-io.md`](wiki/how-to/release-to-crates-io.md).239+  Prebuilt binaries / `cargo binstall` follow once a forge release flow exists.

Remaining changes

Cargo.toml
@@ -5,9 +5,13 @@5 description = "Render a commit message as a complete, annotated diff walkthrough."6 license = "MIT OR Apache-2.0"7 repository = "https://codeberg.org/eduramirezh/commit-throughline"8+homepage = "https://eduramirezh.codeberg.page/commit-throughline/"9 readme = "README.md"10 keywords = ["git", "commit", "diff", "markdown"]11 categories = ["command-line-utilities", "development-tools"]12+# Keep repo-only material out of the published package (the hero image alone13+# is ~370 KB); everything a from-source build or `cargo test` needs stays in.14+exclude = ["docs/assets/", ".githooks/", ".claude/", "scripts/"]15 16 [[bin]]17 name = "throughline"
docs/ROADMAP.md
@@ -232,6 +232,11 @@232   shelling to `git for-each-ref --sort=-committerdate`. Deliberately deferred — the233   [picker](wiki/how-to/render-a-branch.md#the-interactive-picker) covers the234   on-the-base case; completion is the complement for mid-command-line recall.235+- [ ] **Distribution** — publish to crates.io so `cargo install236+  commit-throughline` works without a clone. The package metadata and exclusions237+  are in place and the publish is a one-command human step — the procedure is238+  [`wiki/how-to/release-to-crates-io.md`](wiki/how-to/release-to-crates-io.md).239+  Prebuilt binaries / `cargo binstall` follow once a forge release flow exists.240 241 ## Later — polish & product242 
docs/wiki/README.md
@@ -70,6 +70,9 @@70   - [`how-to/publish-the-live-demo.md`](how-to/publish-the-live-demo.md) —71     (re)generate the Codeberg Pages demo and README hero image from this repo's own72     commits; the orphan `pages` branch, and why it serves only on a public repo.73+  - [`how-to/release-to-crates-io.md`](how-to/release-to-crates-io.md) — publish74+    the crate so `cargo install commit-throughline` works: what's prepared, the75+    publish procedure, and the README lines to flip after the first release.76 - **Automation** — how this repo keeps itself honest.77   - [`automation/docs-sync-hook.md`](automation/docs-sync-hook.md) — the Claude Code78     hook that prompts a docs update whenever code changes.