Skip to content
Merged
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
125 changes: 125 additions & 0 deletions internal/git/delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package git

import (
"os"
"testing"
)

// cloneWithOrigin creates a clone of dir whose origin is dir, chdirs into the
// clone for the test, fetches origin, and returns the clone path.
func cloneWithOrigin(t *testing.T, dir string) string {
t.Helper()
clone := t.TempDir()
runGit(t, "clone", dir, clone)

orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(clone); err != nil {
t.Fatalf("chdir clone: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(orig); err != nil {
t.Fatalf("restore cwd: %v", err)
}
})
runGit(t, "fetch", "origin")
return clone
}

func TestDeleteRemoteBranch(t *testing.T) {
dir := newScratchRepo(t)
commitFile(t, "a.txt", "one", "first commit")
runGit(t, "branch", "-M", "main")
runGit(t, "branch", "env/test")

cloneWithOrigin(t, dir)

exists, err := BranchExists("origin", "env/test")
if err != nil {
t.Fatalf("BranchExists: %v", err)
}
if !exists {
t.Fatalf("precondition: env/test should exist on origin")
}

if err := DeleteRemoteBranch("origin", "env/test"); err != nil {
t.Fatalf("DeleteRemoteBranch: %v", err)
}

runGit(t, "fetch", "origin", "--prune")
exists, err = BranchExists("origin", "env/test")
if err != nil {
t.Fatalf("BranchExists after delete: %v", err)
}
if exists {
t.Fatalf("env/test should be deleted on origin")
}

// Deleting an already-absent branch is a no-op success (idempotent).
if err := DeleteRemoteBranch("origin", "env/test"); err != nil {
t.Fatalf("DeleteRemoteBranch on missing branch should be a no-op: %v", err)
}
}

func TestDeleteRemoteTag(t *testing.T) {
dir := newScratchRepo(t)
commitFile(t, "a.txt", "one", "first commit")
runGit(t, "branch", "-M", "main")
runGit(t, "tag", "v1.4.0-rc.2.hotfix.1")

cloneWithOrigin(t, dir)
runGit(t, "fetch", "origin", "--tags")

tags, err := ListTags()
if err != nil {
t.Fatalf("ListTags: %v", err)
}
if !contains(tags, "v1.4.0-rc.2.hotfix.1") {
t.Fatalf("precondition: hotfix tag should be present, got %v", tags)
}

if err := DeleteRemoteTag("origin", "v1.4.0-rc.2.hotfix.1"); err != nil {
t.Fatalf("DeleteRemoteTag: %v", err)
}

// Deleting an already-absent tag is a no-op success (idempotent).
if err := DeleteRemoteTag("origin", "v1.4.0-rc.2.hotfix.1"); err != nil {
t.Fatalf("DeleteRemoteTag on missing tag should be a no-op: %v", err)
}
}

func TestListRemoteBranches(t *testing.T) {
dir := newScratchRepo(t)
commitFile(t, "a.txt", "one", "first commit")
runGit(t, "branch", "-M", "main")
runGit(t, "branch", "env/test")
runGit(t, "branch", "env/uat")

cloneWithOrigin(t, dir)

branches, err := ListRemoteBranches("origin")
if err != nil {
t.Fatalf("ListRemoteBranches: %v", err)
}

for _, want := range []string{"main", "env/test", "env/uat"} {
if !contains(branches, want) {
t.Errorf("expected branch %q in %v", want, branches)
}
}
// The remote HEAD pointer must not leak through as a branch name.
if contains(branches, "HEAD") {
t.Errorf("HEAD should not be returned as a branch: %v", branches)
}
}

func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
63 changes: 63 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,69 @@ func RemoteBranchSHA(remote, name string) (string, error) {
return strings.TrimSpace(string(output)), nil
}

// ListRemoteBranches returns the branch names known for the given remote via the
// remote-tracking refs refs/remotes/<remote>/*. The remote prefix and the symbolic
// HEAD pointer are stripped, so "refs/remotes/origin/env/test" is returned as
// "env/test". The remote must have been fetched first; a shallow or partial fetch
// that omits branches will leave them out of the result.
func ListRemoteBranches(remote string) ([]string, error) {
prefix := fmt.Sprintf("refs/remotes/%s/", remote)
cmd := exec.Command("git", "for-each-ref", "--format=%(refname)", prefix)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git for-each-ref %s: %w", prefix, err)
}

var branches []string
for _, ref := range parseLines(output) {
name := strings.TrimPrefix(ref, prefix)
if name == "" || name == "HEAD" {
continue
}
branches = append(branches, name)
}
return branches, nil
}

// DeleteRemoteBranch deletes the named branch on the given remote by running
// "git push <remote> --delete <name>". Deleting a branch that does not exist on
// the remote is treated as success so the operation is idempotent: re-running a
// rejoin cleanup after a partial failure does not error on an already-deleted
// branch.
func DeleteRemoteBranch(remote, name string) error {
cmd := exec.Command("git", "push", remote, "--delete", name)
out, err := cmd.CombinedOutput()
if err == nil {
return nil
}
if remoteRefAlreadyGone(out) {
return nil
}
return fmt.Errorf("git push %s --delete %s: %w\n%s", remote, name, err, out)
}

// DeleteRemoteTag deletes the named tag on the given remote by running
// "git push <remote> --delete refs/tags/<name>". Deleting a tag that does not
// exist on the remote is treated as success so the operation is idempotent.
func DeleteRemoteTag(remote, name string) error {
cmd := exec.Command("git", "push", remote, "--delete", "refs/tags/"+name)
out, err := cmd.CombinedOutput()
if err == nil {
return nil
}
if remoteRefAlreadyGone(out) {
return nil
}
return fmt.Errorf("git push %s --delete refs/tags/%s: %w\n%s", remote, name, err, out)
}

// remoteRefAlreadyGone reports whether a failed delete-push is because the ref
// does not exist on the remote, which we treat as success. Git emits "remote ref
// does not exist" (newer) or "unable to delete ... remote ref does not exist".
func remoteRefAlreadyGone(out []byte) bool {
return strings.Contains(string(out), "remote ref does not exist")
}

// GetLatestReleaseTag returns the most recent non-prerelease tag (no -rc suffix).
// This is used to find the base version for calculating next release versions.
func GetLatestReleaseTag(prefix string) (string, string, error) {
Expand Down
70 changes: 70 additions & 0 deletions internal/hotfix/lifecycle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package hotfix

import (
"strings"

"github.com/stablekernel/cascade/internal/config"
"github.com/stablekernel/cascade/internal/version"
)

// EnvBranchPrefix is the prefix of the per-environment integration branches a
// hotfix creates (for example env/test). A branch carrying this prefix exists
// only while its environment is diverged; once the env rejoins trunk the branch
// is deleted.
const EnvBranchPrefix = "env/"

// OrphanEnvBranches returns the env/* branches in branches that have no matching
// divergence in state. A branch env/<name> is healthy only while state[<name>]
// reports IsDiverged(); a branch with no diverged env behind it is an orphan
// left over from an interrupted hotfix or manual meddling and should be flagged.
//
// Non env/* branches are ignored. The returned slice preserves the input order
// and is nil when nothing is orphaned, so callers can treat a nil result as
// "consistent".
func OrphanEnvBranches(branches []string, state map[string]*config.EnvState) []string {
var orphans []string
for _, branch := range branches {
if !strings.HasPrefix(branch, EnvBranchPrefix) {
continue
}
env := strings.TrimPrefix(branch, EnvBranchPrefix)
st := state[env]
if st != nil && st.IsDiverged() {
continue
}
orphans = append(orphans, branch)
}
return orphans
}

// HotfixTagsForBase returns the hotfix tags in tags that belong to the rc base
// of baseVersion. A hotfix tag has the dotted shape vX.Y.Z-rc.N.hotfix.M; it
// shares the rc base (vX.Y.Z-rc.N) of the version the environment held while
// diverged. The RC-shaped cleanup in internal/release deliberately cannot see
// these tags (it matches only ^vX.Y.Z-rc.N$), so divergence-end cleanup must
// collect them explicitly.
//
// baseVersion may itself be a hotfix version (vX.Y.Z-rc.N.hotfix.M); it is
// normalized to its rc base before matching. Tags that do not parse, are not
// hotfix tags, or belong to a different rc base are excluded. The result is nil
// when nothing matches.
func HotfixTagsForBase(baseVersion string, tags []string) []string {
base, err := version.Parse(baseVersion)
if err != nil || base.PreRelease < 0 {
return nil
}
// Normalize to the rc base so a hotfix baseVersion matches its siblings.
rcBase := base.WithRC(base.PreRelease).String()

var matched []string
for _, tag := range tags {
v, err := version.Parse(tag)
if err != nil || v.Hotfix < 0 {
continue
}
if v.WithRC(v.PreRelease).String() == rcBase {
matched = append(matched, tag)
}
}
return matched
}
129 changes: 129 additions & 0 deletions internal/hotfix/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package hotfix

import (
"reflect"
"testing"

"github.com/stablekernel/cascade/internal/config"
)

func TestOrphanEnvBranches(t *testing.T) {
tests := []struct {
name string
branches []string
state map[string]*config.EnvState
want []string
}{
{
name: "no branches yields no orphans",
branches: nil,
state: map[string]*config.EnvState{
"test": {SHA: "abc"},
},
want: nil,
},
{
name: "branch with matching divergence is not an orphan",
branches: []string{"env/test"},
state: map[string]*config.EnvState{
"test": {Ref: "env/test", BaseSHA: "base", Patches: []string{"p1"}},
},
want: nil,
},
{
name: "branch without matching divergence is an orphan",
branches: []string{"env/test"},
state: map[string]*config.EnvState{
"test": {SHA: "abc"}, // not diverged
},
want: []string{"env/test"},
},
{
name: "branch for an env absent from state is an orphan",
branches: []string{"env/staging"},
state: map[string]*config.EnvState{
"test": {Ref: "env/test"},
},
want: []string{"env/staging"},
},
{
name: "non env-prefixed branches are ignored",
branches: []string{"main", "feature/x", "env/test"},
state: map[string]*config.EnvState{
"test": {SHA: "abc"},
},
want: []string{"env/test"},
},
{
name: "mixed orphan and healthy branches",
branches: []string{"env/test", "env/uat"},
state: map[string]*config.EnvState{
"test": {Ref: "env/test", Patches: []string{"p1"}},
"uat": {SHA: "abc"},
},
want: []string{"env/uat"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := OrphanEnvBranches(tt.branches, tt.state)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("OrphanEnvBranches() = %v, want %v", got, tt.want)
}
})
}
}

func TestHotfixTagsForBase(t *testing.T) {
tests := []struct {
name string
baseVersion string
tags []string
want []string
}{
{
name: "rc base matches its dotted hotfix tags only",
baseVersion: "v1.4.0-rc.2",
tags: []string{
"v1.4.0-rc.2",
"v1.4.0-rc.2.hotfix.1",
"v1.4.0-rc.2.hotfix.2",
"v1.4.0-rc.3",
"v1.4.0-rc.3.hotfix.1", // different base rc
"v1.3.0",
},
want: []string{"v1.4.0-rc.2.hotfix.1", "v1.4.0-rc.2.hotfix.2"},
},
{
name: "no hotfix tags yields empty",
baseVersion: "v1.4.0-rc.2",
tags: []string{"v1.4.0-rc.2", "v1.4.0-rc.3"},
want: nil,
},
{
name: "hotfix base version normalizes to its rc base",
baseVersion: "v1.4.0-rc.2.hotfix.1",
tags: []string{
"v1.4.0-rc.2.hotfix.1",
"v1.4.0-rc.2.hotfix.2",
},
want: []string{"v1.4.0-rc.2.hotfix.1", "v1.4.0-rc.2.hotfix.2"},
},
{
name: "unparseable base yields empty",
baseVersion: "not-a-version",
tags: []string{"v1.4.0-rc.2.hotfix.1"},
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HotfixTagsForBase(tt.baseVersion, tt.tags)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("HotfixTagsForBase() = %v, want %v", got, tt.want)
}
})
}
}
Loading
Loading