From 8679380de1f594fcfc68bab4d52f32373ccdc7b5 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 10 Jun 2026 22:40:35 -0400 Subject: [PATCH 1/2] test: add failing tests for EnvState divergence fields Signed-off-by: Joshua Temple --- internal/config/envstate_divergence_test.go | 90 +++++++++++++++++++++ internal/status/command_test.go | 44 ++++++++++ 2 files changed, 134 insertions(+) create mode 100644 internal/config/envstate_divergence_test.go diff --git a/internal/config/envstate_divergence_test.go b/internal/config/envstate_divergence_test.go new file mode 100644 index 00000000..e70794ce --- /dev/null +++ b/internal/config/envstate_divergence_test.go @@ -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()) + }) + } +} diff --git a/internal/status/command_test.go b/internal/status/command_test.go index e191f4d8..4eb4108a 100644 --- a/internal/status/command_test.go +++ b/internal/status/command_test.go @@ -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() { From b7b77c4f8a1ed42fc5518606a08484c3ca7fe2cd Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 10 Jun 2026 22:41:16 -0400 Subject: [PATCH 2/2] feat: add divergence fields to EnvState Signed-off-by: Joshua Temple --- internal/config/types.go | 16 ++++++++++++++++ internal/status/command.go | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/config/types.go b/internal/config/types.go index 67901faa..35a45d6e 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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..previous). Reserved-shape only. type EnvStateSnapshot struct { diff --git a/internal/status/command.go b/internal/status/command.go index 542cc6a1..e520f6a6 100644 --- a/internal/status/command.go +++ b/internal/status/command.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "sort" + "strings" "github.com/spf13/cobra" @@ -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 {