Skip to content

Commit fcdbdaa

Browse files
Add source pop
1 parent 0da70f8 commit fcdbdaa

File tree

1 file changed

+210
-0
lines changed

1 file changed

+210
-0
lines changed

pkg/cli/source.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@ package cli
1616

1717
import (
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

2731
func 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

Comments
 (0)