Skip to content

Commit abbfb7a

Browse files
committed
feat(lock): keep previous commit for repos whose pinned content is unchanged
When the --out file already exists, `gloam lock` now compares each repository's pins against the previous snapshot and carries forward the recorded commit/describe when every pin is byte-identical (same key set, paths, and blobs). Upstream commits that don't touch any pinned file no longer churn the manifest — or anything regenerated from it — so scheduled re-snapshot jobs stop producing no-op regeneration commits. Preservation is per repository, not per file: pins from one repo share a single resolved commit (cluster resolution), and both the generated preamble and --version print one 'repo (describe)' line per repo, so per-file preservation could make pins from the same repo disagree. Any content change (changed blob; file added, removed, or renamed) advances the whole repo to the newly resolved commit. A missing, unreadable, or schema-incompatible --out file is ignored and the snapshot is taken fresh, so deleting the file forces a full re-snapshot at current HEAD. Signed-off-by: Steven Noonan <steven@uplinklabs.net>
1 parent eff5812 commit abbfb7a

5 files changed

Lines changed: 231 additions & 2 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ Commands:
173173
or at upstream HEAD with --fetch. Reuse it later with --lock.
174174
--out <FILE>
175175
Output path for the snapshot [default: manifest.json].
176+
If the file already exists, a repo whose pinned files
177+
all match it keeps its previously recorded
178+
commit/describe; delete the file to force a full
179+
re-snapshot.
176180
```
177181

178182
### Extension selection flags
@@ -228,6 +232,17 @@ This is the mechanism used to generate several loaders from one consistent
228232
upstream snapshot: take a single `gloam lock` snapshot, then drive each
229233
generation with `--lock`.
230234

235+
When the `--out` file already exists from a previous snapshot, `gloam lock`
236+
compares against it per repository: a repo whose pinned files are all
237+
byte-identical (same key set, paths, and blobs) keeps its previously recorded
238+
commit/describe instead of advancing to the current HEAD. Upstream commits that
239+
don't touch any pinned file therefore leave the manifest — and everything
240+
regenerated from it — byte-identical, which keeps scheduled re-snapshot jobs
241+
from churning. Any content change (a changed blob, or a file added, removed, or
242+
renamed) advances the whole repo to the newly resolved commit, so pins from one
243+
repo never disagree about their snapshot. To force every repo to its current
244+
commit regardless, delete the existing snapshot file first.
245+
231246
## Generated output
232247

233248
### Context struct and dispatch

docs/manifest.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,16 @@ any loader — covering every supported upstream source. This gives you a single
249249
point-in-time lock you can reuse across many different loader generations
250250
(`gloam lock` produces what `--lock` consumes).
251251

252+
If the `--out` file already exists, the snapshot is **incremental per
253+
repository**: a repo whose pins are all byte-identical to the previous snapshot
254+
(same key set, `path_in_repo`s, and `blob`s) keeps its previous
255+
`commit`/`describe` rather than advancing to the current HEAD, so
256+
content-neutral upstream commits don't churn the manifest. Any content change
257+
advances every pin of that repo together — pins from one repo always share one
258+
recorded commit. A missing, unreadable, or schema-incompatible `--out` file is
259+
ignored and the snapshot is taken fresh, so deleting the file re-snapshots
260+
every repo at its current commit.
261+
252262
---
253263

254264
## Schema versioning

src/cli.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ pub enum Generator {
9696

9797
#[derive(Args, Debug)]
9898
pub struct LockArgs {
99-
/// Output path for the snapshot manifest.
99+
/// Output path for the snapshot manifest. If the file already exists,
100+
/// repos whose pinned files are all byte-identical to it keep their
101+
/// previously recorded commit/describe, so upstream commits that don't
102+
/// touch any pinned file don't churn the manifest. Delete the file to
103+
/// force a full re-snapshot.
100104
#[arg(long, default_value = "manifest.json")]
101105
pub out: String,
102106
}

src/lib.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,29 @@ fn write_lock_snapshot(
151151
.collect();
152152
pins.sort_keys();
153153

154+
let path = std::path::Path::new(&args.out);
155+
156+
// Carry forward commit/describe from an existing snapshot at --out for
157+
// every repo whose pinned content is unchanged. An upstream commit that
158+
// doesn't touch any pinned file then leaves the manifest — and everything
159+
// regenerated from it — byte-identical. Deleting the file forces a full
160+
// re-snapshot.
161+
if let Some(prev) = read_snapshot(path) {
162+
let kept = provenance::manifest::preserve_unchanged_repos(&mut pins, &prev.provenance);
163+
if !quiet {
164+
for repo in &kept {
165+
eprintln!("gloam: {repo}: pinned content unchanged, keeping previous commit");
166+
}
167+
}
168+
}
169+
154170
let manifest = Manifest {
155171
schema_version: SCHEMA_VERSION,
156172
gloam: gloam_meta(command_line),
157173
provenance: pins,
158174
output: Vec::new(),
159175
};
160176

161-
let path = std::path::Path::new(&args.out);
162177
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
163178
std::fs::create_dir_all(parent)?;
164179
}
@@ -169,6 +184,14 @@ fn write_lock_snapshot(
169184
Ok(())
170185
}
171186

187+
/// Best-effort read of an existing snapshot manifest. Missing, unreadable, or
188+
/// schema-mismatched files are ignored — the snapshot is simply taken fresh.
189+
fn read_snapshot(path: &std::path::Path) -> Option<Manifest> {
190+
let text = std::fs::read_to_string(path).ok()?;
191+
let m = Manifest::from_json(&text).ok()?;
192+
(m.schema_version == SCHEMA_VERSION).then_some(m)
193+
}
194+
172195
/// Write `.gloam/manifest.json` — the deterministic, pretty-printed bill of
173196
/// materials for the output tree. No timestamps: identical inputs + gloam
174197
/// version produce a byte-identical manifest.

src/provenance/manifest.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,60 @@ impl Manifest {
9494
}
9595
}
9696

97+
/// Carry forward `commit`/`describe` from a previous pin set for every
98+
/// repository whose pinned content is unchanged, returning the preserved repo
99+
/// slugs.
100+
///
101+
/// Granularity is per repository, not per file: pins from one repo share a
102+
/// single resolved commit (cluster resolution), and both the generated
103+
/// preamble and `--version` print one `repo (describe)` line per repo, so
104+
/// per-file preservation could make pins from the same repo disagree. A repo
105+
/// is preserved only when its key set matches the previous snapshot exactly
106+
/// and every pin has the same `repo_url`, `path_in_repo`, and `blob`; any
107+
/// difference (changed blob, added/removed/renamed file) keeps the whole repo
108+
/// at the newly resolved commit.
109+
pub fn preserve_unchanged_repos(
110+
pins: &mut IndexMap<String, ProvenancePin>,
111+
prev: &IndexMap<String, ProvenancePin>,
112+
) -> Vec<String> {
113+
let mut by_repo: IndexMap<String, Vec<String>> = IndexMap::new();
114+
for (key, pin) in pins.iter() {
115+
by_repo
116+
.entry(pin.repo.clone())
117+
.or_default()
118+
.push(key.clone());
119+
}
120+
121+
let mut preserved = Vec::new();
122+
for (repo, keys) in &by_repo {
123+
let prev_count = prev.values().filter(|p| &p.repo == repo).count();
124+
if prev_count != keys.len() {
125+
continue;
126+
}
127+
let unchanged = keys.iter().all(|k| {
128+
prev.get(k).is_some_and(|old| {
129+
let new = &pins[k];
130+
old.repo == new.repo
131+
&& old.repo_url == new.repo_url
132+
&& old.path_in_repo == new.path_in_repo
133+
&& old.blob == new.blob
134+
})
135+
});
136+
if !unchanged {
137+
continue;
138+
}
139+
for k in keys {
140+
let old = &prev[k];
141+
let (commit, describe) = (old.commit.clone(), old.describe.clone());
142+
let pin = pins.get_mut(k).unwrap();
143+
pin.commit = commit;
144+
pin.describe = describe;
145+
}
146+
preserved.push(repo.clone());
147+
}
148+
preserved
149+
}
150+
97151
/// Compute the git blob SHA-1 of `content` — identical to `git hash-object`,
98152
/// so manifest hashes equal the file's blob hash in any git repo.
99153
pub fn git_blob_sha1(content: &[u8]) -> String {
@@ -185,4 +239,127 @@ mod tests {
185239
let json2 = serde_json::to_string(&e2).unwrap();
186240
assert!(json2.contains("\"verbatim\":true"));
187241
}
242+
243+
// ---- preserve_unchanged_repos ----
244+
245+
fn pin(repo: &str, path: &str, commit: &str, blob: &str) -> ProvenancePin {
246+
ProvenancePin {
247+
repo: repo.to_string(),
248+
repo_url: format!("https://github.com/{repo}"),
249+
path_in_repo: path.to_string(),
250+
commit: commit.to_string(),
251+
describe: commit[..commit.len().min(7)].to_string(),
252+
blob: blob.to_string(),
253+
}
254+
}
255+
256+
fn pins(entries: &[(&str, ProvenancePin)]) -> IndexMap<String, ProvenancePin> {
257+
entries
258+
.iter()
259+
.map(|(k, p)| (k.to_string(), p.clone()))
260+
.collect()
261+
}
262+
263+
#[test]
264+
fn preserve_keeps_previous_commit_when_blobs_unchanged() {
265+
let prev = pins(&[
266+
("a.xml", pin("org/repo", "x/a.xml", "oldcommit", "blob-a")),
267+
("b.xml", pin("org/repo", "x/b.xml", "oldcommit", "blob-b")),
268+
]);
269+
let mut new = pins(&[
270+
("a.xml", pin("org/repo", "x/a.xml", "newcommit", "blob-a")),
271+
("b.xml", pin("org/repo", "x/b.xml", "newcommit", "blob-b")),
272+
]);
273+
274+
let kept = preserve_unchanged_repos(&mut new, &prev);
275+
assert_eq!(kept, vec!["org/repo"]);
276+
assert_eq!(new["a.xml"].commit, "oldcommit");
277+
assert_eq!(new["a.xml"].describe, "oldcomm");
278+
assert_eq!(new["b.xml"].commit, "oldcommit");
279+
}
280+
281+
#[test]
282+
fn preserve_advances_whole_repo_when_any_blob_changed() {
283+
let prev = pins(&[
284+
("a.xml", pin("org/repo", "x/a.xml", "oldcommit", "blob-a")),
285+
("b.xml", pin("org/repo", "x/b.xml", "oldcommit", "blob-b")),
286+
]);
287+
let mut new = pins(&[
288+
("a.xml", pin("org/repo", "x/a.xml", "newcommit", "blob-a")),
289+
("b.xml", pin("org/repo", "x/b.xml", "newcommit", "blob-b2")),
290+
]);
291+
292+
let kept = preserve_unchanged_repos(&mut new, &prev);
293+
assert!(kept.is_empty());
294+
// Both pins advance together — no intra-repo commit divergence.
295+
assert_eq!(new["a.xml"].commit, "newcommit");
296+
assert_eq!(new["b.xml"].commit, "newcommit");
297+
}
298+
299+
#[test]
300+
fn preserve_advances_on_added_file() {
301+
let prev = pins(&[("a.xml", pin("org/repo", "x/a.xml", "oldcommit", "blob-a"))]);
302+
let mut new = pins(&[
303+
("a.xml", pin("org/repo", "x/a.xml", "newcommit", "blob-a")),
304+
("b.xml", pin("org/repo", "x/b.xml", "newcommit", "blob-b")),
305+
]);
306+
307+
assert!(preserve_unchanged_repos(&mut new, &prev).is_empty());
308+
assert_eq!(new["a.xml"].commit, "newcommit");
309+
}
310+
311+
#[test]
312+
fn preserve_advances_on_removed_file() {
313+
let prev = pins(&[
314+
("a.xml", pin("org/repo", "x/a.xml", "oldcommit", "blob-a")),
315+
("b.xml", pin("org/repo", "x/b.xml", "oldcommit", "blob-b")),
316+
]);
317+
let mut new = pins(&[("a.xml", pin("org/repo", "x/a.xml", "newcommit", "blob-a"))]);
318+
319+
assert!(preserve_unchanged_repos(&mut new, &prev).is_empty());
320+
assert_eq!(new["a.xml"].commit, "newcommit");
321+
}
322+
323+
#[test]
324+
fn preserve_advances_on_renamed_path() {
325+
let prev = pins(&[("a.xml", pin("org/repo", "x/a.xml", "oldcommit", "blob-a"))]);
326+
let mut new = pins(&[("a.xml", pin("org/repo", "y/a.xml", "newcommit", "blob-a"))]);
327+
328+
assert!(preserve_unchanged_repos(&mut new, &prev).is_empty());
329+
assert_eq!(new["a.xml"].commit, "newcommit");
330+
}
331+
332+
#[test]
333+
fn preserve_ignores_repo_absent_from_previous() {
334+
let prev = pins(&[("a.xml", pin("org/repo", "x/a.xml", "oldcommit", "blob-a"))]);
335+
let mut new = pins(&[
336+
("a.xml", pin("org/repo", "x/a.xml", "newcommit", "blob-a")),
337+
("c.xml", pin("org/other", "x/c.xml", "newcommit", "blob-c")),
338+
]);
339+
340+
let kept = preserve_unchanged_repos(&mut new, &prev);
341+
assert_eq!(kept, vec!["org/repo"]);
342+
assert_eq!(new["a.xml"].commit, "oldcommit");
343+
assert_eq!(new["c.xml"].commit, "newcommit");
344+
}
345+
346+
#[test]
347+
fn preserve_handles_repos_independently() {
348+
let prev = pins(&[
349+
("a.xml", pin("org/stable", "x/a.xml", "oldcommit", "blob-a")),
350+
("b.xml", pin("org/churny", "x/b.xml", "oldcommit", "blob-b")),
351+
]);
352+
let mut new = pins(&[
353+
("a.xml", pin("org/stable", "x/a.xml", "newcommit", "blob-a")),
354+
(
355+
"b.xml",
356+
pin("org/churny", "x/b.xml", "newcommit", "blob-b2"),
357+
),
358+
]);
359+
360+
let kept = preserve_unchanged_repos(&mut new, &prev);
361+
assert_eq!(kept, vec!["org/stable"]);
362+
assert_eq!(new["a.xml"].commit, "oldcommit");
363+
assert_eq!(new["b.xml"].commit, "newcommit");
364+
}
188365
}

0 commit comments

Comments
 (0)