diff --git a/README.md b/README.md index 4180be8..4094b55 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/promptctl/main.go b/cmd/promptctl/main.go index c4f3fea..54cd351 100644 --- a/cmd/promptctl/main.go +++ b/cmd/promptctl/main.go @@ -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 { @@ -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. diff --git a/cmd/promptctl/promote_test.go b/cmd/promptctl/promote_test.go new file mode 100644 index 0000000..c6ff832 --- /dev/null +++ b/cmd/promptctl/promote_test.go @@ -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) + } +}