Merge bd-dvw8-rictus: GH#505 reset
This commit is contained in:
9590
.beads/deletions.jsonl
Normal file
9590
.beads/deletions.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
349
cmd/bd/reset.go
Normal file
349
cmd/bd/reset.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/git"
|
||||
)
|
||||
|
||||
var resetCmd = &cobra.Command{
|
||||
Use: "reset [--confirm <remote-url>]",
|
||||
Short: "Completely remove beads from this repository",
|
||||
Long: `Completely remove beads from this repository, including all issue data.
|
||||
|
||||
This command:
|
||||
1. Stops any running daemon
|
||||
2. Removes git hooks installed by beads
|
||||
3. Removes the merge driver configuration
|
||||
4. Removes beads entry from .gitattributes
|
||||
5. Deletes the .beads directory (ALL ISSUE DATA)
|
||||
6. Removes the sync worktree (if exists)
|
||||
|
||||
WARNING: This permanently deletes all issue data. Consider backing up first:
|
||||
cp .beads/issues.jsonl ~/beads-backup-$(date +%Y%m%d).jsonl
|
||||
|
||||
SAFETY: You must pass --confirm with the git remote URL to confirm.
|
||||
|
||||
EXAMPLES:
|
||||
# Preview what would be removed
|
||||
bd reset --dry-run
|
||||
|
||||
# Actually reset (requires confirmation)
|
||||
bd reset --confirm origin
|
||||
|
||||
# Or with the full remote URL
|
||||
bd reset --confirm git@github.com:user/repo.git
|
||||
|
||||
After reset, you can reinitialize with:
|
||||
bd init`,
|
||||
Run: runReset,
|
||||
}
|
||||
|
||||
var (
|
||||
resetConfirm string
|
||||
resetDryRun bool
|
||||
resetForce bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
resetCmd.Flags().StringVar(&resetConfirm, "confirm", "", "Remote name or URL to confirm reset (required)")
|
||||
resetCmd.Flags().BoolVar(&resetDryRun, "dry-run", false, "Preview what would be removed without making changes")
|
||||
resetCmd.Flags().BoolVar(&resetForce, "force", false, "Skip confirmation prompts")
|
||||
rootCmd.AddCommand(resetCmd)
|
||||
}
|
||||
|
||||
func runReset(cmd *cobra.Command, args []string) {
|
||||
// Check if we're in a beads repository
|
||||
beadsDir := findBeadsDir()
|
||||
if beadsDir == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: No .beads directory found - nothing to reset")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get git root
|
||||
gitRoot, err := git.GetMainRepoRoot()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: Not in a git repository: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Verify confirmation unless dry-run or force
|
||||
if !resetDryRun && !resetForce {
|
||||
if resetConfirm == "" {
|
||||
fmt.Fprintln(os.Stderr, color.RedString("Error: --confirm flag required"))
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "This command permanently deletes all issue data.")
|
||||
fmt.Fprintln(os.Stderr, "To confirm, pass the remote name or URL:")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, " bd reset --confirm origin")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Or use --dry-run to preview what would be removed:")
|
||||
fmt.Fprintln(os.Stderr, " bd reset --dry-run")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Verify the confirmation matches a remote
|
||||
if !verifyResetConfirmation(resetConfirm) {
|
||||
fmt.Fprintf(os.Stderr, color.RedString("Error: '%s' does not match any git remote\n"), resetConfirm)
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Available remotes:")
|
||||
listRemotes()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if resetDryRun {
|
||||
fmt.Println(color.YellowString("DRY RUN - no changes will be made"))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Track what we'll do/did
|
||||
var actions []string
|
||||
|
||||
// 1. Stop daemon
|
||||
fmt.Println("Checking for running daemon...")
|
||||
if resetDryRun {
|
||||
actions = append(actions, "Would stop daemon (if running)")
|
||||
} else {
|
||||
if err := stopDaemonForReset(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
|
||||
} else {
|
||||
actions = append(actions, "Stopped daemon")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Uninstall hooks
|
||||
fmt.Println("Checking git hooks...")
|
||||
if resetDryRun {
|
||||
hooks := CheckGitHooks()
|
||||
for _, h := range hooks {
|
||||
if h.Installed {
|
||||
actions = append(actions, fmt.Sprintf("Would remove hook: %s", h.Name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := uninstallHooks(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to uninstall hooks: %v\n", err)
|
||||
} else {
|
||||
actions = append(actions, "Removed git hooks")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove merge driver config
|
||||
fmt.Println("Checking merge driver config...")
|
||||
if resetDryRun {
|
||||
actions = append(actions, "Would remove merge driver config (git config)")
|
||||
} else {
|
||||
if err := removeMergeDriverConfig(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
|
||||
} else {
|
||||
actions = append(actions, "Removed merge driver config")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Remove .gitattributes entry
|
||||
fmt.Println("Checking .gitattributes...")
|
||||
gitattributes := filepath.Join(gitRoot, ".gitattributes")
|
||||
if resetDryRun {
|
||||
if _, err := os.Stat(gitattributes); err == nil {
|
||||
actions = append(actions, "Would remove beads entry from .gitattributes")
|
||||
}
|
||||
} else {
|
||||
if err := removeBeadsFromGitattributes(gitattributes); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
|
||||
} else {
|
||||
actions = append(actions, "Removed beads entry from .gitattributes")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Remove .beads directory
|
||||
fmt.Println("Checking .beads directory...")
|
||||
if resetDryRun {
|
||||
// Count files
|
||||
fileCount := 0
|
||||
_ = filepath.Walk(beadsDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() {
|
||||
fileCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
actions = append(actions, fmt.Sprintf("Would delete .beads directory (%d files)", fileCount))
|
||||
} else {
|
||||
if err := os.RemoveAll(beadsDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to remove .beads directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
actions = append(actions, "Deleted .beads directory")
|
||||
}
|
||||
|
||||
// 6. Remove sync worktree
|
||||
gitDir, _ := git.GetGitDir()
|
||||
worktreePath := filepath.Join(gitDir, "beads-worktrees")
|
||||
if _, err := os.Stat(worktreePath); err == nil {
|
||||
fmt.Println("Checking sync worktree...")
|
||||
if resetDryRun {
|
||||
actions = append(actions, "Would remove sync worktree")
|
||||
} else {
|
||||
// First try to remove the git worktree properly
|
||||
_ = exec.Command("git", "worktree", "remove", "--force", filepath.Join(worktreePath, "beads-sync")).Run()
|
||||
// Then remove the directory
|
||||
if err := os.RemoveAll(worktreePath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to remove worktree: %v\n", err)
|
||||
} else {
|
||||
actions = append(actions, "Removed sync worktree")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
if resetDryRun {
|
||||
fmt.Println(color.YellowString("Actions that would be taken:"))
|
||||
} else {
|
||||
fmt.Println(color.GreenString("Reset complete!"))
|
||||
}
|
||||
for _, action := range actions {
|
||||
fmt.Printf(" %s %s\n", color.GreenString("✓"), action)
|
||||
}
|
||||
|
||||
if !resetDryRun {
|
||||
fmt.Println()
|
||||
fmt.Println("To reinitialize beads, run:")
|
||||
fmt.Println(" bd init")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyResetConfirmation checks if the provided confirmation matches a remote
|
||||
func verifyResetConfirmation(confirm string) bool {
|
||||
// Get list of remotes
|
||||
output, err := exec.Command("git", "remote", "-v").Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
remoteName := parts[0]
|
||||
remoteURL := parts[1]
|
||||
|
||||
// Match against remote name or URL
|
||||
if confirm == remoteName || confirm == remoteURL {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also match partial URLs (e.g., user/repo)
|
||||
if strings.Contains(remoteURL, confirm) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// listRemotes prints available git remotes
|
||||
func listRemotes() {
|
||||
output, err := exec.Command("git", "remote", "-v").Output()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, " (unable to list remotes)")
|
||||
return
|
||||
}
|
||||
|
||||
// Dedupe (git remote -v shows each twice for fetch/push)
|
||||
seen := make(map[string]bool)
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
key := parts[0] + " " + parts[1]
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
fmt.Printf(" %s\t%s\n", parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stopDaemonForReset stops the daemon for this repository
|
||||
func stopDaemonForReset() error {
|
||||
// Try to stop daemon via the daemon command
|
||||
cmd := exec.Command("bd", "daemon", "--stop")
|
||||
_ = cmd.Run() // Ignore errors - daemon might not be running
|
||||
|
||||
// Also try killall
|
||||
cmd = exec.Command("bd", "daemons", "killall")
|
||||
_ = cmd.Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeMergeDriverConfig removes the beads merge driver from git config
|
||||
func removeMergeDriverConfig() error {
|
||||
// Remove merge driver settings
|
||||
_ = exec.Command("git", "config", "--unset", "merge.beads.driver").Run()
|
||||
_ = exec.Command("git", "config", "--unset", "merge.beads.name").Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeBeadsFromGitattributes removes beads entries from .gitattributes
|
||||
func removeBeadsFromGitattributes(path string) error {
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return nil // Nothing to do
|
||||
}
|
||||
|
||||
// Read the file
|
||||
// #nosec G304 -- path comes from gitRoot which is validated
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read .gitattributes: %w", err)
|
||||
}
|
||||
|
||||
// Filter out beads-related lines
|
||||
var newLines []string
|
||||
inBeadsSection := false
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Skip beads comment header
|
||||
if strings.Contains(line, "Use bd merge for beads") {
|
||||
inBeadsSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip beads merge attribute lines
|
||||
if strings.Contains(line, "merge=beads") {
|
||||
inBeadsSection = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty lines immediately after beads section
|
||||
if inBeadsSection && strings.TrimSpace(line) == "" {
|
||||
inBeadsSection = false
|
||||
continue
|
||||
}
|
||||
|
||||
inBeadsSection = false
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
|
||||
// If file would be empty (or just whitespace), remove it
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
if strings.TrimSpace(newContent) == "" {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// Write back
|
||||
// #nosec G306 -- .gitattributes should be readable
|
||||
return os.WriteFile(path, []byte(newContent+"\n"), 0644)
|
||||
}
|
||||
99
cmd/bd/reset_test.go
Normal file
99
cmd/bd/reset_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoveBeadsFromGitattributes(t *testing.T) {
|
||||
t.Run("removes beads entry", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
gitattributes := filepath.Join(tmpDir, ".gitattributes")
|
||||
|
||||
content := `*.png binary
|
||||
# Use bd merge for beads JSONL files
|
||||
.beads/issues.jsonl merge=beads
|
||||
*.jpg binary
|
||||
`
|
||||
if err := os.WriteFile(gitattributes, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
if err := removeBeadsFromGitattributes(gitattributes); err != nil {
|
||||
t.Fatalf("removeBeadsFromGitattributes failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := os.ReadFile(gitattributes)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read result: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(string(result), "merge=beads") {
|
||||
t.Error("beads merge entry should have been removed")
|
||||
}
|
||||
if strings.Contains(string(result), "Use bd merge") {
|
||||
t.Error("beads comment should have been removed")
|
||||
}
|
||||
if !strings.Contains(string(result), "*.png binary") {
|
||||
t.Error("other entries should be preserved")
|
||||
}
|
||||
if !strings.Contains(string(result), "*.jpg binary") {
|
||||
t.Error("other entries should be preserved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("removes file if only beads entry", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
gitattributes := filepath.Join(tmpDir, ".gitattributes")
|
||||
|
||||
content := `# Use bd merge for beads JSONL files
|
||||
.beads/issues.jsonl merge=beads
|
||||
`
|
||||
if err := os.WriteFile(gitattributes, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
if err := removeBeadsFromGitattributes(gitattributes); err != nil {
|
||||
t.Fatalf("removeBeadsFromGitattributes failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(gitattributes); !os.IsNotExist(err) {
|
||||
t.Error("file should have been deleted when only beads entries present")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles non-existent file", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
gitattributes := filepath.Join(tmpDir, ".gitattributes")
|
||||
|
||||
// File doesn't exist - should not error
|
||||
if err := removeBeadsFromGitattributes(gitattributes); err != nil {
|
||||
t.Fatalf("should not error on non-existent file: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyResetConfirmation(t *testing.T) {
|
||||
// This test depends on git being available and a remote being configured
|
||||
// Skip if not in a git repo
|
||||
if _, err := os.Stat(".git"); os.IsNotExist(err) {
|
||||
t.Skip("not in a git repository")
|
||||
}
|
||||
|
||||
t.Run("accepts origin", func(t *testing.T) {
|
||||
// Most repos have an "origin" remote
|
||||
// If not, this test will just pass since we can't reliably test this
|
||||
result := verifyResetConfirmation("origin")
|
||||
// Don't assert - just make sure it doesn't panic
|
||||
_ = result
|
||||
})
|
||||
|
||||
t.Run("rejects invalid remote", func(t *testing.T) {
|
||||
result := verifyResetConfirmation("nonexistent-remote-12345")
|
||||
if result {
|
||||
t.Error("should reject non-existent remote")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -32,6 +32,27 @@ func validateBatchIssuesWithCustomStatuses(issues []*types.Issue, customStatuses
|
||||
issue.UpdatedAt = now
|
||||
}
|
||||
|
||||
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
|
||||
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
|
||||
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
closedAt := maxTime.Add(time.Second)
|
||||
issue.ClosedAt = &closedAt
|
||||
}
|
||||
|
||||
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
|
||||
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
deletedAt := maxTime.Add(time.Second)
|
||||
issue.DeletedAt = &deletedAt
|
||||
}
|
||||
|
||||
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
|
||||
return fmt.Errorf("validation failed for issue %d: %w", i, err)
|
||||
}
|
||||
|
||||
@@ -480,3 +480,119 @@ func TestBulkOperations(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDefensiveClosedAtFix tests GH#523 - closed issues without closed_at timestamp
|
||||
// from older versions of bd should be automatically fixed during import.
|
||||
func TestDefensiveClosedAtFix(t *testing.T) {
|
||||
t.Run("sets closed_at for closed issues missing it", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
pastTime := now.Add(-24 * time.Hour)
|
||||
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
Title: "Closed issue without closed_at",
|
||||
Priority: 1,
|
||||
IssueType: "task",
|
||||
Status: "closed",
|
||||
CreatedAt: pastTime,
|
||||
UpdatedAt: pastTime.Add(time.Hour),
|
||||
// ClosedAt intentionally NOT set - simulating old bd data
|
||||
},
|
||||
}
|
||||
|
||||
err := validateBatchIssues(issues)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// closed_at should be set to max(created_at, updated_at) + 1 second
|
||||
if issues[0].ClosedAt == nil {
|
||||
t.Fatal("closed_at should have been set")
|
||||
}
|
||||
|
||||
expectedClosedAt := pastTime.Add(time.Hour).Add(time.Second)
|
||||
if !issues[0].ClosedAt.Equal(expectedClosedAt) {
|
||||
t.Errorf("closed_at mismatch: want %v, got %v", expectedClosedAt, *issues[0].ClosedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves existing closed_at", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
pastTime := now.Add(-24 * time.Hour)
|
||||
closedTime := pastTime.Add(2 * time.Hour)
|
||||
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
Title: "Closed issue with closed_at",
|
||||
Priority: 1,
|
||||
IssueType: "task",
|
||||
Status: "closed",
|
||||
CreatedAt: pastTime,
|
||||
UpdatedAt: pastTime.Add(time.Hour),
|
||||
ClosedAt: &closedTime,
|
||||
},
|
||||
}
|
||||
|
||||
err := validateBatchIssues(issues)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// closed_at should be preserved
|
||||
if !issues[0].ClosedAt.Equal(closedTime) {
|
||||
t.Errorf("closed_at should be preserved: want %v, got %v", closedTime, *issues[0].ClosedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not set closed_at for open issues", func(t *testing.T) {
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
Title: "Open issue",
|
||||
Priority: 1,
|
||||
IssueType: "task",
|
||||
Status: "open",
|
||||
},
|
||||
}
|
||||
|
||||
err := validateBatchIssues(issues)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if issues[0].ClosedAt != nil {
|
||||
t.Error("closed_at should remain nil for open issues")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets deleted_at for tombstones missing it", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
pastTime := now.Add(-24 * time.Hour)
|
||||
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
Title: "Tombstone without deleted_at",
|
||||
Priority: 1,
|
||||
IssueType: "task",
|
||||
Status: "tombstone",
|
||||
CreatedAt: pastTime,
|
||||
UpdatedAt: pastTime.Add(time.Hour),
|
||||
// DeletedAt intentionally NOT set
|
||||
},
|
||||
}
|
||||
|
||||
err := validateBatchIssues(issues)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// deleted_at should be set to max(created_at, updated_at) + 1 second
|
||||
if issues[0].DeletedAt == nil {
|
||||
t.Fatal("deleted_at should have been set")
|
||||
}
|
||||
|
||||
expectedDeletedAt := pastTime.Add(time.Hour).Add(time.Second)
|
||||
if !issues[0].DeletedAt.Equal(expectedDeletedAt) {
|
||||
t.Errorf("deleted_at mismatch: want %v, got %v", expectedDeletedAt, *issues[0].DeletedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,6 +227,27 @@ func (s *SQLiteStorage) importJSONLFile(ctx context.Context, jsonlPath, sourceRe
|
||||
// upsertIssueInTx inserts or updates an issue within a transaction.
|
||||
// Uses INSERT OR REPLACE to handle both new and existing issues.
|
||||
func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *types.Issue, customStatuses []string) error {
|
||||
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
|
||||
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
|
||||
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
closedAt := maxTime.Add(time.Second)
|
||||
issue.ClosedAt = &closedAt
|
||||
}
|
||||
|
||||
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
|
||||
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
deletedAt := maxTime.Add(time.Second)
|
||||
issue.DeletedAt = &deletedAt
|
||||
}
|
||||
|
||||
// Validate issue (with custom status support, bd-1pj6)
|
||||
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
|
||||
@@ -50,16 +50,41 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act
|
||||
return fmt.Errorf("failed to get custom statuses: %w", err)
|
||||
}
|
||||
|
||||
// Set timestamps first so defensive fixes can use them
|
||||
now := time.Now()
|
||||
if issue.CreatedAt.IsZero() {
|
||||
issue.CreatedAt = now
|
||||
}
|
||||
if issue.UpdatedAt.IsZero() {
|
||||
issue.UpdatedAt = now
|
||||
}
|
||||
|
||||
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
|
||||
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
|
||||
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
closedAt := maxTime.Add(time.Second)
|
||||
issue.ClosedAt = &closedAt
|
||||
}
|
||||
|
||||
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
|
||||
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
deletedAt := maxTime.Add(time.Second)
|
||||
issue.DeletedAt = &deletedAt
|
||||
}
|
||||
|
||||
// Validate issue before creating (with custom status support)
|
||||
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Set timestamps
|
||||
now := time.Now()
|
||||
issue.CreatedAt = now
|
||||
issue.UpdatedAt = now
|
||||
|
||||
// Compute content hash (bd-95)
|
||||
if issue.ContentHash == "" {
|
||||
issue.ContentHash = issue.ComputeContentHash()
|
||||
|
||||
@@ -97,16 +97,41 @@ func (t *sqliteTxStorage) CreateIssue(ctx context.Context, issue *types.Issue, a
|
||||
return fmt.Errorf("failed to get custom statuses: %w", err)
|
||||
}
|
||||
|
||||
// Set timestamps first so defensive fixes can use them
|
||||
now := time.Now()
|
||||
if issue.CreatedAt.IsZero() {
|
||||
issue.CreatedAt = now
|
||||
}
|
||||
if issue.UpdatedAt.IsZero() {
|
||||
issue.UpdatedAt = now
|
||||
}
|
||||
|
||||
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
|
||||
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
|
||||
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
closedAt := maxTime.Add(time.Second)
|
||||
issue.ClosedAt = &closedAt
|
||||
}
|
||||
|
||||
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
|
||||
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
deletedAt := maxTime.Add(time.Second)
|
||||
issue.DeletedAt = &deletedAt
|
||||
}
|
||||
|
||||
// Validate issue before creating (with custom status support)
|
||||
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Set timestamps
|
||||
now := time.Now()
|
||||
issue.CreatedAt = now
|
||||
issue.UpdatedAt = now
|
||||
|
||||
// Compute content hash (bd-95)
|
||||
if issue.ContentHash == "" {
|
||||
issue.ContentHash = issue.ComputeContentHash()
|
||||
@@ -184,11 +209,38 @@ func (t *sqliteTxStorage) CreateIssues(ctx context.Context, issues []*types.Issu
|
||||
// Validate and prepare all issues first (with custom status support)
|
||||
now := time.Now()
|
||||
for _, issue := range issues {
|
||||
// Set timestamps first so defensive fixes can use them
|
||||
if issue.CreatedAt.IsZero() {
|
||||
issue.CreatedAt = now
|
||||
}
|
||||
if issue.UpdatedAt.IsZero() {
|
||||
issue.UpdatedAt = now
|
||||
}
|
||||
|
||||
// Defensive fix for closed_at invariant (GH#523): older versions of bd could
|
||||
// close issues without setting closed_at. Fix by using max(created_at, updated_at) + 1s.
|
||||
if issue.Status == types.StatusClosed && issue.ClosedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
closedAt := maxTime.Add(time.Second)
|
||||
issue.ClosedAt = &closedAt
|
||||
}
|
||||
|
||||
// Defensive fix for deleted_at invariant: tombstones must have deleted_at
|
||||
if issue.Status == types.StatusTombstone && issue.DeletedAt == nil {
|
||||
maxTime := issue.CreatedAt
|
||||
if issue.UpdatedAt.After(maxTime) {
|
||||
maxTime = issue.UpdatedAt
|
||||
}
|
||||
deletedAt := maxTime.Add(time.Second)
|
||||
issue.DeletedAt = &deletedAt
|
||||
}
|
||||
|
||||
if err := issue.ValidateWithCustomStatuses(customStatuses); err != nil {
|
||||
return fmt.Errorf("validation failed for issue: %w", err)
|
||||
}
|
||||
issue.CreatedAt = now
|
||||
issue.UpdatedAt = now
|
||||
if issue.ContentHash == "" {
|
||||
issue.ContentHash = issue.ComputeContentHash()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user