Skip to content

✨ alpha(update): add --squash, --preserve-path, --output-branch for PR-friendly upgrades #5002

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions docs/book/src/reference/commands/alpha_update.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ It uses a **3-way merge** so you do less manual work. The command creates these
- **Merge**: result of merging **Original** into **Upgrade** (this is where conflicts appear)

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

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

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

## How to Use It

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

Create a **single squashed commit** on a stable PR branch:

```shell
kubebuilder alpha update --force --squash
```

Squash while **preserving** paths from your base branch (keep CI/workflows, docs, etc.):

```shell
kubebuilder alpha update --force --squash \
--preserve-path .github/workflows \
--preserve-path docs
```

Use a **custom output branch** name:

```shell
kubebuilder alpha update --force --squash \
-output-branch upgrade/kb-to-v4.7.0
```

## Merge Conflicts with `--force`

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

- **Without `--force`**: the command stops on `tmp-merge-*` and prints guidance; no commit is created.
- **With `--force`**: the merge is committed (on `tmp-merge-*`) and contains the markers.
- **With `--force`**: the merge is committed (on `tmp-merge-*`, or on the output branch if using `--squash`) and contains the markers.

## Commit message used in `--squash` mode

> [kubebuilder-automated-update]: update scaffold from <from> to <to>; (squashed 3-way merge)

<aside class="note warning">
<h1>You might need to upgrade your project first</h1>

This command uses `kubebuilder alpha generate` under the hood.
We support projects created with <strong>v4.5.0+</strong>.
If yours is older, first run `kubebuilder alpha generate` once to modernize the scaffold.
After that, you can use `kubebuilder alpha update` for future upgrades.

</aside>

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

Projects created with **Kubebuilder v4.6.0+** include `cliVersion` in the `PROJECT` file.
We use that value to pick the correct CLI for re-scaffolding.

</aside>

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

## Flags

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

<aside class="note">
<h1>CLI Version Tracking</h1>
Expand Down
69 changes: 69 additions & 0 deletions pkg/cli/alpha/internal/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"net/http"
"os"
"os/exec"
"strings"
"time"

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

// Squash writes the merge result as a single commit on a stable branch when true.
// The branch defaults to "kubebuilder-alpha-update-to-<ToVersion>" unless OutputBranch is set.
Squash bool

// PreservePath lists paths to restore from the base branch when squashing (repeatable).
// Example: ".github/workflows"
PreservePath []string

// OutputBranch is the branch name to use with Squash.
// If empty, it defaults to "kubebuilder-alpha-update-to-<ToVersion>".
OutputBranch string

// UpdateBranches
AncestorBranch string
OriginalBranch string
Expand Down Expand Up @@ -105,6 +118,62 @@ func (opts *Update) Update() error {
if err := opts.mergeOriginalToUpgrade(); err != nil {
return fmt.Errorf("failed to merge upgrade into merge branch: %w", err)
}
// If requested, collapse the merge result into a single commit on a fixed branch
if opts.Squash {
if err := opts.squashToOutputBranch(); err != nil {
return fmt.Errorf("failed to squash to output branch: %w", err)
}
}
return nil
}

// squashToOutputBranch takes the exact tree of the MergeBranch and writes it as ONE commit
// on a branch derived from FromBranch (e.g., "main"). If PreservePath is set, those paths
// are restored from the base branch after copying the merge tree, so CI config etc. stays put.
func (opts *Update) squashToOutputBranch() error {
// Default output branch name if not provided
out := opts.OutputBranch
if out == "" {
out = "kubebuilder-alpha-update-to-" + opts.ToVersion
}

// 1. Start from base (FromBranch)
if err := exec.Command("git", "checkout", opts.FromBranch).Run(); err != nil {
return fmt.Errorf("checkout %s: %w", opts.FromBranch, err)
}
if err := exec.Command("git", "checkout", "-B", out, opts.FromBranch).Run(); err != nil {
return fmt.Errorf("create/reset %s from %s: %w", out, opts.FromBranch, err)
}

// 2. Clean working tree (except .git) so the next checkout is a verbatim snapshot
if err := exec.Command("sh", "-c",
"find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +").Run(); err != nil {
return fmt.Errorf("cleanup output branch: %w", err)
}

// 3. Bring in the exact content from the merge branch (no re-merge -> no new conflicts)
if err := exec.Command("git", "checkout", opts.MergeBranch, "--", ".").Run(); err != nil {
return fmt.Errorf("checkout merge content: %w", err)
}

// 4. Optionally restore preserved paths from base (keep CI, etc.)
for _, p := range opts.PreservePath {
p = strings.TrimSpace(p)
if p != "" {
_ = exec.Command("git", "restore", "--source", opts.FromBranch, "--staged", "--worktree", p).Run()
}
}

// 5. One commit (keep markers; bypass hooks if repos have pre-commit on conflicts)
if err := exec.Command("git", "add", "--all").Run(); err != nil {
return fmt.Errorf("stage output: %w", err)
}
msg := fmt.Sprintf("[kubebuilder-automated-update]: update scaffold from %s to %s; (squashed 3-way merge)",
opts.FromVersion, opts.ToVersion)
if err := exec.Command("git", "commit", "--no-verify", "-m", msg).Run(); err != nil {
return nil
}

return nil
}

Expand Down
95 changes: 91 additions & 4 deletions pkg/cli/alpha/internal/update/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ var _ = Describe("Prepare for internal update", func() {
Expect(err).ToNot(HaveOccurred())
logs, readErr := os.ReadFile(logFile)
Expect(readErr).ToNot(HaveOccurred())
Expect(string(logs)).To(ContainSubstring("checkout %s", opts.FromBranch))
Expect(string(logs)).To(ContainSubstring(fmt.Sprintf("checkout %s", opts.FromBranch)))
})
It("Should fail when git command fails", func() {
fakeBinScript := `#!/bin/bash
Expand All @@ -124,7 +124,7 @@ var _ = Describe("Prepare for internal update", func() {

logs, readErr := os.ReadFile(logFile)
Expect(readErr).ToNot(HaveOccurred())
Expect(string(logs)).To(ContainSubstring("checkout %s", opts.FromBranch))
Expect(string(logs)).To(ContainSubstring(fmt.Sprintf("checkout %s", opts.FromBranch)))
})
It("Should fail when kubebuilder binary could not be downloaded", func() {
gock.Off()
Expand All @@ -141,7 +141,7 @@ var _ = Describe("Prepare for internal update", func() {
Expect(err.Error()).To(ContainSubstring("failed to prepare ancestor branch"))
logs, readErr := os.ReadFile(logFile)
Expect(readErr).ToNot(HaveOccurred())
Expect(string(logs)).To(ContainSubstring("checkout %s", opts.FromBranch))
Expect(string(logs)).To(ContainSubstring(fmt.Sprintf("checkout %s", opts.FromBranch)))
})
})

Expand Down Expand Up @@ -363,10 +363,97 @@ var _ = Describe("Prepare for internal update", func() {
exit 1`
err = mockBinResponse(fakeBinScript, mockGit)
Expect(err).ToNot(HaveOccurred())
err := opts.mergeOriginalToUpgrade()
err = opts.mergeOriginalToUpgrade()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(
"failed to create merge branch %s from %s", opts.MergeBranch, opts.OriginalBranch))
})
})

Context("SquashToOutputBranch", func() {
BeforeEach(func() {
opts.FromBranch = "main"
opts.ToVersion = "v4.6.0"
if opts.MergeBranch == "" {
opts.MergeBranch = "tmp-merge-test"
}
})

It("should create/reset the output branch and commit one squashed snapshot", func() {
opts.OutputBranch = ""
opts.PreservePath = []string{".github/workflows"} // exercise the restore call

err = opts.squashToOutputBranch()
Expect(err).ToNot(HaveOccurred())

logs, readErr := os.ReadFile(logFile)
Expect(readErr).ToNot(HaveOccurred())
s := string(logs)

Expect(s).To(ContainSubstring(fmt.Sprintf("checkout %s", opts.FromBranch)))
Expect(s).To(ContainSubstring(fmt.Sprintf(
"checkout -B kubebuilder-alpha-update-to-%s %s",
opts.ToVersion, opts.FromBranch,
)))
Expect(s).To(ContainSubstring(
"-c find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +",
))
Expect(s).To(ContainSubstring(fmt.Sprintf("checkout %s -- .", opts.MergeBranch)))
Expect(s).To(ContainSubstring(fmt.Sprintf(
"restore --source %s --staged --worktree .github/workflows",
opts.FromBranch,
)))
Expect(s).To(ContainSubstring("add --all"))

msg := fmt.Sprintf(
"[kubebuilder-automated-update]: update scaffold from %s to %s; (squashed 3-way merge)",
opts.FromVersion, opts.ToVersion,
)
Expect(s).To(ContainSubstring(msg))

Expect(s).To(ContainSubstring("commit --no-verify -m"))
})

It("should respect a custom output branch name", func() {
opts.OutputBranch = "my-custom-branch"
err = opts.squashToOutputBranch()
Expect(err).ToNot(HaveOccurred())

logs, _ := os.ReadFile(logFile)
Expect(string(logs)).To(ContainSubstring(
fmt.Sprintf("checkout -B %s %s", "my-custom-branch", opts.FromBranch),
))
})

It("squash: no changes -> commit exits 1 but returns nil", func() {
fake := `#!/bin/bash
echo "$@" >> "` + logFile + `"
if [[ "$1" == "commit" ]]; then exit 1; fi
exit 0`
Expect(mockBinResponse(fake, mockGit)).To(Succeed())

opts.PreservePath = nil
Expect(opts.squashToOutputBranch()).To(Succeed())

s, _ := os.ReadFile(logFile)
Expect(string(s)).To(ContainSubstring("commit --no-verify -m"))
})

It("squash: trims preserve-path and skips blanks", func() {
opts.PreservePath = []string{" .github/workflows ", "", "docs"}
Expect(opts.squashToOutputBranch()).To(Succeed())
s, _ := os.ReadFile(logFile)
Expect(string(s)).To(ContainSubstring("restore --source main --staged --worktree .github/workflows"))
Expect(string(s)).To(ContainSubstring("restore --source main --staged --worktree docs"))
})

It("update: runs squash when --squash is set", func() {
opts.Squash = true
Expect(opts.Update()).To(Succeed())
s, _ := os.ReadFile(logFile)
Expect(string(s)).To(ContainSubstring("checkout -B kubebuilder-alpha-update-to-" + opts.ToVersion + " main"))
Expect(string(s)).To(ContainSubstring("-c find . -mindepth 1"))
Expect(string(s)).To(ContainSubstring("checkout " + opts.MergeBranch + " -- ."))
})
})
})
Loading
Loading