Skip to content

Commit d986484

Browse files
alpha(update): add --squash, --preserve-path, --output-branch for PR-friendly upgrades
This change makes `kubebuilder alpha update` produce a PR-ready branch and a single squashed commit when requested, improving automation and review UX. Key changes ----------- • New `--squash` flag: - Snapshots the exact tree of the temporary merge branch into ONE commit on a stable branch: `kubebuilder-alpha-update-to-<to-version>`. - Intended for opening/refreshing idempotent PRs. - Gracefully handles "no changes" (git commit exits 1 → treated as no-op). • New `--preserve-path` (repeatable): - When squashing, restore given paths from the base branch (e.g. `.github/workflows`) so CI/config files are kept as-is on the PR branch. • New `--output-branch`: - Overrides the default PR branch name created by `--squash`. • Commit message used by `--squash`: - `[kubebuilder-automated-update]: update scaffold from <from> to <to>; (squashed 3-way merge)` • Behavior/ergonomics: - Without `--force`: stops on conflicts on the temporary merge branch. - With `--force`: commits conflict markers on the merge branch (automation-friendly). - After merge, still best-effort run: `make manifests generate fmt vet lint-fix`. Defaults / Compatibility ------------------------ - `--squash` is off by default (no behavior change unless opted-in). - `--from-branch` defaults to `main`. - `--preserve-path` is empty by default (no restores). - Safe to run on projects scaffolded with v4.5.0+ (uses `alpha generate`). Motivation ---------- Make upgrades PR-centric and automation-ready by producing a deterministic, reviewable branch and a single squashed commit that mirrors the merge result. Assisted-by: ChatGPT (OpenAI)
1 parent 4155789 commit d986484

File tree

6 files changed

+337
-62
lines changed

6 files changed

+337
-62
lines changed

docs/book/src/reference/commands/alpha_update.md

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ It uses a **3-way merge** so you do less manual work. The command creates these
1212
- **Merge**: result of merging **Original** into **Upgrade** (this is where conflicts appear)
1313

1414
You can review and test the merge result before applying it to your main branch.
15+
Optionally, use **`--squash`** to put the merge result into **one commit** on a stable output branch (great for PRs).
1516

1617
<aside class="note warning">
1718
<h1>Creates branches and deletes files</h1>
@@ -43,8 +44,13 @@ Use this command when you:
4344
3. **3-way merge**
4445
Creates `tmp-merge-*` from **Upgrade** and merges **Original** into it.
4546
Runs `make manifests generate fmt vet lint-fix` to normalise outputs.
47+
Runs `make manifests generate fmt vet lint-fix` to normalize outputs.
4648

47-
Push either `tmp-merge-*` (no squash) to open a PR.
49+
4. **(Optional) Squash**
50+
With `--squash`, copies the merge result to a stable output branch and commits **once**:
51+
- Default output branch: `kubebuilder-alpha-update-to-<to-version>`
52+
- Or set your own with `--output-branch`
53+
If there are conflicts, the single commit will include conflict markers.
4854

4955
## How to Use It
5056

@@ -68,6 +74,27 @@ Automation-friendly (proceed even with conflicts):
6874
kubebuilder alpha update --force
6975
```
7076

77+
Create a **single squashed commit** on a stable PR branch:
78+
79+
```shell
80+
kubebuilder alpha update --force --squash
81+
```
82+
83+
Squash while **preserving** paths from your base branch (keep CI/workflows, docs, etc.):
84+
85+
```shell
86+
kubebuilder alpha update --force --squash \
87+
--preserve-path .github/workflows \
88+
--preserve-path docs
89+
```
90+
91+
Use a **custom output branch** name:
92+
93+
```shell
94+
kubebuilder alpha update --force --squash \
95+
-output-branch upgrade/kb-to-v4.7.0
96+
```
97+
7198
## Merge Conflicts with `--force`
7299

73100
When you use `--force`, Git finishes the merge even if there are conflicts.
@@ -82,7 +109,29 @@ Incoming changes
82109
```
83110

84111
- **Without `--force`**: the command stops on `tmp-merge-*` and prints guidance; no commit is created.
85-
- **With `--force`**: the merge is committed (on `tmp-merge-*`) and contains the markers.
112+
- **With `--force`**: the merge is committed (on `tmp-merge-*`, or on the output branch if using `--squash`) and contains the markers.
113+
114+
## Commit message used in `--squash` mode
115+
116+
> [kubebuilder-automated-update]: update scaffold from <from> to <to>; (squashed 3-way merge)
117+
118+
<aside class="note warning">
119+
<h1>You might need to upgrade your project first</h1>
120+
121+
This command uses `kubebuilder alpha generate` under the hood.
122+
We support projects created with <strong>v4.5.0+</strong>.
123+
If yours is older, first run `kubebuilder alpha generate` once to modernize the scaffold.
124+
After that, you can use `kubebuilder alpha update` for future upgrades.
125+
126+
</aside>
127+
128+
<aside class="note">
129+
<h1>CLI Version Tracking</h1>
130+
131+
Projects created with **Kubebuilder v4.6.0+** include `cliVersion` in the `PROJECT` file.
132+
We use that value to pick the correct CLI for re-scaffolding.
133+
134+
</aside>
86135

87136
<aside class="note warning">
88137
You must resolve these conflicts before merging into `main` (or your base branch).
@@ -98,13 +147,16 @@ make all
98147

99148
## Flags
100149

101-
| Flag | Description |
102-
|-------------------|--------------------------------------------------------------------------------------------------------------------|
103-
| `--from-version` | Kubebuilder version your project was created with. If unset, taken from the `PROJECT` file. |
104-
| `--to-version` | Version to upgrade to. Defaults to the latest release. |
105-
| `--from-branch` | Git branch that has your current project code. Defaults to `main`. |
106-
| `--force` | Continue even if merge conflicts happen. Conflicted files are committed with conflict markers (useful for CI/cron). |
107-
| `-h, --help` | Show help for this command. |
150+
| Flag | Description |
151+
|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
152+
| `--from-version` | Kubebuilder version your project was created with. If unset, taken from the `PROJECT` file. |
153+
| `--to-version` | Version to upgrade to. Defaults to the latest release. |
154+
| `--from-branch` | Git branch that has your current project code. Defaults to `main`. |
155+
| `--force` | Continue even if merge conflicts happen. Conflicted files are committed with conflict markers (useful for CI/cron). |
156+
| `--squash` | Write the merge result as **one commit** on a stable output branch. |
157+
| `--preserve-path` | Repeatable. With `--squash`, restore these paths from the base branch (e.g., `--preserve-path .github/workflows`). |
158+
| `--output-branch` | Branch name to use for the squashed commit (default: `kubebuilder-alpha-update-to-<to-version>`). |
159+
| `-h, --help` | Show help for this command. |
108160

109161
<aside class="note">
110162
<h1>CLI Version Tracking</h1>

pkg/cli/alpha/internal/update/update.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"net/http"
2525
"os"
2626
"os/exec"
27+
"strings"
2728
"time"
2829

2930
"github.com/spf13/afero"
@@ -42,6 +43,18 @@ type Update struct {
4243
// Force commits the update changes even with merge conflicts
4344
Force bool
4445

46+
// Squash writes the merge result as a single commit on a stable branch when true.
47+
// The branch defaults to "kubebuilder-alpha-update-to-<ToVersion>" unless OutputBranch is set.
48+
Squash bool
49+
50+
// PreservePath lists paths to restore from the base branch when squashing (repeatable).
51+
// Example: ".github/workflows"
52+
PreservePath []string
53+
54+
// OutputBranch is the branch name to use with Squash.
55+
// If empty, it defaults to "kubebuilder-alpha-update-to-<ToVersion>".
56+
OutputBranch string
57+
4558
// UpdateBranches
4659
AncestorBranch string
4760
OriginalBranch string
@@ -105,6 +118,62 @@ func (opts *Update) Update() error {
105118
if err := opts.mergeOriginalToUpgrade(); err != nil {
106119
return fmt.Errorf("failed to merge upgrade into merge branch: %w", err)
107120
}
121+
// If requested, collapse the merge result into a single commit on a fixed branch
122+
if opts.Squash {
123+
if err := opts.squashToOutputBranch(); err != nil {
124+
return fmt.Errorf("failed to squash to output branch: %w", err)
125+
}
126+
}
127+
return nil
128+
}
129+
130+
// squashToOutputBranch takes the exact tree of the MergeBranch and writes it as ONE commit
131+
// on a branch derived from FromBranch (e.g., "main"). If PreservePath is set, those paths
132+
// are restored from the base branch after copying the merge tree, so CI config etc. stays put.
133+
func (opts *Update) squashToOutputBranch() error {
134+
// Default output branch name if not provided
135+
out := opts.OutputBranch
136+
if out == "" {
137+
out = "kubebuilder-alpha-update-to-" + opts.ToVersion
138+
}
139+
140+
// 1. Start from base (FromBranch)
141+
if err := exec.Command("git", "checkout", opts.FromBranch).Run(); err != nil {
142+
return fmt.Errorf("checkout %s: %w", opts.FromBranch, err)
143+
}
144+
if err := exec.Command("git", "checkout", "-B", out, opts.FromBranch).Run(); err != nil {
145+
return fmt.Errorf("create/reset %s from %s: %w", out, opts.FromBranch, err)
146+
}
147+
148+
// 2. Clean working tree (except .git) so the next checkout is a verbatim snapshot
149+
if err := exec.Command("sh", "-c",
150+
"find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +").Run(); err != nil {
151+
return fmt.Errorf("cleanup output branch: %w", err)
152+
}
153+
154+
// 3. Bring in the exact content from the merge branch (no re-merge -> no new conflicts)
155+
if err := exec.Command("git", "checkout", opts.MergeBranch, "--", ".").Run(); err != nil {
156+
return fmt.Errorf("checkout merge content: %w", err)
157+
}
158+
159+
// 4. Optionally restore preserved paths from base (keep CI, etc.)
160+
for _, p := range opts.PreservePath {
161+
p = strings.TrimSpace(p)
162+
if p != "" {
163+
_ = exec.Command("git", "restore", "--source", opts.FromBranch, "--staged", "--worktree", p).Run()
164+
}
165+
}
166+
167+
// 5. One commit (keep markers; bypass hooks if repos have pre-commit on conflicts)
168+
if err := exec.Command("git", "add", "--all").Run(); err != nil {
169+
return fmt.Errorf("stage output: %w", err)
170+
}
171+
msg := fmt.Sprintf("[kubebuilder-automated-update]: update scaffold from %s to %s; (squashed 3-way merge)",
172+
opts.FromVersion, opts.ToVersion)
173+
if err := exec.Command("git", "commit", "--no-verify", "-m", msg).Run(); err != nil {
174+
return nil
175+
}
176+
108177
return nil
109178
}
110179

pkg/cli/alpha/internal/update/update_test.go

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ var _ = Describe("Prepare for internal update", func() {
110110
Expect(err).ToNot(HaveOccurred())
111111
logs, readErr := os.ReadFile(logFile)
112112
Expect(readErr).ToNot(HaveOccurred())
113-
Expect(string(logs)).To(ContainSubstring("checkout %s", opts.FromBranch))
113+
Expect(string(logs)).To(ContainSubstring(fmt.Sprintf("checkout %s", opts.FromBranch)))
114114
})
115115
It("Should fail when git command fails", func() {
116116
fakeBinScript := `#!/bin/bash
@@ -124,7 +124,7 @@ var _ = Describe("Prepare for internal update", func() {
124124

125125
logs, readErr := os.ReadFile(logFile)
126126
Expect(readErr).ToNot(HaveOccurred())
127-
Expect(string(logs)).To(ContainSubstring("checkout %s", opts.FromBranch))
127+
Expect(string(logs)).To(ContainSubstring(fmt.Sprintf("checkout %s", opts.FromBranch)))
128128
})
129129
It("Should fail when kubebuilder binary could not be downloaded", func() {
130130
gock.Off()
@@ -141,7 +141,7 @@ var _ = Describe("Prepare for internal update", func() {
141141
Expect(err.Error()).To(ContainSubstring("failed to prepare ancestor branch"))
142142
logs, readErr := os.ReadFile(logFile)
143143
Expect(readErr).ToNot(HaveOccurred())
144-
Expect(string(logs)).To(ContainSubstring("checkout %s", opts.FromBranch))
144+
Expect(string(logs)).To(ContainSubstring(fmt.Sprintf("checkout %s", opts.FromBranch)))
145145
})
146146
})
147147

@@ -363,10 +363,97 @@ var _ = Describe("Prepare for internal update", func() {
363363
exit 1`
364364
err = mockBinResponse(fakeBinScript, mockGit)
365365
Expect(err).ToNot(HaveOccurred())
366-
err := opts.mergeOriginalToUpgrade()
366+
err = opts.mergeOriginalToUpgrade()
367367
Expect(err).To(HaveOccurred())
368368
Expect(err.Error()).To(ContainSubstring(
369369
"failed to create merge branch %s from %s", opts.MergeBranch, opts.OriginalBranch))
370370
})
371371
})
372+
373+
Context("SquashToOutputBranch", func() {
374+
BeforeEach(func() {
375+
opts.FromBranch = "main"
376+
opts.ToVersion = "v4.6.0"
377+
if opts.MergeBranch == "" {
378+
opts.MergeBranch = "tmp-merge-test"
379+
}
380+
})
381+
382+
It("should create/reset the output branch and commit one squashed snapshot", func() {
383+
opts.OutputBranch = ""
384+
opts.PreservePath = []string{".github/workflows"} // exercise the restore call
385+
386+
err = opts.squashToOutputBranch()
387+
Expect(err).ToNot(HaveOccurred())
388+
389+
logs, readErr := os.ReadFile(logFile)
390+
Expect(readErr).ToNot(HaveOccurred())
391+
s := string(logs)
392+
393+
Expect(s).To(ContainSubstring(fmt.Sprintf("checkout %s", opts.FromBranch)))
394+
Expect(s).To(ContainSubstring(fmt.Sprintf(
395+
"checkout -B kubebuilder-alpha-update-to-%s %s",
396+
opts.ToVersion, opts.FromBranch,
397+
)))
398+
Expect(s).To(ContainSubstring(
399+
"-c find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +",
400+
))
401+
Expect(s).To(ContainSubstring(fmt.Sprintf("checkout %s -- .", opts.MergeBranch)))
402+
Expect(s).To(ContainSubstring(fmt.Sprintf(
403+
"restore --source %s --staged --worktree .github/workflows",
404+
opts.FromBranch,
405+
)))
406+
Expect(s).To(ContainSubstring("add --all"))
407+
408+
msg := fmt.Sprintf(
409+
"[kubebuilder-automated-update]: update scaffold from %s to %s; (squashed 3-way merge)",
410+
opts.FromVersion, opts.ToVersion,
411+
)
412+
Expect(s).To(ContainSubstring(msg))
413+
414+
Expect(s).To(ContainSubstring("commit --no-verify -m"))
415+
})
416+
417+
It("should respect a custom output branch name", func() {
418+
opts.OutputBranch = "my-custom-branch"
419+
err = opts.squashToOutputBranch()
420+
Expect(err).ToNot(HaveOccurred())
421+
422+
logs, _ := os.ReadFile(logFile)
423+
Expect(string(logs)).To(ContainSubstring(
424+
fmt.Sprintf("checkout -B %s %s", "my-custom-branch", opts.FromBranch),
425+
))
426+
})
427+
428+
It("squash: no changes -> commit exits 1 but returns nil", func() {
429+
fake := `#!/bin/bash
430+
echo "$@" >> "` + logFile + `"
431+
if [[ "$1" == "commit" ]]; then exit 1; fi
432+
exit 0`
433+
Expect(mockBinResponse(fake, mockGit)).To(Succeed())
434+
435+
opts.PreservePath = nil
436+
Expect(opts.squashToOutputBranch()).To(Succeed())
437+
438+
s, _ := os.ReadFile(logFile)
439+
Expect(string(s)).To(ContainSubstring("commit --no-verify -m"))
440+
})
441+
442+
It("squash: trims preserve-path and skips blanks", func() {
443+
opts.PreservePath = []string{" .github/workflows ", "", "docs"}
444+
Expect(opts.squashToOutputBranch()).To(Succeed())
445+
s, _ := os.ReadFile(logFile)
446+
Expect(string(s)).To(ContainSubstring("restore --source main --staged --worktree .github/workflows"))
447+
Expect(string(s)).To(ContainSubstring("restore --source main --staged --worktree docs"))
448+
})
449+
450+
It("update: runs squash when --squash is set", func() {
451+
opts.Squash = true
452+
Expect(opts.Update()).To(Succeed())
453+
s, _ := os.ReadFile(logFile)
454+
Expect(string(s)).To(ContainSubstring("checkout -B kubebuilder-alpha-update-to-" + opts.ToVersion + " main"))
455+
Expect(string(s)).To(ContainSubstring("-c find . -mindepth 1"))
456+
Expect(string(s)).To(ContainSubstring("checkout " + opts.MergeBranch + " -- ."))
457+
})
458+
})
372459
})

0 commit comments

Comments
 (0)