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
20 changes: 19 additions & 1 deletion acceptance/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"golang.org/x/crypto/ssh"
"gotest.tools/v3/assert"

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

// gitCurrentBranch returns the current branch of the git repo at dir, or ""
// on any error.
func gitCurrentBranch(t *testing.T, dir string) string {
t.Helper()
out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
return ""
}
b := strings.TrimSpace(string(out))
if b == "HEAD" {
return ""
}
return b
}

// TestValidateHookMode_SessionIsolation verifies that two concurrent Claude
// sessions each see their own sidecar state rather than sharing one file.
func TestValidateHookMode_SessionIsolation(t *testing.T) {
Expand Down
48 changes: 47 additions & 1 deletion internal/cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package cmd
import (
"bufio"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -585,7 +587,7 @@ func resolveOrCreateSidecarID(ctx context.Context, client *circleci.Client, side
if err != nil {
return false, err
}
sandboxName := filepath.Base(workDir) + "-validate"
sandboxName := sidecarAutoName(ctx, workDir)
sc, err := sidecar.Create(ctx, client, resolvedOrgID, sandboxName, image)
if err != nil {
if authErr := notAuthorized("create sidecars", err); authErr != nil {
Expand Down Expand Up @@ -616,6 +618,50 @@ func resolveOrCreateSidecarID(ctx context.Context, client *circleci.Client, side
return true, nil
}

// branchSanitizer is kept for the no-session fallback path.
var branchSanitizer = regexp.MustCompile(`[^a-z0-9-]+`)

// sidecarAutoName builds a sidecar name from workDir, the Claude session ID,
// and the current git branch.
//
// When a session ID is present the branch is encoded as an 8-hex-char suffix
// (sha256(sessionID+":"+branch)[:4]) so the raw branch name is never exposed:
// - Both present → "<base>-<sessionID>-<hash8>"
// - Session only → "<base>-<sessionID>"
//
// Without a session ID the branch is sanitised and included directly (legacy
// fallback):
// - Branch only → "<base>-<branch>-validate"
// - Neither → "<base>-validate"
func sidecarAutoName(ctx context.Context, workDir string) string {
base := filepath.Base(workDir)
sessionID := session.IDFromCtx(ctx)
branch := sidecar.CurrentBranch(workDir)

if sessionID != "" {
if branch != "" {
sum := sha256.Sum256([]byte(sessionID + ":" + branch))
hash8 := fmt.Sprintf("%x", sum[:4])
return base + "-" + sessionID + "-" + hash8
}
return base + "-" + sessionID
}

// No session ID: fall back to sanitised branch name for human readability.
if branch != "" {
branch = strings.ReplaceAll(branch, "/", "-")
branch = strings.ToLower(branch)
branch = branchSanitizer.ReplaceAllString(branch, "")
if len(branch) > 30 {
branch = branch[:30]
}
if branch != "" {
return base + "-" + branch + "-validate"
}
}
return base + "-validate"
}

func mapValidateError(err error) error {
if errors.Is(err, validate.ErrNotConfigured) {
return &userError{
Expand Down
135 changes: 135 additions & 0 deletions internal/cmd/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package cmd
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand All @@ -14,6 +17,7 @@ import (

"github.com/CircleCI-Public/chunk-cli/internal/circleci"
"github.com/CircleCI-Public/chunk-cli/internal/config"
"github.com/CircleCI-Public/chunk-cli/internal/session"
"github.com/CircleCI-Public/chunk-cli/internal/testing/fakes"
)

Expand Down Expand Up @@ -159,3 +163,134 @@ func TestValidateEnvFlagBadValue(t *testing.T) {
assert.Assert(t, err != nil)
assert.Assert(t, strings.Contains(err.Error(), "BADVALUE"), "got: %v", err)
}

// gitSetup initialises a minimal git repo at dir on the given branch name.
func gitSetup(t *testing.T, dir, branch string) {
t.Helper()
run := func(args ...string) {
t.Helper()
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("init", "-b", branch)
run("config", "user.email", "test@example.com")
run("config", "user.name", "Test")
_ = os.WriteFile(filepath.Join(dir, "README"), []byte("init"), 0o644)
run("add", ".")
run("commit", "-m", "init")
}

func hashFor(sessionID, branch string) string {
sum := sha256.Sum256([]byte(sessionID + ":" + branch))
return fmt.Sprintf("%x", sum[:4])
}

// Tests with a session ID: branch must be hashed, never appear raw.

func TestSidecarAutoNameWithSessionAndBranch(t *testing.T) {
dir := t.TempDir()
gitSetup(t, dir, "main")
ctx := session.WithID(context.Background(), "sess-1")
got := sidecarAutoName(ctx, dir)
want := filepath.Base(dir) + "-sess-1-" + hashFor("sess-1", "main")
assert.Equal(t, got, want)
}

func TestSidecarAutoNameWithSessionBranchWithSlashes(t *testing.T) {
dir := t.TempDir()
gitSetup(t, dir, "main")
run := func(args ...string) {
t.Helper()
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("checkout", "-b", "feature/my-branch")
ctx := session.WithID(context.Background(), "sess-2")
got := sidecarAutoName(ctx, dir)
want := filepath.Base(dir) + "-sess-2-" + hashFor("sess-2", "feature/my-branch")
assert.Equal(t, got, want)
assert.Assert(t, !strings.Contains(got, "feature"), "raw branch must not appear in name, got %q", got)
assert.Assert(t, !strings.Contains(got, "my-branch"), "raw branch must not appear in name, got %q", got)
}

func TestSidecarAutoNameWithSessionNoBranch(t *testing.T) {
dir := t.TempDir()
// No git repo → no branch.
ctx := session.WithID(context.Background(), "sess-3")
got := sidecarAutoName(ctx, dir)
assert.Equal(t, got, filepath.Base(dir)+"-sess-3")
}

func TestSidecarAutoNameDifferentBranchesDifferentNames(t *testing.T) {
dir := t.TempDir()
gitSetup(t, dir, "main")
run := func(args ...string) {
t.Helper()
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
ctx := session.WithID(context.Background(), "sess-x")
n1 := sidecarAutoName(ctx, dir)
run("checkout", "-b", "other-branch")
n2 := sidecarAutoName(ctx, dir)
assert.Assert(t, n1 != n2, "different branches must produce different names: %q vs %q", n1, n2)
}

// Tests without a session ID: legacy sanitised-branch fallback.

func TestSidecarAutoNameNoSessionBranchPresent(t *testing.T) {
dir := t.TempDir()
gitSetup(t, dir, "main")
got := sidecarAutoName(context.Background(), dir)
assert.Equal(t, got, filepath.Base(dir)+"-main-validate")
}

func TestSidecarAutoNameNoSessionBranchAbsent(t *testing.T) {
dir := t.TempDir()
// No git repo → falls back to old format.
got := sidecarAutoName(context.Background(), dir)
assert.Equal(t, got, filepath.Base(dir)+"-validate")
}

func TestSidecarAutoNameNoSessionBranchWithSlashes(t *testing.T) {
dir := t.TempDir()
gitSetup(t, dir, "main")
run := func(args ...string) {
t.Helper()
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("checkout", "-b", "feature/my-branch")
got := sidecarAutoName(context.Background(), dir)
assert.Equal(t, got, filepath.Base(dir)+"-feature-my-branch-validate")
}

func TestSidecarAutoNameNoSessionLongBranch(t *testing.T) {
dir := t.TempDir()
long := "abcdefghijklmnopqrstuvwxyz012345" // 32 chars
gitSetup(t, dir, "main")
run := func(args ...string) {
t.Helper()
c := exec.Command("git", append([]string{"-C", dir}, args...)...)
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("checkout", "-b", long)
got := sidecarAutoName(context.Background(), dir)
// branch truncated to 30 chars
assert.Equal(t, got, filepath.Base(dir)+"-"+long[:30]+"-validate")
}
64 changes: 53 additions & 11 deletions internal/sidecar/active.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package sidecar

import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

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

// sidecarFileName returns the name of the sidecar state file. When sessionID
// is non-empty the file is keyed to that session so concurrent Claude sessions
// in the same repo each maintain their own active sidecar.
func sidecarFileName(sessionID string) string {
if sessionID != "" {
// CurrentBranch returns the current git branch for the repo rooted at root.
// Returns "" on any error (no git, detached HEAD, etc.).
func CurrentBranch(root string) string {
var out bytes.Buffer
cmd := exec.Command("git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD")
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return ""
}
b := strings.TrimSpace(out.String())
if b == "HEAD" {
return "" // detached HEAD
}
return b
}

const defaultSidecarFile = "sidecar.json"

// sidecarFileName returns the name of the sidecar state file.
// - Both empty → "sidecar.json" (legacy fallback)
// - Session only → "sidecar.<sessionID>.json" (unchanged behaviour)
// - Both present → "sidecar.<sessionID>-<hash8>.json" where hash8 is the first
// 8 hex chars of sha256(sessionID + ":" + branch), encoding the branch uniquely.
func sidecarFileName(sessionID, branch string) string {
if sessionID == "" {
return defaultSidecarFile
}
if branch == "" {
return "sidecar." + sessionID + ".json"
}
return "sidecar.json"
sum := sha256.Sum256([]byte(sessionID + ":" + branch))
hash8 := fmt.Sprintf("%x", sum[:4])
return "sidecar." + sessionID + "-" + hash8 + ".json"
}

// StateFileName returns the sidecar state file name for the given session ID
// and git branch. Exposed so acceptance tests can construct expected paths.
func StateFileName(sessionID, branch string) string {
return sidecarFileName(sessionID, branch)
}

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

// LoadActiveFrom reads the active sidecar from dir.
func LoadActiveFrom(ctx context.Context, dir string) (*ActiveSidecar, error) {
path, err := findSidecarFile(dir, session.IDFromCtx(ctx))
root, _ := projectRoot()
branch := CurrentBranch(root)
path, err := findSidecarFile(dir, session.IDFromCtx(ctx), branch)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -85,7 +123,9 @@ func SaveActiveTo(ctx context.Context, dir string, a ActiveSidecar) error {
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, sidecarFileName(session.IDFromCtx(ctx))), data, 0o644)
root, _ := projectRoot()
branch := CurrentBranch(root)
return os.WriteFile(filepath.Join(dir, sidecarFileName(session.IDFromCtx(ctx), branch)), data, 0o644)
}

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

// ClearActiveFrom removes the active sidecar state file in dir.
func ClearActiveFrom(ctx context.Context, dir string) error {
path, err := findSidecarFile(dir, session.IDFromCtx(ctx))
root, _ := projectRoot()
branch := CurrentBranch(root)
path, err := findSidecarFile(dir, session.IDFromCtx(ctx), branch)
if err != nil {
return err
}
Expand All @@ -146,8 +188,8 @@ func ClearActiveFrom(ctx context.Context, dir string) error {
}

// findSidecarFile returns the sidecar state file path in dir, or "" if it doesn't exist.
func findSidecarFile(dir, sessionID string) (string, error) {
return statOrEmpty(filepath.Join(dir, sidecarFileName(sessionID)))
func findSidecarFile(dir, sessionID, branch string) (string, error) {
return statOrEmpty(filepath.Join(dir, sidecarFileName(sessionID, branch)))
}

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