Files
beads/internal/syncbranch/worktree_divergence_test.go
Steve Yegge c93b755344 feat(sync): auto-push after merge with safety check (bd-7ch)
Add auto-push functionality to PullFromSyncBranch for true one-command sync:
- After successful content merge, auto-push to remote by default
- Safety check: warn (but dont block) if >50% issues vanished AND >5 existed
- Vanished = removed from JSONL entirely, NOT status=closed

Changes:
- Add push parameter to PullFromSyncBranch function
- Add Pushed field to PullResult struct
- Add countIssuesInContent helper for safety check
- Add test for countIssuesInContent function

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 20:57:53 -08:00

510 lines
17 KiB
Go

package syncbranch
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestGetDivergence tests the divergence detection function
func TestGetDivergence(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
t.Run("no divergence when synced", func(t *testing.T) {
// Create a test repo with a branch
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
// Create and checkout test branch
runGit(t, repoDir, "checkout", "-b", "test-branch")
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "initial commit")
// Simulate remote by creating a local ref
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
localAhead, remoteAhead, err := getDivergence(ctx, repoDir, "test-branch", "origin")
if err != nil {
t.Fatalf("getDivergence() error = %v", err)
}
if localAhead != 0 || remoteAhead != 0 {
t.Errorf("getDivergence() = (%d, %d), want (0, 0)", localAhead, remoteAhead)
}
})
t.Run("local ahead of remote", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
runGit(t, repoDir, "checkout", "-b", "test-branch")
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "initial commit")
// Set remote ref to current HEAD
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
// Add more local commits
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}
{"id":"test-2"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "second commit")
localAhead, remoteAhead, err := getDivergence(ctx, repoDir, "test-branch", "origin")
if err != nil {
t.Fatalf("getDivergence() error = %v", err)
}
if localAhead != 1 || remoteAhead != 0 {
t.Errorf("getDivergence() = (%d, %d), want (1, 0)", localAhead, remoteAhead)
}
})
t.Run("remote ahead of local", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
runGit(t, repoDir, "checkout", "-b", "test-branch")
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "initial commit")
// Save current HEAD as "local"
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Create more commits and set as remote
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}
{"id":"test-2"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "remote commit")
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
// Reset local to previous commit
runGit(t, repoDir, "reset", "--hard", localHead)
localAhead, remoteAhead, err := getDivergence(ctx, repoDir, "test-branch", "origin")
if err != nil {
t.Fatalf("getDivergence() error = %v", err)
}
if localAhead != 0 || remoteAhead != 1 {
t.Errorf("getDivergence() = (%d, %d), want (0, 1)", localAhead, remoteAhead)
}
})
t.Run("diverged histories", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
runGit(t, repoDir, "checkout", "-b", "test-branch")
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "base commit")
// Save base commit
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Create local commit
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}
{"id":"local-2"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "local commit")
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Create remote commit from base
runGit(t, repoDir, "checkout", baseCommit)
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}
{"id":"remote-2"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "remote commit")
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
// Go back to local branch
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
localAhead, remoteAhead, err := getDivergence(ctx, repoDir, "test-branch", "origin")
if err != nil {
t.Fatalf("getDivergence() error = %v", err)
}
if localAhead != 1 || remoteAhead != 1 {
t.Errorf("getDivergence() = (%d, %d), want (1, 1)", localAhead, remoteAhead)
}
})
}
// TestExtractJSONLFromCommit tests extracting JSONL content from git commits
func TestExtractJSONLFromCommit(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
t.Run("extracts file from HEAD", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
content := `{"id":"test-1","title":"Test Issue"}`
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), content)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "test commit")
extracted, err := extractJSONLFromCommit(ctx, repoDir, "HEAD", ".beads/issues.jsonl")
if err != nil {
t.Fatalf("extractJSONLFromCommit() error = %v", err)
}
if strings.TrimSpace(string(extracted)) != content {
t.Errorf("extractJSONLFromCommit() = %q, want %q", extracted, content)
}
})
t.Run("extracts file from specific commit", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
// First commit
content1 := `{"id":"test-1"}`
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), content1)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "first commit")
firstCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Second commit
content2 := `{"id":"test-1"}
{"id":"test-2"}`
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), content2)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "second commit")
// Extract from first commit
extracted, err := extractJSONLFromCommit(ctx, repoDir, firstCommit, ".beads/issues.jsonl")
if err != nil {
t.Fatalf("extractJSONLFromCommit() error = %v", err)
}
if strings.TrimSpace(string(extracted)) != content1 {
t.Errorf("extractJSONLFromCommit() = %q, want %q", extracted, content1)
}
})
t.Run("returns error for nonexistent file", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
writeFile(t, filepath.Join(repoDir, "dummy.txt"), "dummy")
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "test commit")
_, err := extractJSONLFromCommit(ctx, repoDir, "HEAD", ".beads/issues.jsonl")
if err == nil {
t.Error("extractJSONLFromCommit() expected error for nonexistent file")
}
})
}
// TestPerformContentMerge tests the content-based merge function
func TestPerformContentMerge(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
t.Run("merges diverged content", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
runGit(t, repoDir, "checkout", "-b", "test-branch")
// Base content
baseContent := `{"id":"test-1","title":"Base","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), baseContent)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "base commit")
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Create local changes (add issue)
localContent := `{"id":"test-1","title":"Base","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
{"id":"local-1","title":"Local Issue","created_at":"2024-01-02T00:00:00Z","created_by":"user1"}`
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), localContent)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "local commit")
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Create remote changes from base (add different issue)
runGit(t, repoDir, "checkout", baseCommit)
remoteContent := `{"id":"test-1","title":"Base","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
{"id":"remote-1","title":"Remote Issue","created_at":"2024-01-02T00:00:00Z","created_by":"user2"}`
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), remoteContent)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "remote commit")
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
// Go back to local
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
// Perform merge
merged, err := performContentMerge(ctx, repoDir, "test-branch", "origin", ".beads/issues.jsonl")
if err != nil {
t.Fatalf("performContentMerge() error = %v", err)
}
// Check that merged content contains all three issues
mergedStr := string(merged)
if !strings.Contains(mergedStr, "test-1") {
t.Error("merged content missing base issue test-1")
}
if !strings.Contains(mergedStr, "local-1") {
t.Error("merged content missing local issue local-1")
}
if !strings.Contains(mergedStr, "remote-1") {
t.Error("merged content missing remote issue remote-1")
}
})
t.Run("handles deletion correctly", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
runGit(t, repoDir, "checkout", "-b", "test-branch")
// Base content with two issues
baseContent := `{"id":"test-1","title":"Issue 1","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
{"id":"test-2","title":"Issue 2","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), baseContent)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "base commit")
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Local keeps both
localHead := baseCommit
// Remote deletes test-2
runGit(t, repoDir, "checkout", baseCommit)
remoteContent := `{"id":"test-1","title":"Issue 1","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), remoteContent)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "remote delete")
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
// Go back to local
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
// Perform merge
merged, err := performContentMerge(ctx, repoDir, "test-branch", "origin", ".beads/issues.jsonl")
if err != nil {
t.Fatalf("performContentMerge() error = %v", err)
}
// Deletion should win - test-2 should be gone
mergedStr := string(merged)
if !strings.Contains(mergedStr, "test-1") {
t.Error("merged content missing issue test-1")
}
if strings.Contains(mergedStr, "test-2") {
t.Error("merged content still contains deleted issue test-2")
}
})
}
// TestPerformDeletionsMerge tests the deletions merge function
func TestPerformDeletionsMerge(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
t.Run("merges deletions from both sides", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
runGit(t, repoDir, "checkout", "-b", "test-branch")
// Base: no deletions
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "base commit")
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Local: delete issue-A
writeFile(t, filepath.Join(repoDir, ".beads", "deletions.jsonl"), `{"id":"issue-A","deleted_at":"2024-01-01T00:00:00Z"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "local deletion")
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Remote: delete issue-B
runGit(t, repoDir, "checkout", baseCommit)
writeFile(t, filepath.Join(repoDir, ".beads", "deletions.jsonl"), `{"id":"issue-B","deleted_at":"2024-01-02T00:00:00Z"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "remote deletion")
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
// Go back to local
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
// Perform merge
merged, err := performDeletionsMerge(ctx, repoDir, "test-branch", "origin", ".beads/deletions.jsonl")
if err != nil {
t.Fatalf("performDeletionsMerge() error = %v", err)
}
// Both deletions should be present
mergedStr := string(merged)
if !strings.Contains(mergedStr, "issue-A") {
t.Error("merged deletions missing issue-A")
}
if !strings.Contains(mergedStr, "issue-B") {
t.Error("merged deletions missing issue-B")
}
})
t.Run("handles only local deletions", func(t *testing.T) {
repoDir := setupTestRepo(t)
defer os.RemoveAll(repoDir)
runGit(t, repoDir, "checkout", "-b", "test-branch")
// Base: no deletions
writeFile(t, filepath.Join(repoDir, ".beads", "issues.jsonl"), `{"id":"test-1"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "base commit")
baseCommit := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Local: has deletions
writeFile(t, filepath.Join(repoDir, ".beads", "deletions.jsonl"), `{"id":"issue-A"}`)
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-m", "local deletion")
localHead := strings.TrimSpace(getGitOutput(t, repoDir, "rev-parse", "HEAD"))
// Remote: no deletions file
runGit(t, repoDir, "checkout", baseCommit)
runGit(t, repoDir, "update-ref", "refs/remotes/origin/test-branch", "HEAD")
// Go back to local
runGit(t, repoDir, "checkout", "-B", "test-branch", localHead)
// Perform merge
merged, err := performDeletionsMerge(ctx, repoDir, "test-branch", "origin", ".beads/deletions.jsonl")
if err != nil {
t.Fatalf("performDeletionsMerge() error = %v", err)
}
// Local deletions should be present
if !strings.Contains(string(merged), "issue-A") {
t.Error("merged deletions missing issue-A")
}
})
}
// Helper functions
func setupTestRepo(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 .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)
}
return tmpDir
}
func runGit(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)
}
}
func getGitOutput(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
t.Fatalf("git %v failed: %v", args, err)
}
return string(output)
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0750); err != nil {
t.Fatalf("Failed to create directory %s: %v", dir, err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write file %s: %v", path, err)
}
}
// TestCountIssuesInContent tests the issue counting helper function (bd-7ch)
func TestCountIssuesInContent(t *testing.T) {
tests := []struct {
name string
content []byte
want int
}{
{
name: "empty content",
content: []byte{},
want: 0,
},
{
name: "nil content",
content: nil,
want: 0,
},
{
name: "single issue",
content: []byte(`{"id":"test-1"}`),
want: 1,
},
{
name: "multiple issues",
content: []byte(`{"id":"test-1"}` + "\n" + `{"id":"test-2"}` + "\n" + `{"id":"test-3"}`),
want: 3,
},
{
name: "trailing newline",
content: []byte(`{"id":"test-1"}` + "\n" + `{"id":"test-2"}` + "\n"),
want: 2,
},
{
name: "empty lines ignored",
content: []byte(`{"id":"test-1"}` + "\n" + "\n" + `{"id":"test-2"}` + "\n" + " " + "\n"),
want: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := countIssuesInContent(tt.content)
if got != tt.want {
t.Errorf("countIssuesInContent() = %d, want %d", got, tt.want)
}
})
}
}