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)) }