chore: merge and sync issues
This commit is contained in:
File diff suppressed because one or more lines are too long
33
README.md
33
README.md
@@ -881,6 +881,8 @@ Add to git:
|
||||
|
||||
### Workflow
|
||||
|
||||
#### Manual Workflow
|
||||
|
||||
```bash
|
||||
# Create/update issues - they auto-export after 5 seconds
|
||||
bd create "Fix bug" -p 1
|
||||
@@ -896,6 +898,37 @@ git pull
|
||||
bd ready # Automatically imports first, then shows ready work
|
||||
```
|
||||
|
||||
#### Automatic Sync with `bd sync`
|
||||
|
||||
For multi-device workflows, use `bd sync` to automate the entire sync process:
|
||||
|
||||
```bash
|
||||
# Make your changes
|
||||
bd create "Fix auth bug" -p 1
|
||||
bd update bd-42 --status in_progress
|
||||
|
||||
# Sync everything in one command
|
||||
bd sync -m "Update issues"
|
||||
|
||||
# This does:
|
||||
# 1. Exports pending changes to JSONL
|
||||
# 2. Commits to git
|
||||
# 3. Pulls from remote (auto-resolves collisions)
|
||||
# 4. Imports updated JSONL
|
||||
# 5. Pushes to remote
|
||||
```
|
||||
|
||||
Options:
|
||||
```bash
|
||||
bd sync # Auto-generated commit message
|
||||
bd sync -m "Custom message" # Custom commit message
|
||||
bd sync --dry-run # Preview without changes
|
||||
bd sync --no-pull # Skip pulling from remote
|
||||
bd sync --no-push # Commit but don't push
|
||||
```
|
||||
|
||||
The `bd sync` command automatically resolves ID collisions using the same logic as `bd import --resolve-collisions`, making it safe for concurrent updates from multiple devices.
|
||||
|
||||
### Optional: Git Hooks for Immediate Sync
|
||||
|
||||
Create `.git/hooks/pre-commit`:
|
||||
|
||||
273
cmd/bd/sync.go
Normal file
273
cmd/bd/sync.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
var syncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Synchronize issues with git remote",
|
||||
Long: `Synchronize issues with git remote in a single operation:
|
||||
1. Export pending changes to JSONL
|
||||
2. Commit changes to git
|
||||
3. Pull from remote (with conflict resolution)
|
||||
4. Import updated JSONL
|
||||
5. Push local commits to remote
|
||||
|
||||
This command wraps the entire git-based sync workflow for multi-device use.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := context.Background()
|
||||
|
||||
message, _ := cmd.Flags().GetString("message")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
noPush, _ := cmd.Flags().GetBool("no-push")
|
||||
noPull, _ := cmd.Flags().GetBool("no-pull")
|
||||
|
||||
// Find JSONL path
|
||||
jsonlPath := findJSONLPath()
|
||||
if jsonlPath == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: not in a bd workspace (no .beads directory found)\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check if we're in a git repository
|
||||
if !isGitRepo() {
|
||||
fmt.Fprintf(os.Stderr, "Error: not in a git repository\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 1: Export pending changes
|
||||
fmt.Println("→ Exporting pending changes to JSONL...")
|
||||
if err := exportToJSONL(ctx, jsonlPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 2: Check if there are changes to commit
|
||||
hasChanges, err := gitHasChanges(jsonlPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error checking git status: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
if dryRun {
|
||||
fmt.Println("→ [DRY RUN] Would commit changes to git")
|
||||
} else {
|
||||
fmt.Println("→ Committing changes to git...")
|
||||
if err := gitCommit(jsonlPath, message); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error committing: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("→ No changes to commit")
|
||||
}
|
||||
|
||||
// Step 3: Pull from remote
|
||||
if !noPull {
|
||||
if dryRun {
|
||||
fmt.Println("→ [DRY RUN] Would pull from remote")
|
||||
} else {
|
||||
fmt.Println("→ Pulling from remote...")
|
||||
if err := gitPull(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 4: Import updated JSONL after pull
|
||||
fmt.Println("→ Importing updated JSONL...")
|
||||
if err := importFromJSONL(ctx, jsonlPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error importing: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Push to remote
|
||||
if !noPush && hasChanges {
|
||||
if dryRun {
|
||||
fmt.Println("→ [DRY RUN] Would push to remote")
|
||||
} else {
|
||||
fmt.Println("→ Pushing to remote...")
|
||||
if err := gitPush(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error pushing: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Hint: pull may have brought new changes, run 'bd sync' again\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("\n✓ Dry run complete (no changes made)")
|
||||
} else {
|
||||
fmt.Println("\n✓ Sync complete")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
syncCmd.Flags().StringP("message", "m", "", "Commit message (default: auto-generated)")
|
||||
syncCmd.Flags().Bool("dry-run", false, "Preview sync without making changes")
|
||||
syncCmd.Flags().Bool("no-push", false, "Skip pushing to remote")
|
||||
syncCmd.Flags().Bool("no-pull", false, "Skip pulling from remote")
|
||||
rootCmd.AddCommand(syncCmd)
|
||||
}
|
||||
|
||||
// isGitRepo checks if the current directory is in a git repository
|
||||
func isGitRepo() bool {
|
||||
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// gitHasChanges checks if the specified file has uncommitted changes
|
||||
func gitHasChanges(filePath string) (bool, error) {
|
||||
cmd := exec.Command("git", "status", "--porcelain", filePath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status failed: %w", err)
|
||||
}
|
||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||
}
|
||||
|
||||
// gitCommit commits the specified file
|
||||
func gitCommit(filePath string, message string) error {
|
||||
// Stage the file
|
||||
addCmd := exec.Command("git", "add", filePath)
|
||||
if err := addCmd.Run(); err != nil {
|
||||
return fmt.Errorf("git add failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate message if not provided
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// Commit
|
||||
commitCmd := exec.Command("git", "commit", "-m", message)
|
||||
output, err := commitCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git commit failed: %w\n%s", err, output)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitPull pulls from the current branch's upstream
|
||||
func gitPull() error {
|
||||
cmd := exec.Command("git", "pull")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git pull failed: %w\n%s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitPush pushes to the current branch's upstream
|
||||
func gitPush() error {
|
||||
cmd := exec.Command("git", "push")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git push failed: %w\n%s", err, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportToJSONL exports the database to JSONL format
|
||||
func exportToJSONL(ctx context.Context, jsonlPath string) error {
|
||||
// Get all issues
|
||||
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issues: %w", err)
|
||||
}
|
||||
|
||||
// Sort by ID for consistent output
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
return issues[i].ID < issues[j].ID
|
||||
})
|
||||
|
||||
// Populate dependencies for all issues (avoid N+1)
|
||||
allDeps, err := store.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get dependencies: %w", err)
|
||||
}
|
||||
for _, issue := range issues {
|
||||
issue.Dependencies = allDeps[issue.ID]
|
||||
}
|
||||
|
||||
// Populate labels for all issues
|
||||
for _, issue := range issues {
|
||||
labels, err := store.GetLabels(ctx, issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get labels for %s: %w", issue.ID, err)
|
||||
}
|
||||
issue.Labels = labels
|
||||
}
|
||||
|
||||
// Create temp file for atomic write
|
||||
dir := filepath.Dir(jsonlPath)
|
||||
base := filepath.Base(jsonlPath)
|
||||
tempFile, err := os.CreateTemp(dir, base+".tmp.*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
defer func() {
|
||||
tempFile.Close()
|
||||
os.Remove(tempPath)
|
||||
}()
|
||||
|
||||
// Write JSONL
|
||||
encoder := json.NewEncoder(tempFile)
|
||||
exportedIDs := make([]string, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
if err := encoder.Encode(issue); err != nil {
|
||||
return fmt.Errorf("failed to encode issue %s: %w", issue.ID, err)
|
||||
}
|
||||
exportedIDs = append(exportedIDs, issue.ID)
|
||||
}
|
||||
|
||||
// Close temp file before rename
|
||||
tempFile.Close()
|
||||
|
||||
// Atomic replace
|
||||
if err := os.Rename(tempPath, jsonlPath); err != nil {
|
||||
return fmt.Errorf("failed to replace JSONL file: %w", err)
|
||||
}
|
||||
|
||||
// Clear dirty flags for exported issues
|
||||
if err := store.ClearDirtyIssuesByID(ctx, exportedIDs); err != nil {
|
||||
// Non-fatal warning
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to clear dirty flags: %v\n", err)
|
||||
}
|
||||
|
||||
// Clear auto-flush state
|
||||
clearAutoFlushState()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// importFromJSONL imports the JSONL file by running the import command
|
||||
func importFromJSONL(ctx context.Context, jsonlPath string) error {
|
||||
// Run import command with --resolve-collisions to automatically handle conflicts
|
||||
cmd := exec.Command("./bd", "import", "-i", jsonlPath, "--resolve-collisions")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("import failed: %w\n%s", err, output)
|
||||
}
|
||||
// Suppress output unless there's an error
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user