feat(xtask): watch sources and debounce automatic packaging

Wait for a ten-second quiet period before rebuilding and packaging, resetting the timer on each source change. Queue edits during builds or while dist/NOREBUILD exists, exclude generated outputs, and forward pack options. Recompile packaging code for each run and keep watching after build failures.

Document watcher usage and require agents to hold dist/NOREBUILD throughout their work.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-21 22:30:03 +02:00
parent 29275c38fc
commit 6af3df0ecc
5 changed files with 317 additions and 2 deletions

View File

@ -10,6 +10,10 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition
## Agent rules ## Agent rules
- Before starting work, create `dist/NOREBUILD` (creating `dist/` if needed) to
pause automatic rebuilds. Remove it only after all work, validation and commits
are complete. If it already exists, leave it in place; it may belong to another
active agent or the user.
- Never push changes unless explicitly told to. - Never push changes unless explicitly told to.
- After changing files, run `cargo fmt --all`. Do not override project - After changing files, run `cargo fmt --all`. Do not override project
formatting configuration or invoke a formatter directly on individual files. formatting configuration or invoke a formatter directly on individual files.

View File

@ -7,6 +7,8 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added ### Added
- `cargo xtask watch` rebuilds and packs after a resettable 10-second quiet period,
with automatic rebuilds paused while `dist/NOREBUILD` exists.
- Separate gameplay floor and structure layers, with buildable/removable floors - Separate gameplay floor and structure layers, with buildable/removable floors
and windows. Marked painted Tiled tiles become stateful gameplay entities; and windows. Marked painted Tiled tiles become stateful gameplay entities;
removing floors exposes the map's configurable default terrain. removing floors exposes the map's configurable default terrain.

View File

@ -504,6 +504,19 @@ and the changelog. Unpack it and run the binaries from that directory. The unpac
staging directory is kept next to the archive. Options: `--format zip|tar.gz`, staging directory is kept next to the archive. Options: `--format zip|tar.gz`,
`--out-dir DIR`, `--debug`. `--out-dir DIR`, `--debug`.
Run `cargo xtask watch` to rebuild and pack automatically after source changes.
It accepts the same options as `pack`, for example `cargo xtask watch --debug`.
The watcher waits for 10 seconds without further changes; each change resets
that timer. It watches Git-tracked and non-ignored untracked files, including
assets, manifests and packaging code, and ignores `dist/`, `target/`, Git metadata
and the selected output directory. Git must be installed. No build runs on startup.
Create `dist/NOREBUILD` to pause automatic builds. Changes remain queued while
the file exists; deleting it starts a fresh 10-second wait for pending changes.
An already running build finishes, and subsequent builds never overlap. Build
failures are reported without stopping the watcher; a later source change retries.
Press Ctrl-C to stop. Run this from the source checkout, not a packaged archive.
The GitHub `Package` workflow (`.github/workflows/package.yml`) runs `pack` for Windows The GitHub `Package` workflow (`.github/workflows/package.yml`) runs `pack` for Windows
(zip), Linux and macOS (tar.gz), uploads each archive as an artifact and attaches them (zip), Linux and macOS (tar.gz), uploads each archive as an artifact and attaches them
to the release when a `v*` tag is pushed. The Gitea workflow cross-compiles the Windows to the release when a `v*` tag is pushed. The Gitea workflow cross-compiles the Windows

View File

@ -2,8 +2,11 @@
//! //!
//! ```text //! ```text
//! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug] //! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug]
//! cargo xtask watch [pack options]
//! ``` //! ```
mod watch;
use std::{ use std::{
env, env,
error::Error, error::Error,
@ -25,6 +28,7 @@ fn main() -> ExitCode {
let mut args = env::args().skip(1); let mut args = env::args().skip(1);
let result = match args.next().as_deref() { let result = match args.next().as_deref() {
Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)), Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)),
Some("watch") => PackOptions::parse(args).and_then(|opts| watch::run(&opts)),
Some("-h" | "--help") | None => { Some("-h" | "--help") | None => {
print_help(); print_help();
return ExitCode::SUCCESS; return ExitCode::SUCCESS;
@ -44,8 +48,9 @@ fn print_help() {
println!( println!(
"usage: cargo xtask <command>\n\n\ "usage: cargo xtask <command>\n\n\
commands:\n \ commands:\n \
pack build the client and server and pack them with their assets\n\n\ pack build the client and server and pack them with their assets\n \
pack options:\n \ watch pack after sources are unchanged for 10 seconds; pause with dist/NOREBUILD\n\n\
pack/watch options:\n \
--target TRIPLE cross-compile for TRIPLE (default: host)\n \ --target TRIPLE cross-compile for TRIPLE (default: host)\n \
--format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \ --format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \
--out-dir DIR output directory (default: dist)\n \ --out-dir DIR output directory (default: dist)\n \

291
xtask/src/watch.rs Normal file
View File

@ -0,0 +1,291 @@
use std::{
collections::{BTreeMap, hash_map::DefaultHasher},
fs,
hash::{Hash, Hasher},
io,
path::{Path, PathBuf},
process::{Child, Command},
thread,
time::{Duration, Instant},
};
use crate::{PackOptions, Result, cargo, workspace_root};
const DEBOUNCE: Duration = Duration::from_secs(10);
const POLL_INTERVAL: Duration = Duration::from_millis(500);
#[derive(Default)]
struct PendingBuild {
last_change: Option<Instant>,
paused: bool,
}
impl PendingBuild {
fn observe(&mut self, changed: bool, paused: bool, now: Instant) {
if changed || (self.paused && !paused && self.last_change.is_some()) {
self.last_change = Some(now);
}
self.paused = paused;
}
fn ready(&self, now: Instant) -> bool {
!self.paused
&& self
.last_change
.is_some_and(|last| now.duration_since(last) >= DEBOUNCE)
}
}
pub fn run(options: &PackOptions) -> Result<()> {
let root = workspace_root().canonicalize()?;
let output = root.join(options.out_dir.as_deref().unwrap_or(Path::new("dist")));
fs::create_dir_all(&output)?;
let output = output.canonicalize()?;
if root.starts_with(&output) {
return Err("watch output directory must not contain the workspace".into());
}
let excluded = [root.join("dist"), root.join("target"), output];
let guard = root.join("dist/NOREBUILD");
let mut previous = snapshot(&root, &excluded)?;
let mut pending = PendingBuild::default();
let mut build: Option<Child> = None;
println!("Watching sources; packing after 10 seconds without changes. Ctrl-C to stop.");
loop {
let current = snapshot(&root, &excluded)?;
let changed = current != previous;
previous = current;
let paused = guard.try_exists()?;
if paused != pending.paused {
if paused {
println!("Packing paused: {} exists.", guard.display());
} else {
println!("Packing resumed; pending changes wait another 10 seconds.");
}
}
pending.observe(changed, paused, Instant::now());
if changed {
println!("Source changes detected; resetting the 10-second timer.");
}
if let Some(child) = &mut build
&& let Some(status) = child.try_wait()?
{
if status.success() {
println!("Build and pack completed.");
} else {
eprintln!("Build and pack failed ({status}); watching for further changes.");
}
build = None;
}
if build.is_none() && pending.ready(Instant::now()) && !guard.try_exists()? {
println!("Sources settled; rebuilding and packing.");
// Recompile xtask too, so edits to packaging code take effect without restarting the watcher.
build = Some(pack_command(&root, options).spawn()?);
pending.last_change = None;
}
thread::sleep(POLL_INTERVAL);
}
}
fn pack_command(root: &Path, options: &PackOptions) -> Command {
let mut command = cargo();
command
.current_dir(root)
.args(["run", "--package", "xtask", "--", "pack"]);
if let Some(target) = &options.target {
command.args(["--target", target]);
}
if let Some(format) = options.format {
command.args(["--format", format.extension()]);
}
if let Some(output) = &options.out_dir {
command.arg("--out-dir").arg(output);
}
if options.debug {
command.arg("--debug");
}
command
}
fn snapshot(root: &Path, excluded: &[PathBuf]) -> Result<BTreeMap<PathBuf, u64>> {
let files = Command::new("git")
.current_dir(root)
.args([
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"-z",
])
.output()?;
if !files.status.success() {
return Err(format!(
"could not list watched sources: {}",
String::from_utf8_lossy(&files.stderr).trim()
)
.into());
}
let mut snapshot = BTreeMap::new();
for name in files
.stdout
.split(|byte| *byte == 0)
.filter(|name| !name.is_empty())
{
let path = root.join(std::str::from_utf8(name)?);
if excluded.iter().any(|output| path.starts_with(output)) {
continue;
}
let read = || -> io::Result<Option<u64>> {
let metadata = fs::symlink_metadata(&path)?;
let mut hash = DefaultHasher::new();
if metadata.file_type().is_symlink() {
fs::read_link(&path)?.hash(&mut hash);
} else if metadata.is_file() {
fs::read(&path)?.hash(&mut hash);
} else {
return Ok(None);
}
Ok(Some(hash.finish()))
};
match read() {
Ok(Some(hash)) => {
snapshot.insert(path, hash);
}
Ok(None) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("could not watch {}: {error}", path.display()).into()),
}
}
Ok(snapshot)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debounce_waits_ten_seconds_and_each_change_resets_it() {
let start = Instant::now();
let mut pending = PendingBuild::default();
assert!(!pending.ready(start + DEBOUNCE));
pending.observe(true, false, start);
assert!(!pending.ready(start + DEBOUNCE - Duration::from_nanos(1)));
assert!(pending.ready(start + DEBOUNCE));
pending.observe(true, false, start + Duration::from_secs(9));
assert!(!pending.ready(start + Duration::from_secs(18)));
assert!(pending.ready(start + Duration::from_secs(19)));
}
#[test]
fn guard_keeps_changes_pending_and_removal_restarts_debounce() {
let start = Instant::now();
let mut pending = PendingBuild::default();
pending.observe(true, false, start);
pending.observe(false, true, start + Duration::from_secs(9));
assert!(!pending.ready(start + Duration::from_secs(100)));
pending.observe(true, true, start + Duration::from_secs(100));
pending.observe(false, false, start + Duration::from_secs(200));
assert!(!pending.ready(start + Duration::from_secs(209)));
assert!(pending.ready(start + Duration::from_secs(210)));
pending.last_change = None;
pending.observe(false, true, start + Duration::from_secs(211));
pending.observe(false, false, start + Duration::from_secs(212));
assert!(!pending.ready(start + Duration::from_secs(222)));
}
#[test]
fn edits_during_a_build_remain_queued_for_the_next_build() {
let start = Instant::now();
let mut pending = PendingBuild::default();
pending.observe(true, false, start);
assert!(pending.ready(start + DEBOUNCE));
pending.last_change = None;
pending.observe(true, false, start + Duration::from_secs(12));
pending.observe(true, false, start + Duration::from_secs(18));
assert!(!pending.ready(start + Duration::from_secs(27)));
assert!(pending.ready(start + Duration::from_secs(28)));
assert!(pending.ready(start + Duration::from_secs(40)));
}
#[test]
fn forwards_pack_options() {
let options = PackOptions::parse(
[
"--target",
"x86_64-pc-windows-gnu",
"--format",
"zip",
"--out-dir",
"custom output",
"--debug",
]
.into_iter()
.map(str::to_owned),
)
.unwrap();
let command = pack_command(Path::new("."), &options);
assert_eq!(
command.get_args().collect::<Vec<_>>(),
[
"run",
"--package",
"xtask",
"--",
"pack",
"--target",
"x86_64-pc-windows-gnu",
"--format",
"zip",
"--out-dir",
"custom output",
"--debug",
]
);
}
#[test]
fn watches_edits_additions_deletions_and_ignores_git_and_outputs() {
let root = std::env::temp_dir().join(format!(
"station-watch-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
let git = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&root)
.args(args)
.status()
.unwrap();
assert!(status.success());
};
git(&["init", "--quiet"]);
fs::write(root.join(".gitignore"), "target/\ndist/\nignored/\n").unwrap();
fs::write(root.join("source.rs"), "one").unwrap();
git(&["add", ".gitignore", "source.rs"]);
let excluded = [
root.join("dist"),
root.join("target"),
root.join("custom output"),
];
let initial = snapshot(&root, &excluded).unwrap();
fs::write(root.join(".git/test-output"), "metadata").unwrap();
for directory in ["dist", "target", "ignored", "custom output"] {
fs::create_dir(root.join(directory)).unwrap();
fs::write(root.join(directory).join("generated"), "output").unwrap();
}
assert_eq!(snapshot(&root, &excluded).unwrap(), initial);
fs::write(root.join("source.rs"), "two").unwrap();
let edited = snapshot(&root, &excluded).unwrap();
assert_ne!(edited, initial);
fs::write(root.join("new.rs"), "new").unwrap();
let added = snapshot(&root, &excluded).unwrap();
assert_ne!(added, edited);
fs::remove_file(root.join("source.rs")).unwrap();
assert_ne!(snapshot(&root, &excluded).unwrap(), added);
fs::remove_dir_all(root).unwrap();
}
}