Skip to content

Commit 3862ff4

Browse files
Track active snapshot after sidecar setup (#296)
1 parent fc9465a commit 3862ff4

3 files changed

Lines changed: 240 additions & 4 deletions

File tree

internal/cmd/sidecar.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -781,9 +781,26 @@ Example:
781781

782782
// Step 6: Create snapshot.
783783
if !skipSnapshot {
784-
if err := sidecarSetupSnapshot(cmd.Context(), client, sidecarID, scDisplayName, snapshotName, status); err != nil {
784+
snap, err := sidecarSetupSnapshot(cmd.Context(), client, sidecarID, scDisplayName, snapshotName, status)
785+
if err != nil {
785786
return err
786787
}
788+
789+
// Step 7: Record snapshot, persist image to config, clear active sidecar.
790+
if saveErr := sidecar.SaveActiveSnapshot(sidecar.ActiveSnapshot{ID: snap.ID, Name: snap.Name}); saveErr != nil {
791+
streams.ErrPrintf("warning: could not save active snapshot: %v\n", saveErr)
792+
}
793+
if cfg.Validation == nil {
794+
cfg.Validation = &config.ValidationConfig{}
795+
}
796+
cfg.Validation.SidecarImage = snap.ID
797+
if saveErr := config.SaveProjectConfig(dir, cfg); saveErr != nil {
798+
streams.ErrPrintf("warning: could not save snapshot ID to project config: %v\n", saveErr)
799+
}
800+
if clearErr := sidecar.ClearActive(); clearErr != nil {
801+
streams.ErrPrintf("warning: could not clear active sidecar: %v\n", clearErr)
802+
}
803+
status(iostream.LevelDone, fmt.Sprintf("Snapshot ID saved. Create a new sidecar from this snapshot with: chunk sidecar create --image %s", snap.ID))
787804
}
788805

789806
return nil
@@ -968,7 +985,7 @@ func sidecarSetupSnapshot(
968985
client *circleci.Client,
969986
sidecarID, scDisplayName, snapshotName string,
970987
status iostream.StatusFunc,
971-
) error {
988+
) (*circleci.Snapshot, error) {
972989
if snapshotName == "" {
973990
if scDisplayName != "" {
974991
snapshotName = scDisplayName + "-setup"
@@ -979,12 +996,12 @@ func sidecarSetupSnapshot(
979996
status(iostream.LevelStep, fmt.Sprintf("Creating snapshot %q...", snapshotName))
980997
snap, err := client.CreateSnapshot(ctx, sidecarID, snapshotName)
981998
if err != nil {
982-
return &userError{
999+
return nil, &userError{
9831000
msg: "Could not create the snapshot.",
9841001
suggestion: "Check your network connection and try again.",
9851002
err: err,
9861003
}
9871004
}
9881005
status(iostream.LevelDone, fmt.Sprintf("Snapshot created: %s (%s)", snap.Name, snap.ID))
989-
return nil
1006+
return snap, nil
9901007
}

internal/sidecar/snapshot.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package sidecar
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/CircleCI-Public/chunk-cli/internal/config"
10+
)
11+
12+
// ActiveSnapshot holds the most recently created snapshot for a project.
13+
type ActiveSnapshot struct {
14+
ID string `json:"id"`
15+
Name string `json:"name,omitempty"`
16+
}
17+
18+
func snapshotFileName() string {
19+
if id := os.Getenv(config.EnvClaudeSession); id != "" {
20+
return "snapshot." + id + ".json"
21+
}
22+
return "snapshot.json"
23+
}
24+
25+
// LoadActiveSnapshot walks up from cwd looking for .chunk/snapshot.json. Returns nil if not found.
26+
func LoadActiveSnapshot() (*ActiveSnapshot, error) {
27+
path, err := findSnapshotFile()
28+
if err != nil {
29+
return nil, err
30+
}
31+
if path == "" {
32+
return nil, nil
33+
}
34+
data, err := os.ReadFile(path)
35+
if err != nil {
36+
return nil, err
37+
}
38+
var a ActiveSnapshot
39+
if err := json.Unmarshal(data, &a); err != nil {
40+
return nil, err
41+
}
42+
return &a, nil
43+
}
44+
45+
// SaveActiveSnapshot writes .chunk/snapshot.json into the same directory as the active sidecar file.
46+
func SaveActiveSnapshot(a ActiveSnapshot) error {
47+
dir, err := saveDir()
48+
if err != nil {
49+
return err
50+
}
51+
if err := os.MkdirAll(dir, 0o755); err != nil {
52+
return err
53+
}
54+
data, err := json.Marshal(a)
55+
if err != nil {
56+
return err
57+
}
58+
return os.WriteFile(filepath.Join(dir, snapshotFileName()), data, 0o644)
59+
}
60+
61+
// ClearActiveSnapshot removes the .chunk/snapshot.json file found by walking up from cwd.
62+
func ClearActiveSnapshot() error {
63+
path, err := findSnapshotFile()
64+
if err != nil {
65+
return err
66+
}
67+
if path == "" {
68+
return nil
69+
}
70+
return os.Remove(path)
71+
}
72+
73+
// findSnapshotFile walks up from cwd looking for .chunk/snapshot.json, returning the path or "".
74+
// Bounded by the git root, same as findSidecarFile.
75+
func findSnapshotFile() (string, error) {
76+
dir, err := os.Getwd()
77+
if err != nil {
78+
return "", err
79+
}
80+
gitRoot, _ := findGitRoot()
81+
for {
82+
candidate := filepath.Join(dir, ".chunk", snapshotFileName())
83+
if _, err := os.Stat(candidate); err == nil {
84+
return candidate, nil
85+
} else if !errors.Is(err, os.ErrNotExist) {
86+
return "", err
87+
}
88+
if gitRoot == "" || dir == gitRoot {
89+
return "", nil
90+
}
91+
parent := filepath.Dir(dir)
92+
if parent == dir {
93+
return "", nil
94+
}
95+
dir = parent
96+
}
97+
}

internal/sidecar/snapshot_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package sidecar
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"gotest.tools/v3/assert"
9+
10+
"github.com/CircleCI-Public/chunk-cli/internal/config"
11+
)
12+
13+
func TestSaveAndLoadActiveSnapshot(t *testing.T) {
14+
dir := t.TempDir()
15+
t.Chdir(dir)
16+
17+
want := ActiveSnapshot{ID: "snap-abc", Name: "my-snap"}
18+
assert.NilError(t, SaveActiveSnapshot(want))
19+
20+
got, err := LoadActiveSnapshot()
21+
assert.NilError(t, err)
22+
assert.Assert(t, got != nil, "expected non-nil ActiveSnapshot")
23+
assert.Equal(t, got.ID, want.ID)
24+
assert.Equal(t, got.Name, want.Name)
25+
}
26+
27+
func TestLoadActiveSnapshotReturnsNilWhenMissing(t *testing.T) {
28+
dir := t.TempDir()
29+
t.Chdir(dir)
30+
31+
got, err := LoadActiveSnapshot()
32+
assert.NilError(t, err)
33+
assert.Assert(t, got == nil, "expected nil when no snapshot file")
34+
}
35+
36+
func TestClearActiveSnapshot(t *testing.T) {
37+
dir := t.TempDir()
38+
t.Chdir(dir)
39+
40+
assert.NilError(t, SaveActiveSnapshot(ActiveSnapshot{ID: "snap-xyz"}))
41+
42+
got, err := LoadActiveSnapshot()
43+
assert.NilError(t, err)
44+
assert.Assert(t, got != nil)
45+
46+
assert.NilError(t, ClearActiveSnapshot())
47+
48+
got, err = LoadActiveSnapshot()
49+
assert.NilError(t, err)
50+
assert.Assert(t, got == nil)
51+
}
52+
53+
func TestClearActiveSnapshotNoopWhenMissing(t *testing.T) {
54+
dir := t.TempDir()
55+
t.Chdir(dir)
56+
57+
assert.NilError(t, ClearActiveSnapshot())
58+
}
59+
60+
func TestSnapshotSessionKeyed(t *testing.T) {
61+
dir := t.TempDir()
62+
t.Chdir(dir)
63+
64+
// Save without a session — generic file.
65+
assert.NilError(t, SaveActiveSnapshot(ActiveSnapshot{ID: "snap-generic"}))
66+
67+
// With a session ID set, load should return nil (isolated from the generic file).
68+
t.Setenv(config.EnvClaudeSession, "sess-abc")
69+
got, err := LoadActiveSnapshot()
70+
assert.NilError(t, err)
71+
assert.Assert(t, got == nil, "session-keyed load should not see generic file")
72+
73+
// Save under the session.
74+
assert.NilError(t, SaveActiveSnapshot(ActiveSnapshot{ID: "snap-session"}))
75+
76+
got, err = LoadActiveSnapshot()
77+
assert.NilError(t, err)
78+
assert.Assert(t, got != nil)
79+
assert.Equal(t, got.ID, "snap-session")
80+
81+
// Without the session env var, the original generic file is still intact.
82+
t.Setenv(config.EnvClaudeSession, "")
83+
got, err = LoadActiveSnapshot()
84+
assert.NilError(t, err)
85+
assert.Assert(t, got != nil)
86+
assert.Equal(t, got.ID, "snap-generic")
87+
}
88+
89+
func TestLoadActiveSnapshotWalksUpToParent(t *testing.T) {
90+
parent := t.TempDir()
91+
child := filepath.Join(parent, "sub", "dir")
92+
assert.NilError(t, os.MkdirAll(child, 0o755))
93+
94+
assert.NilError(t, os.MkdirAll(filepath.Join(parent, ".git"), 0o755))
95+
assert.NilError(t, os.MkdirAll(filepath.Join(parent, ".chunk"), 0o755))
96+
data := []byte(`{"id":"snap-parent","name":"parent-snap"}`)
97+
assert.NilError(t, os.WriteFile(filepath.Join(parent, ".chunk", "snapshot.json"), data, 0o644))
98+
99+
t.Chdir(child)
100+
101+
got, err := LoadActiveSnapshot()
102+
assert.NilError(t, err)
103+
assert.Assert(t, got != nil)
104+
assert.Equal(t, got.ID, "snap-parent")
105+
}
106+
107+
func TestLoadActiveSnapshotStopsAtGitRoot(t *testing.T) {
108+
grandparent := t.TempDir()
109+
parent := filepath.Join(grandparent, "repo")
110+
child := filepath.Join(parent, "sub")
111+
assert.NilError(t, os.MkdirAll(child, 0o755))
112+
assert.NilError(t, os.MkdirAll(filepath.Join(parent, ".git"), 0o755))
113+
assert.NilError(t, os.MkdirAll(filepath.Join(grandparent, ".chunk"), 0o755))
114+
data := []byte(`{"id":"snap-grandparent"}`)
115+
assert.NilError(t, os.WriteFile(filepath.Join(grandparent, ".chunk", "snapshot.json"), data, 0o644))
116+
117+
t.Chdir(child)
118+
119+
got, err := LoadActiveSnapshot()
120+
assert.NilError(t, err)
121+
assert.Assert(t, got == nil, "walk should not cross the git root boundary")
122+
}

0 commit comments

Comments
 (0)