Add sync branch integrity guards for force-push detection (bd-hlsw.4)

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>
This commit is contained in:
Steve Yegge
2025-12-30 17:06:01 -08:00
parent 6df2d69bbf
commit def4cf4efa
3 changed files with 401 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
// Package syncbranch provides sync branch configuration and integrity checking.
package syncbranch
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/steveyegge/beads/internal/storage"
)
// Config keys for sync branch integrity tracking
const (
// RemoteSHAConfigKey stores the last known remote sync branch commit SHA.
// This is used to detect force pushes on the remote sync branch.
RemoteSHAConfigKey = "sync.remote_sha"
)
// ForcePushStatus represents the result of a force-push detection check.
type ForcePushStatus struct {
// Detected is true if a force-push was detected on the remote sync branch.
Detected bool
// StoredSHA is the SHA we stored after the last successful sync.
StoredSHA string
// CurrentRemoteSHA is the current SHA of the remote sync branch.
CurrentRemoteSHA string
// Message provides a human-readable description of the status.
Message string
// Branch is the sync branch name.
Branch string
// Remote is the remote name (e.g., "origin").
Remote string
}
// CheckForcePush detects if the remote sync branch has been force-pushed since the last sync.
//
// A force-push is detected when:
// 1. We have a stored remote SHA from a previous sync
// 2. The stored SHA is NOT an ancestor of the current remote SHA
//
// This means the remote history was rewritten (e.g., via force-push, rebase).
//
// Parameters:
// - ctx: Context for cancellation
// - store: Storage interface for reading config
// - repoRoot: Path to the git repository root
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
//
// Returns ForcePushStatus with details about the check.
func CheckForcePush(ctx context.Context, store storage.Storage, repoRoot, syncBranch string) (*ForcePushStatus, error) {
status := &ForcePushStatus{
Detected: false,
Branch: syncBranch,
}
// Get stored remote SHA from last sync
storedSHA, err := store.GetConfig(ctx, RemoteSHAConfigKey)
if err != nil {
return nil, fmt.Errorf("failed to get stored remote SHA: %w", err)
}
status.StoredSHA = storedSHA
if storedSHA == "" {
status.Message = "No previous sync recorded (first sync)"
return status, nil
}
// Get worktree path for git operations
worktreePath := getBeadsWorktreePath(ctx, repoRoot, syncBranch)
// Get remote name
status.Remote = getRemoteForBranch(ctx, worktreePath, syncBranch)
// Fetch from remote to get latest state
fetchCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "fetch", status.Remote, syncBranch)
fetchOutput, err := fetchCmd.CombinedOutput()
if err != nil {
// Check if remote branch doesn't exist
if strings.Contains(string(fetchOutput), "couldn't find remote ref") {
status.Message = "Remote sync branch does not exist"
return status, nil
}
return nil, fmt.Errorf("failed to fetch remote: %w", err)
}
// Get current remote SHA
remoteRef := fmt.Sprintf("%s/%s", status.Remote, syncBranch)
revParseCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "rev-parse", remoteRef)
revParseOutput, err := revParseCmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get remote SHA: %w", err)
}
status.CurrentRemoteSHA = strings.TrimSpace(string(revParseOutput))
// If SHA matches, no change at all
if storedSHA == status.CurrentRemoteSHA {
status.Message = "Remote sync branch unchanged since last sync"
return status, nil
}
// Check if stored SHA is an ancestor of current remote SHA
// This means remote was updated normally (fast-forward)
isAncestorCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "merge-base", "--is-ancestor", storedSHA, status.CurrentRemoteSHA)
if isAncestorCmd.Run() == nil {
// Stored SHA is ancestor - normal update, no force-push
status.Message = "Remote sync branch updated normally (fast-forward)"
return status, nil
}
// Stored SHA is NOT an ancestor - this indicates a force-push or rebase
status.Detected = true
status.Message = fmt.Sprintf(
"FORCE-PUSH DETECTED: Remote sync branch history was rewritten.\n"+
" Previous known commit: %s\n"+
" Current remote commit: %s\n"+
" The remote history no longer contains your previously synced commit.\n"+
" This typically happens when someone force-pushed or rebased the sync branch.",
storedSHA[:8], status.CurrentRemoteSHA[:8])
return status, nil
}
// UpdateStoredRemoteSHA stores the current remote sync branch SHA in the database.
// Call this after a successful sync to track the remote state.
//
// Parameters:
// - ctx: Context for cancellation
// - store: Storage interface for writing config
// - repoRoot: Path to the git repository root
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
//
// Returns error if the update fails.
func UpdateStoredRemoteSHA(ctx context.Context, store storage.Storage, repoRoot, syncBranch string) error {
// Get worktree path for git operations
worktreePath := getBeadsWorktreePath(ctx, repoRoot, syncBranch)
// Get remote name
remote := getRemoteForBranch(ctx, worktreePath, syncBranch)
// Get current remote SHA
remoteRef := fmt.Sprintf("%s/%s", remote, syncBranch)
revParseCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "rev-parse", remoteRef)
revParseOutput, err := revParseCmd.Output()
if err != nil {
// Remote branch might not exist yet (first push)
// Try local branch instead
revParseCmd = exec.CommandContext(ctx, "git", "-C", repoRoot, "rev-parse", syncBranch)
revParseOutput, err = revParseCmd.Output()
if err != nil {
return fmt.Errorf("failed to get sync branch SHA: %w", err)
}
}
currentSHA := strings.TrimSpace(string(revParseOutput))
// Store the SHA
if err := store.SetConfig(ctx, RemoteSHAConfigKey, currentSHA); err != nil {
return fmt.Errorf("failed to store remote SHA: %w", err)
}
return nil
}
// ClearStoredRemoteSHA removes the stored remote SHA.
// Use this when resetting the sync state (e.g., after accepting a rebase).
func ClearStoredRemoteSHA(ctx context.Context, store storage.Storage) error {
return store.DeleteConfig(ctx, RemoteSHAConfigKey)
}
// GetStoredRemoteSHA returns the stored remote sync branch SHA.
func GetStoredRemoteSHA(ctx context.Context, store storage.Storage) (string, error) {
return store.GetConfig(ctx, RemoteSHAConfigKey)
}

View File

@@ -0,0 +1,132 @@
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)
}
}