ww: add -w persistent workdir builds with content-identity reuse

A -w DIR workdir replaces the fresh .sepwork scratch with a caller-owned
persistent package-artifact tree. A package is reused only when its
freshly composed unit byte-equals the committed unit and byte copies of
the compiler/assembler recorded in the dir equal the live tools — pure
content identity, no mtimes, no hashes, every decision reproducible
with cmp against plain files. Recompiles stage at .new names and commit
by rename, unit strictly last, so an interrupted build forces a
recompile and can never leave a committed unit vouching for uncommitted
artifacts; .o/.a additionally reject zero size (ELF/ar are never
empty), while .s/.wwi accept legitimate empties (FFI-only rt). A mode
stamp pins the -T/-S shape and the artifact protocol revision. Classic
scratch keeps its exact acquire/refuse/cleanup contract; run rejects
-w; dir-mode test rejects -w; both driver stages implement identical
behavior and wording.
This commit is contained in:
2026-08-08 03:51:45 +09:00
parent e1141330f2
commit e06b871ab9
3 changed files with 693 additions and 72 deletions

View File

@@ -22,9 +22,9 @@
static const char *usage =
"usage: ww [-V] <subcommand> [args...]\n"
" -V print version and exit\n"
" build [-S] [-o FILE] [path] compile module; -S stops after package asm\n"
" build [-S] [-w DIR] [-o FILE] [path] compile module; -S stops after package asm\n"
" run [path] ... build then exec, passing extra args to the program\n"
" test [-S -o STEM] [options] [path] build/run tests; -S emits package asm\n"
" test [-S -o STEM] [-w DIR] [options] [path] build/run tests; -S emits package asm\n"
" fmt <path> reformat ww source\n"
" version print version and exit\n"
"\n"
@@ -868,16 +868,104 @@ archive_o(const char *objpath, const char *apath)
return 0;
}
/* ---- -w workdir freshness ---------------------------------------------
* A `-w DIR` workdir is a caller-owned persistent package-artifact tree
* that replaces the fresh `.sepwork` scratch. Staleness is pure content
* identity, never mtime: a package is reused only when its freshly
* composed unit byte-equals the committed unit AND the tool copies
* recorded in the dir byte-equal the live tools — every decision is
* reproducible by hand with cmp(1) against plain files. Artifacts commit
* via temp + rename with the unit renamed last, so a killed build can
* never leave a committed unit vouching for uncommitted artifacts. The
* caller serializes invocations per workdir (Make target = one workdir)
* and `make clean` reclaims the state; the wwstage twin is the
* fileequal/copyfileatomic/workdirstamp group in selfhost/cmd/ww/main.ww. */
/* `.s`/`.wwi` may be legitimately empty (an FFI-only package like rt
* emits no text), so committed presence is their freshness test; the
* rename-commit protocol owns integrity. `.o`/`.a` are never empty
* (ELF/ar headers), so a zero size there is always a torn write. */
static int
file_is_reg(const char *path)
{
struct stat st;
return stat(path, &st) == 0 && S_ISREG(st.st_mode);
}
static int
file_size_nonzero(const char *path)
{
struct stat st;
return stat(path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0;
}
/* Byte equality of two files; absence or IO error is inequality. */
static int
file_equal(const char *a, const char *b)
{
FILE *fa = fopen(a, "rb");
if (fa == NULL) return 0;
FILE *fb = fopen(b, "rb");
if (fb == NULL) { fclose(fa); return 0; }
static char ba[65536], bb[65536];
int eq = 1;
for (;;) {
size_t na = fread(ba, 1, sizeof ba, fa);
size_t nb = fread(bb, 1, sizeof bb, fb);
if (na != nb || memcmp(ba, bb, na) != 0) { eq = 0; break; }
if (na < sizeof ba) {
if (ferror(fa) || ferror(fb)) eq = 0;
break;
}
}
fclose(fa); fclose(fb);
return eq;
}
/* Replace dst with src's bytes via temp + rename, so a torn write can
* never masquerade as a committed tool copy. */
static int
copy_file_atomic(const char *src, const char *dst)
{
char tmp[1100];
snprintf(tmp, sizeof tmp, "%s.new", dst);
FILE *in = fopen(src, "rb");
if (in == NULL) return -1;
FILE *out = fopen(tmp, "wb");
if (out == NULL) { fclose(in); return -1; }
static char buf[65536];
size_t n;
while ((n = fread(buf, 1, sizeof buf, in)) > 0)
if (fwrite(buf, 1, n, out) != n) {
fclose(in); fclose(out); return -1;
}
int bad = ferror(in);
fclose(in);
if (fclose(out) != 0 || bad) return -1;
return rename(tmp, dst);
}
/* The stamp pins the non-content build inputs a unit compare cannot see:
* the -T/-S shape of the producer pass and the artifact protocol
* revision (bump "fmt" when the unit/archive/commit format changes). */
static void
workdir_stamp_text(char *buf, size_t bufsz, int is_test, int emit_asm)
{
snprintf(buf, bufsz, "ww workdir fmt 1 mode %s asm %d\n",
is_test ? "test" : "build", emit_asm);
}
/* build_one_sep — discover_deps, reverse_topo,
* the transitive producer loop (one `w6c -c -I` per package, dep-first,
* each DEP `.o` wrapped in its own deterministic `.a`), then a
* reverse-topo `w6l` of the root `.o` + dep `.a` set + libwwrt.a. Side
* files land in a cold `<stem>.sepwork` dir. */
* files land in a cold `<stem>.sepwork` dir, or under the persistent
* `-w` workdir with content-identity package reuse. */
static int
build_one_sep_impl(const char *src, int entry_is_dir, const char *out,
const char *objstem, const char *extra_includes, const char *extra_libs,
const char *extra_libdirs, int is_test, int emit_asm, char *scratchout,
size_t scratchoutsz, struct sepgraph **graphout)
const char *extra_libdirs, int is_test, int emit_asm, const char *workdir,
char *scratchout, size_t scratchoutsz, struct sepgraph **graphout)
{
const char *c6 = toolpath("WW_W6C", "w6c");
const char *a6 = toolpath("WW_W6A", "w6a");
@@ -930,16 +1018,50 @@ build_one_sep_impl(const char *src, int entry_is_dir, const char *out,
if (dot && strcmp(dot, ".ww") == 0) *dot = '\0';
}
const char *ostem = (objstem && objstem[0]) ? objstem : stem;
int warm = workdir != NULL && workdir[0] != 0;
char scratch[1100];
snprintf(scratch, sizeof scratch, "%s.sepwork", ostem);
if (mkdir(scratch, 0755) != 0) {
fprintf(stderr, "ww: cannot create scratch %s\n", scratch);
return 1;
if (warm) {
struct stat wst;
if (stat(workdir, &wst) != 0 || !S_ISDIR(wst.st_mode)) {
fprintf(stderr, "ww: workdir %s is not a directory\n",
workdir);
return 1;
}
/* The workdir is caller-owned and persistent: no acquisition,
* no refusal, and scratchout stays empty so the wrapper never
* cleans it. */
snprintf(scratch, sizeof scratch, "%s", workdir);
} else {
snprintf(scratch, sizeof scratch, "%s.sepwork", ostem);
if (mkdir(scratch, 0755) != 0) {
fprintf(stderr, "ww: cannot create scratch %s\n", scratch);
return 1;
}
/* Hand the scratch path back only after mkdir succeeds. The
* wrapper therefore never removes a pre-existing path that this
* build failed to acquire. */
if (scratchout) snprintf(scratchout, scratchoutsz, "%s", scratch);
}
int stale_all = 0, stampok = 0;
char toolc[1200] = {0}, toola[1200] = {0}, stampf[1200] = {0};
char stampwant[128];
if (warm) {
snprintf(toolc, sizeof toolc, "%s/.wwtool.w6c", scratch);
snprintf(toola, sizeof toola, "%s/.wwtool.w6a", scratch);
snprintf(stampf, sizeof stampf, "%s/.wwtool.stamp", scratch);
workdir_stamp_text(stampwant, sizeof stampwant, is_test, emit_asm);
char got[128] = {0};
FILE *sf = fopen(stampf, "rb");
if (sf) {
size_t rn = fread(got, 1, sizeof got - 1, sf);
got[rn] = 0;
fclose(sf);
}
stampok = strcmp(stampwant, got) == 0;
if (!stampok || !file_equal(toolc, c6)
|| (!emit_asm && !file_equal(toola, a6)))
stale_all = 1;
}
/* Hand the scratch path back only after mkdir succeeds. The wrapper
* therefore never removes a pre-existing path that this build failed
* to acquire. */
if (scratchout) snprintf(scratchout, scratchoutsz, "%s", scratch);
struct sepgraph *g = calloc(1, sizeof *g);
if (g == NULL) return 1;
@@ -978,13 +1100,40 @@ build_one_sep_impl(const char *src, int entry_is_dir, const char *out,
/* producer loop — dep-first, one `w6c -c -I` pass per package. */
for (int oi = 0; oi < norder; oi++) {
int pi = order[oi];
char unitf[1024], wwi[1024], asmf[1024], obj[1024], cmd[8192];
char unitf[1024], wwi[1024], asmf[1024], obj[1024], apath[1024];
char unitnew[1024], wwinew[1024], asmnew[1024], objnew[1024];
char anew[1024], cmd[8192];
sep_fname(g, pi, scratch, ".unit.ww", unitf, sizeof unitf);
sep_fname(g, pi, scratch, ".wwi", wwi, sizeof wwi);
sep_fname(g, pi, scratch, ".s", asmf, sizeof asmf);
sep_fname(g, pi, scratch, ".o", obj, sizeof obj);
sep_fname(g, pi, scratch, ".a", apath, sizeof apath);
sep_fname(g, pi, scratch, ".unit.new", unitnew, sizeof unitnew);
sep_fname(g, pi, scratch, ".wwi.new", wwinew, sizeof wwinew);
sep_fname(g, pi, scratch, ".s.new", asmnew, sizeof asmnew);
sep_fname(g, pi, scratch, ".o.new", objnew, sizeof objnew);
sep_fname(g, pi, scratch, ".a.new", anew, sizeof anew);
/* Warm mode compiles from staged `.new` paths and commits by
* rename; classic mode keeps its exact in-place paths. */
const char *cu = warm ? unitnew : unitf;
const char *cw = warm ? wwinew : wwi;
const char *cs = warm ? asmnew : asmf;
const char *co = warm ? objnew : obj;
const char *ca = warm ? anew : apath;
if (sep_compose_unit(g, pi, scratch, order, norder, srcdir,
unitf) < 0) { free(order); return 1; }
cu) < 0) { free(order); return 1; }
if (warm && !stale_all && file_equal(unitnew, unitf)
&& file_is_reg(asmf)
&& (pi == root || file_is_reg(wwi))
&& (emit_asm || (file_size_nonzero(obj)
&& (pi == root || file_size_nonzero(apath))))) {
if (unlink(unitnew) != 0) {
fprintf(stderr, "ww: cannot remove %s\n",
unitnew);
free(order); return 1;
}
continue;
}
/* BUG-1 (#69): -I <wwi> is purely the root's UNUSED
* `.wwi` output path, but it triggers wwi_emit →
* check_exported_type on the root. A terminal binary's
@@ -997,17 +1146,17 @@ build_one_sep_impl(const char *src, int entry_is_dir, const char *out,
* so w6c synthesizes the test main. Deps never
* get -T. */
snprintf(cmd, sizeof cmd, "%s %s-c -o %s %s",
c6, is_test ? "-T " : "", asmf, unitf);
c6, is_test ? "-T " : "", cs, cu);
else
snprintf(cmd, sizeof cmd, "%s -c -I %s -o %s %s",
c6, wwi, asmf, unitf);
c6, cw, cs, cu);
if (run(cmd) != 0) {
fprintf(stderr, "ww: w6c failed for %s\n",
g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)");
free(order); return 1;
}
if (!emit_asm) {
snprintf(cmd, sizeof cmd, "%s -o %s %s", a6, obj, asmf);
snprintf(cmd, sizeof cmd, "%s -o %s %s", a6, co, cs);
if (run(cmd) != 0) {
fprintf(stderr, "ww: w6a failed for %s\n",
g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)");
@@ -1020,14 +1169,53 @@ build_one_sep_impl(const char *src, int entry_is_dir, const char *out,
* before any archive is processed. The link consumes `.o`/`.a`,
* never `.wwi`. */
if (!emit_asm && pi != root) {
char apath[1024];
sep_fname(g, pi, scratch, ".a", apath, sizeof apath);
if (archive_o(obj, apath) != 0) {
if (archive_o(co, ca) != 0) {
fprintf(stderr, "ww: archive failed for %s\n",
g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)");
free(order); return 1;
}
}
/* Commit order: artifacts before the unit that vouches for
* them, unit strictly last. */
if (warm) {
if ((pi != root && rename(wwinew, wwi) != 0)
|| rename(asmnew, asmf) != 0
|| (!emit_asm && rename(objnew, obj) != 0)
|| (!emit_asm && pi != root
&& rename(anew, apath) != 0)
|| rename(unitnew, unitf) != 0) {
fprintf(stderr, "ww: cannot commit %s\n",
g->pkg[pi].path[0] ? g->pkg[pi].path : "(root)");
free(order); return 1;
}
}
}
/* Tool identity commits only after every package artifact it vouches
* for is itself committed; a killed pass leaves the old identity and
* forces a full recompile, never a false reuse. */
if (warm) {
if (!file_equal(toolc, c6)
&& copy_file_atomic(c6, toolc) != 0) {
fprintf(stderr, "ww: cannot record %s\n", toolc);
free(order); return 1;
}
if (!emit_asm && !file_equal(toola, a6)
&& copy_file_atomic(a6, toola) != 0) {
fprintf(stderr, "ww: cannot record %s\n", toola);
free(order); return 1;
}
if (!stampok) {
char stampnew[1300];
snprintf(stampnew, sizeof stampnew, "%s.new", stampf);
FILE *sf = fopen(stampnew, "wb");
int bad = sf == NULL || fputs(stampwant, sf) == EOF;
if (sf != NULL && fclose(sf) != 0) bad = 1;
if (bad || rename(stampnew, stampf) != 0) {
fprintf(stderr, "ww: cannot record %s\n",
stampf);
free(order); return 1;
}
}
}
if (emit_asm) { free(order); return 0; }
@@ -1077,13 +1265,14 @@ build_one_sep_impl(const char *src, int entry_is_dir, const char *out,
static int
build_one_sep(const char *src, int entry_is_dir, const char *out,
const char *objstem, const char *extra_includes, const char *extra_libs,
const char *extra_libdirs, int is_test, int emit_asm, int keepscratch)
const char *extra_libdirs, int is_test, int emit_asm, int keepscratch,
const char *workdir)
{
char scratch[1100] = {0};
struct sepgraph *g = NULL;
int r = build_one_sep_impl(src, entry_is_dir, out, objstem,
extra_includes, extra_libs, extra_libdirs, is_test, emit_asm,
scratch, sizeof scratch, &g);
workdir, scratch, sizeof scratch, &g);
sep_graph_free(g);
if (!keepscratch && scratch[0]) {
size_t sl = strlen(scratch);
@@ -1197,6 +1386,7 @@ parse_build_flags(const char *cmd, int argc, char **argv,
char *libdirs, size_t libdirsz,
char *libs, size_t libsz,
char *outpath, size_t outsz,
char *workdir, size_t workdirsz,
const char **src_out, int *emit_asm_out)
{
*src_out = NULL;
@@ -1209,6 +1399,23 @@ parse_build_flags(const char *cmd, int argc, char **argv,
return -1;
}
*emit_asm_out = 1;
} else if (strcmp(argv[i], "-w") == 0) {
if (workdir == NULL) {
fprintf(stderr, "ww %s: unknown flag\n", cmd);
return -1;
}
if (i + 1 >= argc) {
fprintf(stderr,
"ww %s: -w needs an argument\n", cmd);
return -1;
}
snprintf(workdir, workdirsz, "%s", argv[++i]);
} else if (strncmp(argv[i], "-w", 2) == 0 && argv[i][2]) {
if (workdir == NULL) {
fprintf(stderr, "ww %s: unknown flag\n", cmd);
return -1;
}
snprintf(workdir, workdirsz, "%s", argv[i] + 2);
} else if (strncmp(argv[i], "-l", 2) == 0 && argv[i][2]) {
size_t n = strlen(libs);
snprintf(libs + n, libsz - n,
@@ -1277,10 +1484,12 @@ do_build(int argc, char **argv)
char libdirs[2048] = {0};
char incs[2048] = {0};
char outflag[1024] = {0};
char workdir[1024] = {0};
int emit_asm = 0;
if (parse_build_flags("build", argc, argv, incs, sizeof incs,
libdirs, sizeof libdirs, libs, sizeof libs,
outflag, sizeof outflag, &src, &emit_asm) < 0)
outflag, sizeof outflag, workdir, sizeof workdir,
&src, &emit_asm) < 0)
return 2;
if (src == NULL) src = "."; /* default: build cwd */
char resolved[1024];
@@ -1307,7 +1516,7 @@ do_build(int argc, char **argv)
basename_no_ext(resolved, out, sizeof out);
}
return build_one_sep(resolved, is_dir, out, objstem, incs, libs,
libdirs, 0, emit_asm, 1);
libdirs, 0, emit_asm, 1, workdir);
}
static int
@@ -1320,7 +1529,7 @@ do_run(int argc, char **argv)
char outflag[1024] = {0}; /* -o accepted+ignored: run always uses the temp */
int next = parse_build_flags("run", argc, argv, incs, sizeof incs,
libdirs, sizeof libdirs, libs, sizeof libs,
outflag, sizeof outflag, &src, NULL);
outflag, sizeof outflag, NULL, 0, &src, NULL);
if (next < 0) return 2;
if (src == NULL) src = ".";
char resolved[1024];
@@ -1339,7 +1548,7 @@ do_run(int argc, char **argv)
/* The freshly acquired directory owns both the executable and the
* adjacent main.sepwork tree. Nothing outside it is adopted or removed. */
if (build_one_sep(resolved, is_dir, tmp, tmp, incs, libs, libdirs, 0, 0,
0) != 0) {
0, NULL) != 0) {
if (unlink(tmp) != 0 && errno != ENOENT)
fputs("ww: cannot remove temporary output\n", stderr);
if (rmdir(tmpdir) != 0)
@@ -1398,6 +1607,7 @@ do_test(int argc, char **argv)
int compileonly = 0;
int emit_asm = 0;
char outstem[1024] = {0};
char workdir[1024] = {0};
int packageopts = 0;
int afterdash = 0;
/* #17: an optional second positional after the target is a fnmatch
@@ -1455,6 +1665,15 @@ do_test(int argc, char **argv)
snprintf(outstem, sizeof outstem, "%s", argv[++i]);
} else if (argv[i][1] == 'o' && argv[i][2]) {
snprintf(outstem, sizeof outstem, "%s", argv[i] + 2);
} else if (strcmp(argv[i], "-w") == 0) {
if (i + 1 >= argc) {
fprintf(stderr,
"ww test: -w needs an argument\n");
return 2;
}
snprintf(workdir, sizeof workdir, "%s", argv[++i]);
} else if (argv[i][1] == 'w' && argv[i][2]) {
snprintf(workdir, sizeof workdir, "%s", argv[i] + 2);
} else {
fprintf(stderr, "ww test: unknown flag\n");
return 2;
@@ -1487,6 +1706,11 @@ do_test(int argc, char **argv)
"ww test: -c/-S/-o need a single test file\n");
return 2;
}
if (workdir[0]) {
fprintf(stderr,
"ww test: -w needs a single test file\n");
return 2;
}
if (pattern) {
fprintf(stderr,
"ww test: pattern needs a single test file\n");
@@ -1501,8 +1725,14 @@ do_test(int argc, char **argv)
}
char tmpdir[1024] = {0}, tmp[1024];
const char *outp;
int owntmp = !outstem[0] && !workdir[0];
if (outstem[0]) outp = outstem;
else {
else if (workdir[0]) {
/* The workdir owns the persistent test binary the same
* way it owns the package artifacts. */
snprintf(tmp, sizeof tmp, "%s/main", workdir);
outp = tmp;
} else {
snprintf(tmpdir, sizeof tmpdir, "/tmp/ww_test_%d", getpid());
if (mkdir(tmpdir, 0700) != 0) {
fprintf(stderr, "ww: cannot create temporary directory %s\n",
@@ -1516,32 +1746,32 @@ do_test(int argc, char **argv)
* source. An explicit -o names the caller-owned artifact stem. */
int br = build_one_sep(resolved, is_dir, outp,
outstem[0] ? outstem : tmp, incs, "", "", 1,
emit_asm, outstem[0] ? 1 : 0);
emit_asm, outstem[0] ? 1 : 0, workdir);
if (br != 0) {
if (!outstem[0] && unlink(outp) != 0 && errno != ENOENT)
if (owntmp && unlink(outp) != 0 && errno != ENOENT)
fputs("ww: cannot remove temporary output\n", stderr);
if (!outstem[0] && rmdir(tmpdir) != 0)
if (owntmp && rmdir(tmpdir) != 0)
fputs("ww: cannot remove temporary directory\n", stderr);
return 1;
}
if (compileonly || emit_asm) {
int cleanfail = 0;
if (!outstem[0] && unlink(outp) != 0 && errno != ENOENT) {
if (owntmp && unlink(outp) != 0 && errno != ENOENT) {
fputs("ww: cannot remove temporary output\n", stderr);
cleanfail = 1;
}
if (!outstem[0] && rmdir(tmpdir) != 0) {
if (owntmp && rmdir(tmpdir) != 0) {
fputs("ww: cannot remove temporary directory\n", stderr);
cleanfail = 1;
}
return cleanfail ? 1 : 0;
}
int rc = run_test_bin(outp, pattern);
if (!outstem[0] && unlink(outp) != 0 && errno != ENOENT) {
if (owntmp && unlink(outp) != 0 && errno != ENOENT) {
fputs("ww: cannot remove temporary output\n", stderr);
if (rc == 0) rc = 1;
}
if (!outstem[0] && rmdir(tmpdir) != 0) {
if (owntmp && rmdir(tmpdir) != 0) {
fputs("ww: cannot remove temporary directory\n", stderr);
if (rc == 0) rc = 1;
}
@@ -1556,8 +1786,12 @@ do_test(int argc, char **argv)
/* single .ww file — build, then run unless -c (compile-only). */
char tmpdir[1024] = {0}, tmp[1024];
const char *outp;
int owntmp = !outstem[0] && !workdir[0];
if (outstem[0]) outp = outstem;
else {
else if (workdir[0]) {
snprintf(tmp, sizeof tmp, "%s/main", workdir);
outp = tmp;
} else {
snprintf(tmpdir, sizeof tmpdir, "/tmp/ww_test_%d", getpid());
if (mkdir(tmpdir, 0700) != 0) {
fprintf(stderr, "ww: cannot create temporary directory %s\n",
@@ -1569,32 +1803,32 @@ do_test(int argc, char **argv)
}
/* See module-mode note: no-o scratch is redirected to /tmp. */
int br = build_one_sep(target, 0, outp, outstem[0] ? outstem : tmp,
incs, "", "", 1, emit_asm, outstem[0] ? 1 : 0);
incs, "", "", 1, emit_asm, outstem[0] ? 1 : 0, workdir);
if (br != 0) {
if (!outstem[0] && unlink(outp) != 0 && errno != ENOENT)
if (owntmp && unlink(outp) != 0 && errno != ENOENT)
fputs("ww: cannot remove temporary output\n", stderr);
if (!outstem[0] && rmdir(tmpdir) != 0)
if (owntmp && rmdir(tmpdir) != 0)
fputs("ww: cannot remove temporary directory\n", stderr);
return 1;
}
if (compileonly || emit_asm) {
int cleanfail = 0;
if (!outstem[0] && unlink(outp) != 0 && errno != ENOENT) {
if (owntmp && unlink(outp) != 0 && errno != ENOENT) {
fputs("ww: cannot remove temporary output\n", stderr);
cleanfail = 1;
}
if (!outstem[0] && rmdir(tmpdir) != 0) {
if (owntmp && rmdir(tmpdir) != 0) {
fputs("ww: cannot remove temporary directory\n", stderr);
cleanfail = 1;
}
return cleanfail ? 1 : 0;
}
int rc = run_test_bin(outp, pattern);
if (!outstem[0] && unlink(outp) != 0 && errno != ENOENT) {
if (owntmp && unlink(outp) != 0 && errno != ENOENT) {
fputs("ww: cannot remove temporary output\n", stderr);
if (rc == 0) rc = 1;
}
if (!outstem[0] && rmdir(tmpdir) != 0) {
if (owntmp && rmdir(tmpdir) != 0) {
fputs("ww: cannot remove temporary directory\n", stderr);
if (rc == 0) rc = 1;
}
@@ -1608,6 +1842,10 @@ do_test(int argc, char **argv)
fprintf(stderr, "ww test: -c/-S/-o need a single test file\n");
return 2;
}
if (workdir[0]) {
fprintf(stderr, "ww test: -w needs a single test file\n");
return 2;
}
/* The second bare positional remains the legacy single-file filter form;
* package filtering uses explicit -run/-filter options. */
if (pattern) {