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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ promptctl push -server prompts.internal:8443

`promote` models dev → staging → prod as git branches: it copies a validated
prompt from one branch onto another and commits it — a deliberate, recorded
promotion instead of a copy-paste.
promotion instead of a copy-paste. You stay on the branch you ran it from.

```mermaid
flowchart LR
Expand Down
31 changes: 25 additions & 6 deletions cmd/promptctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func logCmd(args []string) {
// promote brings one prompt from the `from` branch onto the `to` branch and
// commits it there — the dev → staging → prod version promotion. A no-op (the
// prompt already matches on `to`) reports "already up to date" instead of failing
// on an empty commit.
// on an empty commit. The branch (or commit) checked out before is restored after.
// ponytail: assumes both branches exist and the tree is clean; go-git errors surface as-is.
func promote(args []string) {
if len(args) != 3 {
Expand All @@ -165,22 +165,41 @@ func promote(args []string) {
}

wt := worktree(r)
head, err := r.Head()
if err != nil {
log.Fatal(err)
}
if err := wt.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName(to)}); err != nil {
log.Fatalf("checkout %s: %v", to, err)
}
if err := os.WriteFile(filepath.FromSlash(path), []byte(content), 0o644); err != nil {
log.Fatal(err)
h, err := commitFile(r, wt, path, content, fmt.Sprintf("promote %s: %s -> %s", path, from, to))

// Switch back to where the user was (their branch, or a detached commit),
// whether or not the commit landed.
back := &git.CheckoutOptions{Branch: head.Name()}
if !head.Name().IsBranch() {
back = &git.CheckoutOptions{Hash: head.Hash()}
}
if _, err := wt.Add(path); err != nil {
log.Fatal(err)
if berr := wt.Checkout(back); berr != nil {
log.Fatalf("checkout %s (to switch back from %s): %v", head.Name().Short(), to, berr)
}
h, err := wt.Commit(fmt.Sprintf("promote %s: %s -> %s", path, from, to), &git.CommitOptions{Author: author(r)})
if err != nil {
log.Fatal(err)
}
fmt.Printf("promoted %s onto %s (%s)\n", path, to, h.String()[:12])
}

// commitFile writes content to path in the checked-out worktree and commits it.
func commitFile(r *git.Repository, wt *git.Worktree, path, content, msg string) (plumbing.Hash, error) {
if err := os.WriteFile(filepath.FromSlash(path), []byte(content), 0o644); err != nil {
return plumbing.ZeroHash, err
}
if _, err := wt.Add(path); err != nil {
return plumbing.ZeroHash, err
}
return wt.Commit(msg, &git.CommitOptions{Author: author(r)})
}

// pushCmd validates, pushes to the git remote, and — if -server is given —
// publishes every prompt to that live server so subscribers are notified.
// Publishing is idempotent server-side, so only actually-changed prompts notify.
Expand Down
59 changes: 59 additions & 0 deletions cmd/promptctl/promote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"os"
"testing"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)

func TestPromoteRestoresBranch(t *testing.T) {
const devContent = "Hello {name}, welcome to {org}.\n"
dir := t.TempDir()
t.Chdir(dir)
r, err := git.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
wt, _ := r.Worktree()
commit := func(content, msg string) {
t.Helper()
if err := os.MkdirAll("acme", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("acme/a.prompt", []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if _, err := wt.Add("acme/a.prompt"); err != nil {
t.Fatal(err)
}
if _, err := wt.Commit(msg, &git.CommitOptions{Author: &object.Signature{Name: "t", Email: "t@t"}}); err != nil {
t.Fatal(err)
}
}
commit("Hello {name}.\n", "init")
head, _ := r.Head()
for _, b := range []string{"staging", "dev"} {
if err := r.Storer.SetReference(plumbing.NewHashReference(plumbing.NewBranchReferenceName(b), head.Hash())); err != nil {
t.Fatal(err)
}
}
if err := wt.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName("dev")}); err != nil {
t.Fatal(err)
}
commit(devContent, "dev edit")

promote([]string{"acme/a.prompt", "dev", "staging"})

if h, _ := r.Head(); h.Name().Short() != "dev" {
t.Errorf("after promote HEAD is %s, want dev (the branch it started on)", h.Name().Short())
}
if got := diskContent("acme/a.prompt"); got != devContent {
t.Errorf("working tree has %q, want dev's content", got)
}
if got := blobContent(treeAt(r, "staging"), "acme/a.prompt"); got != devContent {
t.Errorf("staging has %q, want the promoted content", got)
}
}
Loading