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:
parent
29275c38fc
commit
6af3df0ecc
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue