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