Skip to content

Commit a465549

Browse files
Add source pop
1 parent 6c68dce commit a465549

File tree

1 file changed

+211
-0
lines changed

1 file changed

+211
-0
lines changed

pkg/cli/source.go

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

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

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

Comments
 (0)