@@ -16,10 +16,14 @@ package cli
1616
1717import (
1818 "fmt"
19+ "os"
20+ "os/exec"
1921 "path/filepath"
22+ "strings"
2023
2124 "github.com/chainguard-dev/clog"
2225 "github.com/spf13/cobra"
26+ "gopkg.in/yaml.v3"
2327)
2428
2529func sourceCmd () * cobra.Command {
@@ -38,6 +42,7 @@ func sourceCmd() *cobra.Command {
3842
3943 // Add subcommands
4044 cmd .AddCommand (sourceGetCmd (& outputDir , & sourceDir ))
45+ cmd .AddCommand (sourcePopCmd (& outputDir , & sourceDir ))
4146
4247 return cmd
4348}
@@ -131,3 +136,209 @@ Currently only supports git-checkout.
131136
132137 return cmd
133138}
139+
140+ func sourcePopCmd (outputDir * string , sourceDir * string ) * cobra.Command {
141+ cmd := & cobra.Command {
142+ Use : "pop [config.yaml]" ,
143+ Short : "Generate patches from modified source and update melange configuration" ,
144+ Long : `Generate git format-patch patches from commits made on top of the expected-commit
145+ and update the melange configuration to use git-am pipeline instead of patch pipeline.
146+
147+ This command:
148+ 1. Reads the expected-commit from git-checkout pipeline
149+ 2. Generates patches from expected-commit..HEAD in the cloned source
150+ 3. Writes patches to the source directory
151+ 4. Updates the YAML to replace 'patch' with 'git-am' pipeline
152+ ` ,
153+ Example : ` melange source pop apk-tools.yaml` ,
154+ Args : cobra .ExactArgs (1 ),
155+ RunE : func (cmd * cobra.Command , args []string ) error {
156+ ctx := cmd .Context ()
157+ log := clog .FromContext (ctx )
158+
159+ buildConfigPath := args [0 ]
160+
161+ cfg , err := config .ParseConfiguration (ctx , buildConfigPath )
162+ if err != nil {
163+ return fmt .Errorf ("failed to parse melange config: %w" , err )
164+ }
165+
166+ // Find git-checkout step to get expected-commit
167+ var expectedCommit string
168+ for _ , step := range cfg .Pipeline {
169+ if step .Uses == "git-checkout" {
170+ expectedCommit = step .With ["expected-commit" ]
171+ break
172+ }
173+ }
174+
175+ if expectedCommit == "" {
176+ return fmt .Errorf ("no expected-commit found in git-checkout pipeline" )
177+ }
178+
179+ // Default sourceDir to package-name subdirectory in config file's directory
180+ srcDir := * sourceDir
181+ if srcDir == "" {
182+ srcDir = filepath .Join (filepath .Dir (buildConfigPath ), cfg .Package .Name )
183+ }
184+
185+ // Make sourceDir absolute
186+ absSrcDir , err := filepath .Abs (srcDir )
187+ if err != nil {
188+ return fmt .Errorf ("failed to get absolute path for source-dir: %w" , err )
189+ }
190+
191+ // Cloned source location
192+ clonedSource := filepath .Join (* outputDir , cfg .Package .Name )
193+ absClonedSource , err := filepath .Abs (clonedSource )
194+ if err != nil {
195+ return fmt .Errorf ("failed to get absolute path for cloned source: %w" , err )
196+ }
197+
198+ log .Infof ("Generating patches from %s in %s" , expectedCommit , absClonedSource )
199+
200+ // Generate patches using git format-patch
201+ formatPatchCmd := exec .CommandContext (ctx , "git" , "format-patch" , "-o" , absSrcDir , expectedCommit + "..HEAD" )
202+ formatPatchCmd .Dir = absClonedSource
203+ output , err := formatPatchCmd .Output ()
204+ if err != nil {
205+ return fmt .Errorf ("failed to generate patches: %w" , err )
206+ }
207+
208+ // Parse the patch filenames from git format-patch output
209+ patchLines := strings .Split (strings .TrimSpace (string (output )), "\n " )
210+ var patchFiles []string
211+ for _ , line := range patchLines {
212+ if line != "" {
213+ // Extract just the filename
214+ patchFiles = append (patchFiles , filepath .Base (line ))
215+ }
216+ }
217+
218+ if len (patchFiles ) == 0 {
219+ return fmt .Errorf ("no patches generated - no commits found after %s" , expectedCommit )
220+ }
221+
222+ log .Infof ("Generated %d patches: %v" , len (patchFiles ), patchFiles )
223+
224+ // Read the original YAML file
225+ yamlData , err := os .ReadFile (buildConfigPath )
226+ if err != nil {
227+ return fmt .Errorf ("failed to read YAML file: %w" , err )
228+ }
229+
230+ // Parse as generic YAML to preserve structure and comments
231+ var doc yaml.Node
232+ if err := yaml .Unmarshal (yamlData , & doc ); err != nil {
233+ return fmt .Errorf ("failed to parse YAML: %w" , err )
234+ }
235+
236+ // Update the pipeline: remove 'patch' steps and add 'git-am' step
237+ if err := updatePipelineWithGitAm (& doc , patchFiles ); err != nil {
238+ return fmt .Errorf ("failed to update pipeline: %w" , err )
239+ }
240+
241+ // Write back the updated YAML
242+ updatedYaml , err := yaml .Marshal (& doc )
243+ if err != nil {
244+ return fmt .Errorf ("failed to marshal YAML: %w" , err )
245+ }
246+
247+ // #nosec G306 these are melange yaml files
248+ if err := os .WriteFile (buildConfigPath , updatedYaml , 0o644 ); err != nil {
249+ return fmt .Errorf ("failed to write updated YAML: %w" , err )
250+ }
251+
252+ // Try to run yam to fix formatting
253+ yamCmd := exec .CommandContext (ctx , "yam" , buildConfigPath )
254+ if err := yamCmd .Run (); err != nil {
255+ log .Warnf ("Failed to run yam for formatting (continuing anyway): %v" , err )
256+ } else {
257+ log .Infof ("Formatted YAML with yam" )
258+ }
259+
260+ log .Infof ("Updated %s with git-am pipeline using %d patches" , buildConfigPath , len (patchFiles ))
261+ return nil
262+ },
263+ }
264+
265+ return cmd
266+ }
267+
268+ // updatePipelineWithGitAm finds the pipeline array in the YAML node tree,
269+ // and replaces any 'patch' pipeline step with a 'git-am' step with the given patches.
270+ // If no patch step exists, inserts git-am after git-checkout.
271+ func updatePipelineWithGitAm (doc * yaml.Node , patchFiles []string ) error {
272+ // Navigate to the pipeline array
273+ // doc.Content[0] is the document node
274+ // doc.Content[0].Content contains key-value pairs of the root map
275+
276+ if len (doc .Content ) == 0 || len (doc .Content [0 ].Content ) == 0 {
277+ return fmt .Errorf ("invalid YAML structure" )
278+ }
279+
280+ rootMap := doc .Content [0 ]
281+ var pipelineNode * yaml.Node
282+
283+ // Find the 'pipeline' key
284+ for i := 0 ; i < len (rootMap .Content ); i += 2 {
285+ if rootMap .Content [i ].Value == "pipeline" {
286+ pipelineNode = rootMap .Content [i + 1 ]
287+ break
288+ }
289+ }
290+
291+ if pipelineNode == nil {
292+ return fmt .Errorf ("no pipeline found in YAML" )
293+ }
294+
295+ // Create git-am step
296+ gitAmStep := & yaml.Node {
297+ Kind : yaml .MappingNode ,
298+ Content : []* yaml.Node {
299+ {Kind : yaml .ScalarNode , Value : "uses" },
300+ {Kind : yaml .ScalarNode , Value : "git-am" },
301+ {Kind : yaml .ScalarNode , Value : "with" },
302+ {
303+ Kind : yaml .MappingNode ,
304+ Content : []* yaml.Node {
305+ {Kind : yaml .ScalarNode , Value : "patches" },
306+ {Kind : yaml .ScalarNode , Value : strings .Join (patchFiles , " " )},
307+ },
308+ },
309+ },
310+ }
311+
312+ // Try to replace 'uses: patch' step with 'uses: git-am' step in place
313+ replacedAny := false
314+ for i , step := range pipelineNode .Content {
315+ // Check if this step has 'uses: patch'
316+ for j := 0 ; j < len (step .Content ); j += 2 {
317+ if step .Content [j ].Value == "uses" && step .Content [j + 1 ].Value == "patch" {
318+ // Replace this step with git-am step
319+ pipelineNode .Content [i ] = gitAmStep
320+ replacedAny = true
321+ break
322+ }
323+ }
324+ }
325+
326+ // If no patch step found, insert git-am after git-checkout
327+ if ! replacedAny {
328+ var newContent []* yaml.Node
329+ for _ , step := range pipelineNode .Content {
330+ newContent = append (newContent , step )
331+ // Check if this is git-checkout step
332+ for j := 0 ; j < len (step .Content ); j += 2 {
333+ if step .Content [j ].Value == "uses" && step .Content [j + 1 ].Value == "git-checkout" {
334+ // Insert git-am step right after
335+ newContent = append (newContent , gitAmStep )
336+ break
337+ }
338+ }
339+ }
340+ pipelineNode .Content = newContent
341+ }
342+
343+ return nil
344+ }
0 commit comments