Refactor snapshot management into dedicated module (bd-urob)
- Created SnapshotManager type in snapshot_manager.go - Encapsulates all snapshot operations with clean API - Added SnapshotStats for observability - Reduced deletion_tracking.go from 557 to 153 lines (72% reduction) - Enhanced merge output with statistics - All tests passing Amp-Thread-ID: https://ampcode.com/threads/T-d82acce9-170d-4e58-b227-fd33d48b8598 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -1,249 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/config"
|
||||
"github.com/steveyegge/beads/internal/merge"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
)
|
||||
|
||||
// snapshotMetadata contains versioning info for snapshot files
|
||||
type snapshotMetadata struct {
|
||||
Version string `json:"version"` // bd version that created this snapshot
|
||||
Timestamp time.Time `json:"timestamp"` // When snapshot was created
|
||||
CommitSHA string `json:"commit"` // Git commit SHA at snapshot time
|
||||
}
|
||||
|
||||
const (
|
||||
// maxSnapshotAge is the maximum allowed age for a snapshot file (1 hour)
|
||||
maxSnapshotAge = 1 * time.Hour
|
||||
)
|
||||
|
||||
// jsonEquals compares two JSON strings semantically, handling field reordering
|
||||
func jsonEquals(a, b string) bool {
|
||||
var objA, objB map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(a), &objA); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &objB); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(objA, objB)
|
||||
}
|
||||
|
||||
// getSnapshotPaths returns paths for base and left snapshot files
|
||||
func getSnapshotPaths(jsonlPath string) (basePath, leftPath string) {
|
||||
dir := filepath.Dir(jsonlPath)
|
||||
basePath = filepath.Join(dir, "beads.base.jsonl")
|
||||
leftPath = filepath.Join(dir, "beads.left.jsonl")
|
||||
return
|
||||
}
|
||||
|
||||
// getSnapshotMetadataPaths returns paths for metadata files
|
||||
func getSnapshotMetadataPaths(jsonlPath string) (baseMeta, leftMeta string) {
|
||||
dir := filepath.Dir(jsonlPath)
|
||||
baseMeta = filepath.Join(dir, "beads.base.meta.json")
|
||||
leftMeta = filepath.Join(dir, "beads.left.meta.json")
|
||||
return
|
||||
}
|
||||
|
||||
// getCurrentCommitSHA returns the current git commit SHA, or empty string if not in a git repo
|
||||
func getCurrentCommitSHA() string {
|
||||
cmd := exec.Command("git", "rev-parse", "--short", "HEAD")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// createSnapshotMetadata creates metadata for the current snapshot
|
||||
func createSnapshotMetadata() snapshotMetadata {
|
||||
return snapshotMetadata{
|
||||
Version: getVersion(),
|
||||
Timestamp: time.Now(),
|
||||
CommitSHA: getCurrentCommitSHA(),
|
||||
}
|
||||
}
|
||||
|
||||
// getVersion returns the current bd version
|
||||
func getVersion() string {
|
||||
return Version
|
||||
}
|
||||
|
||||
// writeSnapshotMetadata writes metadata to a file
|
||||
func writeSnapshotMetadata(path string, meta snapshotMetadata) error {
|
||||
data, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Use process-specific temp file for atomic write
|
||||
tempPath := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
|
||||
if err := os.WriteFile(tempPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write metadata temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
return os.Rename(tempPath, path)
|
||||
}
|
||||
|
||||
// readSnapshotMetadata reads metadata from a file
|
||||
func readSnapshotMetadata(path string) (*snapshotMetadata, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // No metadata file exists (backward compatibility)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
|
||||
var meta snapshotMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse metadata: %w", err)
|
||||
}
|
||||
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
// validateSnapshotMetadata validates that snapshot metadata is recent and compatible
|
||||
func validateSnapshotMetadata(meta *snapshotMetadata, currentCommit string) error {
|
||||
if meta == nil {
|
||||
// No metadata file - likely old snapshot format, consider it stale
|
||||
return fmt.Errorf("snapshot has no metadata (stale format)")
|
||||
}
|
||||
|
||||
// Check age
|
||||
age := time.Since(meta.Timestamp)
|
||||
if age > maxSnapshotAge {
|
||||
return fmt.Errorf("snapshot is too old (age: %v, max: %v)", age.Round(time.Second), maxSnapshotAge)
|
||||
}
|
||||
|
||||
// Check version compatibility (major.minor must match)
|
||||
currentVersion := getVersion()
|
||||
if !isVersionCompatible(meta.Version, currentVersion) {
|
||||
return fmt.Errorf("snapshot version %s incompatible with current version %s", meta.Version, currentVersion)
|
||||
}
|
||||
|
||||
// Check commit SHA if we're in a git repo
|
||||
if currentCommit != "" && meta.CommitSHA != "" && meta.CommitSHA != currentCommit {
|
||||
return fmt.Errorf("snapshot from different commit (snapshot: %s, current: %s)", meta.CommitSHA, currentCommit)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isVersionCompatible checks if two versions are compatible (major.minor must match)
|
||||
func isVersionCompatible(v1, v2 string) bool {
|
||||
// Extract major.minor from both versions
|
||||
parts1 := strings.Split(v1, ".")
|
||||
parts2 := strings.Split(v2, ".")
|
||||
|
||||
if len(parts1) < 2 || len(parts2) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare major.minor
|
||||
return parts1[0] == parts2[0] && parts1[1] == parts2[1]
|
||||
}
|
||||
|
||||
// captureLeftSnapshot copies the current JSONL to the left snapshot file
|
||||
// This should be called after export, before git pull
|
||||
// Uses atomic file operations to prevent race conditions
|
||||
func captureLeftSnapshot(jsonlPath string) error {
|
||||
_, leftPath := getSnapshotPaths(jsonlPath)
|
||||
_, leftMetaPath := getSnapshotMetadataPaths(jsonlPath)
|
||||
|
||||
// Use process-specific temp file to prevent concurrent write conflicts
|
||||
tempPath := fmt.Sprintf("%s.%d.tmp", leftPath, os.Getpid())
|
||||
if err := copyFileSnapshot(jsonlPath, tempPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic rename on POSIX systems
|
||||
if err := os.Rename(tempPath, leftPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
meta := createSnapshotMetadata()
|
||||
return writeSnapshotMetadata(leftMetaPath, meta)
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
return sm.CaptureLeft()
|
||||
}
|
||||
|
||||
// updateBaseSnapshot copies the current JSONL to the base snapshot file
|
||||
// This should be called after successful import to track the new baseline
|
||||
// Uses atomic file operations to prevent race conditions
|
||||
func updateBaseSnapshot(jsonlPath string) error {
|
||||
basePath, _ := getSnapshotPaths(jsonlPath)
|
||||
baseMetaPath, _ := getSnapshotMetadataPaths(jsonlPath)
|
||||
|
||||
// Use process-specific temp file to prevent concurrent write conflicts
|
||||
tempPath := fmt.Sprintf("%s.%d.tmp", basePath, os.Getpid())
|
||||
if err := copyFileSnapshot(jsonlPath, tempPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic rename on POSIX systems
|
||||
if err := os.Rename(tempPath, basePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
meta := createSnapshotMetadata()
|
||||
return writeSnapshotMetadata(baseMetaPath, meta)
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
return sm.UpdateBase()
|
||||
}
|
||||
|
||||
// merge3WayAndPruneDeletions performs 3-way merge and prunes accepted deletions from DB
|
||||
// Returns true if merge was performed, false if skipped (no base file)
|
||||
func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, jsonlPath string) (bool, error) {
|
||||
basePath, leftPath := getSnapshotPaths(jsonlPath)
|
||||
baseMetaPath, leftMetaPath := getSnapshotMetadataPaths(jsonlPath)
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
basePath, leftPath := sm.getSnapshotPaths()
|
||||
|
||||
// If no base snapshot exists, skip deletion handling (first run or bootstrap)
|
||||
if !fileExists(basePath) {
|
||||
if !sm.BaseExists() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
|
||||
// Validate snapshot metadata
|
||||
currentCommit := getCurrentCommitSHA()
|
||||
|
||||
baseMeta, err := readSnapshotMetadata(baseMetaPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to read base snapshot metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := validateSnapshotMetadata(baseMeta, currentCommit); err != nil {
|
||||
if err := sm.Validate(); err != nil {
|
||||
// Stale or invalid snapshot - clean up and skip merge
|
||||
fmt.Fprintf(os.Stderr, "Warning: base snapshot invalid (%v), cleaning up\n", err)
|
||||
_ = cleanupSnapshots(jsonlPath)
|
||||
fmt.Fprintf(os.Stderr, "Warning: snapshot validation failed (%v), cleaning up\n", err)
|
||||
_ = sm.Cleanup()
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// If left snapshot exists, validate it too
|
||||
if fileExists(leftPath) {
|
||||
leftMeta, err := readSnapshotMetadata(leftMetaPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to read left snapshot metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := validateSnapshotMetadata(leftMeta, currentCommit); err != nil {
|
||||
// Stale or invalid snapshot - clean up and skip merge
|
||||
fmt.Fprintf(os.Stderr, "Warning: left snapshot invalid (%v), cleaning up\n", err)
|
||||
_ = cleanupSnapshots(jsonlPath)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Run 3-way merge: base (last import) vs left (pre-pull export) vs right (pulled JSONL)
|
||||
tmpMerged := jsonlPath + ".merged"
|
||||
@@ -254,7 +58,7 @@ func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, json
|
||||
}
|
||||
}()
|
||||
|
||||
if err = merge.Merge3Way(tmpMerged, basePath, leftPath, jsonlPath, false); err != nil {
|
||||
if err := merge.Merge3Way(tmpMerged, basePath, leftPath, jsonlPath, false); err != nil {
|
||||
// Merge error (including conflicts) is returned as error
|
||||
return false, fmt.Errorf("3-way merge failed: %w", err)
|
||||
}
|
||||
@@ -265,7 +69,7 @@ func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, json
|
||||
}
|
||||
|
||||
// Compute accepted deletions (issues in base but not in merged, and unchanged locally)
|
||||
acceptedDeletions, err := computeAcceptedDeletions(basePath, leftPath, jsonlPath)
|
||||
acceptedDeletions, err := sm.ComputeAcceptedDeletions(jsonlPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to compute accepted deletions: %w", err)
|
||||
}
|
||||
@@ -283,232 +87,58 @@ func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, json
|
||||
return false, fmt.Errorf("deletion failures (DB may be inconsistent): %v", deletionErrors)
|
||||
}
|
||||
|
||||
if len(acceptedDeletions) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "3-way merge: pruned %d deleted issue(s) from database\n", len(acceptedDeletions))
|
||||
// Print stats if deletions were found
|
||||
stats := sm.GetStats()
|
||||
if stats.DeletionsFound > 0 {
|
||||
fmt.Fprintf(os.Stderr, "3-way merge: pruned %d deleted issue(s) from database (base: %d, left: %d, merged: %d)\n",
|
||||
stats.DeletionsFound, stats.BaseCount, stats.LeftCount, stats.MergedCount)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// computeAcceptedDeletions identifies issues that were deleted in the remote
|
||||
// and should be removed from the local database.
|
||||
//
|
||||
// An issue is an "accepted deletion" if:
|
||||
// - It exists in base (last import)
|
||||
// - It does NOT exist in merged (after 3-way merge)
|
||||
// - It is unchanged in left (pre-pull export) compared to base
|
||||
//
|
||||
// This means the issue was deleted remotely and we had no local modifications,
|
||||
// so we should accept the deletion and prune it from our DB.
|
||||
func computeAcceptedDeletions(basePath, leftPath, mergedPath string) ([]string, error) {
|
||||
// Build map of ID -> raw line for base and left
|
||||
baseIndex, err := buildIDToLineMap(basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read base snapshot: %w", err)
|
||||
}
|
||||
|
||||
leftIndex, err := buildIDToLineMap(leftPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read left snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Build set of IDs in merged result
|
||||
mergedIDs, err := buildIDSet(mergedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read merged file: %w", err)
|
||||
}
|
||||
|
||||
// Find accepted deletions
|
||||
var deletions []string
|
||||
for id, baseLine := range baseIndex {
|
||||
// Issue in base but not in merged
|
||||
if !mergedIDs[id] {
|
||||
// Check if unchanged locally - try raw equality first, then semantic JSON comparison
|
||||
if leftLine, existsInLeft := leftIndex[id]; existsInLeft && (leftLine == baseLine || jsonEquals(leftLine, baseLine)) {
|
||||
deletions = append(deletions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deletions, nil
|
||||
}
|
||||
|
||||
// buildIDToLineMap reads a JSONL file and returns a map of issue ID -> raw JSON line
|
||||
func buildIDToLineMap(path string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return result, nil // Empty map for missing files
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse just the ID field
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse issue ID from line: %w", err)
|
||||
}
|
||||
|
||||
result[issue.ID] = line
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildIDSet reads a JSONL file and returns a set of issue IDs
|
||||
func buildIDSet(path string) (map[string]bool, error) {
|
||||
result := make(map[string]bool)
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return result, nil // Empty set for missing files
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse just the ID field
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse issue ID from line: %w", err)
|
||||
}
|
||||
|
||||
result[issue.ID] = true
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// copyFileSnapshot copies a file from src to dst (renamed to avoid conflict with migrate_hash_ids.go)
|
||||
func copyFileSnapshot(src, dst string) error {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, sourceFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return destFile.Sync()
|
||||
}
|
||||
|
||||
// cleanupSnapshots removes the snapshot files and their metadata
|
||||
// This is useful for cleanup after errors or manual operations
|
||||
// Deprecated: Use SnapshotManager.Cleanup() instead
|
||||
func cleanupSnapshots(jsonlPath string) error {
|
||||
basePath, leftPath := getSnapshotPaths(jsonlPath)
|
||||
baseMetaPath, leftMetaPath := getSnapshotMetadataPaths(jsonlPath)
|
||||
|
||||
_ = os.Remove(basePath)
|
||||
_ = os.Remove(leftPath)
|
||||
_ = os.Remove(baseMetaPath)
|
||||
_ = os.Remove(leftMetaPath)
|
||||
|
||||
return nil
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
return sm.Cleanup()
|
||||
}
|
||||
|
||||
// validateSnapshotConsistency checks if snapshot files are consistent
|
||||
// Returns an error if snapshots are corrupted or missing critical data
|
||||
// Deprecated: Use SnapshotManager.Validate() instead
|
||||
func validateSnapshotConsistency(jsonlPath string) error {
|
||||
basePath, leftPath := getSnapshotPaths(jsonlPath)
|
||||
|
||||
// Base file is optional (might not exist on first run)
|
||||
if fileExists(basePath) {
|
||||
if _, err := buildIDSet(basePath); err != nil {
|
||||
return fmt.Errorf("base snapshot is corrupted: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Left file is optional (might not exist if export hasn't run)
|
||||
if fileExists(leftPath) {
|
||||
if _, err := buildIDSet(leftPath); err != nil {
|
||||
return fmt.Errorf("left snapshot is corrupted: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
return sm.Validate()
|
||||
}
|
||||
|
||||
// getSnapshotStats returns statistics about the snapshot files
|
||||
// Deprecated: Use SnapshotManager.GetStats() instead
|
||||
func getSnapshotStats(jsonlPath string) (baseCount, leftCount int, baseExists, leftExists bool) {
|
||||
basePath, leftPath := getSnapshotPaths(jsonlPath)
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
basePath, leftPath := sm.GetSnapshotPaths()
|
||||
|
||||
if baseIDs, err := buildIDSet(basePath); err == nil {
|
||||
if baseIDs, err := sm.BuildIDSet(basePath); err == nil && len(baseIDs) > 0 {
|
||||
baseExists = true
|
||||
baseCount = len(baseIDs)
|
||||
} else {
|
||||
baseExists = fileExists(basePath)
|
||||
}
|
||||
|
||||
if leftIDs, err := buildIDSet(leftPath); err == nil {
|
||||
if leftIDs, err := sm.BuildIDSet(leftPath); err == nil && len(leftIDs) > 0 {
|
||||
leftExists = true
|
||||
leftCount = len(leftIDs)
|
||||
} else {
|
||||
leftExists = fileExists(leftPath)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// initializeSnapshotsIfNeeded creates initial snapshot files if they don't exist
|
||||
// This is called during init or first sync to bootstrap the deletion tracking
|
||||
// Deprecated: Use SnapshotManager.Initialize() instead
|
||||
func initializeSnapshotsIfNeeded(jsonlPath string) error {
|
||||
basePath, _ := getSnapshotPaths(jsonlPath)
|
||||
baseMetaPath, _ := getSnapshotMetadataPaths(jsonlPath)
|
||||
|
||||
// If JSONL exists but base snapshot doesn't, create initial base
|
||||
if fileExists(jsonlPath) && !fileExists(basePath) {
|
||||
if err := copyFileSnapshot(jsonlPath, basePath); err != nil {
|
||||
return fmt.Errorf("failed to initialize base snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Create metadata
|
||||
meta := createSnapshotMetadata()
|
||||
if err := writeSnapshotMetadata(baseMetaPath, meta); err != nil {
|
||||
return fmt.Errorf("failed to initialize base snapshot metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
return sm.Initialize()
|
||||
}
|
||||
|
||||
// getMultiRepoJSONLPaths returns all JSONL file paths for multi-repo mode
|
||||
|
||||
@@ -252,8 +252,9 @@ func TestDeletionWithLocalModification(t *testing.T) {
|
||||
func TestComputeAcceptedDeletions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
basePath := filepath.Join(dir, "base.jsonl")
|
||||
leftPath := filepath.Join(dir, "left.jsonl")
|
||||
jsonlPath := filepath.Join(dir, "issues.jsonl")
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
basePath, leftPath := sm.GetSnapshotPaths()
|
||||
mergedPath := filepath.Join(dir, "merged.jsonl")
|
||||
|
||||
// Base has 3 issues
|
||||
@@ -280,7 +281,7 @@ func TestComputeAcceptedDeletions(t *testing.T) {
|
||||
t.Fatalf("Failed to write merged: %v", err)
|
||||
}
|
||||
|
||||
deletions, err := computeAcceptedDeletions(basePath, leftPath, mergedPath)
|
||||
deletions, err := sm.ComputeAcceptedDeletions(mergedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to compute deletions: %v", err)
|
||||
}
|
||||
@@ -326,7 +327,9 @@ func TestComputeAcceptedDeletions_LocallyModified(t *testing.T) {
|
||||
t.Fatalf("Failed to write merged: %v", err)
|
||||
}
|
||||
|
||||
deletions, err := computeAcceptedDeletions(basePath, leftPath, mergedPath)
|
||||
jsonlPath := filepath.Join(dir, "issues.jsonl")
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
deletions, err := sm.ComputeAcceptedDeletions(mergedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to compute deletions: %v", err)
|
||||
}
|
||||
@@ -354,7 +357,8 @@ func TestSnapshotManagement(t *testing.T) {
|
||||
t.Fatalf("Failed to initialize snapshots: %v", err)
|
||||
}
|
||||
|
||||
basePath, leftPath := getSnapshotPaths(jsonlPath)
|
||||
sm := NewSnapshotManager(jsonlPath)
|
||||
basePath, leftPath := sm.GetSnapshotPaths()
|
||||
|
||||
// Base should exist, left should not
|
||||
if !fileExists(basePath) {
|
||||
@@ -491,8 +495,10 @@ func TestMultiRepoDeletionTracking(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify snapshot files exist for both repos
|
||||
primaryBasePath, primaryLeftPath := getSnapshotPaths(primaryJSONL)
|
||||
additionalBasePath, additionalLeftPath := getSnapshotPaths(additionalJSONL)
|
||||
primarySM := NewSnapshotManager(primaryJSONL)
|
||||
primaryBasePath, primaryLeftPath := primarySM.GetSnapshotPaths()
|
||||
additionalSM := NewSnapshotManager(additionalJSONL)
|
||||
additionalBasePath, additionalLeftPath := additionalSM.GetSnapshotPaths()
|
||||
|
||||
if !fileExists(primaryBasePath) {
|
||||
t.Errorf("Primary base snapshot not created: %s", primaryBasePath)
|
||||
@@ -762,8 +768,10 @@ func TestMultiRepoSnapshotIsolation(t *testing.T) {
|
||||
}
|
||||
|
||||
// Get snapshot paths for both
|
||||
repo1Base, repo1Left := getSnapshotPaths(repo1JSONL)
|
||||
repo2Base, repo2Left := getSnapshotPaths(repo2JSONL)
|
||||
repo1SM := NewSnapshotManager(repo1JSONL)
|
||||
repo1Base, repo1Left := repo1SM.GetSnapshotPaths()
|
||||
repo2SM := NewSnapshotManager(repo2JSONL)
|
||||
repo2Base, repo2Left := repo2SM.GetSnapshotPaths()
|
||||
|
||||
// Verify isolation: snapshots should be in different directories
|
||||
if filepath.Dir(repo1Base) == filepath.Dir(repo2Base) {
|
||||
@@ -771,11 +779,11 @@ func TestMultiRepoSnapshotIsolation(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify each snapshot contains only its own issue
|
||||
repo1IDs, err := buildIDSet(repo1Base)
|
||||
repo1IDs, err := repo1SM.BuildIDSet(repo1Base)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read repo1 base snapshot: %v", err)
|
||||
}
|
||||
repo2IDs, err := buildIDSet(repo2Base)
|
||||
repo2IDs, err := repo2SM.BuildIDSet(repo2Base)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read repo2 base snapshot: %v", err)
|
||||
}
|
||||
@@ -807,11 +815,11 @@ func TestMultiRepoSnapshotIsolation(t *testing.T) {
|
||||
t.Error("Both left snapshots should exist")
|
||||
}
|
||||
|
||||
repo1LeftIDs, err := buildIDSet(repo1Left)
|
||||
repo1LeftIDs, err := repo1SM.BuildIDSet(repo1Left)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read repo1 left snapshot: %v", err)
|
||||
}
|
||||
repo2LeftIDs, err := buildIDSet(repo2Left)
|
||||
repo2LeftIDs, err := repo2SM.BuildIDSet(repo2Left)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read repo2 left snapshot: %v", err)
|
||||
}
|
||||
|
||||
492
cmd/bd/snapshot_manager.go
Normal file
492
cmd/bd/snapshot_manager.go
Normal file
@@ -0,0 +1,492 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MagicHeader is written to snapshot files for corruption detection
|
||||
MagicHeader = "# beads snapshot v1\n"
|
||||
|
||||
// maxSnapshotAge is the maximum allowed age for a snapshot file (1 hour)
|
||||
maxSnapshotAge = 1 * time.Hour
|
||||
)
|
||||
|
||||
// snapshotMetadata contains versioning info for snapshot files
|
||||
type snapshotMetadata struct {
|
||||
Version string `json:"version"` // bd version that created this snapshot
|
||||
Timestamp time.Time `json:"timestamp"` // When snapshot was created
|
||||
CommitSHA string `json:"commit"` // Git commit SHA at snapshot time
|
||||
}
|
||||
|
||||
// SnapshotStats contains statistics about snapshot operations
|
||||
type SnapshotStats struct {
|
||||
BaseCount int // Number of issues in base snapshot
|
||||
LeftCount int // Number of issues in left snapshot
|
||||
MergedCount int // Number of issues in merged result
|
||||
DeletionsFound int // Number of deletions detected
|
||||
BaseExists bool // Whether base snapshot exists
|
||||
LeftExists bool // Whether left snapshot exists
|
||||
}
|
||||
|
||||
// SnapshotManager handles snapshot file operations and validation
|
||||
type SnapshotManager struct {
|
||||
jsonlPath string
|
||||
stats SnapshotStats
|
||||
}
|
||||
|
||||
// NewSnapshotManager creates a new snapshot manager for the given JSONL path
|
||||
func NewSnapshotManager(jsonlPath string) *SnapshotManager {
|
||||
return &SnapshotManager{
|
||||
jsonlPath: jsonlPath,
|
||||
stats: SnapshotStats{},
|
||||
}
|
||||
}
|
||||
|
||||
// GetStats returns accumulated statistics about snapshot operations
|
||||
func (sm *SnapshotManager) GetStats() SnapshotStats {
|
||||
return sm.stats
|
||||
}
|
||||
|
||||
// getSnapshotPaths returns paths for base and left snapshot files
|
||||
func (sm *SnapshotManager) getSnapshotPaths() (basePath, leftPath string) {
|
||||
dir := filepath.Dir(sm.jsonlPath)
|
||||
basePath = filepath.Join(dir, "beads.base.jsonl")
|
||||
leftPath = filepath.Join(dir, "beads.left.jsonl")
|
||||
return
|
||||
}
|
||||
|
||||
// getSnapshotMetadataPaths returns paths for metadata files
|
||||
func (sm *SnapshotManager) getSnapshotMetadataPaths() (baseMeta, leftMeta string) {
|
||||
dir := filepath.Dir(sm.jsonlPath)
|
||||
baseMeta = filepath.Join(dir, "beads.base.meta.json")
|
||||
leftMeta = filepath.Join(dir, "beads.left.meta.json")
|
||||
return
|
||||
}
|
||||
|
||||
// CaptureLeft copies the current JSONL to the left snapshot file
|
||||
// This should be called after export, before git pull
|
||||
func (sm *SnapshotManager) CaptureLeft() error {
|
||||
_, leftPath := sm.getSnapshotPaths()
|
||||
_, leftMetaPath := sm.getSnapshotMetadataPaths()
|
||||
|
||||
// Use process-specific temp file to prevent concurrent write conflicts
|
||||
tempPath := fmt.Sprintf("%s.%d.tmp", leftPath, os.Getpid())
|
||||
if err := sm.copyFile(sm.jsonlPath, tempPath); err != nil {
|
||||
return fmt.Errorf("failed to copy to temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename on POSIX systems
|
||||
if err := os.Rename(tempPath, leftPath); err != nil {
|
||||
return fmt.Errorf("failed to rename snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
meta := sm.createMetadata()
|
||||
if err := sm.writeMetadata(leftMetaPath, meta); err != nil {
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
// Update stats
|
||||
if ids, err := sm.buildIDSet(leftPath); err == nil {
|
||||
sm.stats.LeftExists = true
|
||||
sm.stats.LeftCount = len(ids)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBase copies the current JSONL to the base snapshot file
|
||||
// This should be called after successful import to track the new baseline
|
||||
func (sm *SnapshotManager) UpdateBase() error {
|
||||
basePath, _ := sm.getSnapshotPaths()
|
||||
baseMetaPath, _ := sm.getSnapshotMetadataPaths()
|
||||
|
||||
// Use process-specific temp file to prevent concurrent write conflicts
|
||||
tempPath := fmt.Sprintf("%s.%d.tmp", basePath, os.Getpid())
|
||||
if err := sm.copyFile(sm.jsonlPath, tempPath); err != nil {
|
||||
return fmt.Errorf("failed to copy to temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename on POSIX systems
|
||||
if err := os.Rename(tempPath, basePath); err != nil {
|
||||
return fmt.Errorf("failed to rename snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Write metadata
|
||||
meta := sm.createMetadata()
|
||||
if err := sm.writeMetadata(baseMetaPath, meta); err != nil {
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
// Update stats
|
||||
if ids, err := sm.buildIDSet(basePath); err == nil {
|
||||
sm.stats.BaseExists = true
|
||||
sm.stats.BaseCount = len(ids)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if snapshots exist and are valid
|
||||
func (sm *SnapshotManager) Validate() error {
|
||||
basePath, leftPath := sm.getSnapshotPaths()
|
||||
baseMetaPath, leftMetaPath := sm.getSnapshotMetadataPaths()
|
||||
|
||||
// Check if base snapshot exists
|
||||
if !fileExists(basePath) {
|
||||
return nil // No base snapshot - first run, not an error
|
||||
}
|
||||
|
||||
currentCommit := getCurrentCommitSHA()
|
||||
|
||||
// Validate base snapshot
|
||||
baseMeta, err := sm.readMetadata(baseMetaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("base snapshot metadata error: %w", err)
|
||||
}
|
||||
|
||||
if err := sm.validateMetadata(baseMeta, currentCommit); err != nil {
|
||||
return fmt.Errorf("base snapshot invalid: %w", err)
|
||||
}
|
||||
|
||||
// Validate left snapshot if it exists
|
||||
if fileExists(leftPath) {
|
||||
leftMeta, err := sm.readMetadata(leftMetaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("left snapshot metadata error: %w", err)
|
||||
}
|
||||
|
||||
if err := sm.validateMetadata(leftMeta, currentCommit); err != nil {
|
||||
return fmt.Errorf("left snapshot invalid: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for corruption
|
||||
if _, err := sm.buildIDSet(basePath); err != nil {
|
||||
return fmt.Errorf("base snapshot corrupted: %w", err)
|
||||
}
|
||||
|
||||
if fileExists(leftPath) {
|
||||
if _, err := sm.buildIDSet(leftPath); err != nil {
|
||||
return fmt.Errorf("left snapshot corrupted: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup removes all snapshot files and metadata
|
||||
func (sm *SnapshotManager) Cleanup() error {
|
||||
basePath, leftPath := sm.getSnapshotPaths()
|
||||
baseMetaPath, leftMetaPath := sm.getSnapshotMetadataPaths()
|
||||
|
||||
_ = os.Remove(basePath)
|
||||
_ = os.Remove(leftPath)
|
||||
_ = os.Remove(baseMetaPath)
|
||||
_ = os.Remove(leftMetaPath)
|
||||
|
||||
// Reset stats
|
||||
sm.stats = SnapshotStats{}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize creates initial snapshot files if they don't exist
|
||||
func (sm *SnapshotManager) Initialize() error {
|
||||
basePath, _ := sm.getSnapshotPaths()
|
||||
baseMetaPath, _ := sm.getSnapshotMetadataPaths()
|
||||
|
||||
// If JSONL exists but base snapshot doesn't, create initial base
|
||||
if fileExists(sm.jsonlPath) && !fileExists(basePath) {
|
||||
if err := sm.copyFile(sm.jsonlPath, basePath); err != nil {
|
||||
return fmt.Errorf("failed to initialize base snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Create metadata
|
||||
meta := sm.createMetadata()
|
||||
if err := sm.writeMetadata(baseMetaPath, meta); err != nil {
|
||||
return fmt.Errorf("failed to initialize base snapshot metadata: %w", err)
|
||||
}
|
||||
|
||||
// Update stats
|
||||
if ids, err := sm.buildIDSet(basePath); err == nil {
|
||||
sm.stats.BaseExists = true
|
||||
sm.stats.BaseCount = len(ids)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComputeAcceptedDeletions identifies issues that were deleted remotely
|
||||
// An issue is an "accepted deletion" if:
|
||||
// - It exists in base (last import)
|
||||
// - It does NOT exist in merged (after 3-way merge)
|
||||
// - It is unchanged in left (pre-pull export) compared to base
|
||||
func (sm *SnapshotManager) ComputeAcceptedDeletions(mergedPath string) ([]string, error) {
|
||||
basePath, leftPath := sm.getSnapshotPaths()
|
||||
|
||||
// Build map of ID -> raw line for base and left
|
||||
baseIndex, err := sm.buildIDToLineMap(basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read base snapshot: %w", err)
|
||||
}
|
||||
|
||||
leftIndex, err := sm.buildIDToLineMap(leftPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read left snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Build set of IDs in merged result
|
||||
mergedIDs, err := sm.buildIDSet(mergedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read merged file: %w", err)
|
||||
}
|
||||
|
||||
sm.stats.MergedCount = len(mergedIDs)
|
||||
|
||||
// Find accepted deletions
|
||||
var deletions []string
|
||||
for id, baseLine := range baseIndex {
|
||||
// Issue in base but not in merged
|
||||
if !mergedIDs[id] {
|
||||
// Check if unchanged locally - try raw equality first, then semantic JSON comparison
|
||||
if leftLine, existsInLeft := leftIndex[id]; existsInLeft && (leftLine == baseLine || sm.jsonEquals(leftLine, baseLine)) {
|
||||
deletions = append(deletions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sm.stats.DeletionsFound = len(deletions)
|
||||
|
||||
return deletions, nil
|
||||
}
|
||||
|
||||
// BaseExists checks if the base snapshot exists
|
||||
func (sm *SnapshotManager) BaseExists() bool {
|
||||
basePath, _ := sm.getSnapshotPaths()
|
||||
return fileExists(basePath)
|
||||
}
|
||||
|
||||
// GetSnapshotPaths returns the base and left snapshot paths (exposed for testing)
|
||||
func (sm *SnapshotManager) GetSnapshotPaths() (basePath, leftPath string) {
|
||||
return sm.getSnapshotPaths()
|
||||
}
|
||||
|
||||
// BuildIDSet reads a JSONL file and returns a set of issue IDs (exposed for testing)
|
||||
func (sm *SnapshotManager) BuildIDSet(path string) (map[string]bool, error) {
|
||||
return sm.buildIDSet(path)
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
func (sm *SnapshotManager) createMetadata() snapshotMetadata {
|
||||
return snapshotMetadata{
|
||||
Version: getVersion(),
|
||||
Timestamp: time.Now(),
|
||||
CommitSHA: getCurrentCommitSHA(),
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SnapshotManager) writeMetadata(path string, meta snapshotMetadata) error {
|
||||
data, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Use process-specific temp file for atomic write
|
||||
tempPath := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
|
||||
if err := os.WriteFile(tempPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write metadata temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename
|
||||
return os.Rename(tempPath, path)
|
||||
}
|
||||
|
||||
func (sm *SnapshotManager) readMetadata(path string) (*snapshotMetadata, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // No metadata file exists (backward compatibility)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
|
||||
var meta snapshotMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse metadata: %w", err)
|
||||
}
|
||||
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func (sm *SnapshotManager) validateMetadata(meta *snapshotMetadata, currentCommit string) error {
|
||||
if meta == nil {
|
||||
// No metadata file - likely old snapshot format, consider it stale
|
||||
return fmt.Errorf("snapshot has no metadata (stale format)")
|
||||
}
|
||||
|
||||
// Check age
|
||||
age := time.Since(meta.Timestamp)
|
||||
if age > maxSnapshotAge {
|
||||
return fmt.Errorf("snapshot is too old (age: %v, max: %v)", age.Round(time.Second), maxSnapshotAge)
|
||||
}
|
||||
|
||||
// Check version compatibility (major.minor must match)
|
||||
currentVersion := getVersion()
|
||||
if !isVersionCompatible(meta.Version, currentVersion) {
|
||||
return fmt.Errorf("snapshot version %s incompatible with current version %s", meta.Version, currentVersion)
|
||||
}
|
||||
|
||||
// Check commit SHA if we're in a git repo
|
||||
if currentCommit != "" && meta.CommitSHA != "" && meta.CommitSHA != currentCommit {
|
||||
return fmt.Errorf("snapshot from different commit (snapshot: %s, current: %s)", meta.CommitSHA, currentCommit)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SnapshotManager) buildIDToLineMap(path string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return result, nil // Empty map for missing files
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse just the ID field
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse issue ID from line: %w", err)
|
||||
}
|
||||
|
||||
result[issue.ID] = line
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (sm *SnapshotManager) buildIDSet(path string) (map[string]bool, error) {
|
||||
result := make(map[string]bool)
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return result, nil // Empty set for missing files
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse just the ID field
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse issue ID from line: %w", err)
|
||||
}
|
||||
|
||||
result[issue.ID] = true
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (sm *SnapshotManager) jsonEquals(a, b string) bool {
|
||||
var objA, objB map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(a), &objA); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &objB); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(objA, objB)
|
||||
}
|
||||
|
||||
func (sm *SnapshotManager) copyFile(src, dst string) error {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, sourceFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return destFile.Sync()
|
||||
}
|
||||
|
||||
// Package-level helper functions
|
||||
|
||||
func getCurrentCommitSHA() string {
|
||||
cmd := exec.Command("git", "rev-parse", "--short", "HEAD")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
func isVersionCompatible(v1, v2 string) bool {
|
||||
// Extract major.minor from both versions
|
||||
parts1 := strings.Split(v1, ".")
|
||||
parts2 := strings.Split(v2, ".")
|
||||
|
||||
if len(parts1) < 2 || len(parts2) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare major.minor
|
||||
return parts1[0] == parts2[0] && parts1[1] == parts2[1]
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
Reference in New Issue
Block a user