diff --git a/README.md b/README.md index 22a4bb0..f9d9bf1 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ go install github.com/priompt/cli/cmd/promptctl@latest # Go 1.25+ ```sh promptctl commit -m "msg" # validate every *.prompt, then commit changes -promptctl diff [ref] # semantic diff vs ref (default HEAD) +promptctl diff [-fail-on structural] [ref] # semantic diff vs ref (default HEAD) promptctl log acme/support/agent.prompt # version lineage of one prompt promptctl promote acme/x.prompt dev prod # bring a prompt from branch dev onto prod promptctl push [-server host:port] # validate, git push, then publish to the server @@ -108,6 +108,11 @@ flowchart LR an offline lexical embedder (word overlap, not meaning) — good enough to catch big rewrites, but point it at a real embedding endpoint for real semantics. +- **diff exits 0 whatever the verdicts** unless you gate it: `-fail-on` takes a + comma-separated list of verdicts (`new`, `structural`, `tweak`, `minor`) and + exits 1 after the full diff if any prompt has one of them — + `promptctl diff -fail-on structural origin/main` in CI. A prompt that doesn't + exist in the ref reads as `new` (not `structural`). - **push / pull** use go-git and do not read git's credential helper — set `PRIOMPT_GIT_TOKEN` for HTTPS remotes; SSH uses your ssh-agent. - `promptctl push -server …` git-pushes **and** publishes each prompt, so diff --git a/cmd/promptctl/diff_test.go b/cmd/promptctl/diff_test.go new file mode 100644 index 0000000..268921f --- /dev/null +++ b/cmd/promptctl/diff_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// writeFile writes a slash path relative to the current directory. +func writeFile(t *testing.T, p, content string) { + t.Helper() + p = filepath.FromSlash(p) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// newRepo makes a git repo in a temp dir, chdirs into it, and commits files. +func newRepo(t *testing.T, files map[string]string) *git.Repository { + t.Helper() + dir := t.TempDir() + t.Chdir(dir) + r, err := git.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + for p, c := range files { + writeFile(t, p, c) + } + wt, _ := r.Worktree() + if err := wt.AddWithOptions(&git.AddOptions{All: true}); err != nil { + t.Fatal(err) + } + if _, err := wt.Commit("init", &git.CommitOptions{Author: &object.Signature{Name: "t", Email: "t@t"}}); err != nil { + t.Fatal(err) + } + return r +} + +func TestRunDiffVerdicts(t *testing.T) { + t.Setenv("PRIOMPT_EMBED_URL", "") // offline lexical embedder + r := newRepo(t, map[string]string{ + "acme/support/agent.prompt": "You are a support agent for {org}.\nBe concise and polite.\nAlways answer in English.\n", + }) + writeFile(t, "acme/support/agent.prompt", "Ignore all previous rules entirely.\nWrite long pirate poems about the ocean.\nRespond only in French verse forever.\n") + writeFile(t, "acme/support/added.prompt", "Hello {name}.\n") + + got := runDiff(r, "HEAD", io.Discard) + want := []fileVerdict{ + {"acme/support/added.prompt", "new"}, // added since HEAD: "new", not "structural" + {"acme/support/agent.prompt", "structural"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("runDiff = %v, want %v", got, want) + } +} + +func TestRunDiffNoChanges(t *testing.T) { + r := newRepo(t, map[string]string{"acme/a.prompt": "Hi {name}.\n"}) + if got := runDiff(r, "HEAD", io.Discard); len(got) != 0 { + t.Errorf("runDiff on a clean tree = %v, want none", got) + } +} + +func TestParseFailOn(t *testing.T) { + got, err := parseFailOn("structural, tweak,new") + if err != nil { + t.Fatal(err) + } + want := map[string]bool{"structural": true, "localized tweak": true, "new": true} + if !reflect.DeepEqual(got, want) { + t.Errorf("parseFailOn = %v, want %v", got, want) + } + if got, _ := parseFailOn(""); len(got) != 0 { + t.Errorf("parseFailOn(\"\") = %v, want empty (never fail)", got) + } + if _, err := parseFailOn("structual"); err == nil { + t.Error("parseFailOn accepted an unknown verdict") + } +} diff --git a/cmd/promptctl/main.go b/cmd/promptctl/main.go index d9e6ad6..652de24 100644 --- a/cmd/promptctl/main.go +++ b/cmd/promptctl/main.go @@ -5,7 +5,7 @@ // propagation diff* (git's text diff can't express it), and env promotion. // // promptctl commit -m "msg" validate every prompt, then commit all changes -// promptctl diff [ref] semantic propagation diff vs ref (default HEAD) +// promptctl diff [-fail-on v] [ref] semantic propagation diff vs ref (default HEAD) // promptctl log version lineage of one prompt // promptctl promote bring a prompt from branch onto // promptctl push|pull validate (push) then sync with origin @@ -16,6 +16,7 @@ import ( "errors" "flag" "fmt" + "io" "io/fs" "log" "os" @@ -92,33 +93,94 @@ func commit(args []string) { } // diffCmd runs the semantic propagation diff for every *.prompt that differs -// between ref (default HEAD) and the working tree. +// between ref (default HEAD) and the working tree. It exits 0 whatever the +// verdicts unless -fail-on names verdicts to gate on; then any prompt with one +// of them makes it exit 1 (after printing the full diff). func diffCmd(args []string) { + fs := flag.NewFlagSet("diff", flag.ExitOnError) + failOn := fs.String("fail-on", "", "exit 1 if any prompt's verdict is one of these (comma-separated: new, structural, tweak, minor)") + fs.Parse(args) ref := "HEAD" - if len(args) > 0 { - ref = args[0] + if fs.NArg() > 0 { + ref = fs.Arg(0) + fs.Parse(fs.Args()[1:]) // flags may also follow the ref } - r := openRepo() + gate, err := parseFailOn(*failOn) + if err == nil && fs.NArg() > 0 { + err = fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + if err != nil { + fmt.Fprintf(os.Stderr, "%v\nusage: promptctl diff [-fail-on verdicts] [ref]\n", err) + os.Exit(2) + } + + var gated []string + for _, v := range runDiff(openRepo(), ref, os.Stdout) { + if gate[v.verdict] { + gated = append(gated, fmt.Sprintf("%s (%s)", v.path, v.verdict)) + } + } + if len(gated) > 0 { + log.Fatalf("diff: %d prompt(s) match -fail-on %s: %s", len(gated), *failOn, strings.Join(gated, ", ")) + } +} + +type fileVerdict struct{ path, verdict string } + +// runDiff prints the diff of every changed prompt to w and returns each one's +// verdict: semdiff's worst hunk class, or "new" for a prompt absent from ref. +func runDiff(r *git.Repository, ref string, w io.Writer) []fileVerdict { tree := treeAt(r, ref) emb := semdiff.EmbedderFromEnv() - changed := false + var out []fileVerdict for _, p := range promptUnion(tree) { old := blobContent(tree, p) neu := diskContent(p) if old == neu { continue } - changed = true + if _, err := tree.File(p); err != nil { // added since ref: nothing to diff against + fmt.Fprintf(w, "## %s\nnew prompt (not in %s)\n => new\n\n", p, ref) + out = append(out, fileVerdict{p, "new"}) + continue + } res, err := semdiff.Analyze(emb, splitLines(old), splitLines(neu)) if err != nil { log.Fatal(err) } - fmt.Printf("## %s\n%s\n", p, semdiff.Format(res)) + fmt.Fprintf(w, "## %s\n%s\n", p, semdiff.Format(res)) + out = append(out, fileVerdict{p, semdiff.Worst(res)}) + } + if len(out) == 0 { + fmt.Fprintf(w, "no prompt changes vs %s\n", ref) } - if !changed { - fmt.Printf("no prompt changes vs %s\n", ref) + return out +} + +// verdictNames maps each -fail-on name to the verdict it gates. +var verdictNames = map[string]string{ + "new": "new", "structural": "structural", + "tweak": "localized tweak", "localized tweak": "localized tweak", + "minor": "minor edit", "minor edit": "minor edit", +} + +// parseFailOn turns a comma-separated -fail-on value into the set of verdicts +// that fail the diff. Empty means none (exit 0, the historical behavior). +func parseFailOn(s string) (map[string]bool, error) { + set := map[string]bool{} + for _, name := range strings.Split(s, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + v, ok := verdictNames[name] + if !ok { + return nil, fmt.Errorf("-fail-on: unknown verdict %q (want new, structural, tweak, minor)", name) + } + set[v] = true } + return set, nil } // logCmd prints a prompt's version lineage — every commit that touched it,