diff --git a/internal/cmd/config.go b/internal/cmd/config.go index a8c01d60..dcf34567 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -95,7 +95,7 @@ func newConfigSetCmd() *cobra.Command { return &cobra.Command{ Use: "set ", Short: "Set a config value", - Long: "Set a config value. Use 'chunk auth set ' to store credentials with validation.\n\nUser keys: model\nProject keys: orgID, validation.sidecarImage", + Long: "Set a config value. Use 'chunk auth set ' to store credentials with validation.\n\nUser keys: model\nProject keys: orgID, validation.sidecarImage, validation.commitStatus", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { io := iostream.FromCmd(cmd) @@ -118,6 +118,11 @@ func newConfigSetCmd() *cobra.Command { projCfg.Validation = &config.ValidationConfig{} } projCfg.Validation.SidecarImage = value + case "validation.commitStatus": + if projCfg.Validation == nil { + projCfg.Validation = &config.ValidationConfig{} + } + projCfg.Validation.CommitStatus = value == "true" default: return fmt.Errorf("internal: unhandled project config key %q", key) } @@ -131,7 +136,7 @@ func newConfigSetCmd() *cobra.Command { if !config.ValidConfigKeys[key] { return &userError{ msg: fmt.Sprintf("Unknown config key: %q.", key), - detail: "Supported keys: model, orgID, validation.sidecarImage.", + detail: "Supported keys: model, orgID, validation.sidecarImage, validation.commitStatus.", errMsg: fmt.Sprintf("unknown config key %q", key), } } diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 23ba0252..1570620a 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -14,6 +14,7 @@ import ( "github.com/CircleCI-Public/chunk-cli/internal/anthropic" "github.com/CircleCI-Public/chunk-cli/internal/config" "github.com/CircleCI-Public/chunk-cli/internal/gitremote" + "github.com/CircleCI-Public/chunk-cli/internal/gitutil" "github.com/CircleCI-Public/chunk-cli/internal/iostream" "github.com/CircleCI-Public/chunk-cli/internal/settings" "github.com/CircleCI-Public/chunk-cli/internal/skills" @@ -140,6 +141,36 @@ func writeSettingsExample(dir string, data []byte, streams iostream.Streams) err return nil } +const postCommitHookContent = "#!/bin/sh\nchunk validate post-commit\n" + +// writePostCommitHook writes .git/hooks/post-commit to call chunk validate post-commit. +// Skips if the file already contains the hook. Warns if the file exists with different content. +func writePostCommitHook(workDir string, streams iostream.Streams) error { + root, err := gitutil.RepoRoot(workDir) + if err != nil { + return fmt.Errorf("find repo root: %w", err) + } + hookPath := filepath.Join(root, ".git", "hooks", "post-commit") + + existing, readErr := os.ReadFile(hookPath) + if readErr == nil { + if strings.Contains(string(existing), "chunk validate post-commit") { + return nil + } + streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Skipping post-commit hook: %s already exists with different content", hookPath))) + return nil + } + if !errors.Is(readErr, fs.ErrNotExist) { + return fmt.Errorf("read post-commit hook: %w", readErr) + } + + if err := os.WriteFile(hookPath, []byte(postCommitHookContent), 0o755); err != nil { + return fmt.Errorf("write post-commit hook: %w", err) + } + streams.ErrPrintln(ui.Success("Wrote .git/hooks/post-commit")) + return nil +} + var sidecarGitignoreEntries = []string{ ".chunk/sidecar.json", ".chunk/sidecar.*.json", @@ -301,11 +332,14 @@ hook config files.`, streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Could not update .gitignore: %v", err))) } - // Step 3: Write .claude/settings.json + // Step 3: Write .claude/settings.json and git hooks if !skipHooks { if err := writeSettings(workDir, cfg.Commands, streams, tui.Confirm); err != nil { return err } + if err := writePostCommitHook(workDir, streams); err != nil { + streams.ErrPrintf("%s\n", ui.Warning(fmt.Sprintf("Could not write post-commit hook: %v", err))) + } } // Step 4: Shell completions diff --git a/internal/cmd/validate.go b/internal/cmd/validate.go index 72e3b7c7..77cecc62 100644 --- a/internal/cmd/validate.go +++ b/internal/cmd/validate.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "strings" @@ -16,6 +17,7 @@ import ( "github.com/CircleCI-Public/chunk-cli/internal/config" "github.com/CircleCI-Public/chunk-cli/internal/gitremote" + "github.com/CircleCI-Public/chunk-cli/internal/gitutil" "github.com/CircleCI-Public/chunk-cli/internal/iostream" "github.com/CircleCI-Public/chunk-cli/internal/session" "github.com/CircleCI-Public/chunk-cli/internal/sidecar" @@ -181,7 +183,8 @@ func newValidateCmd() *cobra.Command { freshlyCreated = resolveSidecar(ctx, &sidecarID, orgID, image, workDir, hook, streams) } - execErr := runValidate(ctx, workDir, name, inlineCmd, save, sidecarID, freshlyCreated, identityFile, workdir, allRemote, cfg, statusFn, streams) + results, execErr := runValidate(ctx, workDir, name, inlineCmd, save, sidecarID, freshlyCreated, identityFile, workdir, allRemote, cfg, statusFn, streams) + saveValidateResults(workDir, results) if hook != nil { maxAttempts := cfg.StopHookMaxAttempts @@ -206,6 +209,8 @@ func newValidateCmd() *cobra.Command { cmd.Flags().BoolVar(&save, "save", false, "Save --cmd to .chunk/config.json") cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory") + cmd.AddCommand(newPostCommitCmd()) + return cmd } @@ -213,8 +218,9 @@ func newValidateCmd() *cobra.Command { // provided options. It is shared by both direct and hook invocations. // allRemote is true when --remote is passed explicitly (all commands run on the // sidecar); false means only commands with Remote:true are routed to the sidecar. -func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID string, freshlyCreated bool, identityFile, workdir string, allRemote bool, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) error { - // --cmd: inline command (always local in per-command mode) +// Returns nil results for remote/sidecar runs (no result recording on those paths). +func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool, sidecarID string, freshlyCreated bool, identityFile, workdir string, allRemote bool, cfg *config.ProjectConfig, statusFn iostream.StatusFunc, streams iostream.Streams) ([]validate.CommandResult, error) { + // --cmd: inline command if inlineCmd != "" { cmdName := name if cmdName == "" { @@ -222,16 +228,16 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool } if save { if err := config.SaveCommand(workDir, cmdName, inlineCmd); err != nil { - return &userError{msg: "Could not save command to .chunk/config.json.", err: err} + return nil, &userError{msg: "Could not save command to .chunk/config.json.", err: err} } streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", cmdName))) } if sidecarID != "" && allRemote { execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams) if err != nil { - return err + return nil, err } - return validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, statusFn, streams) + return nil, validate.RunRemoteInline(ctx, execFn, cmdName, inlineCmd, dest, statusFn, streams) } return validate.RunInline(ctx, workDir, cmdName, inlineCmd, statusFn, streams) } @@ -240,9 +246,9 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool if sidecarID != "" && allRemote { execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams) if err != nil { - return err + return nil, err } - return validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams) + return nil, validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams) } // Per-command remote routing: commands with Remote:true go to the sidecar, @@ -253,14 +259,14 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool statusFn(iostream.LevelInfo, fmt.Sprintf("running %s on sidecar %s", name, sidecarID)) execFn, dest, err := openSSHSession(ctx, sidecarID, identityFile, workdir, streams) if err != nil { - return err + return nil, err } - return validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams) + return nil, validate.RunRemote(ctx, execFn, cfg, name, dest, statusFn, streams) } statusFn(iostream.LevelInfo, fmt.Sprintf("running %s locally (not marked remote)", name)) // Named command is not marked remote; fall through to local execution. } else { - return runSplitCommands(ctx, sidecarID, freshlyCreated, identityFile, workdir, workDir, cfg, statusFn, streams) + return nil, runSplitCommands(ctx, sidecarID, freshlyCreated, identityFile, workdir, workDir, cfg, statusFn, streams) } } @@ -268,7 +274,7 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool if name != "" { if cfg.FindCommand(name) == nil { if !term.IsTerminal(int(os.Stdin.Fd())) { - return &userError{ + return nil, &userError{ msg: fmt.Sprintf("Command %q is not configured.", name), suggestion: "Add it to .chunk/config.json.", errMsg: fmt.Sprintf("command %q is not configured", name), @@ -279,28 +285,104 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool streams.ErrPrintf("What command should %s run? ", ui.Bold(name)) scanner := bufio.NewScanner(os.Stdin) if !scanner.Scan() { - return &userError{msg: "No command entered.", errMsg: "no input received"} + return nil, &userError{msg: "No command entered.", errMsg: "no input received"} } input := strings.TrimSpace(scanner.Text()) if input == "" { streams.ErrPrintln(ui.Dim("No command entered, aborting.")) - return &userError{msg: "No command entered.", errMsg: "no command entered"} + return nil, &userError{msg: "No command entered.", errMsg: "no command entered"} } if err := config.SaveCommand(workDir, name, input); err != nil { - return &userError{msg: "Could not save command to .chunk/config.json.", err: err} + return nil, &userError{msg: "Could not save command to .chunk/config.json.", err: err} } streams.ErrPrintf("%s\n", ui.Success(fmt.Sprintf("Saved %s to .chunk/config.json", name))) var err error cfg, err = config.LoadProjectConfig(workDir) if err != nil { - return err + return nil, err } } - return mapValidateError(validate.RunNamed(ctx, workDir, name, cfg, statusFn, streams)) + results, err := validate.RunNamed(ctx, workDir, name, cfg, statusFn, streams) + return results, mapValidateError(err) } // Run all - return mapValidateError(validate.RunAll(ctx, workDir, cfg, statusFn, streams)) + results, err := validate.RunAll(ctx, workDir, cfg, statusFn, streams) + return results, mapValidateError(err) +} + +func newPostCommitCmd() *cobra.Command { + var projectDir string + + cmd := &cobra.Command{ + Use: "post-commit", + Short: "Report validate results as GitHub commit statuses", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + streams := iostream.FromCmd(cmd) + ctx := cmd.Context() + + workDir := projectDir + if workDir == "" { + var err error + workDir, err = os.Getwd() + if err != nil { + return err + } + } + + projCfg, cfgErr := config.LoadProjectConfig(workDir) + if cfgErr != nil || projCfg.Validation == nil || !projCfg.Validation.CommitStatus { + return nil + } + + treeOut, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD^{tree}").Output() + if err != nil { + return fmt.Errorf("resolve HEAD tree: %w", err) + } + treeSHA := strings.TrimSpace(string(treeOut)) + + results, found, err := validate.LoadResults(treeSHA) + if err != nil { + return fmt.Errorf("load validate results: %w", err) + } + if !found { + return nil + } + + commitOut, err := exec.Command("git", "-C", workDir, "rev-parse", "HEAD").Output() + if err != nil { + return fmt.Errorf("resolve HEAD: %w", err) + } + commitSHA := strings.TrimSpace(string(commitOut)) + + org, repo, err := gitremote.DetectOrgAndRepo(workDir) + if err != nil { + return fmt.Errorf("detect repo: %w", err) + } + + ghClient, err := ensureGitHubClient(ctx, streams, tui.PromptHidden) + if err != nil { + return err + } + + for _, r := range results { + state := "success" + if !r.Passed { + state = "failure" + } + if postErr := ghClient.CreateCommitStatus(ctx, org, repo, commitSHA, state, "chunk/"+r.Name, "chunk validate: "+r.Name); postErr != nil { + streams.ErrPrintf(" %s\n", ui.Warning(fmt.Sprintf("could not post status for %s: %v", r.Name, postErr))) + continue + } + streams.ErrPrintf(" %s %s/%s → %s\n", ui.Success("posted"), org, repo, r.Name) + } + return nil + }, + } + + cmd.Flags().StringVar(&projectDir, "project", "", "Override project directory") + return cmd } // openSSHSession establishes an SSH session to the sidecar and returns an @@ -369,7 +451,8 @@ func runSplitCommands(ctx context.Context, sidecarID string, freshlyCreated bool } } if len(localCfg.Commands) > 0 { - if err := mapValidateError(validate.RunAll(ctx, workDir, localCfg, statusFn, streams)); err != nil { + _, localErr := validate.RunAll(ctx, workDir, localCfg, statusFn, streams) + if err := mapValidateError(localErr); err != nil { runErr = errors.Join(runErr, err) } } @@ -500,6 +583,19 @@ func resolveOrCreateSidecarID(ctx context.Context, sidecarID *string, orgID, ima return true, nil } +func saveValidateResults(workDir string, results []validate.CommandResult) { + if len(results) == 0 { + return + } + cfg, err := config.LoadProjectConfig(workDir) + if err != nil || cfg.Validation == nil || !cfg.Validation.CommitStatus { + return + } + if treeSHA, err := gitutil.ComputeTreeSHA(workDir); err == nil { + _ = validate.SaveResults(treeSHA, results) + } +} + func mapValidateError(err error) error { if errors.Is(err, validate.ErrNotConfigured) { return &userError{ diff --git a/internal/config/config.go b/internal/config/config.go index 3d4f530d..7df0bd1e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -281,4 +281,5 @@ var ValidConfigKeys = map[string]bool{ var ValidProjectConfigKeys = map[string]bool{ "orgID": true, "validation.sidecarImage": true, + "validation.commitStatus": true, } diff --git a/internal/config/project.go b/internal/config/project.go index 4c690073..f4cfd91f 100644 --- a/internal/config/project.go +++ b/internal/config/project.go @@ -48,6 +48,7 @@ type VCSConfig struct { // ValidationConfig holds project-level defaults for validation behaviour. type ValidationConfig struct { SidecarImage string `json:"sidecarImage,omitempty"` + CommitStatus bool `json:"commitStatus,omitempty"` } // ProjectConfig is the per-repo configuration stored in .chunk/config.json. diff --git a/internal/github/statuses.go b/internal/github/statuses.go new file mode 100644 index 00000000..1dacfc74 --- /dev/null +++ b/internal/github/statuses.go @@ -0,0 +1,28 @@ +package github + +import ( + "context" + "fmt" + + hc "github.com/CircleCI-Public/chunk-cli/internal/httpcl" +) + +// CreateCommitStatus posts a commit status for the given SHA. +// state must be one of: "pending", "success", "failure", "error". +// statusContext is the check name shown in GitHub (e.g. "chunk/test"). +func (c *Client) CreateCommitStatus(ctx context.Context, owner, repo, sha, state, statusContext, description string) error { + body := map[string]string{ + "state": state, + "context": statusContext, + "description": description, + } + req := hc.NewRequest("POST", "/repos/%s/%s/statuses/%s", + hc.RouteParams(owner, repo, sha), + hc.Body(body), + ) + _, err := c.http.Call(ctx, req) + if err == nil { + return nil + } + return mapErr(fmt.Sprintf("create commit status %s", statusContext), err) +} diff --git a/internal/github/statuses_test.go b/internal/github/statuses_test.go new file mode 100644 index 00000000..3fafa359 --- /dev/null +++ b/internal/github/statuses_test.go @@ -0,0 +1,53 @@ +package github_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "gotest.tools/v3/assert" + + "github.com/CircleCI-Public/chunk-cli/internal/github" +) + +func TestCreateCommitStatus(t *testing.T) { + var gotMethod, gotPath string + var gotBody map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotBody) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + c, err := github.New(github.Config{Token: "test-token", BaseURL: srv.URL}) + assert.NilError(t, err) + + err = c.CreateCommitStatus(context.Background(), "myorg", "myrepo", "abc123", "success", "chunk/test", "chunk validate: test") + assert.NilError(t, err) + + assert.Equal(t, gotMethod, "POST") + assert.Equal(t, gotPath, "/repos/myorg/myrepo/statuses/abc123") + assert.Equal(t, gotBody["state"], "success") + assert.Equal(t, gotBody["context"], "chunk/test") + assert.Equal(t, gotBody["description"], "chunk validate: test") +} + +func TestCreateCommitStatus_Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + })) + defer srv.Close() + + c, err := github.New(github.Config{Token: "test-token", BaseURL: srv.URL}) + assert.NilError(t, err) + + err = c.CreateCommitStatus(context.Background(), "org", "repo", "sha", "success", "chunk/test", "desc") + assert.Assert(t, err != nil) +} diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go index 5bf011c6..852f414e 100644 --- a/internal/gitutil/gitutil.go +++ b/internal/gitutil/gitutil.go @@ -2,6 +2,7 @@ package gitutil import ( "fmt" + "io" "os" "os/exec" "path/filepath" @@ -102,6 +103,53 @@ func GeneratePatch(base string) (string, error) { return string(out), nil } +// ComputeTreeSHA computes the git tree SHA for the current worktree state +// (including untracked files) without modifying the real index. +// It copies the index to a temp file, stages all files into the copy, +// and calls git write-tree against it. +func ComputeTreeSHA(workDir string) (string, error) { + root, err := RepoRoot(workDir) + if err != nil { + return "", fmt.Errorf("compute tree sha: %w", err) + } + + src, err := os.Open(filepath.Join(root, ".git", "index")) + if err != nil { + return "", fmt.Errorf("compute tree sha: open index: %w", err) + } + tmp, err := os.CreateTemp("", "chunk-index-*") + if err != nil { + _ = src.Close() + return "", fmt.Errorf("compute tree sha: create temp index: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if _, err := io.Copy(tmp, src); err != nil { + _ = src.Close() + _ = tmp.Close() + return "", fmt.Errorf("compute tree sha: copy index: %w", err) + } + _ = src.Close() + _ = tmp.Close() + + indexEnv := "GIT_INDEX_FILE=" + tmpPath + + addCmd := exec.Command("git", "-C", workDir, "add", "--all") + addCmd.Env = append(os.Environ(), indexEnv) + if out, err := addCmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("compute tree sha: git add: %w: %s", err, out) + } + + treeCmd := exec.Command("git", "-C", workDir, "write-tree") + treeCmd.Env = append(os.Environ(), indexEnv) + out, err := treeCmd.Output() + if err != nil { + return "", fmt.Errorf("compute tree sha: git write-tree: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + func splitNonEmpty(s string) []string { if s == "" { return nil diff --git a/internal/gitutil/gitutil_test.go b/internal/gitutil/gitutil_test.go index ae0a8718..38e8531e 100644 --- a/internal/gitutil/gitutil_test.go +++ b/internal/gitutil/gitutil_test.go @@ -208,3 +208,34 @@ func TestGeneratePatchNoChanges(t *testing.T) { assert.NilError(t, err) assert.Equal(t, patch, "", "patch should be empty when no changes") } + +func TestComputeTreeSHA(t *testing.T) { + dir := setupRepo(t) + + // Write a tracked file and commit it + _ = os.WriteFile(filepath.Join(dir, "tracked.txt"), []byte("tracked\n"), 0o644) + gitRun(t, dir, "add", "tracked.txt") + gitRun(t, dir, "commit", "-m", "add tracked") + + // Add a staged change and an untracked file + _ = os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("staged\n"), 0o644) + gitRun(t, dir, "add", "staged.txt") + _ = os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("untracked\n"), 0o644) + + // Compute tree SHA before commit + preCommitSHA, err := ComputeTreeSHA(dir) + assert.NilError(t, err) + assert.Assert(t, len(preCommitSHA) == 40, "expected a 40-char SHA, got %q", preCommitSHA) + + // Verify real index is untouched: untracked.txt should still be untracked + statusOut := gitRun(t, dir, "status", "--porcelain") + assert.Assert(t, strings.Contains(statusOut, "?? untracked.txt"), "real index must not be modified") + + // Also stage the untracked file and commit everything + gitRun(t, dir, "add", "untracked.txt") + gitRun(t, dir, "commit", "-m", "add staged and untracked") + + // Post-commit tree SHA must match what we computed before the commit + postCommitTreeSHA := gitRun(t, dir, "rev-parse", "HEAD^{tree}") + assert.Equal(t, preCommitSHA, postCommitTreeSHA) +} diff --git a/internal/validate/results.go b/internal/validate/results.go new file mode 100644 index 00000000..bca8fa22 --- /dev/null +++ b/internal/validate/results.go @@ -0,0 +1,49 @@ +package validate + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" +) + +// CommandResult records whether a named validate command passed or failed. +type CommandResult struct { + Name string `json:"name"` + Passed bool `json:"passed"` +} + +func resultsPath(treeSHA string) string { + return filepath.Join(os.TempDir(), "chunk-run", "trees", treeSHA+".json") +} + +// SaveResults persists per-command results keyed to the given tree SHA. +func SaveResults(treeSHA string, results []CommandResult) error { + p := resultsPath(treeSHA) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(results, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0o644) +} + +// LoadResults loads previously saved results for the given tree SHA. +// Returns (nil, false, nil) when no results exist for that SHA. +func LoadResults(treeSHA string) ([]CommandResult, bool, error) { + data, err := os.ReadFile(resultsPath(treeSHA)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return nil, false, err + } + var results []CommandResult + if err := json.Unmarshal(data, &results); err != nil { + return nil, false, err + } + return results, true, nil +} diff --git a/internal/validate/results_test.go b/internal/validate/results_test.go new file mode 100644 index 00000000..dcfdc232 --- /dev/null +++ b/internal/validate/results_test.go @@ -0,0 +1,33 @@ +package validate + +import ( + "os" + "testing" + + "gotest.tools/v3/assert" +) + +func TestSaveLoadResults(t *testing.T) { + treeSHA := "abc123def456abc123def456abc123def456abc1" + t.Cleanup(func() { _ = os.Remove(resultsPath(treeSHA)) }) + + want := []CommandResult{ + {Name: "test", Passed: true}, + {Name: "lint", Passed: false}, + } + + err := SaveResults(treeSHA, want) + assert.NilError(t, err) + + got, found, err := LoadResults(treeSHA) + assert.NilError(t, err) + assert.Assert(t, found) + assert.DeepEqual(t, got, want) +} + +func TestLoadResults_NotFound(t *testing.T) { + results, found, err := LoadResults("0000000000000000000000000000000000000000") + assert.NilError(t, err) + assert.Assert(t, !found) + assert.Assert(t, results == nil) +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 557eb663..28b98b15 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -55,34 +55,39 @@ func List(cfg *config.ProjectConfig, status iostream.StatusFunc) error { } // RunInline runs an inline command string. -func RunInline(ctx context.Context, workDir, name, command string, status iostream.StatusFunc, streams iostream.Streams) error { - return runCommand(ctx, workDir, name, command, 0, status, streams) +func RunInline(ctx context.Context, workDir, name, command string, status iostream.StatusFunc, streams iostream.Streams) ([]CommandResult, error) { + err := runCommand(ctx, workDir, name, command, 0, status, streams) + return []CommandResult{{Name: name, Passed: err == nil}}, err } // RunNamed runs a single named command from config. -func RunNamed(ctx context.Context, workDir, name string, cfg *config.ProjectConfig, status iostream.StatusFunc, streams iostream.Streams) error { +func RunNamed(ctx context.Context, workDir, name string, cfg *config.ProjectConfig, status iostream.StatusFunc, streams iostream.Streams) ([]CommandResult, error) { c := cfg.FindCommand(name) if c == nil { - return fmt.Errorf("command %q not configured", name) + return nil, fmt.Errorf("command %q not configured", name) } - return runCommand(ctx, workDir, c.Name, c.Run, c.Timeout, status, streams) + err := runCommand(ctx, workDir, c.Name, c.Run, c.Timeout, status, streams) + return []CommandResult{{Name: c.Name, Passed: err == nil}}, err } // RunAll runs all configured commands, stopping at the first failure. -func RunAll(ctx context.Context, workDir string, cfg *config.ProjectConfig, status iostream.StatusFunc, streams iostream.Streams) error { +func RunAll(ctx context.Context, workDir string, cfg *config.ProjectConfig, status iostream.StatusFunc, streams iostream.Streams) ([]CommandResult, error) { if !cfg.HasCommands() { - return ErrNotConfigured + return nil, ErrNotConfigured } + var results []CommandResult for i, c := range cfg.Commands { - if err := runCommand(ctx, workDir, c.Name, c.Run, c.Timeout, status, streams); err != nil { + err := runCommand(ctx, workDir, c.Name, c.Run, c.Timeout, status, streams) + results = append(results, CommandResult{Name: c.Name, Passed: err == nil}) + if err != nil { for j := i + 1; j < len(cfg.Commands); j++ { status(iostream.LevelWarn, fmt.Sprintf("%s: skipped (%s failed)", cfg.Commands[j].Name, c.Name)) } - return err + return results, err } } - return nil + return results, nil } // RunDryRun prints commands without executing them. diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index dfd2618f..fb4fc420 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -171,7 +171,8 @@ func TestRunAll(t *testing.T) { streams, out, _ := newStreams() var statusBuf bytes.Buffer - assert.NilError(t, RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams)) + _, err := RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams) + assert.NilError(t, err) assert.Assert(t, strings.Contains(out.String(), "installed"), "got: %s", out.String()) assert.Assert(t, strings.Contains(out.String(), "tested"), "got: %s", out.String()) assert.Assert(t, strings.Contains(statusBuf.String(), "Running install"), "got: %s", statusBuf.String()) @@ -182,7 +183,7 @@ func TestRunAll(t *testing.T) { streams, _, _ := newStreams() var statusBuf bytes.Buffer - err := RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams) + _, err := RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams) assert.ErrorContains(t, err, "no validate commands") }) @@ -193,7 +194,7 @@ func TestRunAll(t *testing.T) { streams, _, _ := newStreams() var statusBuf bytes.Buffer - err := RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams) + _, err := RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams) assert.ErrorContains(t, err, "test command failed") }) @@ -206,7 +207,7 @@ func TestRunAll(t *testing.T) { streams, out, _ := newStreams() var statusBuf bytes.Buffer - err := RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams) + _, err := RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams) assert.Assert(t, err != nil, "expected error") assert.Assert(t, !strings.Contains(out.String(), "should-not-run"), "skipped command should not produce output, got: %s", out.String()) assert.Assert(t, strings.Contains(statusBuf.String(), "test: skipped"), "got: %s", statusBuf.String()) @@ -220,7 +221,8 @@ func TestRunAll(t *testing.T) { streams, out, _ := newStreams() var statusBuf bytes.Buffer - assert.NilError(t, RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams)) + _, err := RunAll(context.Background(), ".", cfg, testStatus(&statusBuf), streams) + assert.NilError(t, err) assert.Assert(t, strings.Contains(out.String(), "ok"), "got: %s", out.String()) }) }