diff --git a/internal/syncbranch/syncbranch_test.go b/internal/syncbranch/syncbranch_test.go index 07cef909..7c69e9dc 100644 --- a/internal/syncbranch/syncbranch_test.go +++ b/internal/syncbranch/syncbranch_test.go @@ -200,12 +200,12 @@ func TestUnset(t *testing.T) { t.Run("removes config value", func(t *testing.T) { store := newTestStore(t) defer store.Close() - + // Set a value first if err := Set(ctx, store, "beads-metadata"); err != nil { t.Fatalf("Set() error = %v", err) } - + // Verify it's set value, err := store.GetConfig(ctx, ConfigKey) if err != nil { @@ -214,12 +214,12 @@ func TestUnset(t *testing.T) { if value != "beads-metadata" { t.Errorf("GetConfig() = %q, want %q", value, "beads-metadata") } - + // Unset it if err := Unset(ctx, store); err != nil { t.Fatalf("Unset() error = %v", err) } - + // Verify it's gone value, err = store.GetConfig(ctx, ConfigKey) if err != nil { @@ -230,3 +230,152 @@ func TestUnset(t *testing.T) { } }) } + +func TestGetFromYAML(t *testing.T) { + // Save and restore any existing env var + origEnv := os.Getenv(EnvVar) + defer os.Setenv(EnvVar, origEnv) + + t.Run("returns empty when nothing configured", func(t *testing.T) { + os.Unsetenv(EnvVar) + branch := GetFromYAML() + // GetFromYAML checks env var first, then config.yaml + // Without env var set, it should return what's in config.yaml (or empty) + // We can't easily mock config.yaml here, so just verify no panic + _ = branch + }) + + t.Run("returns env var value when set", func(t *testing.T) { + os.Setenv(EnvVar, "env-sync-branch") + defer os.Unsetenv(EnvVar) + + branch := GetFromYAML() + if branch != "env-sync-branch" { + t.Errorf("GetFromYAML() = %q, want %q", branch, "env-sync-branch") + } + }) +} + +func TestIsConfigured(t *testing.T) { + // Save and restore any existing env var + origEnv := os.Getenv(EnvVar) + defer os.Setenv(EnvVar, origEnv) + + t.Run("returns true when env var is set", func(t *testing.T) { + os.Setenv(EnvVar, "test-branch") + defer os.Unsetenv(EnvVar) + + if !IsConfigured() { + t.Error("IsConfigured() = false when env var is set, want true") + } + }) + + t.Run("behavior with no env var", func(t *testing.T) { + os.Unsetenv(EnvVar) + // Just verify no panic - actual value depends on config.yaml + _ = IsConfigured() + }) +} + +func TestIsConfiguredWithDB(t *testing.T) { + // Save and restore any existing env var + origEnv := os.Getenv(EnvVar) + defer os.Setenv(EnvVar, origEnv) + + t.Run("returns true when env var is set", func(t *testing.T) { + os.Setenv(EnvVar, "test-branch") + defer os.Unsetenv(EnvVar) + + if !IsConfiguredWithDB("") { + t.Error("IsConfiguredWithDB() = false when env var is set, want true") + } + }) + + t.Run("returns false for nonexistent database", func(t *testing.T) { + os.Unsetenv(EnvVar) + + result := IsConfiguredWithDB("/nonexistent/path/beads.db") + // Should return false because db doesn't exist + if result { + t.Error("IsConfiguredWithDB() = true for nonexistent db, want false") + } + }) + + t.Run("returns false for empty path with no db found", func(t *testing.T) { + os.Unsetenv(EnvVar) + // When empty path is passed and beads.FindDatabasePath() returns empty, + // IsConfiguredWithDB should return false + // This tests the code path where dbPath is empty + tmpDir, _ := os.MkdirTemp("", "test-no-beads-*") + defer os.RemoveAll(tmpDir) + + origWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origWd) + + result := IsConfiguredWithDB("") + // Should return false because no database exists + if result { + t.Error("IsConfiguredWithDB('') with no db = true, want false") + } + }) +} + +func TestGetConfigFromDB(t *testing.T) { + t.Run("returns empty for nonexistent database", func(t *testing.T) { + result := getConfigFromDB("/nonexistent/path/beads.db", ConfigKey) + if result != "" { + t.Errorf("getConfigFromDB() for nonexistent db = %q, want empty", result) + } + }) + + t.Run("returns empty when key not found", func(t *testing.T) { + // Create a temporary database + tmpDir, _ := os.MkdirTemp("", "test-beads-db-*") + defer os.RemoveAll(tmpDir) + dbPath := tmpDir + "/beads.db" + + // Create a valid SQLite database with the config table + store, err := sqlite.New(context.Background(), "file:"+dbPath) + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + store.Close() + + result := getConfigFromDB(dbPath, "nonexistent.key") + if result != "" { + t.Errorf("getConfigFromDB() for missing key = %q, want empty", result) + } + }) + + t.Run("returns value when key exists", func(t *testing.T) { + // Create a temporary database + tmpDir, _ := os.MkdirTemp("", "test-beads-db-*") + defer os.RemoveAll(tmpDir) + dbPath := tmpDir + "/beads.db" + + // Create a valid SQLite database with the config table + ctx := context.Background() + // Use the same connection string format as getConfigFromDB expects + store, err := sqlite.New(ctx, "file:"+dbPath+"?_journal_mode=DELETE") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + // Set issue_prefix first (required by storage) + if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil { + store.Close() + t.Fatalf("Failed to set issue_prefix: %v", err) + } + // Set the config value we're testing + if err := store.SetConfig(ctx, ConfigKey, "test-sync-branch"); err != nil { + store.Close() + t.Fatalf("Failed to set config: %v", err) + } + store.Close() + + result := getConfigFromDB(dbPath, ConfigKey) + if result != "test-sync-branch" { + t.Errorf("getConfigFromDB() = %q, want %q", result, "test-sync-branch") + } + }) +} diff --git a/internal/syncbranch/worktree_helpers_test.go b/internal/syncbranch/worktree_helpers_test.go new file mode 100644 index 00000000..44fb8984 --- /dev/null +++ b/internal/syncbranch/worktree_helpers_test.go @@ -0,0 +1,716 @@ +package syncbranch + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestIsNonFastForwardError tests the non-fast-forward error detection +func TestIsNonFastForwardError(t *testing.T) { + tests := []struct { + name string + output string + want bool + }{ + { + name: "non-fast-forward message", + output: "error: failed to push some refs to 'origin'\n! [rejected] main -> main (non-fast-forward)", + want: true, + }, + { + name: "fetch first message", + output: "error: failed to push some refs to 'origin'\nhint: Updates were rejected because the remote contains work that you do\nhint: not have locally. This is usually caused by another repository pushing\nhint: to the same ref. You may want to first integrate the remote changes\nhint: (e.g., 'git pull ...') before pushing again.\nhint: See the 'Note about fast-forwards' in 'git push --help' for details.\nfetch first", + want: true, + }, + { + name: "rejected behind message", + output: "To github.com:user/repo.git\n! [rejected] main -> main (non-fast-forward)\nerror: failed to push some refs\nhint: rejected because behind remote", + want: true, + }, + { + name: "normal push success", + output: "Everything up-to-date", + want: false, + }, + { + name: "authentication error", + output: "fatal: Authentication failed for 'https://github.com/user/repo.git/'", + want: false, + }, + { + name: "permission denied", + output: "ERROR: Permission to user/repo.git denied to user.", + want: false, + }, + { + name: "empty output", + output: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isNonFastForwardError(tt.output) + if got != tt.want { + t.Errorf("isNonFastForwardError(%q) = %v, want %v", tt.output, got, tt.want) + } + }) + } +} + +// TestHasChangesInWorktree tests change detection in worktree +func TestHasChangesInWorktree(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("no changes in clean worktree", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + hasChanges, err := hasChangesInWorktree(ctx, repoDir, jsonlPath) + if err != nil { + t.Fatalf("hasChangesInWorktree() error = %v", err) + } + if hasChanges { + t.Error("hasChangesInWorktree() = true for clean worktree, want false") + } + }) + + t.Run("detects uncommitted changes", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Modify file without committing + writeFile(t, jsonlPath, `{"id":"test-1"}`+"\n"+`{"id":"test-2"}`) + + hasChanges, err := hasChangesInWorktree(ctx, repoDir, jsonlPath) + if err != nil { + t.Fatalf("hasChangesInWorktree() error = %v", err) + } + if !hasChanges { + t.Error("hasChangesInWorktree() = false with uncommitted changes, want true") + } + }) + + t.Run("detects new untracked files", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Add new file in .beads + writeFile(t, filepath.Join(repoDir, ".beads", "metadata.json"), `{}`) + + hasChanges, err := hasChangesInWorktree(ctx, repoDir, jsonlPath) + if err != nil { + t.Fatalf("hasChangesInWorktree() error = %v", err) + } + if !hasChanges { + t.Error("hasChangesInWorktree() = false with new file, want true") + } + }) + + t.Run("handles file outside .beads dir", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + jsonlPath := filepath.Join(repoDir, "issues.jsonl") // Not in .beads + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Modify file + writeFile(t, jsonlPath, `{"id":"test-1"}`+"\n"+`{"id":"test-2"}`) + + hasChanges, err := hasChangesInWorktree(ctx, repoDir, jsonlPath) + if err != nil { + t.Fatalf("hasChangesInWorktree() error = %v", err) + } + if !hasChanges { + t.Error("hasChangesInWorktree() = false with modified file outside .beads, want true") + } + }) +} + +// TestCommitInWorktree tests committing changes in worktree +func TestCommitInWorktree(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("commits staged changes", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Modify file + writeFile(t, jsonlPath, `{"id":"test-1"}`+"\n"+`{"id":"test-2"}`) + + // Commit using our function + err := commitInWorktree(ctx, repoDir, ".beads/issues.jsonl", "test commit message") + if err != nil { + t.Fatalf("commitInWorktree() error = %v", err) + } + + // Verify commit was made + output := getGitOutput(t, repoDir, "log", "-1", "--format=%s") + if !strings.Contains(output, "test commit message") { + t.Errorf("commit message = %q, want to contain 'test commit message'", output) + } + }) + + t.Run("commits entire .beads directory", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Add multiple files + writeFile(t, filepath.Join(repoDir, ".beads", "metadata.json"), `{"version":"1"}`) + writeFile(t, jsonlPath, `{"id":"test-1"}`+"\n"+`{"id":"test-2"}`) + + err := commitInWorktree(ctx, repoDir, ".beads/issues.jsonl", "multi-file commit") + if err != nil { + t.Fatalf("commitInWorktree() error = %v", err) + } + + // Verify both files were committed + output := getGitOutput(t, repoDir, "diff", "--name-only", "HEAD~1") + if !strings.Contains(output, "issues.jsonl") { + t.Error("issues.jsonl not in commit") + } + if !strings.Contains(output, "metadata.json") { + t.Error("metadata.json not in commit") + } + }) +} + +// TestCopyJSONLToMainRepo tests copying JSONL between worktree and main repo +func TestCopyJSONLToMainRepo(t *testing.T) { + t.Run("copies JSONL file successfully", func(t *testing.T) { + // Setup worktree directory + worktreeDir, _ := os.MkdirTemp("", "test-worktree-*") + defer os.RemoveAll(worktreeDir) + + // Setup main repo directory + mainRepoDir, _ := os.MkdirTemp("", "test-mainrepo-*") + defer os.RemoveAll(mainRepoDir) + + // Create .beads directories + os.MkdirAll(filepath.Join(worktreeDir, ".beads"), 0750) + os.MkdirAll(filepath.Join(mainRepoDir, ".beads"), 0750) + + // Write content to worktree JSONL + worktreeContent := `{"id":"test-1","title":"Test Issue"}` + if err := os.WriteFile(filepath.Join(worktreeDir, ".beads", "issues.jsonl"), []byte(worktreeContent), 0600); err != nil { + t.Fatalf("Failed to write worktree JSONL: %v", err) + } + + mainJSONLPath := filepath.Join(mainRepoDir, ".beads", "issues.jsonl") + + err := copyJSONLToMainRepo(worktreeDir, ".beads/issues.jsonl", mainJSONLPath) + if err != nil { + t.Fatalf("copyJSONLToMainRepo() error = %v", err) + } + + // Verify content was copied + copied, err := os.ReadFile(mainJSONLPath) + if err != nil { + t.Fatalf("Failed to read copied file: %v", err) + } + if string(copied) != worktreeContent { + t.Errorf("copied content = %q, want %q", string(copied), worktreeContent) + } + }) + + t.Run("returns nil when worktree JSONL does not exist", func(t *testing.T) { + worktreeDir, _ := os.MkdirTemp("", "test-worktree-*") + defer os.RemoveAll(worktreeDir) + + mainRepoDir, _ := os.MkdirTemp("", "test-mainrepo-*") + defer os.RemoveAll(mainRepoDir) + + mainJSONLPath := filepath.Join(mainRepoDir, ".beads", "issues.jsonl") + + err := copyJSONLToMainRepo(worktreeDir, ".beads/issues.jsonl", mainJSONLPath) + if err != nil { + t.Errorf("copyJSONLToMainRepo() for nonexistent file = %v, want nil", err) + } + }) + + t.Run("also copies metadata.json if present", func(t *testing.T) { + worktreeDir, _ := os.MkdirTemp("", "test-worktree-*") + defer os.RemoveAll(worktreeDir) + + mainRepoDir, _ := os.MkdirTemp("", "test-mainrepo-*") + defer os.RemoveAll(mainRepoDir) + + // Create .beads directories + os.MkdirAll(filepath.Join(worktreeDir, ".beads"), 0750) + os.MkdirAll(filepath.Join(mainRepoDir, ".beads"), 0750) + + // Write JSONL and metadata to worktree + if err := os.WriteFile(filepath.Join(worktreeDir, ".beads", "issues.jsonl"), []byte(`{}`), 0600); err != nil { + t.Fatalf("Failed to write worktree JSONL: %v", err) + } + metadataContent := `{"prefix":"bd"}` + if err := os.WriteFile(filepath.Join(worktreeDir, ".beads", "metadata.json"), []byte(metadataContent), 0600); err != nil { + t.Fatalf("Failed to write metadata: %v", err) + } + + mainJSONLPath := filepath.Join(mainRepoDir, ".beads", "issues.jsonl") + + err := copyJSONLToMainRepo(worktreeDir, ".beads/issues.jsonl", mainJSONLPath) + if err != nil { + t.Fatalf("copyJSONLToMainRepo() error = %v", err) + } + + // Verify metadata was also copied + metadata, err := os.ReadFile(filepath.Join(mainRepoDir, ".beads", "metadata.json")) + if err != nil { + t.Fatalf("Failed to read metadata: %v", err) + } + if string(metadata) != metadataContent { + t.Errorf("metadata content = %q, want %q", string(metadata), metadataContent) + } + }) +} + +// TestGetRemoteForBranch tests remote detection for branches +func TestGetRemoteForBranch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("returns origin as default", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + remote := getRemoteForBranch(ctx, repoDir, "nonexistent-branch") + if remote != "origin" { + t.Errorf("getRemoteForBranch() = %q, want 'origin'", remote) + } + }) + + t.Run("returns configured remote", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Configure a custom remote for a branch + runGit(t, repoDir, "config", "branch.test-branch.remote", "upstream") + + remote := getRemoteForBranch(ctx, repoDir, "test-branch") + if remote != "upstream" { + t.Errorf("getRemoteForBranch() = %q, want 'upstream'", remote) + } + }) +} + +// TestGetRepoRoot tests repository root detection +func TestGetRepoRoot(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("returns repo root for regular repository", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Change to repo directory + origWd, _ := os.Getwd() + os.Chdir(repoDir) + defer os.Chdir(origWd) + + root, err := GetRepoRoot(ctx) + if err != nil { + t.Fatalf("GetRepoRoot() error = %v", err) + } + + // Resolve symlinks for comparison + expectedRoot, _ := filepath.EvalSymlinks(repoDir) + actualRoot, _ := filepath.EvalSymlinks(root) + + if actualRoot != expectedRoot { + t.Errorf("GetRepoRoot() = %q, want %q", actualRoot, expectedRoot) + } + }) + + t.Run("returns error for non-git directory", func(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "non-git-*") + defer os.RemoveAll(tmpDir) + + origWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origWd) + + _, err := GetRepoRoot(ctx) + if err == nil { + t.Error("GetRepoRoot() expected error for non-git directory") + } + }) + + t.Run("returns repo root from subdirectory", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Create and change to subdirectory + subDir := filepath.Join(repoDir, "subdir", "nested") + os.MkdirAll(subDir, 0750) + + origWd, _ := os.Getwd() + os.Chdir(subDir) + defer os.Chdir(origWd) + + root, err := GetRepoRoot(ctx) + if err != nil { + t.Fatalf("GetRepoRoot() error = %v", err) + } + + // Resolve symlinks for comparison + expectedRoot, _ := filepath.EvalSymlinks(repoDir) + actualRoot, _ := filepath.EvalSymlinks(root) + + if actualRoot != expectedRoot { + t.Errorf("GetRepoRoot() from subdirectory = %q, want %q", actualRoot, expectedRoot) + } + }) + + t.Run("handles worktree correctly", func(t *testing.T) { + // Create main repo + mainRepoDir := setupTestRepo(t) + defer os.RemoveAll(mainRepoDir) + + // Create initial commit + writeFile(t, filepath.Join(mainRepoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, mainRepoDir, "add", ".") + runGit(t, mainRepoDir, "commit", "-m", "initial") + + // Create a worktree + worktreeDir, _ := os.MkdirTemp("", "test-worktree-*") + defer os.RemoveAll(worktreeDir) + runGit(t, mainRepoDir, "worktree", "add", worktreeDir, "-b", "feature") + + // Test from worktree - should return main repo root + origWd, _ := os.Getwd() + os.Chdir(worktreeDir) + defer os.Chdir(origWd) + + root, err := GetRepoRoot(ctx) + if err != nil { + t.Fatalf("GetRepoRoot() from worktree error = %v", err) + } + + // Should return the main repo root, not the worktree + expectedRoot, _ := filepath.EvalSymlinks(mainRepoDir) + actualRoot, _ := filepath.EvalSymlinks(root) + + if actualRoot != expectedRoot { + t.Errorf("GetRepoRoot() from worktree = %q, want main repo %q", actualRoot, expectedRoot) + } + }) +} + +// TestHasGitRemote tests remote detection +func TestHasGitRemote(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("returns false for repo without remote", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + origWd, _ := os.Getwd() + os.Chdir(repoDir) + defer os.Chdir(origWd) + + if HasGitRemote(ctx) { + t.Error("HasGitRemote() = true for repo without remote, want false") + } + }) + + t.Run("returns true for repo with remote", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Add a remote + runGit(t, repoDir, "remote", "add", "origin", "https://github.com/test/repo.git") + + origWd, _ := os.Getwd() + os.Chdir(repoDir) + defer os.Chdir(origWd) + + if !HasGitRemote(ctx) { + t.Error("HasGitRemote() = false for repo with remote, want true") + } + }) + + t.Run("returns false for non-git directory", func(t *testing.T) { + tmpDir, _ := os.MkdirTemp("", "non-git-*") + defer os.RemoveAll(tmpDir) + + origWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origWd) + + if HasGitRemote(ctx) { + t.Error("HasGitRemote() = true for non-git directory, want false") + } + }) +} + +// TestGetCurrentBranch tests current branch detection +func TestGetCurrentBranch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("returns current branch name", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + origWd, _ := os.Getwd() + os.Chdir(repoDir) + defer os.Chdir(origWd) + + branch, err := GetCurrentBranch(ctx) + if err != nil { + t.Fatalf("GetCurrentBranch() error = %v", err) + } + + // The default branch is usually "master" or "main" depending on git config + if branch != "master" && branch != "main" { + // Could also be a user-defined default, just verify it's not empty + if branch == "" { + t.Error("GetCurrentBranch() returned empty string") + } + } + }) + + t.Run("returns correct branch after checkout", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Create and checkout new branch + runGit(t, repoDir, "checkout", "-b", "feature-branch") + + origWd, _ := os.Getwd() + os.Chdir(repoDir) + defer os.Chdir(origWd) + + branch, err := GetCurrentBranch(ctx) + if err != nil { + t.Fatalf("GetCurrentBranch() error = %v", err) + } + + if branch != "feature-branch" { + t.Errorf("GetCurrentBranch() = %q, want 'feature-branch'", branch) + } + }) +} + +// TestFormatVanishedIssues tests the forensic logging formatter +func TestFormatVanishedIssues(t *testing.T) { + t.Run("formats vanished issues correctly", func(t *testing.T) { + localIssues := map[string]issueSummary{ + "bd-1": {ID: "bd-1", Title: "First Issue"}, + "bd-2": {ID: "bd-2", Title: "Second Issue"}, + "bd-3": {ID: "bd-3", Title: "Third Issue"}, + } + mergedIssues := map[string]issueSummary{ + "bd-1": {ID: "bd-1", Title: "First Issue"}, + } + + lines := formatVanishedIssues(localIssues, mergedIssues, 3, 1) + + // Should contain header + found := false + for _, line := range lines { + if strings.Contains(line, "Mass deletion forensic log") { + found = true + break + } + } + if !found { + t.Error("formatVanishedIssues() missing header") + } + + // Should list vanished issues + foundBd2 := false + foundBd3 := false + for _, line := range lines { + if strings.Contains(line, "bd-2") { + foundBd2 = true + } + if strings.Contains(line, "bd-3") { + foundBd3 = true + } + } + if !foundBd2 || !foundBd3 { + t.Errorf("formatVanishedIssues() missing vanished issues: bd-2=%v, bd-3=%v", foundBd2, foundBd3) + } + + // Should show totals + foundTotal := false + for _, line := range lines { + if strings.Contains(line, "Total vanished: 2") { + foundTotal = true + break + } + } + if !foundTotal { + t.Error("formatVanishedIssues() missing total count") + } + }) + + t.Run("truncates long titles", func(t *testing.T) { + longTitle := strings.Repeat("A", 100) + localIssues := map[string]issueSummary{ + "bd-1": {ID: "bd-1", Title: longTitle}, + } + mergedIssues := map[string]issueSummary{} + + lines := formatVanishedIssues(localIssues, mergedIssues, 1, 0) + + // Find the line with bd-1 and check title is truncated + for _, line := range lines { + if strings.Contains(line, "bd-1") { + if len(line) > 80 { // Line should be reasonably short + // Verify it ends with "..." + if !strings.Contains(line, "...") { + t.Error("formatVanishedIssues() should truncate long titles with '...'") + } + } + break + } + } + }) +} + +// TestCheckDivergence tests the public CheckDivergence function +func TestCheckDivergence(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("returns no divergence when remote does not exist", func(t *testing.T) { + repoDir := setupTestRepo(t) + defer os.RemoveAll(repoDir) + + // Create initial commit + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // Add remote but don't create the branch on it + runGit(t, repoDir, "remote", "add", "origin", repoDir) // Use self as remote + + info, err := CheckDivergence(ctx, repoDir, "beads-sync") + if err != nil { + // Expected to fail since remote branch doesn't exist + return + } + + // If it succeeds, verify no divergence + if info.IsDiverged { + t.Error("CheckDivergence() should not report divergence when remote doesn't exist") + } + }) +} + +// helper to run git with error handling (already exists but needed for this file) +func runGitHelper(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, output) + } +} diff --git a/internal/syncbranch/worktree_sync_test.go b/internal/syncbranch/worktree_sync_test.go new file mode 100644 index 00000000..038738b9 --- /dev/null +++ b/internal/syncbranch/worktree_sync_test.go @@ -0,0 +1,416 @@ +package syncbranch + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestCommitToSyncBranch tests the main commit function +func TestCommitToSyncBranch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("commits changes to sync branch", func(t *testing.T) { + // Setup: create a repo with a sync branch + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + + // Create sync branch + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial sync branch commit") + runGit(t, repoDir, "checkout", "master") + + // Write new content to commit + writeFile(t, jsonlPath, `{"id":"test-1"}`+"\n"+`{"id":"test-2"}`) + + result, err := CommitToSyncBranch(ctx, repoDir, syncBranch, jsonlPath, false) + if err != nil { + t.Fatalf("CommitToSyncBranch() error = %v", err) + } + + if !result.Committed { + t.Error("CommitToSyncBranch() Committed = false, want true") + } + if result.Branch != syncBranch { + t.Errorf("CommitToSyncBranch() Branch = %q, want %q", result.Branch, syncBranch) + } + if !strings.Contains(result.Message, "bd sync:") { + t.Errorf("CommitToSyncBranch() Message = %q, want to contain 'bd sync:'", result.Message) + } + }) + + t.Run("returns not committed when no changes", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + + // Create sync branch with content + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + runGit(t, repoDir, "checkout", "master") + + // Write the same content that's in the sync branch + writeFile(t, jsonlPath, `{"id":"test-1"}`) + + // Commit with same content (no changes) + result, err := CommitToSyncBranch(ctx, repoDir, syncBranch, jsonlPath, false) + if err != nil { + t.Fatalf("CommitToSyncBranch() error = %v", err) + } + + if result.Committed { + t.Error("CommitToSyncBranch() Committed = true when no changes, want false") + } + }) +} + +// TestPullFromSyncBranch tests pulling changes from sync branch +func TestPullFromSyncBranch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("handles sync branch not on remote", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + + // Create local sync branch but don't set up remote tracking + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "local sync") + runGit(t, repoDir, "checkout", "master") + + // Pull should handle the case where remote doesn't have the branch + result, err := PullFromSyncBranch(ctx, repoDir, syncBranch, jsonlPath, false) + // This tests the fetch failure path since "origin" points to self without the sync branch + // It should either succeed (not pulled) or fail gracefully + if err != nil { + // Expected - fetch will fail since origin doesn't have sync branch + return + } + if result.Pulled && !result.FastForwarded && !result.Merged { + // Pulled but no change - acceptable + _ = result + } + }) + + t.Run("pulls when already up to date", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + + // Create sync branch and simulate it being tracked + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, jsonlPath, `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "sync commit") + // Set up a fake remote ref at the same commit + runGit(t, repoDir, "update-ref", "refs/remotes/origin/"+syncBranch, "HEAD") + runGit(t, repoDir, "checkout", "master") + + // Pull when already at remote HEAD + result, err := PullFromSyncBranch(ctx, repoDir, syncBranch, jsonlPath, false) + if err != nil { + // Might fail on fetch step, that's acceptable + return + } + // Should have pulled successfully (even if no new content) + if result.Pulled { + // Good - it recognized it's up to date + } + }) + + t.Run("copies JSONL to main repo after sync", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + + // Create sync branch with content + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, jsonlPath, `{"id":"sync-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "sync commit") + runGit(t, repoDir, "update-ref", "refs/remotes/origin/"+syncBranch, "HEAD") + runGit(t, repoDir, "checkout", "master") + + // Remove local JSONL to verify it gets copied back + os.Remove(jsonlPath) + + result, err := PullFromSyncBranch(ctx, repoDir, syncBranch, jsonlPath, false) + if err != nil { + return // Acceptable in test env + } + + if result.Pulled { + // Verify JSONL was copied to main repo + if _, err := os.Stat(jsonlPath); os.IsNotExist(err) { + t.Error("PullFromSyncBranch() did not copy JSONL to main repo") + } + } + }) + + t.Run("handles fast-forward case", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + + // Create sync branch with base commit + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, jsonlPath, `{"id":"base"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "base") + baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD")) + + // Add another commit and set as remote + writeFile(t, jsonlPath, `{"id":"base"}`+"\n"+`{"id":"remote"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "remote commit") + runGit(t, repoDir, "update-ref", "refs/remotes/origin/"+syncBranch, "HEAD") + + // Reset back to base (so remote is ahead) + runGit(t, repoDir, "reset", "--hard", baseCommit) + runGit(t, repoDir, "checkout", "master") + + // Pull should fast-forward + result, err := PullFromSyncBranch(ctx, repoDir, syncBranch, jsonlPath, false) + if err != nil { + return // Acceptable with self-remote + } + + // Just verify result is populated correctly + _ = result.FastForwarded + _ = result.Merged + }) +} + +// TestResetToRemote tests resetting sync branch to remote state +// Note: Full remote tests are in cmd/bd tests; this tests the basic flow +func TestResetToRemote(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("returns error when fetch fails", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + jsonlPath := filepath.Join(repoDir, ".beads", "issues.jsonl") + + // Create local sync branch without remote + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, jsonlPath, `{"id":"local-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "local commit") + runGit(t, repoDir, "checkout", "master") + + // ResetToRemote should fail since remote branch doesn't exist + err := ResetToRemote(ctx, repoDir, syncBranch, jsonlPath) + if err == nil { + // If it succeeds without remote, that's also acceptable + // (the remote is set to self, might not have sync branch) + } + }) +} + +// TestPushSyncBranch tests the push function +// Note: Full push tests require actual remote; this tests basic error handling +func TestPushSyncBranch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("handles missing worktree gracefully", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + + // Create sync branch + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + runGit(t, repoDir, "checkout", "master") + + // PushSyncBranch should handle the worktree creation + err := PushSyncBranch(ctx, repoDir, syncBranch) + // Will fail because origin doesn't have the branch, but should not panic + if err != nil { + // Expected - push will fail since origin doesn't have the branch set up + if !strings.Contains(err.Error(), "push failed") { + // Some other error - acceptable in test env + } + } + }) +} + +// TestRunCmdWithTimeoutMessage tests the timeout message function +func TestRunCmdWithTimeoutMessage(t *testing.T) { + ctx := context.Background() + + t.Run("runs command and returns output", func(t *testing.T) { + cmd := exec.CommandContext(ctx, "echo", "hello") + output, err := runCmdWithTimeoutMessage(ctx, "test message", 5*time.Second, cmd) + if err != nil { + t.Fatalf("runCmdWithTimeoutMessage() error = %v", err) + } + if !strings.Contains(string(output), "hello") { + t.Errorf("runCmdWithTimeoutMessage() output = %q, want to contain 'hello'", output) + } + }) + + t.Run("returns error for failing command", func(t *testing.T) { + cmd := exec.CommandContext(ctx, "false") // Always exits with 1 + _, err := runCmdWithTimeoutMessage(ctx, "test message", 5*time.Second, cmd) + if err == nil { + t.Error("runCmdWithTimeoutMessage() expected error for failing command") + } + }) +} + +// TestPreemptiveFetchAndFastForward tests the pre-emptive fetch function +func TestPreemptiveFetchAndFastForward(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("returns nil when remote branch does not exist", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + // Create sync branch locally but don't push + runGit(t, repoDir, "checkout", "-b", "beads-sync") + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + err := preemptiveFetchAndFastForward(ctx, repoDir, "beads-sync", "origin") + if err != nil { + t.Errorf("preemptiveFetchAndFastForward() error = %v, want nil (not an error when remote doesn't exist)", err) + } + }) + + t.Run("no-op when local equals remote", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + + // Create sync branch + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + // Set remote ref at same commit + runGit(t, repoDir, "update-ref", "refs/remotes/origin/"+syncBranch, "HEAD") + + err := preemptiveFetchAndFastForward(ctx, repoDir, syncBranch, "origin") + // Should succeed since we're already in sync + if err != nil { + // Might fail on fetch step with self-remote, acceptable + return + } + }) +} + +// TestFetchAndRebaseInWorktree tests the fetch and rebase function +func TestFetchAndRebaseInWorktree(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + t.Run("returns error when fetch fails", func(t *testing.T) { + repoDir := setupTestRepoWithRemote(t) + defer os.RemoveAll(repoDir) + + syncBranch := "beads-sync" + + // Create sync branch locally + runGit(t, repoDir, "checkout", "-b", syncBranch) + writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`) + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "initial") + + // fetchAndRebaseInWorktree should fail since remote doesn't have the branch + err := fetchAndRebaseInWorktree(ctx, repoDir, syncBranch, "origin") + if err == nil { + // If it succeeds, it means the test setup allowed it (self remote) + return + } + // Expected to fail + if !strings.Contains(err.Error(), "fetch failed") { + // Some other error - still acceptable + } + }) +} + +// Helper: setup a test repo with a (fake) remote +func setupTestRepoWithRemote(t *testing.T) string { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "bd-test-repo-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Initialize git repo + runGit(t, tmpDir, "init") + runGit(t, tmpDir, "config", "user.email", "test@test.com") + runGit(t, tmpDir, "config", "user.name", "Test User") + + // Create initial commit + writeFile(t, filepath.Join(tmpDir, "README.md"), "# Test Repo") + runGit(t, tmpDir, "add", ".") + runGit(t, tmpDir, "commit", "-m", "initial commit") + + // Create .beads directory + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0750); err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("Failed to create .beads dir: %v", err) + } + + // Add a fake remote (just for configuration purposes) + runGit(t, tmpDir, "remote", "add", "origin", tmpDir) + + return tmpDir +} +