Skip to content

Commit 101c10d

Browse files
committed
feat(regen): add gloam regen subcommand for in-place tree regeneration
Every generated tree records the command line that produced it, but the recorded --out-path is relative to whatever cwd the original invocation used, which is unknowable later — so replaying the recorded command only worked from that exact directory. `gloam regen [paths...]` replays each tree's recorded command with the current gloam, deriving the effective output path from the manifest's own location (<tree>/.gloam/manifest.json) and re-recording the original command line verbatim. Trees therefore regenerate correctly from any working directory, and regeneration never rewrites the recorded command. A path may be a tree root, a directory to search recursively (default: .), or a manifest file itself; bare `gloam lock` snapshots regenerate in place, unrelated files named manifest.json are skipped, and .git/target directories are never descended into. By default each replay is pinned to the tree's recorded provenance (--lock semantics), so a diff shows only the effect of gloam changes; --fresh re-resolves sources instead, advancing the tree. The suggested review filter now matches the current preamble stamps (C ` * @generated`, Rust `// @generated`) and additionally ignores the output BOM's 6-space "blob" lines, which track stamp churn byte-for-byte; that is safe because a provenance pin's blob never moves without its unfiltered sibling "commit" line moving too. Signed-off-by: Steven Noonan <steven@uplinklabs.net>
1 parent 406e1e7 commit 101c10d

5 files changed

Lines changed: 533 additions & 6 deletions

File tree

src/cli.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ pub enum Generator {
9595
/// every supported upstream source at the current bundle (or, with --fetch,
9696
/// upstream HEAD). Reuse it later with --lock for reproducible generation.
9797
Lock(LockArgs),
98+
/// Regenerate existing gloam output trees in place by replaying the
99+
/// command line recorded in each tree's .gloam/manifest.json with this
100+
/// gloam. By default each tree is pinned to its recorded provenance, so
101+
/// output changes only if gloam itself changed; use --fresh to re-resolve
102+
/// sources and advance the tree instead.
103+
Regen(RegenArgs),
98104
}
99105

100106
#[derive(Args, Debug)]
@@ -108,6 +114,24 @@ pub struct LockArgs {
108114
pub out: String,
109115
}
110116

117+
#[derive(Args, Debug)]
118+
pub struct RegenArgs {
119+
/// What to regenerate: a tree root (a directory containing
120+
/// .gloam/manifest.json), a directory to search recursively for trees
121+
/// and `gloam lock` snapshots (files named manifest.json), or a manifest
122+
/// file itself. Defaults to the current directory.
123+
#[arg(default_value = ".")]
124+
pub paths: Vec<std::path::PathBuf>,
125+
126+
/// Re-resolve upstream sources (bundled, or upstream HEAD if the
127+
/// recorded command used --fetch) instead of pinning each tree to its
128+
/// recorded provenance. This is the tree-update workflow; the default
129+
/// locked mode is the audit workflow, where a diff shows only the effect
130+
/// of gloam code changes.
131+
#[arg(long)]
132+
pub fresh: bool,
133+
}
134+
111135
#[derive(Args, Debug)]
112136
pub struct CArgs {
113137
/// Enable bijective function-pointer alias resolution.

src/lib.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod ir;
1616
mod parse;
1717
mod preamble;
1818
pub mod provenance;
19+
mod regen;
1920
mod resolve;
2021
mod version;
2122

@@ -38,9 +39,25 @@ pub fn main() {
3839
fn run() -> Result<()> {
3940
let cli = Cli::parse();
4041

42+
// `gloam regen` replays the command line recorded in existing output
43+
// trees; it never records its own argv, so it branches off before
44+
// command-line reconstruction.
45+
if matches!(cli.generator, Generator::Regen(_)) {
46+
return regen::run(cli);
47+
}
48+
4149
let argv: Vec<String> = std::env::args().collect();
4250
let command_line = reconstruct_command_line(&argv);
51+
execute(cli, &command_line)
52+
}
4353

54+
/// Run one parsed invocation. `command_line` is the string recorded in
55+
/// generated preambles and the manifest: for a direct invocation, the
56+
/// caller's reconstruction of its own argv; under `gloam regen`, the tree's
57+
/// originally recorded command line, preserved verbatim so regeneration
58+
/// never rewrites it (its `--out-path` is a historical record — placement is
59+
/// overridden via `cli.out_path` instead).
60+
pub(crate) fn execute(cli: Cli, command_line: &str) -> Result<()> {
4461
// A --lock manifest pins upstream sources to recorded provenance. Only its
4562
// `provenance` section is used; everything else is regenerated. Unlike the
4663
// best-effort implicit baseline (`read_snapshot`), --lock is a contract, so
@@ -71,7 +88,7 @@ fn run() -> Result<()> {
7188

7289
// `gloam lock`: write a provenance-only snapshot, no loader generation.
7390
if let Generator::Lock(lock_args) = &cli.generator {
74-
return write_lock_snapshot(&store, &command_line, lock_args, diag);
91+
return write_lock_snapshot(&store, command_line, lock_args, diag);
7592
}
7693

7794
diag.info("resolving feature sets...");
@@ -114,7 +131,7 @@ fn run() -> Result<()> {
114131
Generator::C(c_args) => {
115132
diag.info("generating C loader...");
116133
for fs in &feature_sets {
117-
let tree = generator::c::generate(fs, c_args, out, &store, &command_line)?;
134+
let tree = generator::c::generate(fs, c_args, out, &store, command_line)?;
118135
pins.extend(tree.pins);
119136
for f in tree.files {
120137
files.entry(f.path.clone()).or_insert(f);
@@ -124,17 +141,17 @@ fn run() -> Result<()> {
124141
Generator::Rust(rust_args) => {
125142
diag.info("generating Rust loader...");
126143
for fs in &feature_sets {
127-
let tree = generator::rust::generate(fs, rust_args, out, &store, &command_line)?;
144+
let tree = generator::rust::generate(fs, rust_args, out, &store, command_line)?;
128145
pins.extend(tree.pins);
129146
for f in tree.files {
130147
files.entry(f.path.clone()).or_insert(f);
131148
}
132149
}
133150
}
134-
Generator::Lock(_) => unreachable!("handled above"),
151+
Generator::Lock(_) | Generator::Regen(_) => unreachable!("handled above"),
135152
}
136153

137-
write_manifest(out, &command_line, pins, files)?;
154+
write_manifest(out, command_line, pins, files)?;
138155

139156
diag.info("done.");
140157

src/regen.rs

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
//! `gloam regen` — regenerate existing output trees in place.
2+
//!
3+
//! Every generated tree records the exact command line that produced it in
4+
//! `<tree>/.gloam/manifest.json`, so a tree is self-describing: regeneration
5+
//! replays that command with the current gloam. The recorded `--out-path`
6+
//! is a historical record of the original invocation — it was relative to
7+
//! whatever directory the user happened to generate from, which is
8+
//! unknowable later — so replay derives the effective output path from the
9+
//! manifest's own location instead, and re-records the original command line
10+
//! verbatim. Trees therefore regenerate correctly from any working
11+
//! directory, and regeneration never rewrites the recorded command.
12+
//!
13+
//! By default each replay is pinned to the tree's recorded provenance
14+
//! (`--lock` semantics): with an unchanged gloam the output is
15+
//! byte-identical, and after a gloam change `git diff` shows exactly what
16+
//! the change did. `--fresh` re-resolves sources instead (bundled, or
17+
//! upstream if the recorded command used `--fetch`), advancing the tree —
18+
//! the normal update workflow.
19+
20+
use std::path::{Path, PathBuf};
21+
22+
use anyhow::{Context, Result, bail};
23+
use clap::Parser;
24+
25+
use crate::cli::{Cli, Generator};
26+
use crate::diag::Diag;
27+
use crate::provenance::manifest::{Manifest, SCHEMA_VERSION};
28+
29+
pub fn run(cli: Cli) -> Result<()> {
30+
let Generator::Regen(ref args) = cli.generator else {
31+
unreachable!("regen::run dispatched for a non-regen command");
32+
};
33+
let diag = Diag::new(cli.quiet);
34+
35+
// (manifest path, explicitly named). Problems in an explicitly named
36+
// manifest are hard errors; discovered candidates that turn out not to
37+
// be gloam manifests are skipped silently, because any project may
38+
// contain unrelated files named manifest.json.
39+
let mut manifests: Vec<(PathBuf, bool)> = Vec::new();
40+
for path in &args.paths {
41+
if path.is_file() {
42+
manifests.push((path.clone(), true));
43+
} else if path.is_dir() {
44+
let mut found = Vec::new();
45+
find_manifests(path, &mut found);
46+
found.sort();
47+
manifests.extend(found.into_iter().map(|p| (p, false)));
48+
} else {
49+
bail!("regen path {} is not a file or directory", path.display());
50+
}
51+
}
52+
53+
let mut ran = 0usize;
54+
for (path, explicit) in &manifests {
55+
if replay(path, *explicit, args.fresh, cli.quiet, diag)? {
56+
ran += 1;
57+
}
58+
}
59+
if ran == 0 {
60+
bail!("no gloam manifests found under the given path(s)");
61+
}
62+
diag.info(format!("regenerated {ran} tree(s)"));
63+
// The suggested filter hides pure gloam-stamp churn: the C and Rust
64+
// preamble stamp lines, the manifest's gloam meta block (4-space
65+
// indent), and the output BOM's file hashes (6-space "blob"), which
66+
// track stamp changes byte-for-byte. Ignoring 6-space "blob" lines is
67+
// safe even though provenance pins share the indent: a pin's blob never
68+
// changes without its sibling "commit" line (6-space, not filtered)
69+
// changing too, so a real source change always stays visible.
70+
diag.info(
71+
"review with: git diff -I'^ \\* @generated by gloam ' \
72+
-I'^// @generated by gloam ' -I'^ \"(version|describe|commit)\": ' \
73+
-I'^ \"blob\": '",
74+
);
75+
Ok(())
76+
}
77+
78+
/// Recursively collect `manifest.json` files under `dir`. `.git` is never
79+
/// descended into; neither is `target`, because Cargo build directories can
80+
/// hold packaged copies of real trees (`target/package/...`) that must not
81+
/// be regenerated in place. A tree that genuinely lives under a directory
82+
/// named `target` can still be regenerated by naming it explicitly.
83+
fn find_manifests(dir: &Path, out: &mut Vec<PathBuf>) {
84+
let Ok(entries) = std::fs::read_dir(dir) else {
85+
return;
86+
};
87+
for entry in entries.flatten() {
88+
let path = entry.path();
89+
if path.is_dir() {
90+
if path
91+
.file_name()
92+
.is_some_and(|n| n == ".git" || n == "target")
93+
{
94+
continue;
95+
}
96+
find_manifests(&path, out);
97+
} else if path.file_name().is_some_and(|n| n == "manifest.json") {
98+
out.push(path);
99+
}
100+
}
101+
}
102+
103+
/// Replay the command recorded in one manifest. Returns `Ok(true)` if a
104+
/// tree was regenerated and `Ok(false)` if the candidate was skipped (only
105+
/// possible for discovered, non-explicit candidates).
106+
fn replay(
107+
manifest_path: &Path,
108+
explicit: bool,
109+
fresh: bool,
110+
quiet: bool,
111+
diag: Diag,
112+
) -> Result<bool> {
113+
let text = match std::fs::read_to_string(manifest_path) {
114+
Ok(text) => text,
115+
Err(e) if explicit => {
116+
return Err(e).with_context(|| format!("reading {}", manifest_path.display()));
117+
}
118+
Err(_) => return Ok(false),
119+
};
120+
let manifest = match Manifest::from_json(&text) {
121+
Ok(m) => m,
122+
Err(e) if explicit => {
123+
return Err(e)
124+
.with_context(|| format!("{} is not a gloam manifest", manifest_path.display()));
125+
}
126+
Err(_) => return Ok(false),
127+
};
128+
if manifest.schema_version != SCHEMA_VERSION {
129+
if explicit {
130+
bail!(
131+
"{} has schema_version {}, but this gloam understands {}",
132+
manifest_path.display(),
133+
manifest.schema_version,
134+
SCHEMA_VERSION
135+
);
136+
}
137+
diag.warn(format!(
138+
"{}: skipping (schema_version {} != {})",
139+
manifest_path.display(),
140+
manifest.schema_version,
141+
SCHEMA_VERSION
142+
));
143+
return Ok(false);
144+
}
145+
146+
let recorded = manifest.gloam.command_line;
147+
let tokens: Vec<&str> = recorded.split_whitespace().collect();
148+
if tokens.first().copied() != Some("gloam") {
149+
if explicit {
150+
bail!(
151+
"{} does not record a gloam command line (got '{recorded}')",
152+
manifest_path.display()
153+
);
154+
}
155+
return Ok(false);
156+
}
157+
let mut replay_cli = Cli::try_parse_from(&tokens).with_context(|| {
158+
format!(
159+
"re-parsing the command recorded in {}: '{recorded}'",
160+
manifest_path.display()
161+
)
162+
})?;
163+
164+
// Verbosity follows the regen invocation, not the recorded command.
165+
replay_cli.quiet = quiet;
166+
if !fresh {
167+
replay_cli.lock = Some(manifest_path.to_path_buf());
168+
}
169+
170+
let target = match &mut replay_cli.generator {
171+
Generator::C(_) | Generator::Rust(_) => {
172+
// A generation manifest lives at <tree>/.gloam/manifest.json, so
173+
// its own location is the authoritative output path. The
174+
// recorded --out-path (relative to the original invocation's
175+
// cwd) is preserved in the re-recorded command line but ignored
176+
// for placement.
177+
let tree = manifest_path
178+
.parent()
179+
.filter(|p| p.file_name().is_some_and(|n| n == ".gloam"))
180+
.and_then(Path::parent);
181+
let Some(tree) = tree else {
182+
if explicit {
183+
bail!(
184+
"{} records a generation command but does not live at \
185+
<tree>/.gloam/manifest.json",
186+
manifest_path.display()
187+
);
188+
}
189+
return Ok(false);
190+
};
191+
// parent() of a bare relative ".gloam/manifest.json" is "" —
192+
// that tree root is the current directory.
193+
let tree = if tree.as_os_str().is_empty() {
194+
Path::new(".")
195+
} else {
196+
tree
197+
};
198+
replay_cli.out_path = tree.display().to_string();
199+
replay_cli.out_path.clone()
200+
}
201+
Generator::Lock(lock_args) => {
202+
// A lock snapshot is the manifest file itself; rewrite in place.
203+
lock_args.out = manifest_path.display().to_string();
204+
lock_args.out.clone()
205+
}
206+
Generator::Regen(_) => {
207+
bail!(
208+
"{} records a regen command — refusing to recurse",
209+
manifest_path.display()
210+
);
211+
}
212+
};
213+
214+
diag.info(format!("· {target} $ {recorded}"));
215+
crate::execute(replay_cli, &recorded).with_context(|| format!("regenerating {target}"))?;
216+
Ok(true)
217+
}

src/resolve/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ pub fn build_feature_sets(
7272
let alias = match &cli.generator {
7373
crate::cli::Generator::C(c) => c.alias,
7474
crate::cli::Generator::Rust(r) => r.alias,
75-
crate::cli::Generator::Lock(_) => false, // never reached: lock skips resolution
75+
// Never reached: lock skips resolution, regen replays a C/Rust command.
76+
crate::cli::Generator::Lock(_) | crate::cli::Generator::Regen(_) => false,
7677
};
7778

7879
// Batch the requests: a merged build resolves one feature set per spec

0 commit comments

Comments
 (0)