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
|
### Workflow
|
||||||
|
|
||||||
|
#### Manual Workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create/update issues - they auto-export after 5 seconds
|
# Create/update issues - they auto-export after 5 seconds
|
||||||
bd create "Fix bug" -p 1
|
bd create "Fix bug" -p 1
|
||||||
@@ -896,6 +898,37 @@ git pull
|
|||||||
bd ready # Automatically imports first, then shows ready work
|
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
|
### Optional: Git Hooks for Immediate Sync
|
||||||
|
|
||||||
Create `.git/hooks/pre-commit`:
|
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