Skip to content

Commit 13233ec

Browse files
websterclaude
andcommitted
Add validate result recording and GitHub commit status reporting
- ComputeTreeSHA: uses a temp index copy (GIT_INDEX_FILE) to compute the git tree SHA of the full worktree including untracked files, without touching the real index - validate.CommandResult + SaveResults/LoadResults: persist per-command pass/fail results keyed to tree SHA in /tmp/chunk-run/trees/ - RunAll/RunNamed/RunInline now return ([]CommandResult, error) - chunk validate records results after every local run - chunk validate post-commit: reads stored results by HEAD^{tree} SHA and posts a GitHub commit status per command - chunk init writes .git/hooks/post-commit to call post-commit automatically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 09b885c commit 13233ec

10 files changed

Lines changed: 395 additions & 30 deletions

File tree

‎internal/cmd/init.go‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/CircleCI-Public/chunk-cli/internal/anthropic"
1515
"github.com/CircleCI-Public/chunk-cli/internal/config"
1616
"github.com/CircleCI-Public/chunk-cli/internal/gitremote"
17+
"github.com/CircleCI-Public/chunk-cli/internal/gitutil"
1718
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
1819
"github.com/CircleCI-Public/chunk-cli/internal/settings"
1920
"github.com/CircleCI-Public/chunk-cli/internal/tui"
@@ -139,6 +140,36 @@ func writeSettingsExample(dir string, data []byte, streams iostream.Streams) err
139140
return nil
140141
}
141142

143+
const postCommitHookContent = "#!/bin/sh\nchunk validate post-commit\n"
144+
145+
// writePostCommitHook writes .git/hooks/post-commit to call chunk validate post-commit.
146+
// Skips if the file already contains the hook. Warns if the file exists with different content.
147+
func writePostCommitHook(workDir string, streams iostream.Streams) error {
148+
root, err := gitutil.RepoRoot(workDir)
149+
if err != nil {
150+
return fmt.Errorf("find repo root: %w", err)
151+
}
152+
hookPath := filepath.Join(root, ".git", "hooks", "post-commit")
153+
154+
existing, readErr := os.ReadFile(hookPath)
155+
if readErr == nil {
156+
if strings.Contains(string(existing), "chunk validate post-commit") {
157+
return nil
158+
}
159+
streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Skipping post-commit hook: %s already exists with different content", hookPath)))
160+
return nil
161+
}
162+
if !errors.Is(readErr, fs.ErrNotExist) {
163+
return fmt.Errorf("read post-commit hook: %w", readErr)
164+
}
165+
166+
if err := os.WriteFile(hookPath, []byte(postCommitHookContent), 0o755); err != nil {
167+
return fmt.Errorf("write post-commit hook: %w", err)
168+
}
169+
streams.ErrPrintln(ui.Success("Wrote .git/hooks/post-commit"))
170+
return nil
171+
}
172+
142173
var sidecarGitignoreEntries = []string{
143174
".chunk/sidecar.json",
144175
".chunk/sidecar.*.json",
@@ -282,11 +313,14 @@ hook config files.`,
282313
streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Could not update .gitignore: %v", err)))
283314
}
284315

285-
// Step 3: Write .claude/settings.json
316+
// Step 3: Write .claude/settings.json and git hooks
286317
if !skipHooks {
287318
if err := writeSettings(workDir, cfg.Commands, streams, tui.Confirm); err != nil {
288319
return err
289320
}
321+
if err := writePostCommitHook(workDir, streams); err != nil {
322+
streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Could not write post-commit hook: %v", err)))
323+
}
290324
}
291325

292326
// Step 4: Shell completions

‎internal/cmd/validate.go‎

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import (
88
"fmt"
99
"io"
1010
"os"
11+
"os/exec"
1112
"strings"
1213

1314
"github.com/spf13/cobra"
1415
"golang.org/x/term"
1516

1617
"github.com/CircleCI-Public/chunk-cli/internal/config"
18+
"github.com/CircleCI-Public/chunk-cli/internal/gitremote"
19+
"github.com/CircleCI-Public/chunk-cli/internal/gitutil"
1720
"github.com/CircleCI-Public/chunk-cli/internal/iostream"
1821
"github.com/CircleCI-Public/chunk-cli/internal/sidecar"
1922
"github.com/CircleCI-Public/chunk-cli/internal/tui"
@@ -141,7 +144,13 @@ func newValidateCmd() *cobra.Command {
141144
}
142145
}
143146

144-
execErr := runValidate(cmd.Context(), workDir, name, inlineCmd, save, sidecarID, identityFile, workdir, cfg, statusFn, streams)
147+
results, execErr := runValidate(cmd.Context(), workDir, name, inlineCmd, save, sidecarID, identityFile, workdir, cfg, statusFn, streams)
148+
149+
if results != nil {
150+
if treeSHA, treeErr := gitutil.ComputeTreeSHA(workDir); treeErr == nil {
151+
_ = validate.SaveResults(treeSHA, results)
152+
}
153+
}
145154

146155
if hook != nil {
147156
maxAttempts := cfg.StopHookMaxAttempts
@@ -164,12 +173,15 @@ func newValidateCmd() *cobra.Command {
164173
cmd.Flags().BoolVar(&save, "save", false, "Save --cmd to .chunk/config.json")
165174
cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory")
166175

176+
cmd.AddCommand(newPostCommitCmd())
177+
167178
return cmd
168179
}
169180

170181
// runValidate dispatches to the appropriate Run* function based on the
171182
// provided options. It is shared by both direct and hook invocations.
172-
func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID, identityFile, workdir string, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error {
183+
// Returns nil results for remote/sidecar runs (no result recording on those paths).
184+
func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID, identityFile, workdir string, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) ([]validate.CommandResult, error) {
173185
// --cmd: inline command
174186
if inlineCmd != "" {
175187
cmdName := name
@@ -178,16 +190,16 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
178190
}
179191
if save {
180192
if err := config.SaveCommand(workDir, cmdName, inlineCmd); err != nil {
181-
return &userError{msg: "Could not save command to .chunk/config.json.", err: err}
193+
return nil, &userError{msg: "Could not save command to .chunk/config.json.", err: err}
182194
}
183195
streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", cmdName)))
184196
}
185197
if sidecarID != "" {
186198
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
187199
if err != nil {
188-
return err
200+
return nil, err
189201
}
190-
return validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, streams)
202+
return nil, validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, streams)
191203
}
192204
return validate.RunInline(ctx, workDir, cmdName, inlineCmd, statusFn, streams)
193205
}
@@ -196,16 +208,16 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
196208
if sidecarID != "" {
197209
execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams)
198210
if err != nil {
199-
return err
211+
return nil, err
200212
}
201-
return validate.RunRemote(ctx, execFn, cfg, name, dest, streams)
213+
return nil, validate.RunRemote(ctx, execFn, cfg, name, dest, streams)
202214
}
203215

204216
// Named command
205217
if name != "" {
206218
if cfg.FindCommand(name) == nil {
207219
if !term.IsTerminal(int(os.Stdin.Fd())) {
208-
return &userError{
220+
return nil, &userError{
209221
msg: fmt.Sprintf("Command %q is not configured.", name),
210222
suggestion: "Add it to .chunk/config.json.",
211223
errMsg: fmt.Sprintf("command %q is not configured", name),
@@ -216,28 +228,99 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
216228
streams.ErrPrintf("What command should %s run? ", ui.Bold(name))
217229
scanner := bufio.NewScanner(os.Stdin)
218230
if !scanner.Scan() {
219-
return &userError{msg: "No command entered.", errMsg: "no input received"}
231+
return nil, &userError{msg: "No command entered.", errMsg: "no input received"}
220232
}
221233
input := strings.TrimSpace(scanner.Text())
222234
if input == "" {
223235
streams.ErrPrintln(ui.Dim("No command entered, aborting."))
224-
return &userError{msg: "No command entered.", errMsg: "no command entered"}
236+
return nil, &userError{msg: "No command entered.", errMsg: "no command entered"}
225237
}
226238
if err := config.SaveCommand(workDir, name, input); err != nil {
227-
return &userError{msg: "Could not save command to .chunk/config.json.", err: err}
239+
return nil, &userError{msg: "Could not save command to .chunk/config.json.", err: err}
228240
}
229241
streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", name)))
230242
var err error
231243
cfg, err = config.LoadProjectConfig(workDir)
232244
if err != nil {
233-
return err
245+
return nil, err
234246
}
235247
}
236-
return mapValidateError(validate.RunNamed(ctx, workDir, name, cfg, statusFn, streams))
248+
results, err := validate.RunNamed(ctx, workDir, name, cfg, statusFn, streams)
249+
return results, mapValidateError(err)
237250
}
238251

239252
// Run all
240-
return mapValidateError(validate.RunAll(ctx, workDir, cfg, statusFn, streams))
253+
results, err := validate.RunAll(ctx, workDir, cfg, statusFn, streams)
254+
return results, mapValidateError(err)
255+
}
256+
257+
func newPostCommitCmd() *cobra.Command {
258+
var projectDir string
259+
260+
cmd := &cobra.Command{
261+
Use: "post-commit",
262+
Short: "Report validate results as GitHub commit statuses",
263+
SilenceUsage: true,
264+
RunE: func(cmd *cobra.Command, _ []string) error {
265+
streams := iostream.FromCmd(cmd)
266+
ctx := cmd.Context()
267+
268+
workDir := projectDir
269+
if workDir == "" {
270+
var err error
271+
workDir, err = os.Getwd()
272+
if err != nil {
273+
return err
274+
}
275+
}
276+
277+
treeOut, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD^{tree}").Output()
278+
if err != nil {
279+
return fmt.Errorf("resolve HEAD tree: %w", err)
280+
}
281+
treeSHA := strings.TrimSpace(string(treeOut))
282+
283+
results, found, err := validate.LoadResults(treeSHA)
284+
if err != nil {
285+
return fmt.Errorf("load validate results: %w", err)
286+
}
287+
if !found {
288+
return nil
289+
}
290+
291+
commitOut, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD").Output()
292+
if err != nil {
293+
return fmt.Errorf("resolve HEAD: %w", err)
294+
}
295+
commitSHA := strings.TrimSpace(string(commitOut))
296+
297+
org, repo, err := gitremote.DetectOrgAndRepo(workDir)
298+
if err != nil {
299+
return fmt.Errorf("detect repo: %w", err)
300+
}
301+
302+
ghClient, err := ensureGitHubClient(ctx, streams, tui.PromptHidden)
303+
if err != nil {
304+
return err
305+
}
306+
307+
for _, r := range results {
308+
state := "success"
309+
if !r.Passed {
310+
state = "failure"
311+
}
312+
if postErr := ghClient.CreateCommitStatus(ctx, org, repo, commitSHA, state, "chunk/"+r.Name, "chunk validate: "+r.Name); postErr != nil {
313+
streams.ErrPrintf(" %s\n", ui.Warning(fmt.Sprintf("could not post status for %s: %v", r.Name, postErr)))
314+
continue
315+
}
316+
streams.ErrPrintf(" %s %s/%s → %s\n", ui.Success("posted"), org, repo, r.Name)
317+
}
318+
return nil
319+
},
320+
}
321+
322+
cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory")
323+
return cmd
241324
}
242325

243326
// openSSHSession establishes an SSH session to the sidecar and returns an

‎internal/github/statuses.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
hc "github.com/CircleCI-Public/chunk-cli/internal/httpcl"
8+
)
9+
10+
// CreateCommitStatus posts a commit status for the given SHA.
11+
// state must be one of: "pending", "success", "failure", "error".
12+
// statusContext is the check name shown in GitHub (e.g. "chunk/test").
13+
func (c *Client) CreateCommitStatus(ctx context.Context, owner, repo, sha, state, statusContext, description string) error {
14+
body := map[string]string{
15+
"state": state,
16+
"context": statusContext,
17+
"description": description,
18+
}
19+
req := hc.NewRequest("POST", "/repos/%s/%s/statuses/%s",
20+
hc.RouteParams(owner, repo, sha),
21+
hc.Body(body),
22+
)
23+
_, err := c.http.Call(ctx, req)
24+
if err == nil {
25+
return nil
26+
}
27+
return mapErr(fmt.Sprintf("create commit status %s", statusContext), err)
28+
}

‎internal/github/statuses_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package github_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/CircleCI-Public/chunk-cli/internal/github"
12+
"gotest.tools/v3/assert"
13+
)
14+
15+
func TestCreateCommitStatus(t *testing.T) {
16+
var gotMethod, gotPath string
17+
var gotBody map[string]string
18+
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
gotMethod = r.Method
21+
gotPath = r.URL.Path
22+
b, _ := io.ReadAll(r.Body)
23+
_ = json.Unmarshal(b, &gotBody)
24+
w.WriteHeader(http.StatusCreated)
25+
}))
26+
defer srv.Close()
27+
28+
c, err := github.New(github.Config{Token: "test-token", BaseURL: srv.URL})
29+
assert.NilError(t, err)
30+
31+
err = c.CreateCommitStatus(context.Background(), "myorg", "myrepo", "abc123", "success", "chunk/test", "chunk validate: test")
32+
assert.NilError(t, err)
33+
34+
assert.Equal(t, gotMethod, "POST")
35+
assert.Equal(t, gotPath, "/repos/myorg/myrepo/statuses/abc123")
36+
assert.Equal(t, gotBody["state"], "success")
37+
assert.Equal(t, gotBody["context"], "chunk/test")
38+
assert.Equal(t, gotBody["description"], "chunk validate: test")
39+
}
40+
41+
func TestCreateCommitStatus_Error(t *testing.T) {
42+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+
w.WriteHeader(http.StatusUnprocessableEntity)
44+
}))
45+
defer srv.Close()
46+
47+
c, err := github.New(github.Config{Token: "test-token", BaseURL: srv.URL})
48+
assert.NilError(t, err)
49+
50+
err = c.CreateCommitStatus(context.Background(), "org", "repo", "sha", "success", "chunk/test", "desc")
51+
assert.Assert(t, err != nil)
52+
}

‎internal/gitutil/gitutil.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package gitutil
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"os/exec"
78
"path/filepath"
@@ -102,6 +103,53 @@ func GeneratePatch(base string) (string, error) {
102103
return string(out), nil
103104
}
104105

106+
// ComputeTreeSHA computes the git tree SHA for the current worktree state
107+
// (including untracked files) without modifying the real index.
108+
// It copies the index to a temp file, stages all files into the copy,
109+
// and calls git write-tree against it.
110+
func ComputeTreeSHA(workDir string) (string, error) {
111+
root, err := RepoRoot(workDir)
112+
if err != nil {
113+
return "", fmt.Errorf("compute tree sha: %w", err)
114+
}
115+
116+
src, err := os.Open(filepath.Join(root, ".git", "index"))
117+
if err != nil {
118+
return "", fmt.Errorf("compute tree sha: open index: %w", err)
119+
}
120+
tmp, err := os.CreateTemp("", "chunk-index-*")
121+
if err != nil {
122+
_ = src.Close()
123+
return "", fmt.Errorf("compute tree sha: create temp index: %w", err)
124+
}
125+
tmpPath := tmp.Name()
126+
defer func() { _ = os.Remove(tmpPath) }()
127+
128+
if _, err := io.Copy(tmp, src); err != nil {
129+
_ = src.Close()
130+
_ = tmp.Close()
131+
return "", fmt.Errorf("compute tree sha: copy index: %w", err)
132+
}
133+
_ = src.Close()
134+
_ = tmp.Close()
135+
136+
indexEnv := "GIT_INDEX_FILE=" + tmpPath
137+
138+
addCmd := exec.Command("git", "-C", workDir, "add", "--all")
139+
addCmd.Env = append(os.Environ(), indexEnv)
140+
if out, err := addCmd.CombinedOutput(); err != nil {
141+
return "", fmt.Errorf("compute tree sha: git add: %w: %s", err, out)
142+
}
143+
144+
treeCmd := exec.Command("git", "-C", workDir, "write-tree")
145+
treeCmd.Env = append(os.Environ(), indexEnv)
146+
out, err := treeCmd.Output()
147+
if err != nil {
148+
return "", fmt.Errorf("compute tree sha: git write-tree: %w", err)
149+
}
150+
return strings.TrimSpace(string(out)), nil
151+
}
152+
105153
func splitNonEmpty(s string) []string {
106154
if s == "" {
107155
return nil

0 commit comments

Comments
 (0)