* fix: address CI lint errors (gosec, errcheck, unparam, duplicate tests) - Remove duplicate TestHandleDelete_DryRun and TestHandleDelete_PartialSuccess from server_mutations_test.go (already defined in server_delete_test.go) - Add nolint:gosec comments for exec.CommandContext calls in sync_branch.go (variables come from trusted config/git sources) - Fix gosec G304/G306 in yaml_config.go (file read/write permissions) - Fix errcheck in mol_run.go (templateStore.Close) - Add nolint:unparam for updateYamlKey error return * fix: add remaining nolint:gosec comments for exec.CommandContext calls - sync_branch.go: diffCmd, logCmd (dry-run), commitCmd, pushCmd, remoteCmd - sync_check.go: checkLocalCmd * fix: add more nolint:gosec comments for exec.CommandContext calls - sync_branch.go: pullCmd - sync_check.go: localRefCmd, remoteRefCmd, aheadCmd - sync_import.go: checkoutCmd * fix: add final nolint:gosec comments for exec.CommandContext calls - sync_check.go: behindCmd - sync_import.go: fetchCmd --------- Co-authored-by: Charles P. Cross <cpdata@users.noreply.github.com>
396 lines
12 KiB
Go
396 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/syncbranch"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// SyncIntegrityResult contains the results of a pre-sync integrity check.
|
|
// bd-hlsw.1: Pre-sync integrity check
|
|
type SyncIntegrityResult struct {
|
|
ForcedPush *ForcedPushCheck `json:"forced_push,omitempty"`
|
|
PrefixMismatch *PrefixMismatch `json:"prefix_mismatch,omitempty"`
|
|
OrphanedChildren *OrphanedChildren `json:"orphaned_children,omitempty"`
|
|
HasProblems bool `json:"has_problems"`
|
|
}
|
|
|
|
// ForcedPushCheck detects if sync branch has diverged from remote.
|
|
type ForcedPushCheck struct {
|
|
Detected bool `json:"detected"`
|
|
LocalRef string `json:"local_ref,omitempty"`
|
|
RemoteRef string `json:"remote_ref,omitempty"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// PrefixMismatch detects issues with wrong prefix in JSONL.
|
|
type PrefixMismatch struct {
|
|
ConfiguredPrefix string `json:"configured_prefix"`
|
|
MismatchedIDs []string `json:"mismatched_ids,omitempty"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// OrphanedChildren detects issues with parent that doesn't exist.
|
|
type OrphanedChildren struct {
|
|
OrphanedIDs []string `json:"orphaned_ids,omitempty"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// showSyncIntegrityCheck performs pre-sync integrity checks without modifying state.
|
|
// bd-hlsw.1: Detects forced pushes, prefix mismatches, and orphaned children.
|
|
// Exits with code 1 if problems are detected.
|
|
func showSyncIntegrityCheck(ctx context.Context, jsonlPath string) {
|
|
fmt.Println("Sync Integrity Check")
|
|
fmt.Println("====================")
|
|
|
|
result := &SyncIntegrityResult{}
|
|
|
|
// Check 1: Detect forced pushes on sync branch
|
|
forcedPush := checkForcedPush(ctx)
|
|
result.ForcedPush = forcedPush
|
|
if forcedPush.Detected {
|
|
result.HasProblems = true
|
|
}
|
|
printForcedPushResult(forcedPush)
|
|
|
|
// Check 2: Detect prefix mismatches in JSONL
|
|
prefixMismatch, err := checkPrefixMismatch(ctx, jsonlPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: prefix check failed: %v\n", err)
|
|
} else {
|
|
result.PrefixMismatch = prefixMismatch
|
|
if prefixMismatch != nil && prefixMismatch.Count > 0 {
|
|
result.HasProblems = true
|
|
}
|
|
printPrefixMismatchResult(prefixMismatch)
|
|
}
|
|
|
|
// Check 3: Detect orphaned children (parent issues that don't exist)
|
|
orphaned, err := checkOrphanedChildrenInJSONL(jsonlPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: orphaned check failed: %v\n", err)
|
|
} else {
|
|
result.OrphanedChildren = orphaned
|
|
if orphaned != nil && orphaned.Count > 0 {
|
|
result.HasProblems = true
|
|
}
|
|
printOrphanedChildrenResult(orphaned)
|
|
}
|
|
|
|
// Summary
|
|
fmt.Println("\nSummary")
|
|
fmt.Println("-------")
|
|
if result.HasProblems {
|
|
fmt.Println("Problems detected! Review above and consider:")
|
|
if result.ForcedPush != nil && result.ForcedPush.Detected {
|
|
fmt.Println(" - Force push: Reset local sync branch or use 'bd sync --from-main'")
|
|
}
|
|
if result.PrefixMismatch != nil && result.PrefixMismatch.Count > 0 {
|
|
fmt.Println(" - Prefix mismatch: Use 'bd import --rename-on-import' to fix")
|
|
}
|
|
if result.OrphanedChildren != nil && result.OrphanedChildren.Count > 0 {
|
|
fmt.Println(" - Orphaned children: Remove parent references or create missing parents")
|
|
}
|
|
os.Exit(1)
|
|
} else {
|
|
fmt.Println("No problems detected. Safe to sync.")
|
|
}
|
|
|
|
if jsonOutput {
|
|
data, _ := json.MarshalIndent(result, "", " ")
|
|
fmt.Println(string(data))
|
|
}
|
|
}
|
|
|
|
// checkForcedPush detects if the sync branch has diverged from remote.
|
|
// This can happen when someone force-pushes to the sync branch.
|
|
func checkForcedPush(ctx context.Context) *ForcedPushCheck {
|
|
result := &ForcedPushCheck{
|
|
Detected: false,
|
|
Message: "No sync branch configured or no remote",
|
|
}
|
|
|
|
// Get sync branch name
|
|
if err := ensureStoreActive(); err != nil {
|
|
return result
|
|
}
|
|
|
|
syncBranch, _ := syncbranch.Get(ctx, store)
|
|
if syncBranch == "" {
|
|
return result
|
|
}
|
|
|
|
// Check if sync branch exists locally
|
|
checkLocalCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) //nolint:gosec // syncBranch from config
|
|
if checkLocalCmd.Run() != nil {
|
|
result.Message = fmt.Sprintf("Sync branch '%s' does not exist locally", syncBranch)
|
|
return result
|
|
}
|
|
|
|
// Get local ref
|
|
localRefCmd := exec.CommandContext(ctx, "git", "rev-parse", syncBranch) //nolint:gosec // syncBranch from config
|
|
localRefOutput, err := localRefCmd.Output()
|
|
if err != nil {
|
|
result.Message = "Failed to get local sync branch ref"
|
|
return result
|
|
}
|
|
localRef := strings.TrimSpace(string(localRefOutput))
|
|
result.LocalRef = localRef
|
|
|
|
// Check if remote tracking branch exists
|
|
remote := "origin"
|
|
if configuredRemote, err := store.GetConfig(ctx, "sync.remote"); err == nil && configuredRemote != "" {
|
|
remote = configuredRemote
|
|
}
|
|
|
|
// Get remote ref
|
|
remoteRefCmd := exec.CommandContext(ctx, "git", "rev-parse", remote+"/"+syncBranch) //nolint:gosec // remote and syncBranch from config
|
|
remoteRefOutput, err := remoteRefCmd.Output()
|
|
if err != nil {
|
|
result.Message = fmt.Sprintf("Remote tracking branch '%s/%s' does not exist", remote, syncBranch)
|
|
return result
|
|
}
|
|
remoteRef := strings.TrimSpace(string(remoteRefOutput))
|
|
result.RemoteRef = remoteRef
|
|
|
|
// If refs match, no divergence
|
|
if localRef == remoteRef {
|
|
result.Message = "Sync branch is in sync with remote"
|
|
return result
|
|
}
|
|
|
|
// Check if local is ahead of remote (normal case)
|
|
aheadCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", remoteRef, localRef) //nolint:gosec // refs from git rev-parse
|
|
if aheadCmd.Run() == nil {
|
|
result.Message = "Local sync branch is ahead of remote (normal)"
|
|
return result
|
|
}
|
|
|
|
// Check if remote is ahead of local (behind, needs pull)
|
|
behindCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", localRef, remoteRef) //nolint:gosec // refs from git rev-parse
|
|
if behindCmd.Run() == nil {
|
|
result.Message = "Local sync branch is behind remote (needs pull)"
|
|
return result
|
|
}
|
|
|
|
// If neither is ancestor, branches have diverged - likely a force push
|
|
result.Detected = true
|
|
result.Message = fmt.Sprintf("Sync branch has DIVERGED from remote! Local: %s, Remote: %s. This may indicate a force push on the remote.", localRef[:8], remoteRef[:8])
|
|
|
|
return result
|
|
}
|
|
|
|
func printForcedPushResult(fp *ForcedPushCheck) {
|
|
fmt.Println("1. Force Push Detection")
|
|
if fp.Detected {
|
|
fmt.Printf(" [PROBLEM] %s\n", fp.Message)
|
|
} else {
|
|
fmt.Printf(" [OK] %s\n", fp.Message)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// checkPrefixMismatch detects issues in JSONL that don't match the configured prefix.
|
|
func checkPrefixMismatch(ctx context.Context, jsonlPath string) (*PrefixMismatch, error) {
|
|
result := &PrefixMismatch{
|
|
MismatchedIDs: []string{},
|
|
}
|
|
|
|
// Get configured prefix
|
|
if err := ensureStoreActive(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prefix, err := store.GetConfig(ctx, "issue_prefix")
|
|
if err != nil || prefix == "" {
|
|
prefix = "bd" // Default
|
|
}
|
|
result.ConfiguredPrefix = prefix
|
|
|
|
// Read JSONL and check each issue's prefix
|
|
f, err := os.Open(jsonlPath) // #nosec G304 - controlled path
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return result, nil // No JSONL, no mismatches
|
|
}
|
|
return nil, fmt.Errorf("failed to open JSONL: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(bytes.TrimSpace(line)) == 0 {
|
|
continue
|
|
}
|
|
|
|
var issue struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(line, &issue); err != nil {
|
|
continue // Skip malformed lines
|
|
}
|
|
|
|
// Check if ID starts with configured prefix
|
|
if !strings.HasPrefix(issue.ID, prefix+"-") {
|
|
result.MismatchedIDs = append(result.MismatchedIDs, issue.ID)
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("failed to read JSONL: %w", err)
|
|
}
|
|
|
|
result.Count = len(result.MismatchedIDs)
|
|
return result, nil
|
|
}
|
|
|
|
func printPrefixMismatchResult(pm *PrefixMismatch) {
|
|
fmt.Println("2. Prefix Mismatch Check")
|
|
if pm == nil {
|
|
fmt.Println(" [SKIP] Could not check prefix")
|
|
fmt.Println()
|
|
return
|
|
}
|
|
|
|
fmt.Printf(" Configured prefix: %s\n", pm.ConfiguredPrefix)
|
|
if pm.Count > 0 {
|
|
fmt.Printf(" [PROBLEM] Found %d issue(s) with wrong prefix:\n", pm.Count)
|
|
// Show first 10
|
|
limit := pm.Count
|
|
if limit > 10 {
|
|
limit = 10
|
|
}
|
|
for i := 0; i < limit; i++ {
|
|
fmt.Printf(" - %s\n", pm.MismatchedIDs[i])
|
|
}
|
|
if pm.Count > 10 {
|
|
fmt.Printf(" ... and %d more\n", pm.Count-10)
|
|
}
|
|
} else {
|
|
fmt.Println(" [OK] All issues have correct prefix")
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// checkOrphanedChildrenInJSONL detects issues with parent references to non-existent issues.
|
|
func checkOrphanedChildrenInJSONL(jsonlPath string) (*OrphanedChildren, error) {
|
|
result := &OrphanedChildren{
|
|
OrphanedIDs: []string{},
|
|
}
|
|
|
|
// Read JSONL and build maps of IDs and parent references
|
|
f, err := os.Open(jsonlPath) // #nosec G304 - controlled path
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return result, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to open JSONL: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
existingIDs := make(map[string]bool)
|
|
parentRefs := make(map[string]string) // child ID -> parent ID
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(bytes.TrimSpace(line)) == 0 {
|
|
continue
|
|
}
|
|
|
|
var issue struct {
|
|
ID string `json:"id"`
|
|
Parent string `json:"parent,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(line, &issue); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Skip tombstones
|
|
if issue.Status == string(types.StatusTombstone) {
|
|
continue
|
|
}
|
|
|
|
existingIDs[issue.ID] = true
|
|
if issue.Parent != "" {
|
|
parentRefs[issue.ID] = issue.Parent
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("failed to read JSONL: %w", err)
|
|
}
|
|
|
|
// Find orphaned children (parent doesn't exist)
|
|
for childID, parentID := range parentRefs {
|
|
if !existingIDs[parentID] {
|
|
result.OrphanedIDs = append(result.OrphanedIDs, fmt.Sprintf("%s (parent: %s)", childID, parentID))
|
|
}
|
|
}
|
|
|
|
result.Count = len(result.OrphanedIDs)
|
|
return result, nil
|
|
}
|
|
|
|
// runGitCmdWithTimeoutMsg runs a git command and prints a helpful message if it takes too long.
|
|
// This helps when git operations hang waiting for credential/browser auth.
|
|
func runGitCmdWithTimeoutMsg(ctx context.Context, cmd *exec.Cmd, cmdName string, timeoutDelay time.Duration) ([]byte, error) {
|
|
// Use done channel to cleanly exit goroutine when command completes
|
|
done := make(chan struct{})
|
|
go func() {
|
|
select {
|
|
case <-time.After(timeoutDelay):
|
|
fmt.Fprintf(os.Stderr, "⏳ %s is taking longer than expected (possibly waiting for authentication). If this hangs, check for a browser auth prompt or run 'git status' in another terminal.\n", cmdName)
|
|
case <-done:
|
|
// Command completed, exit cleanly
|
|
case <-ctx.Done():
|
|
// Context canceled, don't print message
|
|
}
|
|
}()
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
close(done)
|
|
return output, err
|
|
}
|
|
|
|
func printOrphanedChildrenResult(oc *OrphanedChildren) {
|
|
fmt.Println("3. Orphaned Children Check")
|
|
if oc == nil {
|
|
fmt.Println(" [SKIP] Could not check orphaned children")
|
|
fmt.Println()
|
|
return
|
|
}
|
|
|
|
if oc.Count > 0 {
|
|
fmt.Printf(" [PROBLEM] Found %d issue(s) with missing parent:\n", oc.Count)
|
|
limit := oc.Count
|
|
if limit > 10 {
|
|
limit = 10
|
|
}
|
|
for i := 0; i < limit; i++ {
|
|
fmt.Printf(" - %s\n", oc.OrphanedIDs[i])
|
|
}
|
|
if oc.Count > 10 {
|
|
fmt.Printf(" ... and %d more\n", oc.Count-10)
|
|
}
|
|
} else {
|
|
fmt.Println(" [OK] No orphaned children found")
|
|
}
|
|
fmt.Println()
|
|
}
|