diff --git a/cmd/bd/import_helpers_test.go b/cmd/bd/import_helpers_test.go new file mode 100644 index 00000000..5d0c23da --- /dev/null +++ b/cmd/bd/import_helpers_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +func TestTouchDatabaseFile_UsesJSONLMtime(t *testing.T) { + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "beads.db") + jsonlPath := filepath.Join(tmp, "issues.jsonl") + + if err := os.WriteFile(dbPath, []byte(""), 0o600); err != nil { + t.Fatalf("WriteFile db: %v", err) + } + if err := os.WriteFile(jsonlPath, []byte("{}\n"), 0o600); err != nil { + t.Fatalf("WriteFile jsonl: %v", err) + } + + jsonlTime := time.Now().Add(2 * time.Second) + if err := os.Chtimes(jsonlPath, jsonlTime, jsonlTime); err != nil { + t.Fatalf("Chtimes jsonl: %v", err) + } + + if err := TouchDatabaseFile(dbPath, jsonlPath); err != nil { + t.Fatalf("TouchDatabaseFile: %v", err) + } + + info, err := os.Stat(dbPath) + if err != nil { + t.Fatalf("Stat db: %v", err) + } + if info.ModTime().Before(jsonlTime) { + t.Fatalf("db mtime %v should be >= jsonl mtime %v", info.ModTime(), jsonlTime) + } +} + +func TestImportDetectPrefixFromIssues(t *testing.T) { + if detectPrefixFromIssues(nil) != "" { + t.Fatalf("expected empty") + } + + issues := []*types.Issue{ + {ID: "test-1"}, + {ID: "test-2"}, + {ID: "other-1"}, + } + if got := detectPrefixFromIssues(issues); got != "test" { + t.Fatalf("got %q, want %q", got, "test") + } +} + +func TestCountLines(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "f.txt") + if err := os.WriteFile(p, []byte("a\n\nb\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if got := countLines(p); got != 3 { + t.Fatalf("countLines=%d, want 3", got) + } +} + +func TestCheckUncommittedChanges_Warns(t *testing.T) { + _, cleanup := setupGitRepo(t) + defer cleanup() + + if err := os.WriteFile("issues.jsonl", []byte("{\"id\":\"test-1\"}\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + _ = execCmd(t, "git", "add", "issues.jsonl") + _ = execCmd(t, "git", "commit", "-m", "add issues") + + // Modify without committing. + if err := os.WriteFile("issues.jsonl", []byte("{\"id\":\"test-1\"}\n{\"id\":\"test-2\"}\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + warn := captureStderr(t, func() { + checkUncommittedChanges("issues.jsonl", &ImportResult{}) + }) + if !strings.Contains(warn, "uncommitted changes") { + t.Fatalf("expected warning, got: %q", warn) + } + + noWarn := captureStderr(t, func() { + checkUncommittedChanges("issues.jsonl", &ImportResult{Created: 1}) + }) + if noWarn != "" { + t.Fatalf("expected no warning, got: %q", noWarn) + } +} + +func execCmd(t *testing.T, name string, args ...string) string { + t.Helper() + out, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + t.Fatalf("%s %v failed: %v\n%s", name, args, err, out) + } + return string(out) +} diff --git a/cmd/bd/list_helpers_test.go b/cmd/bd/list_helpers_test.go new file mode 100644 index 00000000..d2725391 --- /dev/null +++ b/cmd/bd/list_helpers_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +func TestListParseTimeFlag(t *testing.T) { + cases := []string{ + "2025-12-26", + "2025-12-26T12:34:56", + "2025-12-26 12:34:56", + time.DateOnly, + time.RFC3339, + } + + for _, c := range cases { + // Just make sure we accept the expected formats. + var s string + switch c { + case time.DateOnly: + s = "2025-12-26" + case time.RFC3339: + s = "2025-12-26T12:34:56Z" + default: + s = c + } + got, err := parseTimeFlag(s) + if err != nil { + t.Fatalf("parseTimeFlag(%q) error: %v", s, err) + } + if got.Year() != 2025 { + t.Fatalf("parseTimeFlag(%q) year=%d, want 2025", s, got.Year()) + } + } + + if _, err := parseTimeFlag("not-a-date"); err == nil { + t.Fatalf("expected error") + } +} + +func TestListPinIndicator(t *testing.T) { + if pinIndicator(&types.Issue{Pinned: true}) == "" { + t.Fatalf("expected pin indicator") + } + if pinIndicator(&types.Issue{Pinned: false}) != "" { + t.Fatalf("expected empty pin indicator") + } +} + +func TestListFormatPrettyIssue_BadgesAndDefaults(t *testing.T) { + iss := &types.Issue{ID: "bd-1", Title: "Hello", Status: "wat", Priority: 99, IssueType: "bug"} + out := formatPrettyIssue(iss) + if !strings.Contains(out, "bd-1") || !strings.Contains(out, "Hello") { + t.Fatalf("unexpected output: %q", out) + } + if !strings.Contains(out, "[BUG]") { + t.Fatalf("expected BUG badge: %q", out) + } +} + +func TestListBuildIssueTree_ParentChildByDotID(t *testing.T) { + parent := &types.Issue{ID: "bd-1", Title: "Parent", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} + child := &types.Issue{ID: "bd-1.1", Title: "Child", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} + orphan := &types.Issue{ID: "bd-2.1", Title: "Orphan", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} + + roots, children := buildIssueTree([]*types.Issue{child, parent, orphan}) + if len(children["bd-1"]) != 1 || children["bd-1"][0].ID != "bd-1.1" { + t.Fatalf("expected bd-1 to have bd-1.1 child: %+v", children) + } + if len(roots) != 2 { + t.Fatalf("expected 2 roots (parent + orphan), got %d", len(roots)) + } +} + +func TestListSortIssues_ClosedNilLast(t *testing.T) { + t1 := time.Now().Add(-2 * time.Hour) + t2 := time.Now().Add(-1 * time.Hour) + + closedOld := &types.Issue{ID: "bd-1", ClosedAt: &t1} + closedNew := &types.Issue{ID: "bd-2", ClosedAt: &t2} + open := &types.Issue{ID: "bd-3", ClosedAt: nil} + + issues := []*types.Issue{open, closedOld, closedNew} + sortIssues(issues, "closed", false) + if issues[0].ID != "bd-2" || issues[1].ID != "bd-1" || issues[2].ID != "bd-3" { + t.Fatalf("unexpected order: %s, %s, %s", issues[0].ID, issues[1].ID, issues[2].ID) + } +} + +func TestListDisplayPrettyList(t *testing.T) { + out := captureStdout(t, func() error { + displayPrettyList(nil, false) + return nil + }) + if !strings.Contains(out, "No issues found") { + t.Fatalf("unexpected output: %q", out) + } + + issues := []*types.Issue{ + {ID: "bd-1", Title: "A", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}, + {ID: "bd-2", Title: "B", Status: types.StatusInProgress, Priority: 1, IssueType: types.TypeFeature}, + {ID: "bd-1.1", Title: "C", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}, + } + + out = captureStdout(t, func() error { + displayPrettyList(issues, false) + return nil + }) + if !strings.Contains(out, "bd-1") || !strings.Contains(out, "bd-1.1") || !strings.Contains(out, "Total:") { + t.Fatalf("unexpected output: %q", out) + } +} diff --git a/cmd/bd/reinit_test.go b/cmd/bd/reinit_test.go index f226dd2b..7e370420 100644 --- a/cmd/bd/reinit_test.go +++ b/cmd/bd/reinit_test.go @@ -9,6 +9,7 @@ import ( "runtime" "testing" + "github.com/steveyegge/beads/internal/git" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) @@ -90,6 +91,7 @@ func testFreshCloneAutoImport(t *testing.T) { // Test checkGitForIssues detects issues.jsonl t.Chdir(dir) + git.ResetCaches() count, path, gitRef := checkGitForIssues() if count != 1 { @@ -169,6 +171,7 @@ func testDatabaseRemovalScenario(t *testing.T) { // Change to test directory t.Chdir(dir) + git.ResetCaches() // Test checkGitForIssues finds issues.jsonl (canonical name) count, path, gitRef := checkGitForIssues() @@ -247,6 +250,7 @@ func testLegacyFilenameSupport(t *testing.T) { // Change to test directory t.Chdir(dir) + git.ResetCaches() // Test checkGitForIssues finds issues.jsonl count, path, gitRef := checkGitForIssues() @@ -323,6 +327,7 @@ func testPrecedenceTest(t *testing.T) { // Change to test directory t.Chdir(dir) + git.ResetCaches() // Test checkGitForIssues prefers issues.jsonl count, path, _ := checkGitForIssues() @@ -369,6 +374,7 @@ func testInitSafetyCheck(t *testing.T) { // Change to test directory t.Chdir(dir) + git.ResetCaches() // Create empty database (simulating failed import) dbPath := filepath.Join(beadsDir, "test.db") diff --git a/cmd/bd/sync_helpers_more_test.go b/cmd/bd/sync_helpers_more_test.go new file mode 100644 index 00000000..ae8479e4 --- /dev/null +++ b/cmd/bd/sync_helpers_more_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/steveyegge/beads/internal/config" +) + +func TestBuildGitCommitArgs_ConfigOptions(t *testing.T) { + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + config.Set("git.author", "Test User ") + config.Set("git.no-gpg-sign", true) + + args := buildGitCommitArgs("/repo", "hello", "--", ".beads") + joined := strings.Join(args, " ") + if !strings.Contains(joined, "--author") { + t.Fatalf("expected --author in args: %v", args) + } + if !strings.Contains(joined, "--no-gpg-sign") { + t.Fatalf("expected --no-gpg-sign in args: %v", args) + } + if !strings.Contains(joined, "-m hello") { + t.Fatalf("expected message in args: %v", args) + } +} + +func TestGitCommitBeadsDir_PathspecDoesNotCommitOtherStagedFiles(t *testing.T) { + _, cleanup := setupGitRepo(t) + defer cleanup() + + if err := config.Initialize(); err != nil { + t.Fatalf("config.Initialize: %v", err) + } + + if err := os.MkdirAll(".beads", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + // Stage an unrelated file before running gitCommitBeadsDir. + if err := os.WriteFile("other.txt", []byte("x\n"), 0o600); err != nil { + t.Fatalf("WriteFile other: %v", err) + } + _ = exec.Command("git", "add", "other.txt").Run() + + // Create a beads sync file to commit. + issuesPath := filepath.Join(".beads", "issues.jsonl") + if err := os.WriteFile(issuesPath, []byte("{\"id\":\"test-1\"}\n"), 0o600); err != nil { + t.Fatalf("WriteFile issues: %v", err) + } + + ctx := context.Background() + if err := gitCommitBeadsDir(ctx, "beads commit"); err != nil { + t.Fatalf("gitCommitBeadsDir: %v", err) + } + + // other.txt should still be staged after the beads-only commit. + out, err := exec.Command("git", "diff", "--cached", "--name-only").CombinedOutput() + if err != nil { + t.Fatalf("git diff --cached: %v\n%s", err, out) + } + if strings.TrimSpace(string(out)) != "other.txt" { + t.Fatalf("expected other.txt still staged, got: %q", out) + } +} diff --git a/cmd/bd/test_wait_helper.go b/cmd/bd/test_wait_helper.go index 78d965c5..d67a9083 100644 --- a/cmd/bd/test_wait_helper.go +++ b/cmd/bd/test_wait_helper.go @@ -5,6 +5,8 @@ import ( "os/exec" "testing" "time" + + "github.com/steveyegge/beads/internal/git" ) // waitFor repeatedly evaluates pred until it returns true or timeout expires. @@ -42,6 +44,7 @@ func setupGitRepo(t *testing.T) (repoPath string, cleanup func()) { _ = os.Chdir(originalWd) t.Fatalf("failed to init git repo: %v", err) } + git.ResetCaches() // Configure git _ = exec.Command("git", "config", "user.email", "test@test.com").Run() @@ -85,6 +88,7 @@ func setupGitRepoWithBranch(t *testing.T, branch string) (repoPath string, clean _ = os.Chdir(originalWd) t.Fatalf("failed to init git repo: %v", err) } + git.ResetCaches() // Configure git _ = exec.Command("git", "config", "user.email", "test@test.com").Run() diff --git a/cmd/bd/worktree_daemon_test.go b/cmd/bd/worktree_daemon_test.go index 6e793499..28228f66 100644 --- a/cmd/bd/worktree_daemon_test.go +++ b/cmd/bd/worktree_daemon_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/git" // Import SQLite driver for test database creation _ "github.com/ncruces/go-sqlite3/driver" @@ -78,6 +79,7 @@ func TestShouldDisableDaemonForWorktree(t *testing.T) { if err := os.Chdir(worktreeDir); err != nil { t.Fatalf("Failed to change to worktree dir: %v", err) } + git.ResetCaches() // No sync-branch configured os.Unsetenv("BEADS_SYNC_BRANCH") @@ -113,6 +115,7 @@ func TestShouldDisableDaemonForWorktree(t *testing.T) { if err := os.Chdir(worktreeDir); err != nil { t.Fatalf("Failed to change to worktree dir: %v", err) } + git.ResetCaches() // Reinitialize config to pick up the new directory's config.yaml if err := config.Initialize(); err != nil { @@ -144,6 +147,7 @@ func TestShouldDisableDaemonForWorktree(t *testing.T) { if err := os.Chdir(worktreeDir); err != nil { t.Fatalf("Failed to change to worktree dir: %v", err) } + git.ResetCaches() // Reinitialize config to pick up the new directory's config.yaml if err := config.Initialize(); err != nil { @@ -194,6 +198,7 @@ func TestShouldAutoStartDaemonWorktreeIntegration(t *testing.T) { if err := os.Chdir(worktreeDir); err != nil { t.Fatalf("Failed to change to worktree dir: %v", err) } + git.ResetCaches() // Clear all daemon-related env vars os.Unsetenv("BEADS_NO_DAEMON") @@ -227,6 +232,7 @@ func TestShouldAutoStartDaemonWorktreeIntegration(t *testing.T) { if err := os.Chdir(worktreeDir); err != nil { t.Fatalf("Failed to change to worktree dir: %v", err) } + git.ResetCaches() // Reinitialize config to pick up the new directory's config.yaml if err := config.Initialize(); err != nil { @@ -260,6 +266,7 @@ func TestShouldAutoStartDaemonWorktreeIntegration(t *testing.T) { if err := os.Chdir(worktreeDir); err != nil { t.Fatalf("Failed to change to worktree dir: %v", err) } + git.ResetCaches() // Reinitialize config to pick up the new directory's config.yaml if err := config.Initialize(); err != nil { diff --git a/internal/storage/memory/memory_more_coverage_test.go b/internal/storage/memory/memory_more_coverage_test.go index 401be669..82651647 100644 --- a/internal/storage/memory/memory_more_coverage_test.go +++ b/internal/storage/memory/memory_more_coverage_test.go @@ -793,7 +793,7 @@ func TestMemoryStorage_UpdateIssue_SearchIssues_ReadyWork_BlockedIssues(t *testi } // Blocked issues: child is blocked by an open blocker. - blocked, err := store.GetBlockedIssues(ctx) + blocked, err := store.GetBlockedIssues(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetBlockedIssues: %v", err) } @@ -810,7 +810,7 @@ func TestMemoryStorage_UpdateIssue_SearchIssues_ReadyWork_BlockedIssues(t *testi store.mu.Lock() store.dependencies[missing.ID] = append(store.dependencies[missing.ID], &types.Dependency{IssueID: missing.ID, DependsOnID: "bd-does-not-exist", Type: types.DepBlocks}) store.mu.Unlock() - blocked, err = store.GetBlockedIssues(ctx) + blocked, err = store.GetBlockedIssues(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetBlockedIssues: %v", err) } diff --git a/scripts/test.sh b/scripts/test.sh index ac5729bf..d5f2c3ba 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -26,7 +26,7 @@ VERBOSE="${TEST_VERBOSE:-}" RUN_PATTERN="${TEST_RUN:-}" COVERAGE="${TEST_COVER:-}" COVERPROFILE="${TEST_COVERPROFILE:-/tmp/beads.coverage.out}" -COVERPKG="${TEST_COVERPKG:-./...}" +COVERPKG="${TEST_COVERPKG:-}" # Parse arguments PACKAGES=() @@ -81,7 +81,10 @@ if [[ -n "$RUN_PATTERN" ]]; then fi if [[ -n "$COVERAGE" ]]; then - CMD+=(-covermode=atomic -coverpkg "$COVERPKG" -coverprofile "$COVERPROFILE") + CMD+=(-covermode=atomic -coverprofile "$COVERPROFILE") + if [[ -n "$COVERPKG" ]]; then + CMD+=(-coverpkg "$COVERPKG") + fi fi CMD+=("${PACKAGES[@]}")