Skip to content
Draft
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
21 changes: 10 additions & 11 deletions .chunk/config.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
{
"commands": [
"fix": [
{
"name": "format",
"run": "task fmt",
"timeout": 30
}
],
"validate": [
{
"name": "test",
"run": "task test",
"role": "gate",
"timeout": 300,
"remote": true
},
Expand All @@ -15,14 +21,7 @@
{
"name": "lint",
"run": "task lint",
"role": "gate",
"timeout": 60
},
{
"name": "format",
"run": "task fmt",
"role": "autofix",
"timeout": 30
}
],
"vcs": {
Expand All @@ -32,6 +31,7 @@
"validation": {
"sidecarImage": "3c128769-f6a7-4219-8240-a3562d91ed17"
},
"orgID": "f22b6566-597d-46d5-ba74-99ef5bb3d85c",
"environment": {
"stack": "go",
"setup": [
Expand All @@ -50,6 +50,5 @@
],
"image": "cimg/go",
"image_version": "1.26.2"
},
"orgID": "f22b6566-597d-46d5-ba74-99ef5bb3d85c"
}
}
22 changes: 6 additions & 16 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,25 @@
]
},
"hooks": {
"PreToolUse": [
"PostToolUse": [
{
"matcher": "Bash(git commit*)",
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "cd ${CLAUDE_PROJECT_DIR:-.} && task fmt",
"command": "cd ${CLAUDE_PROJECT_DIR:-.} && chunk fix",
"timeout": 30
},
{
"type": "command",
"command": "cd ${CLAUDE_PROJECT_DIR:-.} && chunk validate lint",
"timeout": 60
},
{
"type": "command",
"command": "cd ${CLAUDE_PROJECT_DIR:-.} && chunk validate test",
"timeout": 300
}
]
}
],
"Stop": [
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "chunk validate",
"timeout": 420
"command": "cd ${CLAUDE_PROJECT_DIR:-.} && chunk session start",
"timeout": 5
}
]
}
Expand Down
28 changes: 28 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "chunk fix",
"timeout": 30
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash(git commit*)",
"hooks": [
{
"type": "command",
"command": "chunk validate",
"timeout": 600
}
]
}
]
}
}
33 changes: 17 additions & 16 deletions acceptance/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,19 @@ func readInitConfig(t *testing.T, workDir string) map[string]interface{} {
return cfg
}

// configCommands extracts the commands array from a parsed config.
// configCommands extracts all commands from a parsed config, combining both
// validate and fix lists.
func configCommands(cfg map[string]interface{}) []map[string]interface{} {
raw, ok := cfg["commands"].([]interface{})
if !ok {
return nil
}
var cmds []map[string]interface{}
for _, r := range raw {
if m, ok := r.(map[string]interface{}); ok {
cmds = append(cmds, m)
for _, key := range []string{"validate", "fix"} {
raw, ok := cfg[key].([]interface{})
if !ok {
continue
}
for _, r := range raw {
if m, ok := r.(map[string]interface{}); ok {
cmds = append(cmds, m)
}
}
}
return cmds
Expand Down Expand Up @@ -110,9 +113,7 @@ func TestInitSkipAllWritesOnlyVCS(t *testing.T) {
assert.Equal(t, vcs["org"], "test-org")
assert.Equal(t, vcs["repo"], "test-repo")

_, hasCommands := cfg["commands"]
assert.Assert(t, !hasCommands || cfg["commands"] == nil ||
len(cfg["commands"].([]interface{})) == 0,
assert.Assert(t, len(configCommands(cfg)) == 0,
"expected no commands with --skip-validate, got: %s", string(data))
}

Expand Down Expand Up @@ -178,11 +179,11 @@ func TestInitForcePreservesSkippedSections(t *testing.T) {
assert.Equal(t, vcs["repo"], "new-repo")

// Commands should be preserved (--skip-validate).
cmds, ok := cfg["commands"].([]interface{})
assert.Assert(t, ok && len(cmds) > 0, "expected commands preserved, got: %s", string(data))
cmd0 := cmds[0].(map[string]interface{})
assert.Equal(t, cmd0["name"], "test")
assert.Equal(t, cmd0["run"], "echo test")
cmds := configCommands(cfg)
assert.Assert(t, len(cmds) > 0, "expected commands preserved, got: %s", string(data))
testCmd := commandByName(cfg, "test")
assert.Assert(t, testCmd != nil, "expected test command preserved, got: %s", string(data))
assert.Equal(t, testCmd["run"], "echo test")
}

func TestInitNotGitRepo(t *testing.T) {
Expand Down
124 changes: 0 additions & 124 deletions acceptance/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,17 @@ package acceptance
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"encoding/pem"
"fmt"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"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 @@ -62,52 +58,6 @@ func hookStdin(t *testing.T, sessionID string, stopHookActive bool) []byte {
return data
}

// commitAll stages and commits all files in dir.
func commitAll(t *testing.T, dir, message string) {
t.Helper()
for _, args := range [][]string{
{"git", "add", "-A"},
{"git", "commit", "-m", message},
} {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
cmd.Env = gitrepo.GitEnv(dir)
out, err := cmd.CombinedOutput()
assert.NilError(t, err, "%v: %s", args, out)
}
}

// TestValidateHookMode_DirtyTree verifies that piping a hook payload triggers
// hook mode and re-signals the agent (exit 2) when commands fail.
func TestValidateHookMode_DirtyTree(t *testing.T) {
workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo")
// writeProjectConfig creates an untracked file → dirty working tree.
writeProjectConfig(t, workDir, "", "false")

env := testenv.NewTestEnv(t)
result := binary.RunCLIWithStdin(t, []string{"validate"}, env, workDir,
hookStdin(t, "test-session-dirty", false))

assert.Equal(t, result.ExitCode, 2,
"expected exit 2 (hook re-signal) for dirty tree with failing command; stderr: %s", result.Stderr)
}

// TestValidateHookMode_CleanTree verifies that piping a hook payload exits 0
// without running any commands when the working tree is clean.
func TestValidateHookMode_CleanTree(t *testing.T) {
workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo")
// Write config then commit it so the tree is clean.
writeProjectConfig(t, workDir, "", "false") // deliberately failing command
commitAll(t, workDir, "add config")

env := testenv.NewTestEnv(t)
result := binary.RunCLIWithStdin(t, []string{"validate"}, env, workDir,
hookStdin(t, "test-session-clean", false))

assert.Equal(t, result.ExitCode, 0,
"expected exit 0 (skipped) for clean tree; stderr: %s", result.Stderr)
}

func TestValidateRunDryRun(t *testing.T) {
workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo")
writeProjectConfig(t, workDir, "echo install", "echo test")
Expand Down Expand Up @@ -518,35 +468,6 @@ func TestValidateHookAutoCreatesSidecarFromSidecarImage(t *testing.T) {
assert.Equal(t, len(addKeyReqs), 1, "expected 1 add-key request for newly created sidecar; got: %v", reqs)
}

// writeRemoteProjectConfig writes a config with a single remote command.
func writeRemoteProjectConfig(t *testing.T, workDir string) {
t.Helper()
chunkDir := filepath.Join(workDir, ".chunk")
assert.NilError(t, os.MkdirAll(chunkDir, 0o755))
cfg := `{"commands":[{"name":"test","run":"true","remote":true}]}`
assert.NilError(t, os.WriteFile(filepath.Join(chunkDir, "config.json"), []byte(cfg), 0o644))
}

// writeSidecarState writes a session-keyed sidecar state file into the test
// environment's XDG data directory for the given project root.
func writeSidecarState(t *testing.T, e *testenv.TestEnv, projectRoot, sessionID, sidecarID string) {
t.Helper()
// Resolve symlinks so the hash matches what os.Getwd() returns in the subprocess.
// On macOS, t.TempDir() returns /var/folders/... but os.Getwd() resolves to /private/var/...
realRoot, err := filepath.EvalSymlinks(projectRoot)
assert.NilError(t, err)
// Compute the data dir directly from e.HomeDir so we don't touch the parent process env.
// This mirrors config.ProjectDataDir: <XDG_DATA_HOME>/chunk/<sha256(root)>
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))
// 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))
}

// TestValidateHookMode_SuccessLine verifies that the "chunk validate passed"
// success line is written to stderr after a clean hook run.
func TestValidateHookMode_SuccessLine(t *testing.T) {
Expand Down Expand Up @@ -598,48 +519,3 @@ func TestValidateHookMode_SetupErrorFlushedToStderr(t *testing.T) {
assert.Assert(t, strings.Contains(result.Stderr, "Syncing workspace"),
"expected sync attempt in stderr; got: %s", result.Stderr)
}

// 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) {
workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo")
writeRemoteProjectConfig(t, workDir)
// Add an untracked file so the working tree is dirty and validate runs.
assert.NilError(t, os.WriteFile(filepath.Join(workDir, "dirty.txt"), []byte("x"), 0o644))

envA := testenv.NewTestEnv(t)
envB := testenv.NewTestEnv(t)

writeSidecarState(t, envA, workDir, "sess-a", "sidecar-aaa")
writeSidecarState(t, envB, workDir, "sess-b", "sidecar-bbb")

resultA := binary.RunCLIWithStdin(t, []string{"validate"}, envA, workDir,
hookStdin(t, "sess-a", true))
resultB := binary.RunCLIWithStdin(t, []string{"validate"}, envB, workDir,
hookStdin(t, "sess-b", true))

assert.Assert(t, strings.Contains(resultA.Stderr, "sidecar-aaa"),
"session A should load sidecar-aaa; stderr: %s", resultA.Stderr)
assert.Assert(t, !strings.Contains(resultA.Stderr, "sidecar-bbb"),
"session A should not see sidecar-bbb; stderr: %s", resultA.Stderr)

assert.Assert(t, strings.Contains(resultB.Stderr, "sidecar-bbb"),
"session B should load sidecar-bbb; stderr: %s", resultB.Stderr)
assert.Assert(t, !strings.Contains(resultB.Stderr, "sidecar-aaa"),
"session B should not see sidecar-aaa; stderr: %s", resultB.Stderr)
}
54 changes: 54 additions & 0 deletions internal/cmd/fix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cmd

import (
"os"

"github.com/spf13/cobra"

"github.com/CircleCI-Public/chunk-cli/internal/config"
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
"github.com/CircleCI-Public/chunk-cli/internal/validate"
)

func newFixCmd() *cobra.Command {
var projectDir string

cmd := &cobra.Command{
Use: "fix [name]",
Short: "Run fix commands (formatters and other file rewrites)",
SilenceUsage: true,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
streams := iostream.FromCmd(cmd)

workDir := projectDir
if workDir == "" {
var err error
workDir, err = os.Getwd()
if err != nil {
return err
}
}

cfg, err := config.LoadProjectConfig(workDir)
if err != nil || !cfg.HasFixCommands() {
return &userError{
msg: "No fix commands configured.",
suggestion: "Add fix commands to .chunk/config.json under the \"fix\" key.",
hideDetail: true,
}
}

var name string
if len(args) == 1 {
name = args[0]
}

statusFn := newStatusFunc(streams)
return validate.RunFix(cmd.Context(), workDir, name, cfg.Fix, statusFn, streams)
},
}

cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory")
return cmd
}
Loading