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
90 changes: 90 additions & 0 deletions internal/config/envstate_divergence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package config

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

func TestEnvState_DivergenceFields_RoundTrip(t *testing.T) {
original := &EnvState{
SHA: "abc123",
Version: "v1.2.3",
Ref: "hotfix/v1.2.3-integration",
BaseSHA: "base000",
Patches: []string{"patch1sha", "patch2sha"},
}

t.Run("yaml", func(t *testing.T) {
data, err := yaml.Marshal(original)
require.NoError(t, err)

var got EnvState
require.NoError(t, yaml.Unmarshal(data, &got))

assert.Equal(t, original.Ref, got.Ref)
assert.Equal(t, original.BaseSHA, got.BaseSHA)
assert.Equal(t, original.Patches, got.Patches)
assert.True(t, got.IsDiverged())
})

t.Run("json", func(t *testing.T) {
data, err := json.Marshal(original)
require.NoError(t, err)

var got EnvState
require.NoError(t, json.Unmarshal(data, &got))

assert.Equal(t, original.Ref, got.Ref)
assert.Equal(t, original.BaseSHA, got.BaseSHA)
assert.Equal(t, original.Patches, got.Patches)
assert.True(t, got.IsDiverged())
})
}

func TestEnvState_AbsentFieldsMeanTrackingTrunk(t *testing.T) {
// A manifest that predates the divergence fields parses unchanged and is
// not considered diverged.
src := `
sha: abc123def456
version: v1.2.3-rc.1
committed_at: "2026-01-01T10:00:00Z"
committed_by: alice
`
var state EnvState
require.NoError(t, yaml.Unmarshal([]byte(src), &state))

assert.Empty(t, state.Ref)
assert.Empty(t, state.BaseSHA)
assert.Empty(t, state.Patches)
assert.False(t, state.IsDiverged())

// omitempty: re-marshalling does not introduce the new keys.
data, err := yaml.Marshal(&state)
require.NoError(t, err)
assert.NotContains(t, string(data), "ref:")
assert.NotContains(t, string(data), "base_sha:")
assert.NotContains(t, string(data), "patches:")
}

func TestEnvState_IsDiverged(t *testing.T) {
tests := []struct {
name string
state EnvState
want bool
}{
{name: "empty", state: EnvState{}, want: false},
{name: "ref only", state: EnvState{Ref: "hotfix/x"}, want: true},
{name: "patches only", state: EnvState{Patches: []string{"sha"}}, want: true},
{name: "both", state: EnvState{Ref: "hotfix/x", Patches: []string{"sha"}}, want: true},
{name: "base_sha only does not diverge", state: EnvState{BaseSHA: "base"}, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, tc.state.IsDiverged())
})
}
}
16 changes: 16 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,27 @@ type EnvState struct {
Builds map[string]*BuildState `yaml:"builds,omitempty" json:"builds,omitempty"`
Deploys map[string]*DeployState `yaml:"deploys,omitempty" json:"deploys,omitempty"`
External map[string]*ExternalDeployState `yaml:"external,omitempty" json:"external,omitempty"` // External repo deploy states
// Ref is the integration branch this environment tracks instead of trunk
// (e.g., a hotfix branch). Empty means the environment tracks trunk.
Ref string `yaml:"ref,omitempty" json:"ref,omitempty"`
// BaseSHA is the trunk commit the integration branch diverged from.
BaseSHA string `yaml:"base_sha,omitempty" json:"base_sha,omitempty"`
// Patches lists the patch commit SHAs applied on top of BaseSHA.
Patches []string `yaml:"patches,omitempty" json:"patches,omitempty"`
// Previous is the reserved "roll back to N-1" ring (#23). Reserved-shape,
// optional: populated only if deterministic history-walking is wired later.
Previous []EnvStateSnapshot `yaml:"previous,omitempty" json:"previous,omitempty"`
}

// IsDiverged reports whether the environment is on an integration branch rather
// than tracking trunk: true when it has a custom Ref or has Patches applied.
func (s *EnvState) IsDiverged() bool {
if s == nil {
return false
}
return s.Ref != "" || len(s.Patches) > 0
}

// EnvStateSnapshot is a single prior env-state entry in the reserved rollback
// ring (state.<env>.previous). Reserved-shape only.
type EnvStateSnapshot struct {
Expand Down
11 changes: 11 additions & 0 deletions internal/status/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"sort"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -143,6 +144,16 @@ func printEnvState(state *config.EnvState, indent string) {
fmt.Printf("%scommitted_at: %s\n", indent, orDash(state.CommittedAt))
fmt.Printf("%scommitted_by: %s\n", indent, orDash(state.CommittedBy))

if state.Ref != "" {
fmt.Printf("%sref: %s\n", indent, state.Ref)
}
if state.BaseSHA != "" {
fmt.Printf("%sbase_sha: %s\n", indent, state.BaseSHA)
}
if len(state.Patches) > 0 {
fmt.Printf("%spatches: %s\n", indent, strings.Join(state.Patches, ", "))
}

if len(state.Builds) > 0 {
fmt.Printf("%sbuilds:\n", indent)
for name, b := range state.Builds {
Expand Down
44 changes: 44 additions & 0 deletions internal/status/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,50 @@ func TestRunEnv_KnownEnv(t *testing.T) {
assert.Contains(t, out, "services")
}

func TestStatus_PrintsDivergence(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
content := `ci:
config:
trunk_branch: main
environments:
- dev
- prod
state:
dev:
sha: abc123
version: v1.2.3-rc.1
ref: hotfix/v1.2.3-integration
base_sha: base000aaa
patches:
- patch1sha
- patch2sha
prod:
sha: 111aaa
version: v1.2.2
`
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))

// Diverged env surfaces the new fields.
devOut := captureOutput(t, func() {
err := runEnv(path, "ci", false, "dev")
require.NoError(t, err)
})
assert.Contains(t, devOut, "hotfix/v1.2.3-integration")
assert.Contains(t, devOut, "base000aaa")
assert.Contains(t, devOut, "patch1sha")
assert.Contains(t, devOut, "patch2sha")

// Non-diverged env does not print ref/base_sha/patches labels at all.
prodOut := captureOutput(t, func() {
err := runEnv(path, "ci", false, "prod")
require.NoError(t, err)
})
assert.NotContains(t, prodOut, "ref:")
assert.NotContains(t, prodOut, "base_sha:")
assert.NotContains(t, prodOut, "patches:")
}

func TestRunEnv_FilterJSON(t *testing.T) {
path := fixtureManifest(t)
out := captureOutput(t, func() {
Expand Down
Loading