@@ -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.
99153pub 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