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>
This commit is contained in:
Steve Yegge
2025-12-02 20:57:53 -08:00
parent 7f13623683
commit c93b755344
3 changed files with 105 additions and 2 deletions

View File

@@ -378,7 +378,7 @@ Use --merge to merge the sync branch back to main branch.`,
if useSyncBranch {
// Pull from sync branch via worktree (bd-e3w)
fmt.Printf("→ Pulling from sync branch '%s'...\n", syncBranchName)
pullResult, err := syncbranch.PullFromSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath)
pullResult, err := syncbranch.PullFromSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush)
if err != nil {
fmt.Fprintf(os.Stderr, "Error pulling from sync branch: %v\n", err)
os.Exit(1)
@@ -387,6 +387,11 @@ Use --merge to merge the sync branch back to main branch.`,
if pullResult.Merged {
// bd-3s8 fix: divergent histories were merged at content level
fmt.Printf("✓ Merged divergent histories from %s\n", syncBranchName)
// bd-7ch: auto-push after merge
if pullResult.Pushed {
fmt.Printf("✓ Pushed merged changes to %s\n", syncBranchName)
pushedViaSyncBranch = true
}
} else if pullResult.FastForwarded {
fmt.Printf("✓ Fast-forwarded from %s\n", syncBranchName)
} else {

View File

@@ -29,6 +29,7 @@ type PullResult struct {
JSONLPath string // Path to the synced JSONL in main repo
Merged bool // True if divergent histories were merged
FastForwarded bool // True if fast-forward was possible
Pushed bool // True if changes were pushed after merge (bd-7ch)
}
// CommitToSyncBranch commits JSONL changes to the sync branch using a git worktree.
@@ -182,6 +183,10 @@ func preemptiveFetchAndFastForward(ctx context.Context, worktreePath, branch, re
// 5. Reset to remote's history (adopt remote commit graph)
// 6. Commit merged content on top
//
// IMPORTANT (bd-7ch): After successful content merge, auto-pushes to remote by default.
// Includes safety check: warns (but doesn't block) if >50% issues vanished AND >5 existed.
// "Vanished" means removed from issues.jsonl entirely, NOT status=closed.
//
// This ensures sync never fails due to git merge conflicts, as we handle merging at the
// JSONL content level where we have semantic understanding of the data.
//
@@ -190,9 +195,10 @@ func preemptiveFetchAndFastForward(ctx context.Context, worktreePath, branch, re
// - repoRoot: Path to the git repository root
// - syncBranch: Name of the sync branch (e.g., "beads-sync")
// - jsonlPath: Absolute path to the JSONL file in the main repo
// - push: If true, push to remote after merge (bd-7ch)
//
// Returns PullResult with details about what was done, or error if failed.
func PullFromSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath string) (*PullResult, error) {
func PullFromSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath string, push bool) (*PullResult, error) {
result := &PullResult{
Branch: syncBranch,
JSONLPath: jsonlPath,
@@ -271,6 +277,9 @@ func PullFromSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath str
// 3. Reset to remote's commit history
// 4. Commit merged content on top
// bd-7ch: Extract local content before merge for safety check
localContent, _ := extractJSONLFromCommit(ctx, worktreePath, "HEAD", jsonlRelPath)
mergedContent, err := performContentMerge(ctx, worktreePath, syncBranch, remote, jsonlRelPath)
if err != nil {
return nil, fmt.Errorf("content merge failed: %w", err)
@@ -330,6 +339,31 @@ func PullFromSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath str
return nil, err
}
// bd-7ch: Auto-push after successful content merge
if push && hasChanges {
// Safety check: count issues before and after merge to detect mass deletion
localCount := countIssuesInContent(localContent)
mergedCount := countIssuesInContent(mergedContent)
// Warn if >50% issues vanished AND >5 existed before
// "Vanished" = removed from JSONL entirely (not status=closed)
if localCount > 5 && mergedCount < localCount {
vanishedPercent := float64(localCount-mergedCount) / float64(localCount) * 100
if vanishedPercent > 50 {
fmt.Fprintf(os.Stderr, "⚠️ Warning: %.0f%% of issues vanished during merge (%d → %d issues)\n",
vanishedPercent, localCount, mergedCount)
fmt.Fprintf(os.Stderr, " This may indicate accidental mass deletion. Pushing anyway.\n")
fmt.Fprintf(os.Stderr, " If this was unintended, use 'git reflog' on the sync branch to recover.\n")
}
}
// Push regardless of safety check (don't block happy path)
if err := pushFromWorktree(ctx, worktreePath, syncBranch); err != nil {
return nil, fmt.Errorf("failed to push after merge: %w", err)
}
result.Pushed = true
}
return result, nil
}
@@ -636,6 +670,21 @@ func GetRepoRoot(ctx context.Context) (string, error) {
return strings.TrimSpace(string(output)), nil
}
// countIssuesInContent counts the number of non-empty lines in JSONL content.
// Each non-empty line represents one issue. Used for safety checks (bd-7ch).
func countIssuesInContent(content []byte) int {
if len(content) == 0 {
return 0
}
count := 0
for _, line := range strings.Split(string(content), "\n") {
if strings.TrimSpace(line) != "" {
count++
}
}
return count
}
// HasGitRemote checks if any git remote exists
func HasGitRemote(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "git", "remote")

View File

@@ -458,3 +458,52 @@ func writeFile(t *testing.T, path, content string) {
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)
}
})
}
}