Skip to content

Commit d48d9fa

Browse files
michael-websterwebsterclaude
authored
Hash session+branch for sidecar dedup (FACT-176) (#330)
Hash session+branch for sidecar dedup (FACT-176) - Sidecar state files are now named sidecar.<sessionID>-<hash8>.json where hash8 = sha256(sessionID+":"+branch)[:4], isolating concurrent sessions and branches without exposing raw branch names in filenames. - Auto-named sidecars use the same <base>-<sessionID>-<hash8> scheme; falls back to sanitised branch name when no session ID is present. - Export sidecar.CurrentBranch so cmd/validate.go can reuse it instead of duplicating the git invocation inline. - Pin hash assertions in tests with hashFor() instead of structural checks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix goconst lint: extract defaultSidecarFile constant Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: webster <michael@webster.fyi> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent eb3ebcf commit d48d9fa

5 files changed

Lines changed: 285 additions & 14 deletions

File tree

acceptance/validate_test.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"golang.org/x/crypto/ssh"
1818
"gotest.tools/v3/assert"
1919

20+
"github.com/CircleCI-Public/chunk-cli/internal/sidecar"
2021
"github.com/CircleCI-Public/chunk-cli/internal/testing/binary"
2122
testenv "github.com/CircleCI-Public/chunk-cli/internal/testing/env"
2223
"github.com/CircleCI-Public/chunk-cli/internal/testing/fakes"
@@ -476,11 +477,28 @@ func writeSidecarState(t *testing.T, e *testenv.TestEnv, projectRoot, sessionID,
476477
sum := sha256.Sum256([]byte(filepath.Clean(realRoot)))
477478
dir := filepath.Join(e.HomeDir, ".local", "share", "chunk", fmt.Sprintf("%x", sum))
478479
assert.NilError(t, os.MkdirAll(dir, 0o755))
479-
filename := "sidecar." + sessionID + ".json"
480+
// Detect the branch so the file name matches what the subprocess will look for.
481+
branch := gitCurrentBranch(t, projectRoot)
482+
filename := sidecar.StateFileName(sessionID, branch)
480483
data := []byte(`{"sidecar_id":"` + sidecarID + `"}`)
481484
assert.NilError(t, os.WriteFile(filepath.Join(dir, filename), data, 0o644))
482485
}
483486

487+
// gitCurrentBranch returns the current branch of the git repo at dir, or ""
488+
// on any error.
489+
func gitCurrentBranch(t *testing.T, dir string) string {
490+
t.Helper()
491+
out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
492+
if err != nil {
493+
return ""
494+
}
495+
b := strings.TrimSpace(string(out))
496+
if b == "HEAD" {
497+
return ""
498+
}
499+
return b
500+
}
501+
484502
// TestValidateHookMode_SessionIsolation verifies that two concurrent Claude
485503
// sessions each see their own sidecar state rather than sharing one file.
486504
func TestValidateHookMode_SessionIsolation(t *testing.T) {

internal/cmd/validate.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package cmd
33
import (
44
"bufio"
55
"context"
6+
"crypto/sha256"
67
"encoding/json"
78
"errors"
89
"fmt"
910
"io"
1011
"os"
1112
"path/filepath"
13+
"regexp"
1214
"strings"
1315

1416
"github.com/spf13/cobra"
@@ -585,7 +587,7 @@ func resolveOrCreateSidecarID(ctx context.Context, client *circleci.Client, side
585587
if err != nil {
586588
return false, err
587589
}
588-
sandboxName := filepath.Base(workDir) + "-validate"
590+
sandboxName := sidecarAutoName(ctx, workDir)
589591
sc, err := sidecar.Create(ctx, client, resolvedOrgID, sandboxName, image)
590592
if err != nil {
591593
if authErr := notAuthorized("create sidecars", err); authErr != nil {
@@ -616,6 +618,50 @@ func resolveOrCreateSidecarID(ctx context.Context, client *circleci.Client, side
616618
return true, nil
617619
}
618620

621+
// branchSanitizer is kept for the no-session fallback path.
622+
var branchSanitizer = regexp.MustCompile(`[^a-z0-9-]+`)
623+
624+
// sidecarAutoName builds a sidecar name from workDir, the Claude session ID,
625+
// and the current git branch.
626+
//
627+
// When a session ID is present the branch is encoded as an 8-hex-char suffix
628+
// (sha256(sessionID+":"+branch)[:4]) so the raw branch name is never exposed:
629+
// - Both present → "<base>-<sessionID>-<hash8>"
630+
// - Session only → "<base>-<sessionID>"
631+
//
632+
// Without a session ID the branch is sanitised and included directly (legacy
633+
// fallback):
634+
// - Branch only → "<base>-<branch>-validate"
635+
// - Neither → "<base>-validate"
636+
func sidecarAutoName(ctx context.Context, workDir string) string {
637+
base := filepath.Base(workDir)
638+
sessionID := session.IDFromCtx(ctx)
639+
branch := sidecar.CurrentBranch(workDir)
640+
641+
if sessionID != "" {
642+
if branch != "" {
643+
sum := sha256.Sum256([]byte(sessionID + ":" + branch))
644+
hash8 := fmt.Sprintf("%x", sum[:4])
645+
return base + "-" + sessionID + "-" + hash8
646+
}
647+
return base + "-" + sessionID
648+
}
649+
650+
// No session ID: fall back to sanitised branch name for human readability.
651+
if branch != "" {
652+
branch = strings.ReplaceAll(branch, "/", "-")
653+
branch = strings.ToLower(branch)
654+
branch = branchSanitizer.ReplaceAllString(branch, "")
655+
if len(branch) > 30 {
656+
branch = branch[:30]
657+
}
658+
if branch != "" {
659+
return base + "-" + branch + "-validate"
660+
}
661+
}
662+
return base + "-validate"
663+
}
664+
619665
func mapValidateError(err error) error {
620666
if errors.Is(err, validate.ErrNotConfigured) {
621667
return &userError{

internal/cmd/validate_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package cmd
33
import (
44
"bytes"
55
"context"
6+
"crypto/sha256"
67
"errors"
8+
"fmt"
79
"net/http/httptest"
810
"os"
11+
"os/exec"
912
"path/filepath"
1013
"strings"
1114
"testing"
@@ -14,6 +17,7 @@ import (
1417

1518
"github.com/CircleCI-Public/chunk-cli/internal/circleci"
1619
"github.com/CircleCI-Public/chunk-cli/internal/config"
20+
"github.com/CircleCI-Public/chunk-cli/internal/session"
1721
"github.com/CircleCI-Public/chunk-cli/internal/testing/fakes"
1822
)
1923

@@ -159,3 +163,134 @@ func TestValidateEnvFlagBadValue(t *testing.T) {
159163
assert.Assert(t, err != nil)
160164
assert.Assert(t, strings.Contains(err.Error(), "BADVALUE"), "got: %v", err)
161165
}
166+
167+
// gitSetup initialises a minimal git repo at dir on the given branch name.
168+
func gitSetup(t *testing.T, dir, branch string) {
169+
t.Helper()
170+
run := func(args ...string) {
171+
t.Helper()
172+
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
173+
out, err := c.CombinedOutput()
174+
if err != nil {
175+
t.Fatalf("git %v: %v\n%s", args, err, out)
176+
}
177+
}
178+
run("init", "-b", branch)
179+
run("config", "user.email", "test@example.com")
180+
run("config", "user.name", "Test")
181+
_ = os.WriteFile(filepath.Join(dir, "README"), []byte("init"), 0o644)
182+
run("add", ".")
183+
run("commit", "-m", "init")
184+
}
185+
186+
func hashFor(sessionID, branch string) string {
187+
sum := sha256.Sum256([]byte(sessionID + ":" + branch))
188+
return fmt.Sprintf("%x", sum[:4])
189+
}
190+
191+
// Tests with a session ID: branch must be hashed, never appear raw.
192+
193+
func TestSidecarAutoNameWithSessionAndBranch(t *testing.T) {
194+
dir := t.TempDir()
195+
gitSetup(t, dir, "main")
196+
ctx := session.WithID(context.Background(), "sess-1")
197+
got := sidecarAutoName(ctx, dir)
198+
want := filepath.Base(dir) + "-sess-1-" + hashFor("sess-1", "main")
199+
assert.Equal(t, got, want)
200+
}
201+
202+
func TestSidecarAutoNameWithSessionBranchWithSlashes(t *testing.T) {
203+
dir := t.TempDir()
204+
gitSetup(t, dir, "main")
205+
run := func(args ...string) {
206+
t.Helper()
207+
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
208+
out, err := c.CombinedOutput()
209+
if err != nil {
210+
t.Fatalf("git %v: %v\n%s", args, err, out)
211+
}
212+
}
213+
run("checkout", "-b", "feature/my-branch")
214+
ctx := session.WithID(context.Background(), "sess-2")
215+
got := sidecarAutoName(ctx, dir)
216+
want := filepath.Base(dir) + "-sess-2-" + hashFor("sess-2", "feature/my-branch")
217+
assert.Equal(t, got, want)
218+
assert.Assert(t, !strings.Contains(got, "feature"), "raw branch must not appear in name, got %q", got)
219+
assert.Assert(t, !strings.Contains(got, "my-branch"), "raw branch must not appear in name, got %q", got)
220+
}
221+
222+
func TestSidecarAutoNameWithSessionNoBranch(t *testing.T) {
223+
dir := t.TempDir()
224+
// No git repo → no branch.
225+
ctx := session.WithID(context.Background(), "sess-3")
226+
got := sidecarAutoName(ctx, dir)
227+
assert.Equal(t, got, filepath.Base(dir)+"-sess-3")
228+
}
229+
230+
func TestSidecarAutoNameDifferentBranchesDifferentNames(t *testing.T) {
231+
dir := t.TempDir()
232+
gitSetup(t, dir, "main")
233+
run := func(args ...string) {
234+
t.Helper()
235+
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
236+
out, err := c.CombinedOutput()
237+
if err != nil {
238+
t.Fatalf("git %v: %v\n%s", args, err, out)
239+
}
240+
}
241+
ctx := session.WithID(context.Background(), "sess-x")
242+
n1 := sidecarAutoName(ctx, dir)
243+
run("checkout", "-b", "other-branch")
244+
n2 := sidecarAutoName(ctx, dir)
245+
assert.Assert(t, n1 != n2, "different branches must produce different names: %q vs %q", n1, n2)
246+
}
247+
248+
// Tests without a session ID: legacy sanitised-branch fallback.
249+
250+
func TestSidecarAutoNameNoSessionBranchPresent(t *testing.T) {
251+
dir := t.TempDir()
252+
gitSetup(t, dir, "main")
253+
got := sidecarAutoName(context.Background(), dir)
254+
assert.Equal(t, got, filepath.Base(dir)+"-main-validate")
255+
}
256+
257+
func TestSidecarAutoNameNoSessionBranchAbsent(t *testing.T) {
258+
dir := t.TempDir()
259+
// No git repo → falls back to old format.
260+
got := sidecarAutoName(context.Background(), dir)
261+
assert.Equal(t, got, filepath.Base(dir)+"-validate")
262+
}
263+
264+
func TestSidecarAutoNameNoSessionBranchWithSlashes(t *testing.T) {
265+
dir := t.TempDir()
266+
gitSetup(t, dir, "main")
267+
run := func(args ...string) {
268+
t.Helper()
269+
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
270+
out, err := c.CombinedOutput()
271+
if err != nil {
272+
t.Fatalf("git %v: %v\n%s", args, err, out)
273+
}
274+
}
275+
run("checkout", "-b", "feature/my-branch")
276+
got := sidecarAutoName(context.Background(), dir)
277+
assert.Equal(t, got, filepath.Base(dir)+"-feature-my-branch-validate")
278+
}
279+
280+
func TestSidecarAutoNameNoSessionLongBranch(t *testing.T) {
281+
dir := t.TempDir()
282+
long := "abcdefghijklmnopqrstuvwxyz012345" // 32 chars
283+
gitSetup(t, dir, "main")
284+
run := func(args ...string) {
285+
t.Helper()
286+
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
287+
out, err := c.CombinedOutput()
288+
if err != nil {
289+
t.Fatalf("git %v: %v\n%s", args, err, out)
290+
}
291+
}
292+
run("checkout", "-b", long)
293+
got := sidecarAutoName(context.Background(), dir)
294+
// branch truncated to 30 chars
295+
assert.Equal(t, got, filepath.Base(dir)+"-"+long[:30]+"-validate")
296+
}

internal/sidecar/active.go

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package sidecar
22

33
import (
4+
"bytes"
45
"context"
6+
"crypto/sha256"
57
"encoding/json"
68
"errors"
9+
"fmt"
710
"os"
11+
"os/exec"
812
"path/filepath"
13+
"strings"
914

1015
"github.com/CircleCI-Public/chunk-cli/internal/config"
1116
"github.com/CircleCI-Public/chunk-cli/internal/session"
@@ -18,14 +23,45 @@ type ActiveSidecar struct {
1823
Workspace string `json:"workspace,omitempty"`
1924
}
2025

21-
// sidecarFileName returns the name of the sidecar state file. When sessionID
22-
// is non-empty the file is keyed to that session so concurrent Claude sessions
23-
// in the same repo each maintain their own active sidecar.
24-
func sidecarFileName(sessionID string) string {
25-
if sessionID != "" {
26+
// CurrentBranch returns the current git branch for the repo rooted at root.
27+
// Returns "" on any error (no git, detached HEAD, etc.).
28+
func CurrentBranch(root string) string {
29+
var out bytes.Buffer
30+
cmd := exec.Command("git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD")
31+
cmd.Stdout = &out
32+
if err := cmd.Run(); err != nil {
33+
return ""
34+
}
35+
b := strings.TrimSpace(out.String())
36+
if b == "HEAD" {
37+
return "" // detached HEAD
38+
}
39+
return b
40+
}
41+
42+
const defaultSidecarFile = "sidecar.json"
43+
44+
// sidecarFileName returns the name of the sidecar state file.
45+
// - Both empty → "sidecar.json" (legacy fallback)
46+
// - Session only → "sidecar.<sessionID>.json" (unchanged behaviour)
47+
// - Both present → "sidecar.<sessionID>-<hash8>.json" where hash8 is the first
48+
// 8 hex chars of sha256(sessionID + ":" + branch), encoding the branch uniquely.
49+
func sidecarFileName(sessionID, branch string) string {
50+
if sessionID == "" {
51+
return defaultSidecarFile
52+
}
53+
if branch == "" {
2654
return "sidecar." + sessionID + ".json"
2755
}
28-
return "sidecar.json"
56+
sum := sha256.Sum256([]byte(sessionID + ":" + branch))
57+
hash8 := fmt.Sprintf("%x", sum[:4])
58+
return "sidecar." + sessionID + "-" + hash8 + ".json"
59+
}
60+
61+
// StateFileName returns the sidecar state file name for the given session ID
62+
// and git branch. Exposed so acceptance tests can construct expected paths.
63+
func StateFileName(sessionID, branch string) string {
64+
return sidecarFileName(sessionID, branch)
2965
}
3066

3167
// StateDir returns the XDG_DATA_HOME directory for the current project.
@@ -49,7 +85,9 @@ func LoadActive(ctx context.Context) (*ActiveSidecar, error) {
4985

5086
// LoadActiveFrom reads the active sidecar from dir.
5187
func LoadActiveFrom(ctx context.Context, dir string) (*ActiveSidecar, error) {
52-
path, err := findSidecarFile(dir, session.IDFromCtx(ctx))
88+
root, _ := projectRoot()
89+
branch := CurrentBranch(root)
90+
path, err := findSidecarFile(dir, session.IDFromCtx(ctx), branch)
5391
if err != nil {
5492
return nil, err
5593
}
@@ -85,7 +123,9 @@ func SaveActiveTo(ctx context.Context, dir string, a ActiveSidecar) error {
85123
if err != nil {
86124
return err
87125
}
88-
return os.WriteFile(filepath.Join(dir, sidecarFileName(session.IDFromCtx(ctx))), data, 0o644)
126+
root, _ := projectRoot()
127+
branch := CurrentBranch(root)
128+
return os.WriteFile(filepath.Join(dir, sidecarFileName(session.IDFromCtx(ctx), branch)), data, 0o644)
89129
}
90130

91131
// saveDir returns the XDG_DATA_HOME directory for the current project.
@@ -135,7 +175,9 @@ func ClearActive(ctx context.Context) error {
135175

136176
// ClearActiveFrom removes the active sidecar state file in dir.
137177
func ClearActiveFrom(ctx context.Context, dir string) error {
138-
path, err := findSidecarFile(dir, session.IDFromCtx(ctx))
178+
root, _ := projectRoot()
179+
branch := CurrentBranch(root)
180+
path, err := findSidecarFile(dir, session.IDFromCtx(ctx), branch)
139181
if err != nil {
140182
return err
141183
}
@@ -146,8 +188,8 @@ func ClearActiveFrom(ctx context.Context, dir string) error {
146188
}
147189

148190
// findSidecarFile returns the sidecar state file path in dir, or "" if it doesn't exist.
149-
func findSidecarFile(dir, sessionID string) (string, error) {
150-
return statOrEmpty(filepath.Join(dir, sidecarFileName(sessionID)))
191+
func findSidecarFile(dir, sessionID, branch string) (string, error) {
192+
return statOrEmpty(filepath.Join(dir, sidecarFileName(sessionID, branch)))
151193
}
152194

153195
// statOrEmpty returns path if it exists, "" if it does not, or an error for other failures.

0 commit comments

Comments
 (0)