krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

a34f015d787b6da2b0a0fec72c702f982f9b0004

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T05:39:51Z

v0.11: watch on OS filesystem events

Replaces a 500ms poll loop that re-walked the whole tree twice a second to compare
mtimes. Native events (notify) cost nothing while nothing happens and arrive in
milliseconds when something does. Write bursts are debounced over 120ms, because an
editor saving a file writes a temp file, renames it over the original and touches the
directory — one edit, several events.

What counts as a change is deliberately not the rule the build uses to find content:
- A build input is a change. Editing org-ssg.toml or a template rebuilds, even though
  discovery skips both as non-content. The question is "would this change the site?",
  not "is this a page?".
- Our own output is not. Dot-directories and editor scratch files are also out —
  including Emacs' file.org~ backups, which do not start with a dot and would otherwise
  look like content to a tool aimed at Emacs users.

Where native watching is unavailable (some network and container filesystems) it falls
back to polling and says so, rather than failing outright.

Two bugs, both found by mutation-testing the end-to-end test rather than by running it:

The first was in the test. `watch . -o _site` puts the output inside the source, so a
rebuild's writes raise events that trigger a rebuild forever, and the test asserted that
index.html's mtime held still. It does hold still during a runaway loop — the incremental
build leaves an unchanged page alone — so the assertion passed with the filter deleted.
It now watches syntax.css, which is rewritten on every build and is therefore a direct
record of how many builds have run.

With the assertion fixed, the unmutated code failed too, which is the second bug. On
macOS the temp directory is /var/…, a symlink to /private/var/…, and FSEvents reports the
resolved path. Stripping event paths with the root as the user typed it silently failed,
every event kept its absolute path, every absolute path looked like a source change, and
watch rebuilt in a loop. The filter now recognizes every spelling of the root, and a path
it cannot place is discarded rather than treated as a change.

Both mutations — deleting the output filter, and dropping the canonical root — are
verified to fail the test.
 Cargo.lock     | 185 ++++++++++++++++++++++++++++++++++++++++++--
 Cargo.toml     |   3 +-
 README.md      |  37 +++++++--
 src/lib.rs     |   1 +
 src/main.rs    |  83 +++++++-------------
 src/watch.rs   | 238 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/watch.rs | 193 ++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 668 insertions(+), 72 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 4331cb6..63348e4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -53,7 +53,7 @@ version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
 dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
 dependencies = [
  "anstyle",
  "once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -225,7 +225,7 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
 dependencies = [
  "encode_unicode",
  "libc",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -314,7 +314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -345,6 +345,15 @@ version = "1.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
 
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "futures-core"
 version = "0.3.33"
@@ -426,6 +435,26 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "inotify"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
+dependencies = [
+ "bitflags",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "insta"
 version = "1.48.0"
@@ -462,6 +491,26 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "kqueue"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
 [[package]]
 name = "libc"
 version = "0.2.189"
@@ -518,6 +567,45 @@ dependencies = [
  "simd-adler32",
 ]
 
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags",
+]
+
 [[package]]
 name = "num-conv"
 version = "0.2.2"
@@ -569,7 +657,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.10.0"
+version = "0.11.0"
 dependencies = [
  "anyhow",
  "blake3",
@@ -578,6 +666,7 @@ dependencies = [
  "clap",
  "insta",
  "minijinja",
+ "notify",
  "rayon",
  "serde",
  "serde_json",
@@ -687,7 +776,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -840,7 +929,7 @@ dependencies = [
  "getrandom",
  "once_cell",
  "rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -954,6 +1043,12 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
 [[package]]
 name = "wasm-bindgen"
 version = "0.2.127"
@@ -1005,7 +1100,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -1067,6 +1162,15 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets",
+]
+
 [[package]]
 name = "windows-sys"
 version = "0.61.2"
@@ -1076,6 +1180,71 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
 [[package]]
 name = "winnow"
 version = "1.0.4"
diff --git a/Cargo.toml b/Cargo.toml
index b017cf6..bd5dabc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "org-ssg"
-version = "0.10.0"
+version = "0.11.0"
 edition = "2021"
 description = "Org-mode static site generator that renders the org element tree straight to HTML"
 license = "MIT"
@@ -32,6 +32,7 @@ anyhow = "1"
 thiserror = "2"
 rayon = "1.12.0"
 toml = "1.1.4"
+notify = "8.2.0"
 
 [dev-dependencies]
 insta = { version = "1", features = ["json"] }
diff --git a/README.md b/README.md
index 1221b1b..4498347 100644
--- a/README.md
+++ b/README.md
@@ -295,13 +295,14 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
 | 3 | Inline objects — emphasis, links, bare URLs, footnote refs, timestamps | done |
 | 4 | Rendering to HTML — tree walk, tables, footnote two-pass, minijinja templating, syntect highlighting | done |
 | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done |
-| 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done |
+| 6 | Incremental build layer (hashing, dep graph, invalidation); `watch` on OS filesystem events | done |
 | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** |
 | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** |
 | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** |
 | **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** |
 | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** |
 | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** |
+| **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** |
 
 ### v0.2 in / out
 
@@ -395,8 +396,32 @@ as literal text; drawers other than PROPERTIES are captured and dropped; unmodel
 types keep their content verbatim.
 
 **Still out:** `#+TODO:` per-file keyword sequences; planning lines
-(`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; fixed-width `: ` lines;
-and the `watch` fs-notify integration.
+(`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; and fixed-width `: `
+lines.
+
+## Watching
+
+```bash
+cargo run -- watch my-site -o _site
+```
+
+Rebuilds on OS filesystem events rather than polling, so it costs nothing while nothing
+happens. Write bursts are debounced — an editor saving a file writes a temp file, renames
+it over the original and touches the directory, which is one edit and several events.
+
+Two rules decide what counts as a change, and they are not the same rules the build uses
+to find content:
+
+- **A build input is a change.** Editing `org-ssg.toml` or a template rebuilds, even
+  though discovery skips both as non-content. The question is "would this change the
+  site?", not "is this a page?".
+- **Our own output is not.** `watch . -o _site` puts the output inside the source, so a
+  rebuild's writes raise events that would trigger a rebuild, forever. Dot-directories go
+  the same way — `.git` churns on every command — as do editor scratch files, including
+  Emacs' `file.org~` backups, which do not start with a dot.
+
+Where native watching is unavailable (some network and container filesystems), it falls
+back to polling and says so, rather than failing.
 
 ## Phase 0: the corpus audit and the Emacs oracle
 
@@ -558,20 +583,20 @@ Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is
 line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax
 highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later),
 `minijinja` (runtime templates), `blake3` (content/cache hashing), `rayon` (parallel
-PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`.
+PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`.
 `insta` for snapshot tests, and `emacs --batch` — optional, and only for the oracle.
 
 ## Build & test
 
 ```
 cargo build
-cargo test                                                # 122 tests
+cargo test                                                # 128 tests
 cargo run -- init my-site                                 # scaffold a new site
 cargo run -- build fixtures/minimal.org -o minimal.html   # single file
 cargo run -- build fixtures/site -o _site                 # whole site (incremental)
 cargo run -- audit fixtures/site                          # corpus audit (Phase 0)
 cargo run -- build fixtures/site -o _site --no-cache      # force a full rebuild
-cargo run -- watch fixtures/site -o _site                 # poll + rebuild on change
+cargo run -- watch fixtures/site -o _site                 # rebuild on filesystem events
 cargo run -- clean _site                                  # remove output + cache
 ```
 
diff --git a/src/lib.rs b/src/lib.rs
index c5cc7a6..bb0564f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -19,3 +19,4 @@ pub mod resolve;
 pub mod site;
 pub mod template;
 pub mod util;
+pub mod watch;
diff --git a/src/main.rs b/src/main.rs
index 450efc4..897e008 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -40,13 +40,23 @@ enum Command {
         #[arg(long, value_name = "FILE")]
         config: Option<Utf8PathBuf>,
     },
-    /// Watch a source directory and rebuild incrementally on change (simple poll loop).
+    /// Watch a source directory and rebuild incrementally on change, driven by OS
+    /// filesystem events.
     Watch {
         /// Source directory to watch.
         input: Utf8PathBuf,
         /// Output directory.
         #[arg(short, long)]
         output: Utf8PathBuf,
+        /// Bypass the incremental cache on every rebuild.
+        #[arg(long)]
+        no_cache: bool,
+        /// Treat broken links and parse diagnostics as errors.
+        #[arg(long)]
+        strict: bool,
+        /// Config file to use, overriding `org-ssg.toml` in the source directory.
+        #[arg(long, value_name = "FILE")]
+        config: Option<Utf8PathBuf>,
     },
     /// Remove the build output directory (which holds the cache manifest).
     Clean {
@@ -105,10 +115,21 @@ fn main() -> Result<()> {
             }
             Ok(())
         }
-        // Watch is intentionally a minimal poll loop, not an OS file-watch (spec §5 Phase
-        // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds
-        // incrementally whenever any source file's mtime advances.
-        Command::Watch { input, output } => watch(&input, &output),
+        Command::Watch {
+            input,
+            output,
+            no_cache,
+            strict,
+            config,
+        } => org_ssg::watch::run(
+            &input,
+            &output,
+            &BuildOptions {
+                no_cache,
+                strict,
+                config_path: config,
+            },
+        ),
         Command::Audit { input } => {
             let result = org_ssg::audit::audit(&input)?;
             print!("{}", org_ssg::audit::report(&result));
@@ -194,58 +215,6 @@ fn init(dir: &Utf8Path) -> Result<()> {
     Ok(())
 }
 
-/// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes.
-/// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms.
-fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
-    use std::time::{Duration, SystemTime};
-
-    if !input.is_dir() {
-        anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
-    }
-    let opts = BuildOptions::default();
-
-    let snapshot = |root: &Utf8Path| -> Vec<(Utf8PathBuf, SystemTime)> {
-        let mut v = Vec::new();
-        for entry in walkdir::WalkDir::new(root).sort_by_file_name() {
-            let Ok(entry) = entry else { continue };
-            if !entry.file_type().is_file() {
-                continue;
-            }
-            if let (Ok(path), Ok(meta)) = (
-                Utf8PathBuf::from_path_buf(entry.path().to_owned()),
-                entry.metadata(),
-            ) {
-                let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
-                v.push((path, mtime));
-            }
-        }
-        v
-    };
-
-    let report = build_site(input, output, &opts)?;
-    println!(
-        "watching {input} -> {output}: built {} page(s) ({} rendered). Ctrl-C to stop.",
-        report.pages.len(),
-        report.rendered.len()
-    );
-    let mut last = snapshot(input);
-    loop {
-        std::thread::sleep(Duration::from_millis(500));
-        let now = snapshot(input);
-        if now != last {
-            match build_site(input, output, &opts) {
-                Ok(report) => println!(
-                    "rebuilt: {} rendered, {} cached",
-                    report.rendered.len(),
-                    report.skipped.len()
-                ),
-                Err(e) => eprintln!("build error: {e:#}"),
-            }
-            last = now;
-        }
-    }
-}
-
 /// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link
 /// resolution (there is no corpus to resolve against); links keep their best-effort
 /// URLs. Whole-site link resolution lives in [`build_site`]. The syntax stylesheet is
diff --git a/src/watch.rs b/src/watch.rs
new file mode 100644
index 0000000..6756f44
--- /dev/null
+++ b/src/watch.rs
@@ -0,0 +1,238 @@
+//! `watch`: rebuild when the source changes, driven by OS filesystem events.
+//!
+//! This replaced a 500ms poll loop that re-walked the whole tree twice a second to
+//! compare mtimes. Native events cost nothing while nothing happens, and arrive in
+//! milliseconds when something does.
+//!
+//! Two things matter more than the watching itself:
+//!
+//! 1. **Not watching our own output.** `org-ssg watch . -o _site` puts the output inside
+//!    the source. Rebuilding writes files, writing files raises events, and events
+//!    trigger a rebuild — a loop that never stops and never idles. [`ChangeFilter`] is
+//!    what prevents it, and it is a pure function precisely so it can be tested without
+//!    a filesystem.
+//! 2. **Debouncing.** Saving a file in an editor is rarely one event: editors write a
+//!    temp file, rename it over the original, and touch the directory. Rebuilding per
+//!    event would rebuild several times per save.
+
+use std::sync::mpsc;
+use std::time::Duration;
+
+use anyhow::{Context, Result};
+use camino::{Utf8Path, Utf8PathBuf};
+use notify::{Config as NotifyConfig, RecursiveMode, Watcher};
+
+use crate::site::{build_site, BuildOptions};
+
+/// How long the tree must be quiet before a rebuild starts. Long enough to coalesce an
+/// editor's write burst, short enough to feel immediate.
+pub const DEBOUNCE: Duration = Duration::from_millis(120);
+
+/// Poll interval for the fallback watcher, used where native events are unavailable
+/// (some network and container filesystems). Slower than the old poll loop on purpose:
+/// it is a fallback, not the primary path.
+const POLL_INTERVAL: Duration = Duration::from_secs(2);
+
+/// Decides whether a changed path should trigger a rebuild.
+///
+/// Deliberately *not* the same rule as build-time discovery. Discovery skips the config
+/// file and the templates directory because they are not site content — but a change to
+/// either must rebuild, because both change the output. The rule here is "would this
+/// change the site?", not "is this a page?".
+#[derive(Debug, Clone)]
+pub struct ChangeFilter {
+    /// Output directory, relative to the source root, when it lives inside it.
+    output_inside: Option<Utf8PathBuf>,
+    /// Every spelling of the source root an event path might carry, longest first.
+    ///
+    /// One entry is not enough. On macOS the temp directory is `/var/…`, a symlink to
+    /// `/private/var/…`, and FSEvents reports the resolved path — so stripping event
+    /// paths with the root *as the user typed it* silently fails, every event keeps its
+    /// absolute path, and every absolute path looks like a source change. That includes
+    /// the build's own writes, so `watch` rebuilds in a loop forever.
+    roots: Vec<Utf8PathBuf>,
+}
+
+impl ChangeFilter {
+    /// Build a filter for a source and output directory. Paths are canonicalized so
+    /// `.`, `./src`, an absolute path and a symlinked one all compare equal.
+    pub fn new(src: &Utf8Path, out: &Utf8Path) -> Self {
+        let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> {
+            std::fs::canonicalize(p)
+                .ok()
+                .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
+        };
+        let src_canon = canon(src);
+        let output_inside = match (&src_canon, canon(out)) {
+            (Some(src), Some(out)) => out
+                .strip_prefix(src)
+                .ok()
+                .filter(|rel| !rel.as_str().is_empty())
+                .map(|rel| rel.to_owned()),
+            _ => out
+                .strip_prefix(src)
+                .ok()
+                .filter(|rel| !rel.as_str().is_empty())
+                .map(|rel| rel.to_owned()),
+        };
+
+        let mut roots: Vec<Utf8PathBuf> = src_canon.into_iter().chain([src.to_owned()]).collect();
+        roots.dedup();
+        // Longest first, so the most specific spelling wins.
+        roots.sort_by_key(|r| std::cmp::Reverse(r.as_str().len()));
+        ChangeFilter {
+            output_inside,
+            roots,
+        }
+    }
+
+    /// Should a change to `rel` (relative to the source root) cause a rebuild?
+    pub fn is_relevant(&self, rel: &Utf8Path) -> bool {
+        if let Some(out) = &self.output_inside {
+            if rel.starts_with(out) {
+                return false;
+            }
+        }
+        // Dot-entries: `.git` churns on every command, and the cache manifest lives in
+        // the output anyway. Emacs' `.#lock` files land here too.
+        if rel
+            .components()
+            .any(|c| c.as_str().starts_with('.') && c.as_str().len() > 1)
+        {
+            return false;
+        }
+        let Some(name) = rel.file_name() else {
+            return false;
+        };
+        !is_editor_scratch(name)
+    }
+
+    /// Filter absolute event paths down to the relevant ones, as source-relative paths.
+    ///
+    /// A path that cannot be made relative to the source root is discarded rather than
+    /// kept: an event from outside the watched tree cannot be a source change, and
+    /// treating unrecognized paths as changes is what turns a path-spelling mismatch
+    /// into an endless rebuild.
+    pub fn relevant(&self, paths: impl IntoIterator<Item = Utf8PathBuf>) -> Vec<Utf8PathBuf> {
+        let mut out: Vec<Utf8PathBuf> = paths
+            .into_iter()
+            .filter_map(|p| self.to_relative(&p))
+            .filter(|rel| self.is_relevant(rel))
+            .collect();
+        out.sort();
+        out.dedup();
+        out
+    }
+
+    /// An event path as a source-relative path, under whichever spelling of the root it
+    /// arrived with. Already-relative paths pass through.
+    fn to_relative(&self, path: &Utf8Path) -> Option<Utf8PathBuf> {
+        if path.is_relative() {
+            return Some(path.to_owned());
+        }
+        self.roots
+            .iter()
+            .find_map(|root| path.strip_prefix(root).ok())
+            .map(Utf8Path::to_owned)
+    }
+}
+
+/// Files an editor writes beside the real one. Emacs is the relevant case: it leaves
+/// `file.org~` backups, which do not start with a dot and would otherwise look like a
+/// content change to a tool aimed squarely at Emacs users.
+fn is_editor_scratch(name: &str) -> bool {
+    name.ends_with('~')
+        || name.ends_with(".swp")
+        || name.ends_with(".swx")
+        || name.ends_with(".tmp")
+        || (name.starts_with('#') && name.ends_with('#'))
+}
+
+/// Build once, then rebuild whenever the source changes. Runs until interrupted.
+pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> {
+    if !src.is_dir() {
+        anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
+    }
+
+    let report = build_site(src, out, opts)?;
+    println!(
+        "watching {src} -> {out}: built {} page(s) ({} rendered). Ctrl-C to stop.",
+        report.pages.len(),
+        report.rendered.len()
+    );
+
+    let filter = ChangeFilter::new(src, out);
+    let (tx, rx) = mpsc::channel();
+    let mut watcher = make_watcher(tx)?;
+    watcher
+        .watch(src.as_std_path(), RecursiveMode::Recursive)
+        .with_context(|| format!("watching {src}"))?;
+
+    loop {
+        // Block until something happens, then keep draining while events keep arriving
+        // inside the debounce window — one save produces several events, and they should
+        // produce one rebuild.
+        let Ok(first) = rx.recv() else {
+            return Ok(()); // watcher dropped
+        };
+        let mut batch = vec![first];
+        while let Ok(next) = rx.recv_timeout(DEBOUNCE) {
+            batch.push(next);
+        }
+
+        let changed = filter.relevant(batch.into_iter().flatten());
+        if changed.is_empty() {
+            continue;
+        }
+
+        let summary = summarize(&changed);
+        match build_site(src, out, opts) {
+            Ok(report) => println!(
+                "{summary}: {} rendered, {} cached",
+                report.rendered.len(),
+                report.skipped.len()
+            ),
+            // A rebuild that fails must not end the session — the usual cause is a
+            // half-saved file, and the next keystroke fixes it.
+            Err(e) => eprintln!("{summary}: build failed: {e:#}"),
+        }
+    }
+}
+
+fn summarize(changed: &[Utf8PathBuf]) -> String {
+    match changed {
+        [one] => format!("{one} changed"),
+        [first, rest @ ..] => format!("{first} and {} more changed", rest.len()),
+        [] => "changed".to_string(),
+    }
+}
+
+/// The platform's native watcher, falling back to polling where that is unavailable —
+/// some network and container filesystems have no event API, and `watch` failing outright
+/// there would be worse than being slow.
+fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> {
+    let handler = move |result: notify::Result<notify::Event>| {
+        if let Ok(event) = result {
+            let paths: Vec<Utf8PathBuf> = event
+                .paths
+                .into_iter()
+                .filter_map(|p| Utf8PathBuf::from_path_buf(p).ok())
+                .collect();
+            if !paths.is_empty() {
+                // The receiver going away just means the loop ended.
+                let _ = tx.send(paths);
+            }
+        }
+    };
+
+    match notify::RecommendedWatcher::new(handler.clone(), NotifyConfig::default()) {
+        Ok(watcher) => Ok(Box::new(watcher)),
+        Err(e) => {
+            eprintln!("note: native file watching unavailable ({e}); polling every {POLL_INTERVAL:?}");
+            let config = NotifyConfig::default().with_poll_interval(POLL_INTERVAL);
+            let watcher = notify::PollWatcher::new(handler, config)
+                .context("starting the fallback poll watcher")?;
+            Ok(Box::new(watcher))
+        }
+    }
+}
diff --git a/tests/watch.rs b/tests/watch.rs
new file mode 100644
index 0000000..6cb66aa
--- /dev/null
+++ b/tests/watch.rs
@@ -0,0 +1,193 @@
+//! `watch`: the change filter, and one end-to-end run against real filesystem events.
+//!
+//! The filter carries the weight here. `org-ssg watch . -o _site` puts the output inside
+//! the source, so a rebuild writes files, writing files raises events, and events trigger
+//! a rebuild — a loop that never stops. That it is a pure function is what makes the
+//! guarantee testable without waiting on a filesystem.
+
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::time::{Duration, Instant};
+
+use camino::{Utf8Path, Utf8PathBuf};
+
+use org_ssg::site::{build_site, BuildOptions};
+use org_ssg::watch::ChangeFilter;
+
+fn tmpdir(tag: &str) -> Utf8PathBuf {
+    static N: AtomicU32 = AtomicU32::new(0);
+    let n = N.fetch_add(1, Ordering::Relaxed);
+    let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
+        .expect("utf-8 temp dir")
+        .join(format!("org-ssg-watch-{}-{tag}-{n}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&base);
+    std::fs::create_dir_all(&base).unwrap();
+    base
+}
+
+// ---------------------------------------------------------------------------
+// The change filter
+// ---------------------------------------------------------------------------
+
+/// The one that matters: without it, `watch . -o _site` rebuilds forever.
+#[test]
+fn changes_under_the_output_directory_are_ignored() {
+    let root = tmpdir("filterout");
+    let src = root.join("src");
+    let out = src.join("_site");
+    std::fs::create_dir_all(&out).unwrap();
+
+    let filter = ChangeFilter::new(&src, &out);
+    assert!(!filter.is_relevant(Utf8Path::new("_site/index.html")));
+    assert!(!filter.is_relevant(Utf8Path::new("_site/blog/post.html")));
+    assert!(!filter.is_relevant(Utf8Path::new("_site/.org-ssg-cache.json")));
+    assert!(filter.is_relevant(Utf8Path::new("index.org")), "real sources still count");
+}
+
+/// An output directory outside the source cannot cause a loop, and must not accidentally
+/// suppress a similarly-named source directory.
+#[test]
+fn an_external_output_directory_suppresses_nothing() {
+    let root = tmpdir("filterext");
+    let src = root.join("src");
+    let out = root.join("out");
+    std::fs::create_dir_all(&src).unwrap();
+    std::fs::create_dir_all(&out).unwrap();
+
+    let filter = ChangeFilter::new(&src, &out);
+    assert!(filter.is_relevant(Utf8Path::new("index.org")));
+    assert!(filter.is_relevant(Utf8Path::new("out/notes.org")), "a source dir named `out`");
+}
+
+/// A change to the config or a template changes the output, so both must rebuild — even
+/// though build-time *discovery* skips them as non-content. The watch rule is "would this
+/// change the site?", not "is this a page?".
+#[test]
+fn build_inputs_trigger_a_rebuild_even_though_discovery_skips_them() {
+    let root = tmpdir("filterinputs");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    let filter = ChangeFilter::new(&src, &root.join("out"));
+
+    assert!(filter.is_relevant(Utf8Path::new("org-ssg.toml")));
+    assert!(filter.is_relevant(Utf8Path::new("templates/base.html")));
+    assert!(filter.is_relevant(Utf8Path::new("templates/feed.xml")));
+    assert!(filter.is_relevant(Utf8Path::new("style.css")), "assets are copied through");
+}
+
+/// `.git` churns on every command, and rebuilding the site because git wrote an index
+/// lock would make watch useless in any repository.
+#[test]
+fn dot_directories_and_editor_scratch_files_are_ignored() {
+    let root = tmpdir("filterdots");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    let filter = ChangeFilter::new(&src, &root.join("out"));
+
+    for ignored in [
+        ".git/index",
+        ".git/objects/ab/cdef",
+        ".DS_Store",
+        "blog/.#post.org",  // Emacs lock
+        "post.org~",        // Emacs backup
+        ".post.org.swp",    // vim
+        "#post.org#",       // Emacs auto-save
+        "build.tmp",
+    ] {
+        assert!(
+            !filter.is_relevant(Utf8Path::new(ignored)),
+            "{ignored} should not trigger a rebuild"
+        );
+    }
+    for relevant in ["post.org", "blog/post.org", "a-file~with-tilde.org"] {
+        assert!(
+            filter.is_relevant(Utf8Path::new(relevant)),
+            "{relevant} should trigger a rebuild"
+        );
+    }
+}
+
+/// Events arrive as absolute paths and in bursts, often naming one file several times.
+#[test]
+fn absolute_event_paths_are_reduced_to_a_sorted_unique_set() {
+    let root = tmpdir("filterrel");
+    let src = root.join("src");
+    let out = src.join("_site");
+    std::fs::create_dir_all(&out).unwrap();
+
+    let filter = ChangeFilter::new(&src, &out);
+    let events = vec![
+        src.join("b.org"),
+        src.join("a.org"),
+        src.join("b.org"),
+        src.join("_site/a.html"),
+        src.join("a.org~"),
+    ];
+    assert_eq!(
+        filter.relevant(events),
+        vec![Utf8PathBuf::from("a.org"), Utf8PathBuf::from("b.org")]
+    );
+}
+
+// ---------------------------------------------------------------------------
+// End to end
+// ---------------------------------------------------------------------------
+
+/// Drive the real watcher against a real edit. Timing-dependent by nature, so it polls
+/// for the expected result with a generous ceiling rather than sleeping a fixed amount.
+#[test]
+fn watching_rebuilds_the_site_when_a_source_file_changes() {
+    let root = tmpdir("watchrun");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap();
+    let out = src.join("_site"); // deliberately inside the source: the loop case
+
+    build_site(&src, &out, &BuildOptions::default()).unwrap();
+    assert!(std::fs::read_to_string(out.join("index.html"))
+        .unwrap()
+        .contains("First version."));
+
+    let (src_t, out_t) = (src.clone(), out.clone());
+    let handle = std::thread::spawn(move || {
+        let _ = org_ssg::watch::run(&src_t, &out_t, &BuildOptions::default());
+    });
+
+    // Give the watcher a moment to register before making the change it should see.
+    std::thread::sleep(Duration::from_millis(300));
+    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap();
+
+    let deadline = Instant::now() + Duration::from_secs(20);
+    let mut rebuilt = false;
+    while Instant::now() < deadline {
+        if std::fs::read_to_string(out.join("index.html"))
+            .map(|h| h.contains("Second version."))
+            .unwrap_or(false)
+        {
+            rebuilt = true;
+            break;
+        }
+        std::thread::sleep(Duration::from_millis(50));
+    }
+    assert!(rebuilt, "an edit should trigger a rebuild within 20s");
+
+    // The output lives inside the source, so the rebuild's own writes raised events. If
+    // those are not filtered out, watch spins forever.
+    //
+    // The file to watch for that is `syntax.css`, not `index.html`. The incremental
+    // build leaves an unchanged page alone, so `index.html` holds still even *during* a
+    // runaway loop — an assertion on it passes whether or not the filter works, which is
+    // exactly what it did before this comment existed. `syntax.css` is rewritten on
+    // every build, so its mtime is a direct record of how many builds have run.
+    let stylesheet = out.join("syntax.css");
+    std::thread::sleep(Duration::from_millis(700));
+    let first = std::fs::metadata(&stylesheet).unwrap().modified().unwrap();
+    std::thread::sleep(Duration::from_millis(1200));
+    let second = std::fs::metadata(&stylesheet).unwrap().modified().unwrap();
+    assert_eq!(
+        first, second,
+        "the build's own writes must not feed back in as changes — watch is rebuilding \
+         in a loop"
+    );
+
+    drop(handle); // the watcher thread ends with the process
+}