55 "context"
66 "errors"
77 "fmt"
8+ "maps"
89 "os"
910 "path/filepath"
1011 "sort"
@@ -351,104 +352,120 @@ func (s *State) AllDeps() []dep.Dependency {
351352// Resolution of owner/repo numeric IDs happens lazily per NWO and is cached
352353// for the lifetime of the store.
353354func (s * State ) Set (ctx context.Context , workflowKey string , deps []dep.Dependency , parentMap map [string ][]string , directKeys map [string ]bool ) error {
354- return s .set (ctx , workflowKey , deps , parentMap , directKeys , nil )
355+ return s .SetWorkflows (ctx , []WorkflowUpdate {{
356+ WorkflowKey : workflowKey ,
357+ Deps : deps ,
358+ ParentMap : parentMap ,
359+ DirectKeys : directKeys ,
360+ ReplaceGraph : true ,
361+ }})
355362}
356363
357- // SetScoped updates a workflow while preserving existing graph edges on
358- // dependencies also reached by workflows outside the current command scope.
359- func (s * State ) SetScoped (ctx context.Context , workflowKey string , deps []dep.Dependency , parentMap map [string ][]string , directKeys , scopedWorkflows map [string ]bool ) error {
360- return s .set (ctx , workflowKey , deps , parentMap , directKeys , scopedWorkflows )
364+ // WorkflowUpdate is one workflow's contribution to a batch lockfile write.
365+ type WorkflowUpdate struct {
366+ WorkflowKey string
367+ Deps []dep.Dependency
368+ ParentMap dep.ParentMap
369+ DirectKeys map [string ]bool
370+ ReplaceGraph bool
361371}
362372
363- func (s * State ) set (ctx context.Context , workflowKey string , deps []dep.Dependency , parentMap map [string ][]string , directKeys , scopedWorkflows map [string ]bool ) error {
373+ // SetWorkflows applies workflow roots and global dependency metadata together.
374+ // Complete live graphs replace recorded edges; incomplete or unscanned
375+ // workflows retain the recorded edge union they still reach.
376+ func (s * State ) SetWorkflows (ctx context.Context , updates []WorkflowUpdate ) error {
364377 // Resolve repo IDs for every unique owner/repo BEFORE taking s.mu so
365378 // concurrent pin workers don't serialize on the network round-trip.
366379 // lookupIDs is safe to call without s.mu and dedups in-flight fetches
367380 // for the same key via singleflight.
368- seenRepos := make (map [string ]struct {}, len (deps ))
369- for _ , d := range deps {
370- pin , err := depToPin (d )
371- if err != nil {
372- return err
373- }
374- k := pin .Owner + "/" + pin .Repo
375- if _ , ok := seenRepos [k ]; ok {
376- continue
377- }
378- seenRepos [k ] = struct {}{}
379- if _ , err := s .lookupIDs (ctx , pin .Owner , pin .Repo ); err != nil {
380- return fmt .Errorf ("resolving repo IDs for %s/%s: %w" , pin .Owner , pin .Repo , err )
381+ seenRepos := make (map [string ]struct {})
382+ for _ , update := range updates {
383+ for _ , d := range update .Deps {
384+ pin , err := depToPin (d )
385+ if err != nil {
386+ return err
387+ }
388+ k := pin .Owner + "/" + pin .Repo
389+ if _ , ok := seenRepos [k ]; ok {
390+ continue
391+ }
392+ seenRepos [k ] = struct {}{}
393+ if _ , err := s .lookupIDs (ctx , pin .Owner , pin .Repo ); err != nil {
394+ return fmt .Errorf ("resolving repo IDs for %s/%s: %w" , pin .Owner , pin .Repo , err )
395+ }
381396 }
382397 }
383398
384399 s .mu .Lock ()
385400 defer s .mu .Unlock ()
386- directPins := make ([]string , 0 )
387- seenDirect := map [string ]bool {}
388- // keyToPin: Dependency.Key() (NWO@Ref) → canonical pin (NWO@Ref:algo-hex).
389- keyToPin := make (map [string ]string , len (deps ))
390- for _ , d := range deps {
391- pin , err := depToPin (d )
392- if err != nil {
393- return err
394- }
395- pin = pin .Canonical ()
396- pinKey := pin .String ()
397- keyToPin [d .Key ()] = pinKey
398- var isDirect bool
399- if directKeys != nil {
400- isDirect = directKeys [d .Key ()]
401- } else {
402- _ , hasParent := parentMap [d .Key ()]
403- isDirect = ! hasParent
404- }
405- if isDirect && ! seenDirect [pinKey ] {
406- seenDirect [pinKey ] = true
407- directPins = append (directPins , pinKey )
408- }
409- }
410-
411- // Invert parentMap (child → parents) into parent → children, in canonical
412- // pin-key form. Translate both sides via keyToPin; entries that don't
413- // resolve to a known dep are skipped (they were filtered out before
414- // reaching the writer).
401+ recordedDependencies := maps .Clone (s .file .Dependencies )
402+ recordedWorkflows := maps .Clone (s .file .Workflows )
403+ replacingWorkflows := make (map [string ]bool , len (updates ))
404+ workflowPins := make (map [string ][]string , len (updates ))
405+ depsByPin := make (map [string ]dep.Dependency )
406+ replacePins := make (map [string ]bool )
415407 parentToChildren := make (map [string ]map [string ]bool )
416- for childDepKey , parents := range parentMap {
417- childPin , ok := keyToPin [childDepKey ]
418- if ! ok {
408+ for _ , update := range updates {
409+ replacingWorkflows [update .WorkflowKey ] = update .ReplaceGraph
410+ keyToPin := make (map [string ]string , len (update .Deps ))
411+ seenDirect := make (map [string ]bool )
412+ for _ , d := range update .Deps {
413+ pin , err := depToPin (d )
414+ if err != nil {
415+ return err
416+ }
417+ pinKey := pin .Canonical ().String ()
418+ keyToPin [d .Key ()] = pinKey
419+ if existing , ok := depsByPin [pinKey ]; ok && ! strings .EqualFold (existing .SHA , d .SHA ) {
420+ return fmt .Errorf ("conflicting commits for %s" , pinKey )
421+ }
422+ depsByPin [pinKey ] = d
423+ if update .ReplaceGraph {
424+ replacePins [pinKey ] = true
425+ }
426+ isDirect := update .DirectKeys [d .Key ()]
427+ if update .DirectKeys == nil {
428+ _ , hasParent := update .ParentMap [d .Key ()]
429+ isDirect = ! hasParent
430+ }
431+ if isDirect && ! seenDirect [pinKey ] {
432+ seenDirect [pinKey ] = true
433+ workflowPins [update .WorkflowKey ] = append (workflowPins [update .WorkflowKey ], pinKey )
434+ }
435+ }
436+ if ! update .ReplaceGraph {
419437 continue
420438 }
421- for _ , parentDepKey := range parents {
422- parentPin , ok := keyToPin [parentDepKey ]
439+ for childDepKey , parents := range update . ParentMap {
440+ childPin , ok := keyToPin [childDepKey ]
423441 if ! ok {
424442 continue
425443 }
426- children , exists := parentToChildren [parentPin ]
427- if ! exists {
428- children = make (map [string ]bool )
429- parentToChildren [parentPin ] = children
444+ for _ , parentDepKey := range parents {
445+ parentPin , ok := keyToPin [parentDepKey ]
446+ if ! ok {
447+ continue
448+ }
449+ if parentToChildren [parentPin ] == nil {
450+ parentToChildren [parentPin ] = make (map [string ]bool )
451+ }
452+ parentToChildren [parentPin ][childPin ] = true
430453 }
431- children [childPin ] = true
432454 }
455+ sort .Strings (workflowPins [update .WorkflowKey ])
433456 }
434457
435- // Now upsert action entries with their per-pin uses lists.
436- for _ , d := range deps {
458+ for pinKey , d := range depsByPin {
437459 pin , err := depToPin (d )
438460 if err != nil {
439461 return err
440462 }
441- pin = pin .Canonical ()
442- pinKey := pin .String ()
443463 // IDs were pre-resolved above (outside the mutex); read from cache
444464 // directly so we don't recursively re-acquire s.mu.
445465 ids , ok := s .idCache [strings .ToLower (pin .Owner + "/" + pin .Repo )]
446466 if ! ok {
447467 return fmt .Errorf ("resolving repo IDs for %s/%s: not in cache after pre-resolve" , pin .Owner , pin .Repo )
448468 }
449- // Merge uses: each workflow contributes its own transitive edges.
450- // A dep that is a parent in one workflow but direct (no children)
451- // in another must not clobber the first workflow's uses list.
452469 usesSet := make (map [string ]bool )
453470 if children , ok := parentToChildren [pinKey ]; ok {
454471 for c := range children {
@@ -473,10 +490,7 @@ func (s *State) set(ctx context.Context, workflowKey string, deps []dep.Dependen
473490 if ref == "" {
474491 ref = existing .Ref
475492 }
476- if existing .Commit == commit || s .reachableFromUnscopedWorkflow (pinKey , workflowKey , scopedWorkflows ) {
477- // ponytail: actions.lock stores a global edge union, so keep the
478- // old union when an untouched workflow reaches this parent.
479- // A full-scope refresh can replace it exactly.
493+ if ! replacePins [pinKey ] || reachableFromPreservedWorkflow (pinKey , replacingWorkflows , recordedWorkflows , recordedDependencies ) {
480494 for _ , u := range existing .Uses {
481495 usesSet [u ] = true
482496 }
@@ -498,12 +512,18 @@ func (s *State) set(ctx context.Context, workflowKey string, deps []dep.Dependen
498512 Uses : uses ,
499513 }
500514 }
501- sort .Strings (directPins )
502- s .file .Workflows [workflowKey ] = directPins
515+ for workflowKey , directPins := range workflowPins {
516+ s .file .Workflows [workflowKey ] = directPins
517+ }
518+ for _ , update := range updates {
519+ if _ , ok := workflowPins [update .WorkflowKey ]; ! ok {
520+ s .file .Workflows [update .WorkflowKey ] = nil
521+ }
522+ }
503523 return nil
504524}
505525
506- func ( s * State ) reachableFromUnscopedWorkflow ( target , workflowKey string , scopedWorkflows map [string ]bool ) bool {
526+ func reachableFromPreservedWorkflow ( target string , replacingWorkflows map [ string ] bool , workflows map [ string ][] string , dependencies map [string ]parserlock. Action ) bool {
507527 var reaches func (string , map [string ]bool ) bool
508528 reaches = func (key string , seen map [string ]bool ) bool {
509529 if key == target {
@@ -513,15 +533,15 @@ func (s *State) reachableFromUnscopedWorkflow(target, workflowKey string, scoped
513533 return false
514534 }
515535 seen [key ] = true
516- for _ , child := range s . file . Dependencies [key ].Uses {
536+ for _ , child := range dependencies [key ].Uses {
517537 if reaches (child , seen ) {
518538 return true
519539 }
520540 }
521541 return false
522542 }
523- for workflow , roots := range s . file . Workflows {
524- if workflow == workflowKey || scopedWorkflows [workflow ] {
543+ for workflow , roots := range workflows {
544+ if replacingWorkflows [workflow ] {
525545 continue
526546 }
527547 for _ , root := range roots {
0 commit comments