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
|
||||
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
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`,
|
||||
`--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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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