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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
88 changes: 88 additions & 0 deletions cmd/promptctl/diff_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
82 changes: 72 additions & 10 deletions cmd/promptctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file.prompt> version lineage of one prompt
// promptctl promote <file> <from> <to> bring a prompt from branch <from> onto <to>
// promptctl push|pull validate (push) then sync with origin
Expand All @@ -16,6 +16,7 @@ import (
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"os"
Expand Down Expand Up @@ -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,
Expand Down
Loading