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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user