test: add unit tests for cmd, config, git, linter, logger, prompts, todo, and workon
Some checks failed
CI / Test (push) Successful in 40s
CI / Lint (push) Failing after 20s
CI / Build (push) Successful in 22s

- Introduce tests for analyze, recipe, workon commands
- Expand scaffold tests with language detection and context harvesting
- Add tests for config getters, git utilities (tags, logs, diff)
- Enhance linter with primary language detection tests
- Cover logger level setting branches
- New prompts loading tests with local/global fallback
- Todo bootstrap and structure tests
- Comprehensive workon flow tests including file moves, git integration, README updates
- Update README coverage from 54% to 62%
This commit is contained in:
Greg Gauthier 2026-03-31 22:47:36 +01:00
parent 65f67ff7b1
commit 5c9689e4da
12 changed files with 1619 additions and 2 deletions

View File

@ -3,7 +3,7 @@
Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat/edit functionality. Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat/edit functionality.
[![Test Coverage](https://img.shields.io/badge/coverage-54%25-orange)]() [![Test Coverage](https://img.shields.io/badge/coverage-62%25-yellow)]()
[![Go Version](https://img.shields.io/badge/go-1.24.2-blue)]() [![Go Version](https://img.shields.io/badge/go-1.24.2-blue)]()
[![License](https://img.shields.io/badge/license-Unlicense-lightgrey)]() [![License](https://img.shields.io/badge/license-Unlicense-lightgrey)]()
@ -188,7 +188,7 @@ Generate shell completions for faster command entry:
- ✅ **Automated Workon** - AI-powered todo/fix workflow with branch management, work plan generation, and completion tracking - ✅ **Automated Workon** - AI-powered todo/fix workflow with branch management, work plan generation, and completion tracking
### Quality & Testing ### Quality & Testing
- ✅ **Test coverage 54%+** - Comprehensive unit tests including all command message builders - ✅ **Test coverage 60%+** - Comprehensive unit tests including all command message builders
- ✅ **Coverage gate in CI** - Builds fail if coverage drops below 50% - ✅ **Coverage gate in CI** - Builds fail if coverage drops below 50%
- ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run - ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run
- ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more - ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more

238
cmd/analyze_test.go Normal file
View File

@ -0,0 +1,238 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestAnalyzeCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd)
func TestAnalyzeCmd(t *testing.T) {
t.Log("✓ Fast analyze unit test (no Grok API call)")
found := false
for _, c := range rootCmd.Commands() {
if c.Use == "analyze [flags]" {
found = true
break
}
}
if !found {
t.Fatal("analyze command not registered on rootCmd")
}
}
func TestAnalyzeFlagRegistration(t *testing.T) {
tests := []struct {
name string
flagName string
shorthand string
}{
{"output flag", "output", "o"},
{"yes flag", "yes", "y"},
{"dir flag", "dir", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flag := analyzeCmd.Flags().Lookup(tt.flagName)
if flag == nil {
t.Fatalf("flag %q not found on analyze command", tt.flagName)
}
if tt.shorthand != "" && flag.Shorthand != tt.shorthand {
t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand)
}
})
}
}
func TestDiscoverSourceFiles(t *testing.T) {
tmpDir := t.TempDir()
// Create some source files
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "helper.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
// Create a non-source file (should be ignored)
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
t.Fatal(err)
}
files, err := discoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("discoverSourceFiles failed: %v", err)
}
if len(files) < 2 {
t.Errorf("expected at least 2 Go files, got %d: %v", len(files), files)
}
// Verify .txt was not included
for _, f := range files {
if strings.HasSuffix(f, ".txt") {
t.Errorf("unexpected .txt file in results: %s", f)
}
}
}
func TestDiscoverSourceFilesSkipsDotDirs(t *testing.T) {
tmpDir := t.TempDir()
// Create a hidden directory with a Go file
hiddenDir := filepath.Join(tmpDir, ".hidden")
if err := os.MkdirAll(hiddenDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(hiddenDir, "secret.go"), []byte("package hidden"), 0644); err != nil {
t.Fatal(err)
}
// Create vendor directory with a Go file
vendorDir := filepath.Join(tmpDir, "vendor")
if err := os.MkdirAll(vendorDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(vendorDir, "dep.go"), []byte("package dep"), 0644); err != nil {
t.Fatal(err)
}
// Create a normal Go file
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
files, err := discoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("discoverSourceFiles failed: %v", err)
}
for _, f := range files {
if strings.Contains(f, ".hidden") {
t.Errorf("should not include files from hidden dirs: %s", f)
}
if strings.Contains(f, "vendor") {
t.Errorf("should not include files from vendor dir: %s", f)
}
}
if len(files) != 1 {
t.Errorf("expected 1 file (main.go only), got %d: %v", len(files), files)
}
}
func TestDiscoverSourceFilesSkipsBuildAndDist(t *testing.T) {
tmpDir := t.TempDir()
for _, dir := range []string{"build", "dist", "node_modules"} {
d := filepath.Join(tmpDir, dir)
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "file.go"), []byte("package x"), 0644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
files, err := discoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("discoverSourceFiles failed: %v", err)
}
if len(files) != 1 {
t.Errorf("expected 1 file, got %d: %v", len(files), files)
}
}
func TestDiscoverSourceFilesEmptyDir(t *testing.T) {
tmpDir := t.TempDir()
files, err := discoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("discoverSourceFiles failed: %v", err)
}
if len(files) != 0 {
t.Errorf("expected 0 files for empty dir, got %d", len(files))
}
}
func TestBuildProjectContext(t *testing.T) {
tmpDir := t.TempDir()
files := []string{
filepath.Join(tmpDir, "main.go"),
filepath.Join(tmpDir, "cmd", "root.go"),
}
ctx := buildProjectContext(tmpDir, files)
if !strings.Contains(ctx, "Project Root:") {
t.Error("expected context to contain 'Project Root:'")
}
if !strings.Contains(ctx, "Total source files discovered: 2") {
t.Error("expected context to contain file count")
}
if !strings.Contains(ctx, "Key files") {
t.Error("expected context to contain 'Key files'")
}
}
func TestPreviewLines(t *testing.T) {
tests := []struct {
name string
content string
n int
}{
{
name: "fewer lines than limit",
content: "line1\nline2\nline3\n",
n: 10,
},
{
name: "more lines than limit",
content: "a\nb\nc\nd\ne\nf\ng\nh\n",
n: 3,
},
{
name: "empty content",
content: "",
n: 5,
},
{
name: "zero lines requested",
content: "line1\nline2\n",
n: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// previewLines prints to stdout; just verify it doesn't panic
previewLines(tt.content, tt.n)
})
}
}
func TestBuildProjectContextLimitsSize(t *testing.T) {
tmpDir := t.TempDir()
// Generate many files to test the 2500 byte limit
var files []string
for i := 0; i < 200; i++ {
files = append(files, filepath.Join(tmpDir, "file_with_a_long_name_for_testing_"+strings.Repeat("x", 20)+".go"))
}
ctx := buildProjectContext(tmpDir, files)
// Should be reasonably bounded (the 2500 byte check truncates the file list)
if len(ctx) > 4000 {
t.Errorf("context unexpectedly large: %d bytes", len(ctx))
}
}

149
cmd/recipe_test.go Normal file
View File

@ -0,0 +1,149 @@
package cmd
import (
"os"
"path/filepath"
"testing"
)
func TestResolveRecipePathDirectFile(t *testing.T) {
tmpDir := t.TempDir()
recipePath := filepath.Join(tmpDir, "my-recipe.md")
if err := os.WriteFile(recipePath, []byte("# Recipe\n"), 0644); err != nil {
t.Fatal(err)
}
got, err := resolveRecipePath(recipePath)
if err != nil {
t.Fatalf("resolveRecipePath(%q) failed: %v", recipePath, err)
}
if got != recipePath {
t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath)
}
}
func TestResolveRecipePathDirectFileMissing(t *testing.T) {
_, err := resolveRecipePath("/nonexistent/recipe.md")
if err == nil {
t.Fatal("expected error for missing recipe file, got nil")
}
}
func TestResolveRecipePathProjectLocal(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Create .grokkit/recipes with a recipe, and a .git dir for project root detection
recipeDir := filepath.Join(tmpDir, ".grokkit", "recipes")
if err := os.MkdirAll(recipeDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil {
t.Fatal(err)
}
recipePath := filepath.Join(recipeDir, "refactor.md")
if err := os.WriteFile(recipePath, []byte("# Refactor\n"), 0644); err != nil {
t.Fatal(err)
}
got, err := resolveRecipePath("refactor")
if err != nil {
t.Fatalf("resolveRecipePath('refactor') failed: %v", err)
}
if got != recipePath {
t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath)
}
}
func TestResolveRecipePathNotFound(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Create .git so findProjectRoot works, but no recipes
if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil {
t.Fatal(err)
}
// Override HOME to empty dir so global fallback also fails
oldHome := os.Getenv("HOME")
defer func() { _ = os.Setenv("HOME", oldHome) }()
_ = os.Setenv("HOME", tmpDir)
_, err := resolveRecipePath("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent recipe, got nil")
}
}
func TestFindProjectRoot(t *testing.T) {
tests := []struct {
name string
marker string
}{
{"git repo", ".git"},
{"gitignore", ".gitignore"},
{"grokkit dir", ".grokkit"},
{"go module", "go.mod"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
// Create a subdirectory and chdir into it
subDir := filepath.Join(tmpDir, "sub", "deep")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Chdir(subDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Place the marker at the root
markerPath := filepath.Join(tmpDir, tt.marker)
if tt.marker == ".git" || tt.marker == ".grokkit" {
if err := os.MkdirAll(markerPath, 0755); err != nil {
t.Fatal(err)
}
} else {
if err := os.WriteFile(markerPath, []byte(""), 0644); err != nil {
t.Fatal(err)
}
}
root, err := findProjectRoot()
if err != nil {
t.Fatalf("findProjectRoot() failed: %v", err)
}
if root != tmpDir {
t.Errorf("findProjectRoot() = %q, want %q", root, tmpDir)
}
})
}
}
func TestFindProjectRootNotInProject(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Empty dir — no markers at all. This will walk up to / and fail.
_, err := findProjectRoot()
if err == nil {
// Might succeed if a parent dir has .git — that's OK in CI
t.Log("findProjectRoot() succeeded (likely found .git in a parent dir)")
}
}

View File

@ -3,6 +3,8 @@ package cmd
import ( import (
"os" "os"
"path/filepath"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -16,6 +18,83 @@ func TestScaffoldCmd(t *testing.T) {
assert.True(t, true, "command is registered and basic structure is intact") assert.True(t, true, "command is registered and basic structure is intact")
} }
func TestDetectLanguage(t *testing.T) {
tests := []struct {
name string
path string
override string
expected string
}{
{"Go file", "main.go", "", "Go"},
{"Python file", "script.py", "", "Python"},
{"JS file", "app.js", "", "TypeScript"},
{"TS file", "app.ts", "", "TypeScript"},
{"C file", "main.c", "", "C"},
{"C++ file", "main.cpp", "", "C++"},
{"Java file", "Main.java", "", "Java"},
{"unknown extension", "data.xyz", "", "code"},
{"override takes precedence", "main.go", "Rust", "Rust"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectLanguage(tt.path, tt.override)
if got != tt.expected {
t.Errorf("detectLanguage(%q, %q) = %q, want %q", tt.path, tt.override, got, tt.expected)
}
})
}
}
func TestHarvestContext(t *testing.T) {
tmpDir := t.TempDir()
// Create the target file
targetPath := filepath.Join(tmpDir, "target.go")
if err := os.WriteFile(targetPath, []byte("package main\n\nfunc Target() {}\n"), 0644); err != nil {
t.Fatal(err)
}
// Create a sibling .go file (should be included)
siblingPath := filepath.Join(tmpDir, "sibling.go")
if err := os.WriteFile(siblingPath, []byte("package main\n\nfunc Sibling() {}\n"), 0644); err != nil {
t.Fatal(err)
}
// Create a non-.go sibling (should not be included)
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
t.Fatal(err)
}
ctx := harvestContext(targetPath, "Go")
if !strings.Contains(ctx, "sibling.go") {
t.Error("expected sibling.go in context")
}
if !strings.Contains(ctx, "func Sibling()") {
t.Error("expected sibling content in context")
}
if strings.Contains(ctx, "target.go") {
t.Error("target file should not appear in its own context")
}
if strings.Contains(ctx, "notes.txt") {
t.Error("non-matching extension should not appear in context")
}
}
func TestHarvestContextEmptyDir(t *testing.T) {
tmpDir := t.TempDir()
targetPath := filepath.Join(tmpDir, "lonely.go")
if err := os.WriteFile(targetPath, []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
ctx := harvestContext(targetPath, "Go")
if ctx != "" {
t.Errorf("expected empty context for solo file, got: %q", ctx)
}
}
// TestScaffoldCmd_Live — LIVE INTEGRATION TEST (only runs when you ask) // TestScaffoldCmd_Live — LIVE INTEGRATION TEST (only runs when you ask)
func TestScaffoldCmd_Live(t *testing.T) { func TestScaffoldCmd_Live(t *testing.T) {
if !testing.Short() { if !testing.Short() {

107
cmd/workon_test.go Normal file
View File

@ -0,0 +1,107 @@
package cmd
import (
"os"
"testing"
)
// TestWorkonCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd)
func TestWorkonCmd(t *testing.T) {
t.Log("✓ Fast workon unit test (no Grok API call)")
// Verify the command is registered
found := false
for _, c := range rootCmd.Commands() {
if c.Use == "workon <todo_item_title>" {
found = true
break
}
}
if !found {
t.Fatal("workon command not registered on rootCmd")
}
}
func TestWorkonFlagRegistration(t *testing.T) {
tests := []struct {
name string
flagName string
shorthand string
}{
{"message flag", "message", "M"},
{"fix flag", "fix", "f"},
{"complete flag", "complete", "c"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flag := workonCmd.Flags().Lookup(tt.flagName)
if flag == nil {
t.Fatalf("flag %q not found on workon command", tt.flagName)
}
if flag.Shorthand != tt.shorthand {
t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand)
}
})
}
}
func TestWorkonFlagExclusivity(t *testing.T) {
// Run in a temp dir so todo.Bootstrap() doesn't leave orphan dirs in cmd/
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
tests := []struct {
name string
args []string
expectErr bool
}{
{
name: "complete alone is valid",
args: []string{"workon", "test-item", "-c"},
expectErr: false, // will fail at Run level (no git repo), but not at flag validation
},
{
name: "complete with fix is valid",
args: []string{"workon", "test-item", "-c", "-f"},
expectErr: false,
},
{
name: "complete with message is invalid",
args: []string{"workon", "test-item", "-c", "-M", "some msg"},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rootCmd.SetArgs(tt.args)
err := rootCmd.Execute()
if tt.expectErr && err == nil {
t.Error("expected error, got nil")
}
// Note: non-error cases may still fail (no git, no API key, etc.)
// We only assert that the flag validation error fires when expected
if tt.expectErr && err != nil {
// Verify it's the right error
if err.Error() != "-c cannot be combined with -m" {
// The error might be wrapped
t.Logf("got error: %v (flag validation may have been triggered)", err)
}
}
})
}
}
func TestWorkonRequiresArgs(t *testing.T) {
rootCmd.SetArgs([]string{"workon"})
err := rootCmd.Execute()
if err == nil {
t.Error("expected error when no args provided, got nil")
}
}

View File

@ -136,6 +136,26 @@ func TestGetTimeout(t *testing.T) {
} }
} }
func TestGetIDECommand(t *testing.T) {
viper.Reset()
t.Run("empty by default", func(t *testing.T) {
got := GetIDECommand()
if got != "" {
t.Errorf("GetIDECommand() default = %q, want empty", got)
}
})
t.Run("returns configured value", func(t *testing.T) {
viper.Set("commands.workon.ide", "code")
got := GetIDECommand()
if got != "code" {
t.Errorf("GetIDECommand() = %q, want 'code'", got)
}
viper.Set("commands.workon.ide", "") // reset
})
}
func TestGetLogLevel(t *testing.T) { func TestGetLogLevel(t *testing.T) {
viper.Reset() viper.Reset()
viper.SetDefault("log_level", "info") viper.SetDefault("log_level", "info")

View File

@ -1,6 +1,9 @@
package git package git
import ( import (
"os"
"os/exec"
"strings"
"testing" "testing"
) )
@ -64,3 +67,162 @@ func TestGitRunner(t *testing.T) {
// Test IsRepo // Test IsRepo
_ = runner.IsRepo() // Just ensure it doesn't panic _ = runner.IsRepo() // Just ensure it doesn't panic
} }
// initTaggedGitRepo creates a temp git repo with an initial commit and a tag.
func initTaggedGitRepo(t *testing.T) string {
t.Helper()
tmpDir := t.TempDir()
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = tmpDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git setup failed (%v): %s", err, out)
}
}
// Create initial commit and tag
if err := os.WriteFile(tmpDir+"/init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"git", "add", "."},
{"git", "commit", "-m", "initial commit"},
{"git", "tag", "v0.1.0"},
} {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = tmpDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git setup (%v) failed: %s", args, out)
}
}
return tmpDir
}
func TestLatestTag(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
tag, err := LatestTag()
if err != nil {
t.Fatalf("LatestTag() failed: %v", err)
}
if tag != "v0.1.0" {
t.Errorf("LatestTag() = %q, want 'v0.1.0'", tag)
}
}
func TestLatestTagMultiple(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Add a second commit and tag
if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", ".").Run()
exec.Command("git", "commit", "-m", "second commit").Run()
exec.Command("git", "tag", "v0.2.0").Run()
tag, err := LatestTag()
if err != nil {
t.Fatalf("LatestTag() failed: %v", err)
}
if tag != "v0.2.0" {
t.Errorf("LatestTag() = %q, want 'v0.2.0'", tag)
}
}
func TestPreviousTag(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Add second commit and tag
if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", ".").Run()
exec.Command("git", "commit", "-m", "second commit").Run()
exec.Command("git", "tag", "v0.2.0").Run()
prev, err := PreviousTag("v0.2.0")
if err != nil {
t.Fatalf("PreviousTag() failed: %v", err)
}
if prev != "v0.1.0" {
t.Errorf("PreviousTag('v0.2.0') = %q, want 'v0.1.0'", prev)
}
}
func TestLogSince(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Add commits after the tag
if err := os.WriteFile("new.txt", []byte("new"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", ".").Run()
exec.Command("git", "commit", "-m", "feat: add new feature").Run()
log, err := LogSince("v0.1.0")
if err != nil {
t.Fatalf("LogSince() failed: %v", err)
}
if !strings.Contains(log, "feat: add new feature") {
t.Errorf("LogSince() missing expected commit message, got: %q", log)
}
}
func TestDiff(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
t.Run("no changes returns empty", func(t *testing.T) {
out, err := Diff([]string{"diff"})
if err != nil {
t.Fatalf("Diff() failed: %v", err)
}
if out != "" {
t.Errorf("Diff() with no changes = %q, want empty", out)
}
})
t.Run("with unstaged changes returns diff", func(t *testing.T) {
if err := os.WriteFile("init.txt", []byte("modified"), 0644); err != nil {
t.Fatal(err)
}
out, err := Diff([]string{"diff"})
if err != nil {
t.Fatalf("Diff() failed: %v", err)
}
if !strings.Contains(out, "modified") {
t.Errorf("Diff() missing change content, got: %q", out)
}
})
}

View File

@ -317,6 +317,91 @@ func TestLanguageStructure(t *testing.T) {
} }
} }
func TestDetectPrimaryLanguage(t *testing.T) {
tests := []struct {
name string
files []string
expected string
}{
{
name: "empty list returns unknown",
files: []string{},
expected: "unknown",
},
{
name: "all Go files",
files: []string{"main.go", "handler.go", "util.go"},
expected: "go",
},
{
name: "all Python files",
files: []string{"app.py", "utils.py"},
expected: "python",
},
{
name: "mixed with Go bias",
files: []string{"main.go", "helper.go", "script.py", "app.js", "style.ts"},
expected: "go",
},
{
name: "unsupported files only returns unknown",
files: []string{"notes.txt", "data.csv", "readme.md"},
expected: "unknown",
},
{
name: "C files normalized",
files: []string{"main.c", "util.c", "helper.c"},
expected: "c",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create real temp files so DetectLanguage can stat them
tmpDir := t.TempDir()
var paths []string
for _, f := range tt.files {
path := filepath.Join(tmpDir, f)
if err := os.WriteFile(path, []byte("content"), 0644); err != nil {
t.Fatal(err)
}
paths = append(paths, path)
}
got := DetectPrimaryLanguage(paths)
if got != tt.expected {
t.Errorf("DetectPrimaryLanguage() = %q, want %q", got, tt.expected)
}
})
}
}
func TestSupportedLanguages(t *testing.T) {
langs := SupportedLanguages()
if len(langs) == 0 {
t.Fatal("SupportedLanguages() returned empty list")
}
// Should be lowercase
for _, l := range langs {
if l == "" {
t.Error("SupportedLanguages() contains empty string")
}
}
// Should contain "go"
foundGo := false
for _, l := range langs {
if l == "go" {
foundGo = true
break
}
}
if !foundGo {
t.Errorf("SupportedLanguages() missing 'go', got: %v", langs)
}
}
// Helper function // Helper function
func contains(s, substr string) bool { func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&

View File

@ -105,6 +105,24 @@ func TestSetLevel(t *testing.T) {
} }
} }
func TestSetLevelAllBranches(t *testing.T) {
tmpDir := t.TempDir()
oldHome := os.Getenv("HOME")
setHome(t, tmpDir)
defer func() { _ = os.Setenv("HOME", oldHome) }()
if err := Init("info"); err != nil {
t.Fatalf("Init() failed: %v", err)
}
levels := []string{"debug", "info", "warn", "error"}
for _, lvl := range levels {
t.Run(lvl, func(t *testing.T) {
SetLevel(lvl) // should not panic
})
}
}
func TestWith(t *testing.T) { func TestWith(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
oldHome := os.Getenv("HOME") oldHome := os.Getenv("HOME")

View File

@ -0,0 +1,162 @@
package prompts
import (
"os"
"path/filepath"
"testing"
)
func TestLoadAnalysisPromptProjectLocal(t *testing.T) {
tmpDir := t.TempDir()
promptDir := filepath.Join(tmpDir, ".grokkit", "prompts")
if err := os.MkdirAll(promptDir, 0755); err != nil {
t.Fatal(err)
}
expected := "You are a Go expert educator."
if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte(expected), 0644); err != nil {
t.Fatal(err)
}
path, content, err := LoadAnalysisPrompt(tmpDir, "go")
if err != nil {
t.Fatalf("LoadAnalysisPrompt failed: %v", err)
}
if content != expected {
t.Errorf("content = %q, want %q", content, expected)
}
if !filepath.IsAbs(path) || path == "" {
// path should be meaningful
t.Logf("returned path: %s", path)
}
}
func TestLoadAnalysisPromptGlobalFallback(t *testing.T) {
projectDir := t.TempDir() // no .grokkit/prompts here
globalDir := t.TempDir()
promptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts")
if err := os.MkdirAll(promptDir, 0755); err != nil {
t.Fatal(err)
}
expected := "You are a Python expert."
if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte(expected), 0644); err != nil {
t.Fatal(err)
}
// Override HOME to point to our temp global dir
oldHome := os.Getenv("HOME")
defer func() { _ = os.Setenv("HOME", oldHome) }()
_ = os.Setenv("HOME", globalDir)
path, content, err := LoadAnalysisPrompt(projectDir, "python")
if err != nil {
t.Fatalf("LoadAnalysisPrompt (global fallback) failed: %v", err)
}
if content != expected {
t.Errorf("content = %q, want %q", content, expected)
}
_ = path
}
func TestLoadAnalysisPromptProjectOverridesGlobal(t *testing.T) {
projectDir := t.TempDir()
globalDir := t.TempDir()
// Create both project-local and global prompts
localPromptDir := filepath.Join(projectDir, ".grokkit", "prompts")
if err := os.MkdirAll(localPromptDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(localPromptDir, "go.md"), []byte("local prompt"), 0644); err != nil {
t.Fatal(err)
}
globalPromptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts")
if err := os.MkdirAll(globalPromptDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(globalPromptDir, "go.md"), []byte("global prompt"), 0644); err != nil {
t.Fatal(err)
}
oldHome := os.Getenv("HOME")
defer func() { _ = os.Setenv("HOME", oldHome) }()
_ = os.Setenv("HOME", globalDir)
_, content, err := LoadAnalysisPrompt(projectDir, "go")
if err != nil {
t.Fatalf("LoadAnalysisPrompt failed: %v", err)
}
if content != "local prompt" {
t.Errorf("expected project-local to override global, got: %q", content)
}
}
func TestLoadAnalysisPromptNotFound(t *testing.T) {
projectDir := t.TempDir()
emptyHome := t.TempDir()
oldHome := os.Getenv("HOME")
defer func() { _ = os.Setenv("HOME", oldHome) }()
_ = os.Setenv("HOME", emptyHome)
_, _, err := LoadAnalysisPrompt(projectDir, "rust")
if err == nil {
t.Fatal("expected error when no prompt found, got nil")
}
if !os.IsNotExist(err) {
t.Errorf("expected os.ErrNotExist, got: %v", err)
}
}
func TestLoadAnalysisPromptDefaultsToGo(t *testing.T) {
projectDir := t.TempDir()
promptDir := filepath.Join(projectDir, ".grokkit", "prompts")
if err := os.MkdirAll(promptDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte("go prompt"), 0644); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
language string
}{
{"empty string defaults to go", ""},
{"unknown defaults to go", "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, content, err := LoadAnalysisPrompt(projectDir, tt.language)
if err != nil {
t.Fatalf("LoadAnalysisPrompt(%q) failed: %v", tt.language, err)
}
if content != "go prompt" {
t.Errorf("expected go prompt, got: %q", content)
}
})
}
}
func TestLoadAnalysisPromptNormalizesCase(t *testing.T) {
projectDir := t.TempDir()
promptDir := filepath.Join(projectDir, ".grokkit", "prompts")
if err := os.MkdirAll(promptDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte("python prompt"), 0644); err != nil {
t.Fatal(err)
}
_, content, err := LoadAnalysisPrompt(projectDir, "Python")
if err != nil {
t.Fatalf("LoadAnalysisPrompt with uppercase failed: %v", err)
}
if content != "python prompt" {
t.Errorf("expected case-insensitive match, got: %q", content)
}
}

123
internal/todo/todo_test.go Normal file
View File

@ -0,0 +1,123 @@
package todo
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestBootstrapCreatesStructure(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
if err := Bootstrap(); err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
// Verify directories
for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} {
info, err := os.Stat(dir)
if err != nil {
t.Errorf("expected %s to exist: %v", dir, err)
continue
}
if !info.IsDir() {
t.Errorf("expected %s to be a directory", dir)
}
}
// Verify README
data, err := os.ReadFile("todo/README.md")
if err != nil {
t.Fatalf("expected todo/README.md to exist: %v", err)
}
content := string(data)
if !strings.Contains(content, "## Queued") {
t.Error("README missing ## Queued heading")
}
if !strings.Contains(content, "## Completed") {
t.Error("README missing ## Completed heading")
}
}
func TestBootstrapIdempotent(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Run twice
if err := Bootstrap(); err != nil {
t.Fatalf("first Bootstrap failed: %v", err)
}
if err := Bootstrap(); err != nil {
t.Fatalf("second Bootstrap failed: %v", err)
}
// Verify structure still intact
for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} {
if _, err := os.Stat(dir); err != nil {
t.Errorf("expected %s to exist after second Bootstrap: %v", dir, err)
}
}
}
func TestBootstrapPreservesExistingReadme(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Create structure with custom README
if err := os.MkdirAll("todo", 0755); err != nil {
t.Fatal(err)
}
customContent := "# My Custom TODO\n\nCustom content here.\n"
if err := os.WriteFile("todo/README.md", []byte(customContent), 0644); err != nil {
t.Fatal(err)
}
if err := Bootstrap(); err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
// Verify custom README was not overwritten
data, err := os.ReadFile("todo/README.md")
if err != nil {
t.Fatal(err)
}
if string(data) != customContent {
t.Errorf("Bootstrap overwrote existing README.\ngot: %q\nwant: %q", string(data), customContent)
}
}
func TestBootstrapCreatesReadmeWithCorrectPermissions(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
if err := Bootstrap(); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join("todo", "README.md"))
if err != nil {
t.Fatal(err)
}
perm := info.Mode().Perm()
if perm != 0644 {
t.Errorf("README permissions = %o, want 0644", perm)
}
}

View File

@ -0,0 +1,474 @@
package workon
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestRunRejectsEmptyTitle(t *testing.T) {
err := Run("", "", false, false)
if err == nil {
t.Fatal("expected error for empty title, got nil")
}
if !strings.Contains(err.Error(), "todo_item_title is required") {
t.Errorf("unexpected error message: %s", err.Error())
}
}
func TestMoveQueuedToDoing(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Set up todo structure
if err := os.MkdirAll("todo/queued", 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll("todo/doing", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("todo/queued/test-item.md", []byte("# Test Item\n"), 0644); err != nil {
t.Fatal(err)
}
if err := moveQueuedToDoing("test-item"); err != nil {
t.Fatalf("moveQueuedToDoing failed: %v", err)
}
// Verify source is gone
if _, err := os.Stat("todo/queued/test-item.md"); !os.IsNotExist(err) {
t.Error("expected queued file to be removed")
}
// Verify destination exists
if _, err := os.Stat("todo/doing/test-item.md"); err != nil {
t.Errorf("expected doing file to exist: %v", err)
}
}
func TestMoveQueuedToDoingMissingFile(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
if err := os.MkdirAll("todo/queued", 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll("todo/doing", 0755); err != nil {
t.Fatal(err)
}
err := moveQueuedToDoing("nonexistent")
if err == nil {
t.Fatal("expected error for missing queued file, got nil")
}
}
func TestCreateFixFile(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "my-fix.md")
if err := createFixFile(path, "my-fix"); err != nil {
t.Fatalf("createFixFile failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read created file: %v", err)
}
content := string(data)
if !strings.Contains(content, "# my-fix") {
t.Errorf("expected title heading, got: %s", content)
}
if !strings.Contains(content, "## Work Plan") {
t.Errorf("expected Work Plan heading, got: %s", content)
}
}
func TestReadFileContent(t *testing.T) {
tmpDir := t.TempDir()
t.Run("existing file", func(t *testing.T) {
path := filepath.Join(tmpDir, "exists.md")
expected := "hello world"
if err := os.WriteFile(path, []byte(expected), 0644); err != nil {
t.Fatal(err)
}
got := readFileContent(path)
if got != expected {
t.Errorf("readFileContent = %q, want %q", got, expected)
}
})
t.Run("missing file returns empty string", func(t *testing.T) {
got := readFileContent(filepath.Join(tmpDir, "missing.md"))
if got != "" {
t.Errorf("readFileContent for missing file = %q, want empty", got)
}
})
}
func TestCompleteItemMovesFile(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Set up todo structure with a doing item
if err := os.MkdirAll("todo/doing", 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll("todo/completed", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("todo/doing/test-item.md", []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
// completeItem will try to git commit — that will fail in a non-git temp dir,
// but we can verify the file move happened before the commit step
_ = completeItem("test-item", true)
// For a fix, file should be moved regardless of commit outcome
if _, err := os.Stat("todo/completed/test-item.md"); err != nil {
t.Errorf("expected completed file to exist: %v", err)
}
if _, err := os.Stat("todo/doing/test-item.md"); !os.IsNotExist(err) {
t.Error("expected doing file to be removed")
}
}
func TestUpdateReadmeIndex(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
if err := os.MkdirAll("todo", 0755); err != nil {
t.Fatal(err)
}
readme := `# Todo Index
## Queued
* [1] [my-feature](queued/my-feature.md): My feature
* [2] [other-item](queued/other-item.md): Other item
## Completed
* [old-item](./completed/old-item.md) : old-item *(done)*
`
if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil {
t.Fatal(err)
}
if err := updateReadmeIndex("my-feature"); err != nil {
t.Fatalf("updateReadmeIndex failed: %v", err)
}
data, err := os.ReadFile("todo/README.md")
if err != nil {
t.Fatal(err)
}
result := string(data)
// Should have removed the queued entry
if strings.Contains(result, "queued/my-feature.md") {
t.Error("expected my-feature to be removed from Queued section")
}
// Should still have the other queued item
if !strings.Contains(result, "queued/other-item.md") {
t.Error("expected other-item to remain in Queued section")
}
// Should have added to Completed section
if !strings.Contains(result, "completed/my-feature.md") {
t.Error("expected my-feature to appear in Completed section")
}
// Old completed item should still be there
if !strings.Contains(result, "completed/old-item.md") {
t.Error("expected old-item to remain in Completed section")
}
}
func TestUpdateReadmeIndexMissingReadme(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
err := updateReadmeIndex("anything")
if err == nil {
t.Fatal("expected error when README doesn't exist, got nil")
}
}
// initTempGitRepo creates a temp dir with a git repo and returns it.
// The caller is responsible for chdir-ing back.
func initTempGitRepo(t *testing.T) string {
t.Helper()
tmpDir := t.TempDir()
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = tmpDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init setup failed (%v): %s", err, out)
}
}
return tmpDir
}
func TestCreateGitBranchNew(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Need at least one commit for branches to work
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", ".").Run()
exec.Command("git", "commit", "-m", "initial").Run()
if err := createGitBranch("feature/test-branch"); err != nil {
t.Fatalf("createGitBranch failed: %v", err)
}
// Verify we're on the new branch
out, err := exec.Command("git", "branch", "--show-current").Output()
if err != nil {
t.Fatalf("git branch --show-current failed: %v", err)
}
branch := strings.TrimSpace(string(out))
if branch != "feature/test-branch" {
t.Errorf("expected branch 'feature/test-branch', got %q", branch)
}
}
func TestCreateGitBranchExisting(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit + create branch
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", ".").Run()
exec.Command("git", "commit", "-m", "initial").Run()
exec.Command("git", "checkout", "-b", "fix/existing").Run()
exec.Command("git", "checkout", "-").Run() // back to main/master
// Should checkout existing branch, not error
if err := createGitBranch("fix/existing"); err != nil {
t.Fatalf("createGitBranch for existing branch failed: %v", err)
}
out, _ := exec.Command("git", "branch", "--show-current").Output()
branch := strings.TrimSpace(string(out))
if branch != "fix/existing" {
t.Errorf("expected branch 'fix/existing', got %q", branch)
}
}
func TestCommitChangesInGitRepo(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", ".").Run()
exec.Command("git", "commit", "-m", "initial").Run()
// Create a todo file to commit
if err := os.MkdirAll("todo/doing", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("todo/doing/test.md", []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
if err := commitChanges("test commit message"); err != nil {
t.Fatalf("commitChanges failed: %v", err)
}
// Verify the commit was created
out, err := exec.Command("git", "log", "--oneline", "-1").Output()
if err != nil {
t.Fatalf("git log failed: %v", err)
}
if !strings.Contains(string(out), "test commit message") {
t.Errorf("expected commit message in log, got: %s", out)
}
}
func TestCommitChangesNothingToCommit(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", ".").Run()
exec.Command("git", "commit", "-m", "initial").Run()
// Create empty todo dir but no files to stage
if err := os.MkdirAll("todo", 0755); err != nil {
t.Fatal(err)
}
// Should fail — nothing to commit
err := commitChanges("empty commit")
if err == nil {
t.Error("expected error when nothing to commit, got nil")
}
}
func TestCompleteItemWithGit(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", ".").Run()
exec.Command("git", "commit", "-m", "initial").Run()
// Set up todo structure
for _, d := range []string{"todo/doing", "todo/completed"} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile("todo/doing/my-fix.md", []byte("# Fix\n"), 0644); err != nil {
t.Fatal(err)
}
// Stage and commit the doing file first so git tracks it
exec.Command("git", "add", "todo/").Run()
exec.Command("git", "commit", "-m", "add doing item").Run()
// Now complete as a fix (no README update)
if err := completeItem("my-fix", true); err != nil {
t.Fatalf("completeItem failed: %v", err)
}
// Verify moved
if _, err := os.Stat("todo/completed/my-fix.md"); err != nil {
t.Errorf("expected completed file: %v", err)
}
if _, err := os.Stat("todo/doing/my-fix.md"); !os.IsNotExist(err) {
t.Error("expected doing file to be gone")
}
// Verify commit
out, _ := exec.Command("git", "log", "--oneline", "-1").Output()
if !strings.Contains(string(out), "Complete work on my-fix") {
t.Errorf("expected completion commit, got: %s", out)
}
}
func TestCompleteItemNonFixUpdatesReadme(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", ".").Run()
exec.Command("git", "commit", "-m", "initial").Run()
// Set up todo structure with README
for _, d := range []string{"todo/doing", "todo/completed"} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
readme := "# Todo\n\n## Queued\n\n* [my-todo](queued/my-todo.md): My todo\n\n## Completed\n\n"
if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("todo/doing/my-todo.md", []byte("# My Todo\n"), 0644); err != nil {
t.Fatal(err)
}
exec.Command("git", "add", "todo/").Run()
exec.Command("git", "commit", "-m", "add todo item").Run()
// Complete as a non-fix (should update README)
if err := completeItem("my-todo", false); err != nil {
t.Fatalf("completeItem (non-fix) failed: %v", err)
}
data, err := os.ReadFile("todo/README.md")
if err != nil {
t.Fatal(err)
}
result := string(data)
if strings.Contains(result, "queued/my-todo.md") {
t.Error("expected my-todo removed from Queued")
}
if !strings.Contains(result, "completed/my-todo.md") {
t.Error("expected my-todo added to Completed")
}
}
func TestRunCnaddIfAvailable(t *testing.T) {
// Just exercise the function — it should not panic regardless
// of whether cnadd is installed
runCnaddIfAvailable("test-item")
}
func TestOpenIDEIfConfigured(t *testing.T) {
// With no IDE configured (default), this should silently skip
openIDEIfConfigured()
}