diff --git a/cmd/bd/import.go b/cmd/bd/import.go index faeb1127..cab0df30 100644 --- a/cmd/bd/import.go +++ b/cmd/bd/import.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "context" "encoding/json" "fmt" @@ -96,15 +97,20 @@ NOTE: Import requires direct database access and does not work with daemon mode. for scanner.Scan() { lineNum++ - line := scanner.Text() + rawLine := scanner.Bytes() + line := string(rawLine) // Skip empty lines if line == "" { continue } - // Detect git conflict markers and attempt automatic 3-way merge - if strings.Contains(line, "<<<<<<<") || strings.Contains(line, "=======") || strings.Contains(line, ">>>>>>>") { + // Detect git conflict markers in raw bytes (before JSON decoding) + // This prevents false positives when issue content contains these strings + trimmed := bytes.TrimSpace(rawLine) + if bytes.HasPrefix(trimmed, []byte("<<<<<<< ")) || + bytes.Equal(trimmed, []byte("=======")) || + bytes.HasPrefix(trimmed, []byte(">>>>>>> ")) { fmt.Fprintf(os.Stderr, "Git conflict markers detected in JSONL file (line %d)\n", lineNum) fmt.Fprintf(os.Stderr, "→ Attempting automatic 3-way merge...\n\n") diff --git a/cmd/bd/main_test.go b/cmd/bd/main_test.go index 9951582c..b0d5499f 100644 --- a/cmd/bd/main_test.go +++ b/cmd/bd/main_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "os" "path/filepath" @@ -1067,6 +1068,83 @@ func TestAutoImportMergeConflict(t *testing.T) { } } +// TestAutoImportConflictMarkerFalsePositive tests that conflict marker detection +// doesn't trigger on JSON-encoded conflict markers in issue content (bd-17d5) +func TestAutoImportConflictMarkerFalsePositive(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "bd-test-false-positive-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + dbPath = filepath.Join(tmpDir, "test.db") + jsonlPath := filepath.Join(tmpDir, "issues.jsonl") + + testStore := newTestStore(t, dbPath) + + store = testStore + storeMutex.Lock() + storeActive = true + storeMutex.Unlock() + defer func() { + storeMutex.Lock() + storeActive = false + storeMutex.Unlock() + testStore.Close() + }() + + ctx := context.Background() + + // Create a JSONL file with an issue that has conflict markers in the description + // The conflict markers are JSON-encoded (as \u003c\u003c\u003c...) which should NOT trigger detection + now := time.Now().Format(time.RFC3339Nano) + jsonlContent := fmt.Sprintf(`{"id":"test-fp-1","title":"Test false positive","description":"This issue documents git conflict markers:\n\u003c\u003c\u003c\u003c\u003c\u003c\u003c HEAD\n=======\n\u003e\u003e\u003e\u003e\u003e\u003e\u003e branch","status":"open","priority":1,"issue_type":"task","created_at":"%s","updated_at":"%s"}`, now, now) + if err := os.WriteFile(jsonlPath, []byte(jsonlContent+"\n"), 0644); err != nil { + t.Fatalf("Failed to create JSONL: %v", err) + } + + // Verify the JSONL contains JSON-encoded conflict markers (not literal ones) + jsonlData, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("Failed to read JSONL: %v", err) + } + jsonlStr := string(jsonlData) + if !strings.Contains(jsonlStr, `\u003c\u003c\u003c`) { + t.Logf("JSONL content: %s", jsonlStr) + t.Fatalf("Expected JSON-encoded conflict markers in JSONL") + } + + // Capture stderr + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + // Run auto-import - should succeed without conflict detection + autoImportIfNewer() + + w.Close() + os.Stderr = oldStderr + + var buf bytes.Buffer + io.Copy(&buf, r) + stderrOutput := buf.String() + + // Verify NO conflict was detected + if strings.Contains(stderrOutput, "conflict") { + t.Errorf("False positive: conflict detection triggered on JSON-encoded markers. stderr: %s", stderrOutput) + } + + // Verify the issue was successfully imported + result, err := testStore.GetIssue(ctx, "test-fp-1") + if err != nil { + t.Fatalf("Failed to get issue (import failed): %v", err) + } + expectedDesc := "This issue documents git conflict markers:\n<<<<<<< HEAD\n=======\n>>>>>>> branch" + if result.Description != expectedDesc { + t.Errorf("Expected description with conflict markers, got: %s", result.Description) + } +} + // TestAutoImportClosedAtInvariant tests that auto-import enforces status/closed_at invariant func TestAutoImportClosedAtInvariant(t *testing.T) { tmpDir, err := os.MkdirTemp("", "bd-test-invariant-*") diff --git a/cmd/bd/validate.go b/cmd/bd/validate.go index 9989efec..e74eee50 100644 --- a/cmd/bd/validate.go +++ b/cmd/bd/validate.go @@ -1,5 +1,6 @@ package main import ( + "bytes" "context" "fmt" "os" @@ -324,14 +325,15 @@ func validateGitConflicts(_ context.Context, fix bool) checkResult { result.err = fmt.Errorf("failed to read JSONL: %w", err) return result } - // Look for git conflict markers - lines := strings.Split(string(data), "\n") + // Look for git conflict markers in raw bytes (before JSON decoding) + // This prevents false positives when issue content contains these strings + lines := bytes.Split(data, []byte("\n")) var conflictLines []int for i, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "<<<<<<< ") || - trimmed == "=======" || - strings.HasPrefix(trimmed, ">>>>>>> ") { + trimmed := bytes.TrimSpace(line) + if bytes.HasPrefix(trimmed, []byte("<<<<<<< ")) || + bytes.Equal(trimmed, []byte("=======")) || + bytes.HasPrefix(trimmed, []byte(">>>>>>> ")) { conflictLines = append(conflictLines, i+1) } }