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
- 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.
- After changing files, run `cargo fmt --all`. Do not override project
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
- `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
and windows. Marked painted Tiled tiles become stateful gameplay entities;
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`,
`--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
(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

View File

@ -2,8 +2,11 @@
//!
//! ```text
//! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug]
//! cargo xtask watch [pack options]
//! ```
mod watch;
use std::{
env,
error::Error,
@ -25,6 +28,7 @@ fn main() -> ExitCode {
let mut args = env::args().skip(1);
let result = match args.next().as_deref() {
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 => {
print_help();
return ExitCode::SUCCESS;
@ -44,8 +48,9 @@ fn print_help() {
println!(
"usage: cargo xtask <command>\n\n\
commands:\n \
pack build the client and server and pack them with their assets\n\n\
pack options:\n \
pack build the client and server and pack them with their assets\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 \
--format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\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();
}
}