This implements sync branch integrity guards that detect when the remote sync branch has been force-pushed since the last sync, preventing silent data corruption. Changes: - Add internal/syncbranch/integrity.go with: - CheckForcePush() - detects force-push via stored remote SHA comparison - UpdateStoredRemoteSHA() - stores current remote SHA after successful sync - ClearStoredRemoteSHA() - clears stored SHA when resetting - GetStoredRemoteSHA() - retrieves stored SHA for inspection - Update cmd/bd/sync.go to: - Add --accept-rebase flag for non-interactive reset to remote - Add force-push detection before sync branch pull operations - Prompt user for confirmation when force-push detected - Update stored remote SHA after successful sync The implementation: 1. Tracks the remote sync branch commit SHA in config after each sync 2. On subsequent syncs, checks if stored SHA is ancestor of current remote 3. If not (force-push detected), warns user with details and prompts 4. User can accept reset or abort to investigate manually 5. --accept-rebase flag allows scripted/non-interactive recovery 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
133 lines
3.6 KiB
Go
133 lines
3.6 KiB
Go
package syncbranch
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
)
|
|
|
|
func TestGetStoredRemoteSHA(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
defer store.Close()
|
|
|
|
// Test getting SHA when not set
|
|
sha, err := GetStoredRemoteSHA(ctx, store)
|
|
if err != nil {
|
|
t.Fatalf("GetStoredRemoteSHA() error = %v", err)
|
|
}
|
|
if sha != "" {
|
|
t.Errorf("GetStoredRemoteSHA() = %q, want empty string", sha)
|
|
}
|
|
|
|
// Set a SHA
|
|
testSHA := "abc123def456"
|
|
if err := store.SetConfig(ctx, RemoteSHAConfigKey, testSHA); err != nil {
|
|
t.Fatalf("SetConfig() error = %v", err)
|
|
}
|
|
|
|
// Test getting SHA when set
|
|
sha, err = GetStoredRemoteSHA(ctx, store)
|
|
if err != nil {
|
|
t.Fatalf("GetStoredRemoteSHA() error = %v", err)
|
|
}
|
|
if sha != testSHA {
|
|
t.Errorf("GetStoredRemoteSHA() = %q, want %q", sha, testSHA)
|
|
}
|
|
}
|
|
|
|
func TestClearStoredRemoteSHA(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
defer store.Close()
|
|
|
|
// Set a SHA first
|
|
testSHA := "abc123def456"
|
|
if err := store.SetConfig(ctx, RemoteSHAConfigKey, testSHA); err != nil {
|
|
t.Fatalf("SetConfig() error = %v", err)
|
|
}
|
|
|
|
// Clear it
|
|
if err := ClearStoredRemoteSHA(ctx, store); err != nil {
|
|
t.Fatalf("ClearStoredRemoteSHA() error = %v", err)
|
|
}
|
|
|
|
// Verify it's gone
|
|
sha, err := GetStoredRemoteSHA(ctx, store)
|
|
if err != nil {
|
|
t.Fatalf("GetStoredRemoteSHA() error = %v", err)
|
|
}
|
|
if sha != "" {
|
|
t.Errorf("SHA should be empty after clear, got %q", sha)
|
|
}
|
|
}
|
|
|
|
func TestForcePushStatus(t *testing.T) {
|
|
// Test ForcePushStatus struct
|
|
status := &ForcePushStatus{
|
|
Detected: true,
|
|
StoredSHA: "abc123",
|
|
CurrentRemoteSHA: "def456",
|
|
Message: "Force push detected",
|
|
Branch: "beads-sync",
|
|
Remote: "origin",
|
|
}
|
|
|
|
if !status.Detected {
|
|
t.Error("Expected Detected to be true")
|
|
}
|
|
if status.StoredSHA != "abc123" {
|
|
t.Errorf("StoredSHA = %q, want 'abc123'", status.StoredSHA)
|
|
}
|
|
if status.CurrentRemoteSHA != "def456" {
|
|
t.Errorf("CurrentRemoteSHA = %q, want 'def456'", status.CurrentRemoteSHA)
|
|
}
|
|
}
|
|
|
|
// newTestStoreIntegrity creates a test store for integrity tests
|
|
// Note: This is a duplicate of newTestStore from syncbranch_test.go
|
|
// but we need it here since tests are in the same package
|
|
func newTestStoreIntegrity(t *testing.T) *sqlite.SQLiteStorage {
|
|
t.Helper()
|
|
store, err := sqlite.New(context.Background(), "file::memory:?mode=memory&cache=private")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test database: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
_ = store.Close()
|
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func TestCheckForcePush_NoStoredSHA(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newTestStoreIntegrity(t)
|
|
defer store.Close()
|
|
|
|
// When no stored SHA exists, CheckForcePush should return "first sync" status
|
|
// Note: We can't fully test this without a git repo, but we can test the early return
|
|
status, err := CheckForcePush(ctx, store, "/nonexistent", "beads-sync")
|
|
if err != nil {
|
|
t.Fatalf("CheckForcePush() error = %v", err)
|
|
}
|
|
if status.Detected {
|
|
t.Error("Expected Detected to be false when no stored SHA")
|
|
}
|
|
if status.StoredSHA != "" {
|
|
t.Errorf("StoredSHA = %q, want empty", status.StoredSHA)
|
|
}
|
|
if status.Message != "No previous sync recorded (first sync)" {
|
|
t.Errorf("Message = %q, want 'No previous sync recorded (first sync)'", status.Message)
|
|
}
|
|
}
|
|
|
|
func TestRemoteSHAConfigKey(t *testing.T) {
|
|
// Verify the config key is what we expect
|
|
if RemoteSHAConfigKey != "sync.remote_sha" {
|
|
t.Errorf("RemoteSHAConfigKey = %q, want 'sync.remote_sha'", RemoteSHAConfigKey)
|
|
}
|
|
}
|