Skip to content
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
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260602-160809.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'integration: Add storage model for integration branch state (internal)'
time: 2026-06-02T16:08:09.643553-04:00
34 changes: 34 additions & 0 deletions internal/spice/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,40 @@ func (s *Service) RenameBranch(ctx context.Context, oldName, newName string) err
}
s.log.Debug("Renamed tracked name of branch", "old", oldName, "new", newName)

if err := s.renameIntegrationTip(ctx, oldName, newName); err != nil {
// Branch rename already succeeded; surface as warning only.
s.log.Warnf("integration tip rename: %v", err)
}

return nil
}

// renameIntegrationTip rewrites any integration tip that references
// oldName to use newName instead.
func (s *Service) renameIntegrationTip(ctx context.Context, oldName, newName string) error {
info, err := s.store.Integration(ctx)
if errors.Is(err, state.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("get integration: %w", err)
}

var updated bool
for i, tip := range info.Tips {
if tip.Name == oldName {
info.Tips[i].Name = newName
updated = true
}
}
if !updated {
return nil
}

if err := s.store.SetIntegration(ctx, info); err != nil {
return fmt.Errorf("save integration: %w", err)
}
s.log.Debug("Renamed integration tip", "old", oldName, "new", newName)
return nil
}

Expand Down
29 changes: 29 additions & 0 deletions internal/spice/mock_service_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions internal/spice/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ type Store interface {

LoadCachedTemplates(context.Context) (string, []*state.CachedTemplate, error)
CacheTemplates(context.Context, string, []*state.CachedTemplate) error

// Integration returns the configured integration branch,
// or [state.ErrNotExist] if none is configured.
Integration(ctx context.Context) (*state.IntegrationInfo, error)

// SetIntegration writes the integration branch configuration.
// Pass nil to clear.
SetIntegration(ctx context.Context, info *state.IntegrationInfo) error
}

var _ Store = (*state.Store)(nil)
Expand Down
93 changes: 93 additions & 0 deletions internal/spice/state/integration_rebuild.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package state

import (
"context"
"errors"
"fmt"

"go.abhg.dev/gs/internal/git"
)

const _integrationRebuildJSON = "integration-rebuild"

type integrationRebuildState struct {
Integration string `json:"integration"`
Tips []integrationRebuildTip `json:"tips"`
NextTipIndex int `json:"nextTipIndex"`
}

type integrationRebuildTip struct {
Name string `json:"name"`
Hash string `json:"hash"`
}

// IntegrationRebuild describes an in-progress integration rebuild that
// was paused (typically due to a merge conflict). Its presence signals
// that the next [gs integration rebuild] invocation should resume
// rather than start fresh.
type IntegrationRebuild struct {
// Integration is the local name of the integration branch.
Integration string

// Tips holds the full set of tips for the rebuild, with the hashes
// captured at the start of the original attempt. Indexed positions
// align with [NextTipIndex].
Tips []IntegrationTip

// NextTipIndex points to the first tip that has not yet been
// started. Tips with index < NextTipIndex have been merged
// successfully (or the merge is in progress in the worktree, in
// the case of the most recently attempted one).
NextTipIndex int
}

// PendingIntegrationRebuild returns the saved pending rebuild, or
// [ErrNotExist] if no rebuild is in progress.
func (s *Store) PendingIntegrationRebuild(ctx context.Context) (*IntegrationRebuild, error) {
var st integrationRebuildState
if err := s.db.Get(ctx, _integrationRebuildJSON, &st); err != nil {
if errors.Is(err, ErrNotExist) {
return nil, ErrNotExist
}
return nil, fmt.Errorf("get pending rebuild: %w", err)
}

tips := make([]IntegrationTip, len(st.Tips))
for i, t := range st.Tips {
tips[i] = IntegrationTip{Name: t.Name, Hash: git.Hash(t.Hash)}
}
return &IntegrationRebuild{
Integration: st.Integration,
Tips: tips,
NextTipIndex: st.NextTipIndex,
}, nil
}

// SetPendingIntegrationRebuild records a pending integration rebuild.
func (s *Store) SetPendingIntegrationRebuild(ctx context.Context, rb *IntegrationRebuild) error {
tips := make([]integrationRebuildTip, len(rb.Tips))
for i, t := range rb.Tips {
tips[i] = integrationRebuildTip{Name: t.Name, Hash: t.Hash.String()}
}
st := integrationRebuildState{
Integration: rb.Integration,
Tips: tips,
NextTipIndex: rb.NextTipIndex,
}
if err := s.db.Set(ctx, _integrationRebuildJSON, st, "save pending integration rebuild"); err != nil {
return fmt.Errorf("save pending rebuild: %w", err)
}
return nil
}

// ClearPendingIntegrationRebuild removes any saved pending rebuild.
// No-op if none is saved.
func (s *Store) ClearPendingIntegrationRebuild(ctx context.Context) error {
if err := s.db.Delete(ctx, _integrationRebuildJSON, "clear pending integration rebuild"); err != nil {
if errors.Is(err, ErrNotExist) {
return nil
}
return fmt.Errorf("clear pending rebuild: %w", err)
}
return nil
}
77 changes: 77 additions & 0 deletions internal/spice/state/integration_rebuild_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package state_test

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.abhg.dev/gs/internal/silog/silogtest"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/spice/state/storage"
)

func TestStore_PendingIntegrationRebuild_notExist(t *testing.T) {
mem := make(storage.MapBackend)
_, err := state.InitStore(t.Context(), state.InitStoreRequest{
DB: storage.NewDB(mem),
Trunk: "main",
})
require.NoError(t, err)

store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t))
require.NoError(t, err)

_, err = store.PendingIntegrationRebuild(t.Context())
assert.ErrorIs(t, err, state.ErrNotExist)
}

func TestStore_SetPendingIntegrationRebuild_roundTrip(t *testing.T) {
mem := make(storage.MapBackend)
_, err := state.InitStore(t.Context(), state.InitStoreRequest{
DB: storage.NewDB(mem),
Trunk: "main",
})
require.NoError(t, err)

store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t))
require.NoError(t, err)

want := &state.IntegrationRebuild{
Integration: "preview",
Tips: []state.IntegrationTip{
{Name: "feat-a", Hash: "hash-a"},
{Name: "feat-b", Hash: "hash-b"},
{Name: "feat-c", Hash: "hash-c"},
},
NextTipIndex: 2,
}
require.NoError(t, store.SetPendingIntegrationRebuild(t.Context(), want))

got, err := store.PendingIntegrationRebuild(t.Context())
require.NoError(t, err)
assert.Equal(t, want, got)
}

func TestStore_ClearPendingIntegrationRebuild(t *testing.T) {
mem := make(storage.MapBackend)
_, err := state.InitStore(t.Context(), state.InitStoreRequest{
DB: storage.NewDB(mem),
Trunk: "main",
})
require.NoError(t, err)

store, err := state.OpenStore(t.Context(), storage.NewDB(mem), silogtest.New(t))
require.NoError(t, err)

require.NoError(t, store.SetPendingIntegrationRebuild(t.Context(), &state.IntegrationRebuild{
Integration: "preview",
}))

require.NoError(t, store.ClearPendingIntegrationRebuild(t.Context()))

_, err = store.PendingIntegrationRebuild(t.Context())
assert.ErrorIs(t, err, state.ErrNotExist)

// Idempotent
require.NoError(t, store.ClearPendingIntegrationRebuild(t.Context()))
}
Loading
Loading