package tui
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"codey/internal/agent"
"codey/internal/approval"
"codey/internal/config"
"codey/internal/llm"
"codey/internal/tools"
)
// isolateConfig points the config directory at a temp dir, so a test never
// writes the developer's real ~/.config/codey/worktree.sh or makes a worktree
// in their real tree.
func isolateConfig(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
return filepath.Join(dir, config.Dir)
}
func TestSlugifyMakesNamesThatCannotEscape(t *testing.T) {
cases := []struct{ in, want string }{
{"dark mode toggle", "dark-mode-toggle"},
{"Add A Dark Mode Toggle", "add-a-dark-mode-toggle"},
{" fix the login ", "fix-the-login"},
{"feat/login", "feat-login"},
{"../../etc/passwd", "etc-passwd"},
{"..", ""},
{"-rf", "rf"},
{"--force--", "force"},
{"fix: the login (again!)", "fix-the-login-again"},
{"réndu naïf", "r-ndu-na-f"},
{"", ""},
{" ", ""},
{"already-a-slug", "already-a-slug"},
// A model that answers with a sentence under the slug gets only its
// first line read.
{"dark-mode\n\nI chose this because...", "dark-mode"},
}
for _, c := range cases {
if got := slugify(c.in); got != c.want {
t.Errorf("slugify(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestSlugifyIsBoundedAndNeverEndsInADash(t *testing.T) {
got := slugify(strings.Repeat("word ", 40))
if len(got) > nameMax {
t.Errorf("slug %q is %d long, over the %d cap", got, len(got), nameMax)
}
if strings.HasPrefix(got, "-") || strings.HasSuffix(got, "-") {
t.Errorf("slug %q has a leading or trailing dash", got)
}
if strings.Contains(got, "--") {
t.Errorf("slug %q has a doubled dash", got)
}
}
// A name is only used verbatim when it is already exactly what slugify would
// produce. Anything else is a description of work, and goes to the model.
func TestVerbatimNameSeparatesNamesFromTasks(t *testing.T) {
cases := []struct {
in string
want string
verbatim bool
}{
{"dark-mode", "dark-mode", true},
{"fix", "fix", true},
{"v2", "v2", true},
// One word is a name, repaired rather than refused.
{"Dark-Mode", "dark-mode", true},
{"feat/login", "feat-login", true},
{"../escape", "escape", true},
// A phrase is work, and the model names it.
{"add a dark mode toggle", "", false},
{"fix the login", "", false},
}
for _, c := range cases {
name, verbatim := verbatimName(c.in)
if verbatim != c.verbatim {
t.Errorf("verbatimName(%q) verbatim = %v, want %v", c.in, verbatim, c.verbatim)
continue
}
if verbatim && name != c.want {
t.Errorf("verbatimName(%q) = %q, want %q", c.in, name, c.want)
}
}
}
// The name is never worth failing the command over: an unreachable endpoint
// still has to produce a usable directory name.
func TestWorktreeNameFallsBackWhenTheModelCannotBeReached(t *testing.T) {
client := llm.New(llm.WithAPIKey("x"), llm.WithBaseURL("http://127.0.0.1:1"))
got := worktreeName(context.Background(), client, "", "fix the login redirect loop")
if got != "fix-the-login-redirect-loop" {
t.Fatalf("name = %q, want the task slugified", got)
}
}
// A task that slugifies to nothing still has to yield a directory name.
func TestWorktreeNameAlwaysProducesSomething(t *testing.T) {
if got := worktreeName(context.Background(), nil, "", "!!!"); got == "" {
t.Fatal("name is empty, which would make an unnameable directory")
}
}
func TestEnsureScriptWritesTheDefaultOnceAndThenLeavesItAlone(t *testing.T) {
isolateConfig(t)
path, err := ensureScript()
if err != nil {
t.Fatal(err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm()&0o100 == 0 {
t.Errorf("mode = %v, want the owner execute bit set", fi.Mode().Perm())
}
// Once it is on disk it belongs to whoever edited it.
edited := "#!/usr/bin/env bash\necho /somewhere\n"
if err := os.WriteFile(path, []byte(edited), 0o755); err != nil {
t.Fatal(err)
}
if _, err := ensureScript(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(b) != edited {
t.Error("ensureScript overwrote a script that was already there")
}
}
// The script decides where the tree lands, so its stdout is the answer. A
// script that prints nothing, or names a directory that is not there, is a
// failure rather than a silent move to nowhere.
func TestRunWorktreeScriptReadsThePathBack(t *testing.T) {
dir := isolateConfig(t)
repo := t.TempDir()
target := t.TempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
script := filepath.Join(dir, config.ScriptFilename)
t.Run("takes the last line of stdout", func(t *testing.T) {
write(t, script, "#!/usr/bin/env bash\nread -r name\necho \"chatter about $name\" >&2\necho "+target+"\n")
got, err := runWorktreeScript(context.Background(), repo, "tree")
if err != nil {
t.Fatal(err)
}
if got != target {
t.Errorf("path = %q, want %q", got, target)
}
})
t.Run("is given the name on stdin", func(t *testing.T) {
write(t, script, "#!/usr/bin/env bash\nread -r name\nmkdir -p "+target+"/$name\necho "+target+"/$name\n")
got, err := runWorktreeScript(context.Background(), repo, "from-stdin")
if err != nil {
t.Fatal(err)
}
if filepath.Base(got) != "from-stdin" {
t.Errorf("path = %q, want it to end in the name piped in", got)
}
})
t.Run("a path that is not there is an error", func(t *testing.T) {
write(t, script, "#!/usr/bin/env bash\ncat >/dev/null\necho /no/such/place\n")
if _, err := runWorktreeScript(context.Background(), repo, "tree"); err == nil {
t.Fatal("want an error when the script names a directory that does not exist")
}
})
t.Run("no path at all is an error", func(t *testing.T) {
write(t, script, "#!/usr/bin/env bash\ncat >/dev/null\n")
if _, err := runWorktreeScript(context.Background(), repo, "tree"); err == nil {
t.Fatal("want an error when the script prints no path")
}
})
t.Run("the script's own complaint is reported", func(t *testing.T) {
write(t, script, "#!/usr/bin/env bash\ncat >/dev/null\necho 'branch already checked out' >&2\nexit 1\n")
_, err := runWorktreeScript(context.Background(), repo, "tree")
if err == nil || !strings.Contains(err.Error(), "branch already checked out") {
t.Fatalf("err = %v, want it to carry the script's own message", err)
}
})
}
func write(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
// worktreeModel is a model wired the way main.go wires one: with a factory
// that records which root the tools were last built against.
func worktreeModel(t *testing.T, root string) (*model, *string) {
t.Helper()
built := new(string)
m := newModel(context.Background(), Options{
Client: llm.New(llm.WithAPIKey("x")),
Root: root,
Policy: approval.New(nil),
NewTools: func(r string) *tools.Registry {
*built = r
return tools.NewRegistry(nil)
},
})
m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return m, built
}
func TestWorktreeRefusesWithoutAnArgument(t *testing.T) {
m, _ := worktreeModel(t, "/tmp/repo")
m.submit("/worktree")
if view := m.tr.render(); !strings.Contains(view, "needs a name or a task") {
t.Fatalf("transcript = %q, want the usage error", view)
}
}
func TestWorktreeIsRefusedWhenNothingCanMoveTheTools(t *testing.T) {
m := newTestModel(t) // no NewTools wired
m.submit("/worktree dark-mode")
if view := m.tr.render(); !strings.Contains(view, "no way to move the tools") {
t.Fatalf("transcript = %q, want the unavailable error", view)
}
}
// switchRoot moves the work and nothing else: the session keeps its home, so
// the conversation stays filed where Codey was started.
func TestSwitchRootMovesTheWorkButNotTheSession(t *testing.T) {
m, built := worktreeModel(t, "/tmp/repo")
m.opts.HomeRoot = "/tmp/repo"
m.opts.SessionDir = "/tmp/repo/.codey/sessions"
m.switchRoot("/trees/dark-mode")
if m.opts.Root != "/trees/dark-mode" {
t.Errorf("Root = %q, want the worktree", m.opts.Root)
}
if m.opts.Worktree != "/trees/dark-mode" {
t.Errorf("Worktree = %q, want it recorded", m.opts.Worktree)
}
if m.opts.HomeRoot != "/tmp/repo" {
t.Errorf("HomeRoot = %q, want it left alone", m.opts.HomeRoot)
}
if m.opts.SessionDir != "/tmp/repo/.codey/sessions" {
t.Errorf("SessionDir = %q, want it left in the main checkout", m.opts.SessionDir)
}
if *built != "/trees/dark-mode" {
t.Errorf("tools were rebuilt against %q, want the worktree", *built)
}
}
// A bare name creates the tree and stops. A task creates it and starts work.
func TestFinishWorktreeStartsTheTurnOnlyWhenThereIsATask(t *testing.T) {
tree := t.TempDir()
m, _ := worktreeModel(t, "/tmp/repo")
m.finishWorktree(worktreeReady{name: "dark-mode", path: tree})
if m.phase == phaseRunning {
t.Error("a bare /worktree started a turn with nothing to work on")
}
m2, _ := worktreeModel(t, "/tmp/repo")
m2.opts.Agent = agent.New(llm.New(llm.WithAPIKey("x")), tools.NewRegistry(nil),
agent.NewState(), nil, agent.Options{})
m2.finishWorktree(worktreeReady{name: "dark-mode", task: "add a toggle", path: tree})
if m2.phase != phaseRunning {
t.Error("a /worktree with a task did not start the turn")
}
if view := m2.tr.render(); !strings.Contains(view, "add a toggle") {
t.Errorf("transcript = %q, want the task shown as the prompt", view)
}
}
// An interrupt is something you did, so it reads as a notice rather than as a
// failure, and the work stays where it was.
func TestInterruptedWorktreeDoesNotMoveTheWork(t *testing.T) {
m, built := worktreeModel(t, "/tmp/repo")
m.finishWorktree(worktreeReady{name: "x", err: context.Canceled})
if m.opts.Root != "/tmp/repo" {
t.Errorf("Root = %q, want it unchanged", m.opts.Root)
}
if *built != "" {
t.Error("the tools were rebuilt for a worktree that was never made")
}
if view := m.tr.render(); !strings.Contains(view, "interrupted") {
t.Errorf("transcript = %q, want the interruption reported", view)
}
}
// The whole thing against real git: the default script, a real repository, and
// the session moving into the tree it makes.
func TestWorktreeEndToEnd(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
isolateConfig(t)
repo := initRepo(t)
m, built := worktreeModel(t, repo)
m.opts.HomeRoot = repo
// The bare-name path, so no model call is needed to run this.
name, verbatim := verbatimName("dark-mode")
if !verbatim {
t.Fatal("dark-mode should be taken as a name")
}
path, err := runWorktreeScript(context.Background(), repo, name)
if err != nil {
t.Fatal(err)
}
trees, err := config.WorktreesDir()
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(trees, "dark-mode"); path != want {
t.Errorf("path = %q, want %q", path, want)
}
if _, err := os.Stat(filepath.Join(path, "README")); err != nil {
t.Errorf("the worktree has no checkout in it: %v", err)
}
if got := branchOf(t, path); got != "dark-mode" {
t.Errorf("branch = %q, want dark-mode", got)
}
m.finishWorktree(worktreeReady{name: name, path: path})
if m.opts.Root != path {
t.Errorf("Root = %q, want the worktree %q", m.opts.Root, path)
}
if *built != path {
t.Errorf("tools were built against %q, want %q", *built, path)
}
}
// A second worktree from the same repository must not trip over the first.
func TestWorktreeScriptReusesAnExistingBranch(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
isolateConfig(t)
repo := initRepo(t)
path, err := runWorktreeScript(context.Background(), repo, "reused")
if err != nil {
t.Fatal(err)
}
// Remove the tree but leave the branch, which is what happens when a tree
// is cleaned up by hand.
git(t, repo, "worktree", "remove", "--force", path)
again, err := runWorktreeScript(context.Background(), repo, "reused")
if err != nil {
t.Fatalf("a second worktree on an existing branch failed: %v", err)
}
if again != path {
t.Errorf("path = %q, want the same %q", again, path)
}
}
func initRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
git(t, repo, "init", "-b", "main")
git(t, repo, "config", "user.email", "test@example.com")
git(t, repo, "config", "user.name", "Test")
if err := os.WriteFile(filepath.Join(repo, "README"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
git(t, repo, "add", "README")
git(t, repo, "commit", "-m", "first")
return repo
}
func git(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
func branchOf(t *testing.T, dir string) string {
t.Helper()
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
return strings.TrimSpace(string(out))
}