From 6cfbf01569164e83b6530b90404743b6b2f5ea49 Mon Sep 17 00:00:00 2001 From: javanhut Date: Wed, 14 Jan 2026 17:43:54 +0000 Subject: [PATCH 1/2] feat: minor fixes for v0.1.0 --- cli/download.go | 35 +-- cli/fuse.go | 26 +- cli/timeline.go | 21 +- internal/colors/colors.go | 46 ---- internal/config/config.go | 7 +- internal/config/config_test.go | 397 +++++++++++++++++++++++++++++ internal/fsmerkle/bench_test.go | 4 +- internal/fsmerkle/fsmerkle_test.go | 38 ++- internal/fsmerkle/storage.go | 11 +- internal/fsmerkle/types.go | 6 +- internal/gitclone/cloner.go | 207 +++++++++++++++ internal/github/sync_push.go | 2 +- internal/history/mmr.go | 9 +- internal/keys/keys.go | 4 +- internal/objects/object.go | 27 -- internal/progress/progress.go | 14 - internal/proto/negotitate.go | 31 --- internal/seals/sealnames.go | 115 --------- internal/submodule/config.go | 6 - internal/workspace/workspace.go | 11 +- 20 files changed, 712 insertions(+), 305 deletions(-) create mode 100644 internal/config/config_test.go delete mode 100644 internal/proto/negotitate.go diff --git a/cli/download.go b/cli/download.go index 53b0d01..8b27d4c 100644 --- a/cli/download.go +++ b/cli/download.go @@ -142,7 +142,9 @@ func handleGitHubDownload(rawURL string, args []string, depth int, skipHistory b cleanup := func() { if createdDir { // Change back to original directory first - os.Chdir(originalDir) + if err := os.Chdir(originalDir); err != nil { + log.Printf("Warning: failed to change back to original directory: %v", err) + } // Remove the target directory if err := os.RemoveAll(filepath.Join(originalDir, targetDir)); err != nil { log.Printf("Warning: Failed to cleanup directory '%s': %v", targetDir, err) @@ -323,7 +325,9 @@ func handleGitLabDownload(rawURL string, args []string, baseURL string, depth in // Cleanup function to remove directory on failure cleanup := func() { if createdDir { - os.Chdir(originalDir) + if err := os.Chdir(originalDir); err != nil { + log.Printf("Warning: failed to change back to original directory: %v", err) + } if err := os.RemoveAll(filepath.Join(originalDir, targetDir)); err != nil { log.Printf("Warning: Failed to cleanup directory '%s': %v", targetDir, err) } else { @@ -450,7 +454,9 @@ func handleGenericGitDownload(rawURL string, args []string, depth int, skipHisto // Cleanup function to remove directory on failure cleanup := func() { if createdDir { - os.Chdir(originalDir) + if err := os.Chdir(originalDir); err != nil { + log.Printf("Warning: failed to change back to original directory: %v", err) + } if err := os.RemoveAll(filepath.Join(originalDir, targetDir)); err != nil { log.Printf("Warning: Failed to cleanup directory '%s': %v", targetDir, err) } else { @@ -727,26 +733,9 @@ Use --with-history to download full commit history (requires API, subject to rat return handleGenericGitDownload(url, args, depth, skipHistory, includeTags, username, password, token, sshKey) } - // Standard Ivaldi remote download - targetDir := "" - if len(args) > 1 { - targetDir = args[1] - } else { - // Extract directory name from URL - parts := strings.Split(strings.TrimSuffix(url, "/"), "/") - targetDir = strings.TrimSuffix(parts[len(parts)-1], ".git") - } - - // Check if directory already exists - if _, err := os.Stat(targetDir); !os.IsNotExist(err) { - return fmt.Errorf("directory '%s' already exists", targetDir) - } - - // TODO: Implement actual download/clone functionality for standard Ivaldi remotes - fmt.Printf("Downloading repository from '%s' into '%s'...\n", url, targetDir) - fmt.Println("Note: Standard Ivaldi remote download functionality not yet implemented.") - - return nil + // Treat unrecognized URLs as generic Git URLs - most URLs are Git-compatible + // This handles self-hosted Git servers, file:// URLs, and other Git transports + return handleGenericGitDownload(url, args, depth, skipHistory, includeTags, username, password, token, sshKey) }, } diff --git a/cli/fuse.go b/cli/fuse.go index 0b81222..a20e5a9 100644 --- a/cli/fuse.go +++ b/cli/fuse.go @@ -3,6 +3,7 @@ package cli import ( "bufio" "fmt" + "log" "os" "path/filepath" "strings" @@ -251,14 +252,21 @@ func handleMerge(ivaldiDir, workDir string, casStore cas.CAS, refsManager *refs. if len(targetCommit.Parents) > 0 { baseCommit, err := commit.NewCommitReader(casStore).ReadCommit(targetCommit.Parents[0]) if err == nil { - baseIndex, _ = getCommitWorkspaceIndex(casStore, baseCommit) + baseIndex, err = getCommitWorkspaceIndex(casStore, baseCommit) + if err != nil { + log.Printf("Warning: could not get base workspace index: %v", err) + } } } // If no base, use empty workspace if baseIndex.Count == 0 { wsBuilder := wsindex.NewBuilder(casStore) - baseIndex, _ = wsBuilder.Build(nil) + var err error + baseIndex, err = wsBuilder.Build(nil) + if err != nil { + log.Printf("Warning: could not build empty workspace index: %v", err) + } } // Parse merge strategy @@ -399,7 +407,9 @@ func handleMerge(ivaldiDir, workDir string, casStore cas.CAS, refsManager *refs. // Generate seal name sealName := seals.GenerateSealName(mergeHashArray) - _ = refsManager.StoreSealName(sealName, mergeHashArray, fmt.Sprintf("Fuse %s into %s", sourceTimeline, targetTimeline)) + if err := refsManager.StoreSealName(sealName, mergeHashArray, fmt.Sprintf("Fuse %s into %s", sourceTimeline, targetTimeline)); err != nil { + log.Printf("Warning: failed to store seal name: %v", err) + } // Clean up resolution storage (merge succeeded) resStorage := diffmerge.NewResolutionStorage(ivaldiDir) @@ -647,7 +657,11 @@ func continueMerge(ivaldiDir, workDir string) error { } var baseCommit *commit.CommitObject if len(targetCommit.Parents) > 0 { - baseCommit, _ = commitReader.ReadCommit(targetCommit.Parents[0]) + var err error + baseCommit, err = commitReader.ReadCommit(targetCommit.Parents[0]) + if err != nil { + log.Printf("Warning: could not read base commit: %v", err) + } } // Resolve each conflicting file interactively @@ -781,7 +795,9 @@ func continueMerge(ivaldiDir, workDir string) error { // Generate seal name sealName := seals.GenerateSealName(mergeHashArray) - _ = refsManager.StoreSealName(sealName, mergeHashArray, fmt.Sprintf("Fuse %s into %s", state.SourceTimeline, state.TargetTimeline)) + if err := refsManager.StoreSealName(sealName, mergeHashArray, fmt.Sprintf("Fuse %s into %s", state.SourceTimeline, state.TargetTimeline)); err != nil { + log.Printf("Warning: failed to store seal name: %v", err) + } // Clean up merge state os.Remove(filepath.Join(ivaldiDir, "MERGE_HEAD")) diff --git a/cli/timeline.go b/cli/timeline.go index 5e74817..d6bb7ae 100644 --- a/cli/timeline.go +++ b/cli/timeline.go @@ -27,6 +27,7 @@ var timelineCmd = &cobra.Command{ var createTimelineCmd = &cobra.Command{ Use: "create ", + Aliases: []string{"cr"}, Short: "Create a new timeline", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -75,11 +76,17 @@ var createTimelineCmd = &cobra.Command{ if err == nil && currentTimelineRef.Blake3Hash != [32]byte{} { // Timeline has commits, get its committed state wsBuilder := wsindex.NewBuilder(casStore) - currentBaseIndex, _ = wsBuilder.Build(nil) // Simplified - should read actual commit + currentBaseIndex, err = wsBuilder.Build(nil) // Simplified - should read actual commit + if err != nil { + log.Printf("Warning: could not build workspace index: %v", err) + } } else { // No commits, use empty base wsBuilder := wsindex.NewBuilder(casStore) - currentBaseIndex, _ = wsBuilder.Build(nil) + currentBaseIndex, err = wsBuilder.Build(nil) + if err != nil { + log.Printf("Warning: could not build workspace index: %v", err) + } } // Create auto-shelf for current timeline BEFORE creating new timeline @@ -186,13 +193,19 @@ var listTimelineCmd = &cobra.Command{ // Initialize butterfly manager for timeline listing objectsDir := filepath.Join(ivaldiDir, "objects") - casStore, _ := cas.NewFileCAS(objectsDir) + casStore, err := cas.NewFileCAS(objectsDir) + if err != nil { + log.Printf("Warning: could not initialize CAS store: %v", err) + } var bfManager *butterfly.Manager if casStore != nil { mmr, err := history.NewPersistentMMR(casStore, ivaldiDir) if err == nil { defer mmr.Close() - bfManager, _ = butterfly.NewManager(ivaldiDir, casStore, refsManager, mmr) + bfManager, err = butterfly.NewManager(ivaldiDir, casStore, refsManager, mmr) + if err != nil { + log.Printf("Warning: could not initialize butterfly manager: %v", err) + } if bfManager != nil { defer bfManager.Close() } diff --git a/internal/colors/colors.go b/internal/colors/colors.go index 86969b1..ff6aca3 100644 --- a/internal/colors/colors.go +++ b/internal/colors/colors.go @@ -8,7 +8,6 @@ package colors import ( - "fmt" "os" "runtime" "strings" @@ -172,51 +171,6 @@ func Dim(text string) string { return ColorDim + text + ColorReset } -// Status prefixes with colors -func AddedPrefix() string { - return Added("A") -} - -func ModifiedPrefix() string { - return Modified("M") -} - -func DeletedPrefix() string { - return Deleted("D") -} - -func UntrackedPrefix() string { - return Untracked("?") -} - -func IgnoredPrefix() string { - return Ignored("!") -} - -func StagedPrefix() string { - return Staged("S") -} - -// Colorize file status text with appropriate prefix -func ColorizeFileStatus(status, filePath string) string { - switch strings.ToLower(status) { - case "added", "new file": - return fmt.Sprintf(" %s %s", AddedPrefix(), Green(filePath)) - case "modified": - return fmt.Sprintf(" %s %s", ModifiedPrefix(), Blue(filePath)) - case "deleted": - return fmt.Sprintf(" %s %s", DeletedPrefix(), Red(filePath)) - case "untracked": - return fmt.Sprintf(" %s %s", UntrackedPrefix(), Yellow(filePath)) - case "ignored": - return fmt.Sprintf(" %s %s", IgnoredPrefix(), Gray(filePath)) - case "staged": - return fmt.Sprintf(" %s %s", StagedPrefix(), Green(filePath)) - default: - return fmt.Sprintf(" %s", filePath) - } -} - // Section headers with colors func SectionHeader(text string) string { return Bold(text) diff --git a/internal/config/config.go b/internal/config/config.go index bed4a05..71aac1d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -103,7 +103,7 @@ func LoadConfig() (*Config, error) { func SaveGlobalConfig(cfg *Config) error { globalPath, err := globalConfigPath() if err != nil { - return err + return fmt.Errorf("failed to get global config path: %w", err) } data, err := json.MarshalIndent(cfg, "", " ") @@ -265,7 +265,10 @@ func SetValue(key, value string, global bool) error { err = SaveRepoConfig(cfg) } - return err + if err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil } // GetAuthor returns the formatted author string "Name " diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..f6cfb9c --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,397 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + // User config should be empty by default + if cfg.User.Name != "" { + t.Errorf("Expected empty user name, got %q", cfg.User.Name) + } + if cfg.User.Email != "" { + t.Errorf("Expected empty user email, got %q", cfg.User.Email) + } + + // Core config should have sensible defaults + if cfg.Core.AutoShelf != true { + t.Error("Expected AutoShelf to be true by default") + } + + // Color config should be enabled by default + if cfg.Color.UI != true { + t.Error("Expected Color.UI to be true by default") + } + if cfg.Color.Status != true { + t.Error("Expected Color.Status to be true by default") + } + if cfg.Color.Diff != true { + t.Error("Expected Color.Diff to be true by default") + } +} + +func TestGetSetValue(t *testing.T) { + // Create a temporary directory for test config + tempDir := t.TempDir() + ivaldiDir := filepath.Join(tempDir, ".ivaldi") + if err := os.MkdirAll(ivaldiDir, 0755); err != nil { + t.Fatalf("Failed to create .ivaldi directory: %v", err) + } + + // Change to temp directory to use repo config + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + defer os.Chdir(originalDir) + + // Test setting and getting user.name + testName := "Test User" + if err := SetValue("user.name", testName, false); err != nil { + t.Fatalf("Failed to set user.name: %v", err) + } + + gotName, err := GetValue("user.name") + if err != nil { + t.Fatalf("Failed to get user.name: %v", err) + } + if gotName != testName { + t.Errorf("user.name: expected %q, got %q", testName, gotName) + } + + // Test setting and getting user.email + testEmail := "test@example.com" + if err := SetValue("user.email", testEmail, false); err != nil { + t.Fatalf("Failed to set user.email: %v", err) + } + + gotEmail, err := GetValue("user.email") + if err != nil { + t.Fatalf("Failed to get user.email: %v", err) + } + if gotEmail != testEmail { + t.Errorf("user.email: expected %q, got %q", testEmail, gotEmail) + } + + // Test setting and getting core.editor + testEditor := "vim" + if err := SetValue("core.editor", testEditor, false); err != nil { + t.Fatalf("Failed to set core.editor: %v", err) + } + + gotEditor, err := GetValue("core.editor") + if err != nil { + t.Fatalf("Failed to get core.editor: %v", err) + } + if gotEditor != testEditor { + t.Errorf("core.editor: expected %q, got %q", testEditor, gotEditor) + } + + // Test boolean value (core.autoshelf) + if err := SetValue("core.autoshelf", "false", false); err != nil { + t.Fatalf("Failed to set core.autoshelf: %v", err) + } + + gotAutoShelf, err := GetValue("core.autoshelf") + if err != nil { + t.Fatalf("Failed to get core.autoshelf: %v", err) + } + if gotAutoShelf != "false" { + t.Errorf("core.autoshelf: expected %q, got %q", "false", gotAutoShelf) + } +} + +func TestGetValueInvalidKey(t *testing.T) { + tests := []struct { + name string + key string + wantErr string + }{ + { + name: "single part key", + key: "username", + wantErr: "invalid config key", + }, + { + name: "three part key", + key: "user.name.first", + wantErr: "invalid config key", + }, + { + name: "empty key", + key: "", + wantErr: "invalid config key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := GetValue(tt.key) + if err == nil { + t.Error("Expected error for invalid key, got nil") + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Error should contain %q, got %q", tt.wantErr, err.Error()) + } + }) + } +} + +func TestSetValueInvalidSection(t *testing.T) { + // Create a temporary directory for test config + tempDir := t.TempDir() + ivaldiDir := filepath.Join(tempDir, ".ivaldi") + if err := os.MkdirAll(ivaldiDir, 0755); err != nil { + t.Fatalf("Failed to create .ivaldi directory: %v", err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + defer os.Chdir(originalDir) + + tests := []struct { + name string + key string + wantErr string + }{ + { + name: "unknown section", + key: "invalid.setting", + wantErr: "unknown config section", + }, + { + name: "unknown user field", + key: "user.invalid", + wantErr: "unknown user config field", + }, + { + name: "unknown core field", + key: "core.invalid", + wantErr: "unknown core config field", + }, + { + name: "unknown color field", + key: "color.invalid", + wantErr: "unknown color config field", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := SetValue(tt.key, "value", false) + if err == nil { + t.Error("Expected error for invalid section/field, got nil") + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Error should contain %q, got %q", tt.wantErr, err.Error()) + } + }) + } +} + +func TestGetAuthor(t *testing.T) { + // Create a temporary directory for test config + tempDir := t.TempDir() + ivaldiDir := filepath.Join(tempDir, ".ivaldi") + if err := os.MkdirAll(ivaldiDir, 0755); err != nil { + t.Fatalf("Failed to create .ivaldi directory: %v", err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + defer os.Chdir(originalDir) + + // Test that GetAuthor fails when user.name and user.email are not set + _, err = GetAuthor() + if err == nil { + t.Error("Expected error when user.name and user.email not configured") + } + + // Set user.name and user.email + if err := SetValue("user.name", "Test User", false); err != nil { + t.Fatalf("Failed to set user.name: %v", err) + } + if err := SetValue("user.email", "test@example.com", false); err != nil { + t.Fatalf("Failed to set user.email: %v", err) + } + + // Now GetAuthor should succeed + author, err := GetAuthor() + if err != nil { + t.Fatalf("GetAuthor failed: %v", err) + } + + expectedAuthor := "Test User " + if author != expectedAuthor { + t.Errorf("GetAuthor: expected %q, got %q", expectedAuthor, author) + } +} + +// TestSetConfigValueErrorContext verifies Phase 11 error wrapping improvements. +// The error should contain context about what failed. +func TestSetConfigValueErrorContext(t *testing.T) { + // Create a temporary directory for test config + tempDir := t.TempDir() + ivaldiDir := filepath.Join(tempDir, ".ivaldi") + if err := os.MkdirAll(ivaldiDir, 0755); err != nil { + t.Fatalf("Failed to create .ivaldi directory: %v", err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + defer os.Chdir(originalDir) + + // Make the .ivaldi directory read-only to cause a write error + configPath := filepath.Join(ivaldiDir, "config") + // Create the config file first + if err := os.WriteFile(configPath, []byte("{}"), 0644); err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + // Make it read-only + if err := os.Chmod(configPath, 0444); err != nil { + t.Fatalf("Failed to make config read-only: %v", err) + } + defer os.Chmod(configPath, 0644) // Restore for cleanup + + // Try to set a value - should fail with contextual error + err = SetValue("user.name", "Test", false) + if err == nil { + t.Skip("Could not trigger write error - skipping context check") + } + + // The error should contain context from Phase 11's %w wrapping + errStr := err.Error() + if !strings.Contains(errStr, "failed to save config") { + t.Errorf("Error should contain 'failed to save config' context, got: %s", errStr) + } +} + +// TestErrorUnwrapping verifies that Phase 11's %w fixes enable proper error chain inspection. +func TestErrorUnwrapping(t *testing.T) { + // Create a temporary directory for test config + tempDir := t.TempDir() + ivaldiDir := filepath.Join(tempDir, ".ivaldi") + if err := os.MkdirAll(ivaldiDir, 0755); err != nil { + t.Fatalf("Failed to create .ivaldi directory: %v", err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + defer os.Chdir(originalDir) + + // Create the config file first + configPath := filepath.Join(ivaldiDir, "config") + if err := os.WriteFile(configPath, []byte("{}"), 0644); err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + // Make it read-only to cause a write error + if err := os.Chmod(configPath, 0444); err != nil { + t.Fatalf("Failed to make config read-only: %v", err) + } + defer os.Chmod(configPath, 0644) + + // Try to set a value + err = SetValue("user.name", "Test", false) + if err == nil { + t.Skip("Could not trigger write error - skipping unwrap test") + } + + // Verify the error chain can be inspected using errors.Unwrap + // The wrapped error should be accessible via the error chain + unwrapped := errors.Unwrap(err) + if unwrapped == nil { + t.Error("Error should be unwrappable (wrapped with %w)") + } + + // Verify errors.Is works with the wrapped error chain + // We can't directly check for os.ErrPermission because the underlying + // error might be different on different systems, but we can verify + // that the chain is navigable + if unwrapped != nil { + // The unwrapped error should contain filesystem-related info + unwrappedStr := unwrapped.Error() + // This just verifies unwrapping works - the specific message varies by OS + if len(unwrappedStr) == 0 { + t.Error("Unwrapped error should have content") + } + } +} + +func TestLoadConfigMerging(t *testing.T) { + // Create a temporary directory structure + tempDir := t.TempDir() + ivaldiDir := filepath.Join(tempDir, ".ivaldi") + if err := os.MkdirAll(ivaldiDir, 0755); err != nil { + t.Fatalf("Failed to create .ivaldi directory: %v", err) + } + + // Create repo config with specific values + repoConfig := `{ + "user": {"name": "Repo User", "email": ""}, + "core": {"editor": "nano"}, + "color": {"ui": false} + }` + if err := os.WriteFile(filepath.Join(ivaldiDir, "config"), []byte(repoConfig), 0644); err != nil { + t.Fatalf("Failed to create repo config: %v", err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + defer os.Chdir(originalDir) + + // Load config and verify merging + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Repo config should override defaults where specified + if cfg.User.Name != "Repo User" { + t.Errorf("Expected user.name from repo config, got %q", cfg.User.Name) + } + if cfg.Core.Editor != "nano" { + t.Errorf("Expected core.editor from repo config, got %q", cfg.Core.Editor) + } + // Color.UI should be false from repo config + if cfg.Color.UI != false { + t.Error("Expected color.ui to be false from repo config") + } +} diff --git a/internal/fsmerkle/bench_test.go b/internal/fsmerkle/bench_test.go index 960d6ff..71cb974 100644 --- a/internal/fsmerkle/bench_test.go +++ b/internal/fsmerkle/bench_test.go @@ -38,7 +38,9 @@ func BenchmarkBlobHashing(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = blob.Hash(content) + if _, err := blob.Hash(content); err != nil { + b.Fatal(err) + } } b.SetBytes(int64(size)) }) diff --git a/internal/fsmerkle/fsmerkle_test.go b/internal/fsmerkle/fsmerkle_test.go index 0eb7453..fe1f0b5 100644 --- a/internal/fsmerkle/fsmerkle_test.go +++ b/internal/fsmerkle/fsmerkle_test.go @@ -20,8 +20,14 @@ func TestBlobNode(t *testing.T) { } // Test hashing - hash1 := blob.Hash(content) - hash2 := blob.Hash(content) + hash1, err := blob.Hash(content) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + hash2, err := blob.Hash(content) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } if hash1 != hash2 { t.Error("Same blob should produce same hash") } @@ -29,21 +35,21 @@ func TestBlobNode(t *testing.T) { // Test with different content should produce different hash otherContent := []byte("Different content") otherBlob := &BlobNode{Size: len(otherContent)} - otherHash := otherBlob.Hash(otherContent) + otherHash, err := otherBlob.Hash(otherContent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } if hash1 == otherHash { t.Error("Different blobs should produce different hashes") } } -func TestBlobNodePanic(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Error("Expected panic for content size mismatch") - } - }() - +func TestBlobNodeSizeMismatch(t *testing.T) { blob := &BlobNode{Size: 5} - blob.Hash([]byte("too long content")) + _, err := blob.Hash([]byte("too long content")) + if err == nil { + t.Error("Expected error for content size mismatch") + } } func TestTreeNode(t *testing.T) { @@ -348,8 +354,14 @@ func TestHashStability(t *testing.T) { content := []byte("stable content") blob := &BlobNode{Size: len(content)} - hash1 := blob.Hash(content) - hash2 := blob.Hash(content) + hash1, err := blob.Hash(content) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + hash2, err := blob.Hash(content) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } if hash1 != hash2 { t.Error("Hash should be stable for same input") diff --git a/internal/fsmerkle/storage.go b/internal/fsmerkle/storage.go index c6296eb..6b21889 100644 --- a/internal/fsmerkle/storage.go +++ b/internal/fsmerkle/storage.go @@ -101,18 +101,21 @@ func NewStore(cas CAS) *Store { // PutBlob implements Builder.PutBlob. func (s *Store) PutBlob(content []byte) (Hash, int, error) { blob := &BlobNode{Size: len(content)} - hash := blob.Hash(content) - + hash, err := blob.Hash(content) + if err != nil { + return Hash{}, 0, fmt.Errorf("failed to hash blob: %w", err) + } + // Create canonical representation (header + content) var buf bytes.Buffer buf.Write(blob.CanonicalBytes()) buf.Write(content) canonical := buf.Bytes() - + if err := s.cas.Put(hash, canonical); err != nil { return Hash{}, 0, fmt.Errorf("failed to store blob: %w", err) } - + return hash, blob.Size, nil } diff --git a/internal/fsmerkle/types.go b/internal/fsmerkle/types.go index d0392fe..2ac2452 100644 --- a/internal/fsmerkle/types.go +++ b/internal/fsmerkle/types.go @@ -138,16 +138,16 @@ func (b *BlobNode) CanonicalBytes() []byte { } // Hash computes the BLAKE3 hash of the blob's canonical representation plus content. -func (b *BlobNode) Hash(content []byte) Hash { +func (b *BlobNode) Hash(content []byte) (Hash, error) { if len(content) != b.Size { - panic(fmt.Sprintf("content size mismatch: expected %d, got %d", b.Size, len(content))) + return Hash{}, fmt.Errorf("content size mismatch: expected %d, got %d", b.Size, len(content)) } var buf bytes.Buffer buf.Write(b.CanonicalBytes()) buf.Write(content) - return blake3.Sum256(buf.Bytes()) + return blake3.Sum256(buf.Bytes()), nil } // CanonicalBytes returns the canonical byte representation of a TreeNode. diff --git a/internal/gitclone/cloner.go b/internal/gitclone/cloner.go index 2d271a4..b0ca8da 100644 --- a/internal/gitclone/cloner.go +++ b/internal/gitclone/cloner.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "strings" "time" "github.com/go-git/go-git/v5" @@ -98,6 +99,13 @@ func (c *Cloner) Clone(ctx context.Context, opts *CloneOptions) error { return c.handleCloneError(err, opts.URL) } + // Check if cloned repo has existing Ivaldi data + ivaldiSrcDir := filepath.Join(tempDir, ".ivaldi") + if info, err := os.Stat(ivaldiSrcDir); err == nil && info.IsDir() { + fmt.Println("Found existing Ivaldi data in repository") + return c.importExistingIvaldiData(ivaldiSrcDir, tempDir) + } + // Get HEAD reference ref, err := repo.Head() if err != nil { @@ -450,3 +458,202 @@ func findSubstring(s, substr string) bool { } return false } + +// copyFile copies a single file from src to dst, preserving file mode +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + sourceInfo, err := sourceFile.Stat() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + if _, err = io.Copy(destFile, sourceFile); err != nil { + return err + } + + return os.Chmod(dst, sourceInfo.Mode()) +} + +// copyDir recursively copies a directory from src to dst +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + dstPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(dstPath, info.Mode()) + } + return copyFile(path, dstPath) + }) +} + +// importExistingIvaldiData imports an existing .ivaldi directory from a cloned repo +// instead of converting Git commits to Ivaldi format. This preserves the original +// Ivaldi hashes, mappings, and history. +func (c *Cloner) importExistingIvaldiData(srcIvaldiDir, srcWorkDir string) error { + fmt.Println("Importing existing Ivaldi data...") + + // 1. Copy objects directory (CAS store) + srcObjects := filepath.Join(srcIvaldiDir, "objects") + if info, err := os.Stat(srcObjects); err == nil && info.IsDir() { + dstObjects := filepath.Join(c.ivaldiDir, "objects") + if err := copyDir(srcObjects, dstObjects); err != nil { + return fmt.Errorf("failed to copy objects directory: %w", err) + } + fmt.Println(" Copied CAS objects") + } + + // 2. Copy refs directory (timelines, tags, seals) + srcRefs := filepath.Join(srcIvaldiDir, "refs") + if info, err := os.Stat(srcRefs); err == nil && info.IsDir() { + dstRefs := filepath.Join(c.ivaldiDir, "refs") + if err := copyDir(srcRefs, dstRefs); err != nil { + return fmt.Errorf("failed to copy refs directory: %w", err) + } + fmt.Println(" Copied refs") + } + + // 3. Copy objects.db (BoltDB with hash mappings and config) + srcDB := filepath.Join(srcIvaldiDir, "objects.db") + if _, err := os.Stat(srcDB); err == nil { + dstDB := filepath.Join(c.ivaldiDir, "objects.db") + if err := copyFile(srcDB, dstDB); err != nil { + return fmt.Errorf("failed to copy objects.db: %w", err) + } + fmt.Println(" Copied database") + } + + // 4. Copy HEAD file + srcHEAD := filepath.Join(srcIvaldiDir, "HEAD") + if _, err := os.Stat(srcHEAD); err == nil { + dstHEAD := filepath.Join(c.ivaldiDir, "HEAD") + if err := copyFile(srcHEAD, dstHEAD); err != nil { + return fmt.Errorf("failed to copy HEAD: %w", err) + } + fmt.Println(" Copied HEAD") + } + + // 5. Copy config if present + srcConfig := filepath.Join(srcIvaldiDir, "config") + if _, err := os.Stat(srcConfig); err == nil { + dstConfig := filepath.Join(c.ivaldiDir, "config") + if err := copyFile(srcConfig, dstConfig); err != nil { + return fmt.Errorf("failed to copy config: %w", err) + } + fmt.Println(" Copied config") + } + + // 6. Copy MMR data if present + srcMMR := filepath.Join(srcIvaldiDir, "mmr.db") + if _, err := os.Stat(srcMMR); err == nil { + dstMMR := filepath.Join(c.ivaldiDir, "mmr.db") + if err := copyFile(srcMMR, dstMMR); err != nil { + return fmt.Errorf("failed to copy mmr.db: %w", err) + } + fmt.Println(" Copied MMR data") + } + + // 7. Copy workspace files (non-.git, non-.ivaldi files) + err := filepath.Walk(srcWorkDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(srcWorkDir, path) + if err != nil { + return err + } + + // Skip .git and .ivaldi directories + if relPath == ".git" || relPath == ".ivaldi" || + strings.HasPrefix(relPath, ".git"+string(filepath.Separator)) || + strings.HasPrefix(relPath, ".ivaldi"+string(filepath.Separator)) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + dstPath := filepath.Join(c.workDir, relPath) + + if info.IsDir() { + return os.MkdirAll(dstPath, info.Mode()) + } + return copyFile(path, dstPath) + }) + if err != nil { + return fmt.Errorf("failed to copy workspace files: %w", err) + } + fmt.Println(" Copied workspace files") + + // 8. Validate imported data + if err := c.validateImportedData(); err != nil { + return fmt.Errorf("imported Ivaldi data validation failed: %w", err) + } + + fmt.Println("Successfully imported existing Ivaldi repository") + return nil +} + +// validateImportedData verifies that the imported Ivaldi data is valid +func (c *Cloner) validateImportedData() error { + // Reinitialize CAS to use the imported objects + objectsDir := filepath.Join(c.ivaldiDir, "objects") + casStore, err := cas.NewFileCAS(objectsDir) + if err != nil { + return fmt.Errorf("failed to open CAS: %w", err) + } + c.casStore = casStore + + // Verify HEAD points to valid timeline + refsManager, err := refs.NewRefsManager(c.ivaldiDir) + if err != nil { + return fmt.Errorf("failed to open refs manager: %w", err) + } + defer refsManager.Close() + + timeline, err := refsManager.GetCurrentTimeline() + if err != nil { + return fmt.Errorf("no current timeline: %w", err) + } + + // Verify latest commit exists in CAS + timelineInfo, err := refsManager.GetTimeline(timeline, refs.LocalTimeline) + if err != nil { + return fmt.Errorf("failed to get timeline %s: %w", timeline, err) + } + + // If timeline has a commit, verify it exists + var zeroHash [32]byte + if timelineInfo.Blake3Hash != zeroHash { + var commitHash cas.Hash + copy(commitHash[:], timelineInfo.Blake3Hash[:]) + if _, err := c.casStore.Get(commitHash); err != nil { + return fmt.Errorf("commit %x not found in CAS: %w", timelineInfo.Blake3Hash[:8], err) + } + } + + return nil +} diff --git a/internal/github/sync_push.go b/internal/github/sync_push.go index c320c41..f37aa7b 100644 --- a/internal/github/sync_push.go +++ b/internal/github/sync_push.go @@ -235,7 +235,7 @@ func (rs *RepoSyncer) createBlobsParallel(ctx context.Context, owner, repo strin } if len(errors) > 0 { - return nil, fmt.Errorf("failed to upload %d files: %v", len(errors), errors[0]) + return nil, fmt.Errorf("failed to upload %d files: %w", len(errors), errors[0]) } // NOTE: When using base_tree for delta uploads, deletions are handled automatically diff --git a/internal/history/mmr.go b/internal/history/mmr.go index 8dd0e4d..4a026fa 100644 --- a/internal/history/mmr.go +++ b/internal/history/mmr.go @@ -249,8 +249,8 @@ func (m *MMR) leafIndexToPos(leafIdx uint64) uint64 { return 2*leafIdx - popcount(leafIdx+1) + 1 } -// Popcount returns the number of set bits in x. -func Popcount(x uint64) uint64 { +// popcount returns the number of set bits in x. +func popcount(x uint64) uint64 { count := uint64(0) for x > 0 { count += x & 1 @@ -259,11 +259,6 @@ func Popcount(x uint64) uint64 { return count } -// popcount is an internal alias for Popcount -func popcount(x uint64) uint64 { - return Popcount(x) -} - // getHeight returns the height of a node at the given position. func (m *MMR) getHeight(pos uint64) uint64 { // Count trailing ones in (pos + 1) diff --git a/internal/keys/keys.go b/internal/keys/keys.go index bf9c011..23d350f 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -21,7 +21,9 @@ var words = []string{ func randUint32() uint32 { var b [4]byte - _, _ = rand.Read(b[:]) + if _, err := rand.Read(b[:]); err != nil { + panic("crypto/rand failed: " + err.Error()) + } return binary.LittleEndian.Uint32(b[:]) } diff --git a/internal/objects/object.go b/internal/objects/object.go index 9fda623..4e1cf6a 100644 --- a/internal/objects/object.go +++ b/internal/objects/object.go @@ -116,33 +116,6 @@ type DualDigest struct { Size int } -// DigestsFromZstdGitBlob returns both canonical digests from a zstd blob. -func DigestsFromZstdGitBlob(r io.Reader) (*DualDigest, error) { - blob, err := DecodeZstdGitBlob(r) - if err != nil { - return nil, err - } - return &DualDigest{ - SHA256: HashBlobSHA256(blob.Content), - BLAKE3: HashBlobBLAKE3(blob.Content), - Size: blob.Size, - }, nil -} - -// ConvertZstdBlobToBLAKE3 re-hashes content as BLAKE3. -func ConvertZstdBlobToBLAKE3(r io.Reader) (content []byte, blake3Sum [32]byte, err error) { - blob, err := DecodeZstdGitBlob(r) - if err != nil { - return nil, [32]byte{}, err - } - return blob.Content, HashBlobBLAKE3(blob.Content), nil -} - -// ConvertContentBLAKE3ToSHA256 re-hashes content as SHA-256. -func ConvertContentBLAKE3ToSHA256(content []byte) [32]byte { - return HashBlobSHA256(content) -} - // --------------------------- // Small helpers // --------------------------- diff --git a/internal/progress/progress.go b/internal/progress/progress.go index c8f635a..a65ae14 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -64,20 +64,6 @@ func NewUploadBar(total int, description string) *Bar { return &Bar{bar: bar} } -// NewSpinner creates a spinner-style progress indicator for unknown totals -func NewSpinner(description string) *Bar { - bar := progressbar.NewOptions(-1, - progressbar.OptionSetDescription(description), - progressbar.OptionSetWriter(os.Stderr), - progressbar.OptionThrottle(65*time.Millisecond), - progressbar.OptionSpinnerType(14), - progressbar.OptionOnCompletion(func() { - fmt.Fprintln(os.Stderr) - }), - ) - return &Bar{bar: bar} -} - // Increment advances the progress bar by one func (b *Bar) Increment() error { return b.bar.Add(1) diff --git a/internal/proto/negotitate.go b/internal/proto/negotitate.go deleted file mode 100644 index fe809a8..0000000 --- a/internal/proto/negotitate.go +++ /dev/null @@ -1,31 +0,0 @@ -package proto - -// NegotiateCompression picks the best common compression between local preference -// (preferZstd) and the remote's advertised capabilities. -// -// remoteCaps example: []string{"zlib","zstd"} -// return value: "zstd" or "zlib" -func NegotiateCompression(remoteCaps []string, preferZstd bool) string { - hasZstd := false - hasZlib := false - for _, c := range remoteCaps { - switch c { - case "zstd": - hasZstd = true - case "zlib", "deflate": - hasZlib = true - } - } - if preferZstd && hasZstd { - return "zstd" - } - // fallback - if hasZlib { - return "zlib" - } - // last resort default - if hasZstd { - return "zstd" - } - return "zlib" -} diff --git a/internal/seals/sealnames.go b/internal/seals/sealnames.go index a30b9d3..605ce31 100644 --- a/internal/seals/sealnames.go +++ b/internal/seals/sealnames.go @@ -11,12 +11,10 @@ package seals import ( - "crypto/sha256" "encoding/binary" "encoding/hex" "fmt" "math/rand" - "strings" "time" ) @@ -96,116 +94,3 @@ func GenerateSealName(hash [32]byte) string { return fmt.Sprintf("%s-%s-%s-%s-%s", adj, noun, verb, adv, shortHash) } -// GenerateCustomSealName creates a seal name with user-provided base and hash suffix -func GenerateCustomSealName(customName string, hash [32]byte) string { - // Sanitize custom name (replace spaces with dashes, lowercase) - sanitized := strings.ToLower(strings.ReplaceAll(customName, " ", "-")) - // Remove any non-alphanumeric characters except dashes - var result strings.Builder - for _, r := range sanitized { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - result.WriteRune(r) - } - } - - shortHash := hex.EncodeToString(hash[:4]) - return fmt.Sprintf("%s-%s", result.String(), shortHash) -} - -// ParseSealName extracts components from a seal name -func ParseSealName(name string) (adjective, noun, verb, adverb, shortHash string, isValid bool) { - parts := strings.Split(name, "-") - if len(parts) < 2 { - return "", "", "", "", "", false - } - - // For auto-generated names, expect 5 parts - if len(parts) == 5 { - return parts[0], parts[1], parts[2], parts[3], parts[4], true - } - - // For custom names, the last part should be the hash - lastPart := parts[len(parts)-1] - if len(lastPart) == 8 { - // Check if last part is a valid hex string - if _, err := hex.DecodeString(lastPart); err == nil { - baseName := strings.Join(parts[:len(parts)-1], "-") - return baseName, "", "", "", lastPart, true - } - } - - return "", "", "", "", "", false -} - -// GetShortHashFromName extracts the 8-character hash from a seal name -func GetShortHashFromName(name string) (string, bool) { - parts := strings.Split(name, "-") - if len(parts) < 2 { - return "", false - } - - lastPart := parts[len(parts)-1] - if len(lastPart) == 8 { - if _, err := hex.DecodeString(lastPart); err == nil { - return lastPart, true - } - } - - return "", false -} - -// ValidateSealName checks if a seal name follows the expected format -func ValidateSealName(name string) bool { - _, _, _, _, _, valid := ParseSealName(name) - return valid -} - -// GetBaseName returns the name without the hash suffix -func GetBaseName(name string) string { - parts := strings.Split(name, "-") - if len(parts) < 2 { - return name - } - - lastPart := parts[len(parts)-1] - if len(lastPart) == 8 { - if _, err := hex.DecodeString(lastPart); err == nil { - return strings.Join(parts[:len(parts)-1], "-") - } - } - - return name -} - -// GenerateTestHash creates a test hash for development purposes -func GenerateTestHash(input string) [32]byte { - hash := sha256.Sum256([]byte(input)) - var result [32]byte - copy(result[:], hash[:]) - return result -} - -// SealNameGenerator provides methods for creating and managing seal names -type SealNameGenerator struct { - // Can be extended with configuration options later -} - -// NewSealNameGenerator creates a new seal name generator -func NewSealNameGenerator() *SealNameGenerator { - return &SealNameGenerator{} -} - -// Generate creates a new seal name from a hash -func (g *SealNameGenerator) Generate(hash [32]byte) string { - return GenerateSealName(hash) -} - -// GenerateCustom creates a seal name with user-provided base -func (g *SealNameGenerator) GenerateCustom(customName string, hash [32]byte) string { - return GenerateCustomSealName(customName, hash) -} - -// Validate checks if a seal name is valid -func (g *SealNameGenerator) Validate(name string) bool { - return ValidateSealName(name) -} diff --git a/internal/submodule/config.go b/internal/submodule/config.go index 753896f..8617b4b 100644 --- a/internal/submodule/config.go +++ b/internal/submodule/config.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" ) @@ -213,8 +212,3 @@ func SubmoduleToConfig(sub *Submodule) Config { Freeze: sub.Freeze, } } - -func ParseBool(s string) bool { - b, _ := strconv.ParseBool(s) - return b -} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 39e5c38..c5fced4 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -16,6 +16,7 @@ package workspace import ( "fmt" "io/fs" + "log" "os" "path/filepath" "strings" @@ -214,7 +215,10 @@ func (m *Materializer) MaterializeTimelineWithAutoShelf(timelineName string, ena if err != nil { // If we can't get the base, use an empty index wsBuilder := wsindex.NewBuilder(m.CAS) - currentTimelineBase, _ = wsBuilder.Build(nil) + currentTimelineBase, err = wsBuilder.Build(nil) + if err != nil { + log.Printf("Warning: could not build timeline base index: %v", err) + } } // ALWAYS create new auto-shelf with the CURRENT workspace state @@ -739,7 +743,10 @@ func (sm *StashManager) CreateStash(name, description string) error { fmt.Sprintf("Stash: %s - %s", name, description), ) - return err + if err != nil { + return fmt.Errorf("failed to create stash tag: %w", err) + } + return nil } // ApplyStash applies a stash to the current workspace. From 77c5378c6f6329d708c3602d5557255d6f7bf918 Mon Sep 17 00:00:00 2001 From: javanhut Date: Tue, 20 Jan 2026 03:17:00 +0000 Subject: [PATCH 2/2] feat: implemented fixes for slow upload --- cli/gather.go | 366 +++++++++++++++++++++++------- cli/gather_test.go | 135 +++++++++++ internal/github/sync_push.go | 289 +++++++++++++++++------ internal/github/sync_push_test.go | 249 ++++++++++++++++++++ 4 files changed, 882 insertions(+), 157 deletions(-) create mode 100644 cli/gather_test.go create mode 100644 internal/github/sync_push_test.go diff --git a/cli/gather.go b/cli/gather.go index 842f929..065257c 100644 --- a/cli/gather.go +++ b/cli/gather.go @@ -6,12 +6,284 @@ import ( "log" "os" "path/filepath" + "runtime" "strings" + "sync" "github.com/javanhut/Ivaldi-vcs/internal/colors" "github.com/spf13/cobra" ) +// PatternCache holds pre-compiled ignore patterns for fast matching +type PatternCache struct { + patterns []string + dirPatterns []string // Patterns ending with / + globPatterns []string // Patterns with wildcards + literalMatches map[string]bool +} + +// NewPatternCache creates a pattern cache from a list of patterns +func NewPatternCache(patterns []string) *PatternCache { + cache := &PatternCache{ + patterns: patterns, + literalMatches: make(map[string]bool), + } + + for _, pattern := range patterns { + if strings.HasSuffix(pattern, "/") { + cache.dirPatterns = append(cache.dirPatterns, strings.TrimSuffix(pattern, "/")) + } else if strings.ContainsAny(pattern, "*?[") { + cache.globPatterns = append(cache.globPatterns, pattern) + } else { + cache.literalMatches[pattern] = true + } + } + + return cache +} + +// IsIgnored checks if a path matches any cached pattern +func (pc *PatternCache) IsIgnored(path string) bool { + if path == ".ivaldiignore" || filepath.Base(path) == ".ivaldiignore" { + return false + } + + baseName := filepath.Base(path) + + // Fast literal match check + if pc.literalMatches[path] || pc.literalMatches[baseName] { + return true + } + + // Check directory patterns + for _, dirPattern := range pc.dirPatterns { + if strings.HasPrefix(path, dirPattern+"/") || path == dirPattern { + return true + } + } + + // Check glob patterns + for _, pattern := range pc.globPatterns { + // Try matching the full path + if matched, _ := filepath.Match(pattern, path); matched { + return true + } + // Try matching just the basename + if matched, _ := filepath.Match(pattern, baseName); matched { + return true + } + // Handle ** patterns + if strings.Contains(pattern, "**") { + parts := strings.Split(pattern, "**") + if len(parts) == 2 { + prefix := strings.TrimPrefix(parts[0], "/") + suffix := strings.TrimPrefix(parts[1], "/") + + if prefix != "" && !strings.HasPrefix(path, prefix) { + continue + } + + if suffix != "" { + if matched, _ := filepath.Match(suffix, baseName); matched { + return true + } + } + } + } + } + + return false +} + +// fileResult holds a file path discovered during parallel walking +type fileResult struct { + path string + err error +} + +// dirJob represents a directory to be processed +type dirJob struct { + path string +} + +// parallelWalker performs parallel directory traversal +type parallelWalker struct { + workDir string + allowAll bool + patternCache *PatternCache + results chan fileResult + jobs chan dirJob + wg sync.WaitGroup + workerCount int + dotFileAsks chan string + dotFileResult chan bool +} + +// newParallelWalker creates a new parallel walker +func newParallelWalker(workDir string, allowAll bool, patternCache *PatternCache) *parallelWalker { + workerCount := runtime.NumCPU() + if workerCount < 4 { + workerCount = 4 + } + + return ¶llelWalker{ + workDir: workDir, + allowAll: allowAll, + patternCache: patternCache, + results: make(chan fileResult, 1000), + jobs: make(chan dirJob, 1000), + workerCount: workerCount, + dotFileAsks: make(chan string), + dotFileResult: make(chan bool), + } +} + +// walk performs the parallel directory walk +func (pw *parallelWalker) walk() []string { + // Start workers + for i := 0; i < pw.workerCount; i++ { + pw.wg.Add(1) + go pw.worker() + } + + // Start with root directory + pw.jobs <- dirJob{path: pw.workDir} + + // Start collector goroutine + var files []string + var mu sync.Mutex + done := make(chan struct{}) + + go func() { + for result := range pw.results { + if result.err != nil { + log.Printf("Warning: %v", result.err) + continue + } + mu.Lock() + files = append(files, result.path) + mu.Unlock() + } + close(done) + }() + + // Handle dot file prompts in main goroutine (for user interaction) + go func() { + for path := range pw.dotFileAsks { + pw.dotFileResult <- shouldGatherDotFile(path) + } + }() + + // Wait for all workers to finish + pw.wg.Wait() + close(pw.jobs) + close(pw.results) + close(pw.dotFileAsks) + + <-done + + return files +} + +// worker processes directory jobs +func (pw *parallelWalker) worker() { + defer pw.wg.Done() + + for job := range pw.jobs { + pw.processDir(job.path) + } +} + +// processDir processes a single directory +func (pw *parallelWalker) processDir(dirPath string) { + entries, err := os.ReadDir(dirPath) + if err != nil { + pw.results <- fileResult{err: fmt.Errorf("failed to read directory %s: %w", dirPath, err)} + return + } + + for _, entry := range entries { + fullPath := filepath.Join(dirPath, entry.Name()) + relPath, err := filepath.Rel(pw.workDir, fullPath) + if err != nil { + continue + } + + if entry.IsDir() { + // Skip .ivaldi directory + if relPath == ".ivaldi" || strings.HasPrefix(relPath, ".ivaldi"+string(filepath.Separator)) { + continue + } + + // Check if directory is auto-excluded + if isAutoExcluded(relPath) { + log.Printf("Auto-excluded directory for security: %s", relPath) + continue + } + + // Check if directory matches ignore patterns + if pw.patternCache.IsIgnored(relPath) || pw.patternCache.IsIgnored(relPath+"/") { + log.Printf("Skipping ignored directory: %s", relPath) + continue + } + + // Check for hidden directories + if entry.Name()[0] == '.' && relPath != "." { + if !pw.allowAll { + log.Printf("Skipping hidden directory: %s", relPath) + continue + } + } + + // Queue subdirectory for processing + select { + case pw.jobs <- dirJob{path: fullPath}: + pw.wg.Add(1) + default: + // If channel is full, process synchronously + pw.processDir(fullPath) + } + } else { + // Process file + pw.processFile(relPath, entry.Name()) + } + } +} + +// processFile processes a single file +func (pw *parallelWalker) processFile(relPath, baseName string) { + // Skip .ivaldi directory files + if strings.HasPrefix(relPath, ".ivaldi"+string(filepath.Separator)) || relPath == ".ivaldi" { + return + } + + // Check if file is auto-excluded + if isAutoExcluded(relPath) { + log.Printf("Auto-excluded for security: %s", relPath) + return + } + + // Skip hidden files EXCEPT .ivaldiignore + if baseName[0] == '.' && relPath != ".ivaldiignore" { + if !pw.allowAll { + // For dot files, we need to ask the user (done synchronously via channel) + pw.dotFileAsks <- relPath + if <-pw.dotFileResult { + pw.results <- fileResult{path: relPath} + } + return + } + fmt.Printf("Warning: Gathering hidden file: %s\n", relPath) + } + + // Skip ignored files + if pw.patternCache.IsIgnored(relPath) { + return + } + + pw.results <- fileResult{path: relPath} +} + // Auto-excluded patterns that are always ignored for security var autoExcludePatterns = []string{ ".env", @@ -42,11 +314,12 @@ var gatherCmd = &cobra.Command{ return fmt.Errorf("failed to get allow-all flag: %w", err) } - // Load ignore patterns from .ivaldiignore + // Load ignore patterns from .ivaldiignore and create pattern cache ignorePatterns, err := loadIgnorePatternsForGather(workDir) if err != nil { log.Printf("Warning: Failed to load ignore patterns: %v", err) } + patternCache := NewPatternCache(ignorePatterns) // Create staging area directory stageDir := filepath.Join(ivaldiDir, "stage") @@ -57,89 +330,12 @@ var gatherCmd = &cobra.Command{ var filesToGather []string if len(args) == 0 { - // If no arguments, gather all modified files + // If no arguments, gather all files using parallel walker fmt.Println("No files specified, gathering all files in working directory...") - err := filepath.Walk(workDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - // Get relative path - relPath, err := filepath.Rel(workDir, path) - if err != nil { - return err - } - - // Handle directories - check exclusions BEFORE deciding to skip - if info.IsDir() { - // Skip .ivaldi directory - if relPath == ".ivaldi" || strings.HasPrefix(relPath, ".ivaldi"+string(filepath.Separator)) { - return filepath.SkipDir - } - - // Check if directory is auto-excluded - if isAutoExcluded(relPath) { - log.Printf("Auto-excluded directory for security: %s", relPath) - return filepath.SkipDir - } - - // Check if directory matches ignore patterns - // Try both with and without trailing slash - if isFileIgnored(relPath, ignorePatterns) || isFileIgnored(relPath+"/", ignorePatterns) { - log.Printf("Skipping ignored directory: %s", relPath) - return filepath.SkipDir - } - - // Check for hidden directories (except .ivaldiignore parent) - if filepath.Base(path)[0] == '.' && relPath != "." { - if !allowAll { - log.Printf("Skipping hidden directory: %s", relPath) - return filepath.SkipDir - } - } - - // Directory is not excluded, continue into it - return nil - } - - // From here on, we're dealing with files only - - // Skip .ivaldi directory files (shouldn't happen but just in case) - if strings.HasPrefix(relPath, ".ivaldi"+string(filepath.Separator)) || relPath == ".ivaldi" { - return nil - } - - // Check if file is auto-excluded (.env, .venv, etc.) - if isAutoExcluded(relPath) { - log.Printf("Auto-excluded for security: %s", relPath) - return nil - } - // Skip hidden files EXCEPT .ivaldiignore - if filepath.Base(path)[0] == '.' && relPath != ".ivaldiignore" { - // Prompt user for dot files unless --allow-all is set - if !allowAll { - if shouldGatherDotFile(relPath) { - filesToGather = append(filesToGather, relPath) - } - return nil - } else { - // With --allow-all, still warn about dot files - fmt.Printf("Warning: Gathering hidden file: %s\n", relPath) - } - } - - // Skip ignored files (but never ignore .ivaldiignore itself) - if isFileIgnored(relPath, ignorePatterns) { - return nil - } - - filesToGather = append(filesToGather, relPath) - return nil - }) - if err != nil { - return fmt.Errorf("failed to walk directory: %w", err) - } + // Use parallel walker for better performance + walker := newParallelWalker(workDir, allowAll, patternCache) + filesToGather = walker.walk() } else { // Use specified files for _, arg := range args { @@ -182,7 +378,7 @@ var gatherCmd = &cobra.Command{ } // Check if directory matches ignore patterns - if isFileIgnored(relPath, ignorePatterns) || isFileIgnored(relPath+"/", ignorePatterns) { + if patternCache.IsIgnored(relPath) || patternCache.IsIgnored(relPath+"/") { log.Printf("Skipping ignored directory: %s", relPath) return filepath.SkipDir } @@ -230,7 +426,7 @@ var gatherCmd = &cobra.Command{ } // Skip ignored files (but never ignore .ivaldiignore itself) - if isFileIgnored(relPath, ignorePatterns) { + if patternCache.IsIgnored(relPath) { log.Printf("Skipping ignored file: %s", relPath) return nil } @@ -267,7 +463,7 @@ var gatherCmd = &cobra.Command{ } // Check if file is ignored - if isFileIgnored(relPath, ignorePatterns) { + if patternCache.IsIgnored(relPath) { log.Printf("Warning: File '%s' is in .ivaldiignore, skipping", relPath) continue } diff --git a/cli/gather_test.go b/cli/gather_test.go new file mode 100644 index 0000000..bd4fe72 --- /dev/null +++ b/cli/gather_test.go @@ -0,0 +1,135 @@ +package cli + +import ( + "testing" +) + +// BenchmarkPatternCache benchmarks the pattern cache matching +func BenchmarkPatternCache(b *testing.B) { + patterns := []string{ + "*.log", + "*.tmp", + "node_modules/", + "vendor/", + "dist/", + "build/", + "**/*.bak", + "**/*.swp", + ".git/", + ".DS_Store", + "Thumbs.db", + "coverage/", + "*.pyc", + "__pycache__/", + } + + testPaths := []string{ + "src/main.go", + "src/utils/helper.go", + "node_modules/express/index.js", + "dist/bundle.js", + "README.md", + "internal/parser/parser.go", + "test/integration_test.go", + "docs/api.md", + "config.yaml", + "main.log", + "debug.tmp", + "vendor/github.com/example/pkg/file.go", + } + + b.Run("PatternCache", func(b *testing.B) { + cache := NewPatternCache(patterns) + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, path := range testPaths { + cache.IsIgnored(path) + } + } + }) + + b.Run("OriginalPatternMatching", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, path := range testPaths { + isFileIgnored(path, patterns) + } + } + }) +} + +// BenchmarkPatternCacheLargePatternSet benchmarks with many patterns +func BenchmarkPatternCacheLargePatternSet(b *testing.B) { + // Generate a large pattern set + patterns := make([]string, 100) + for i := 0; i < 50; i++ { + patterns[i] = "dir" + string(rune('a'+i%26)) + "/" + } + for i := 50; i < 100; i++ { + patterns[i] = "*." + string(rune('a'+i%26)) + "xt" + } + + testPaths := make([]string, 100) + for i := 0; i < 100; i++ { + testPaths[i] = "src/pkg" + string(rune('a'+i%26)) + "/file" + string(rune('0'+i%10)) + ".go" + } + + b.Run("PatternCache_100Patterns", func(b *testing.B) { + cache := NewPatternCache(patterns) + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, path := range testPaths { + cache.IsIgnored(path) + } + } + }) + + b.Run("Original_100Patterns", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, path := range testPaths { + isFileIgnored(path, patterns) + } + } + }) +} + +// TestPatternCacheCorrectness verifies pattern cache matches original behavior +func TestPatternCacheCorrectness(t *testing.T) { + patterns := []string{ + "*.log", + "node_modules/", + "dist/", + "**/*.bak", + ".DS_Store", + } + + testCases := []struct { + path string + expected bool + }{ + {"main.log", true}, + {"src/main.go", false}, + {"node_modules/express/index.js", true}, + {"dist/bundle.js", true}, + {".DS_Store", true}, + {"README.md", false}, + {"file.bak", true}, + {".ivaldiignore", false}, // Should never be ignored + } + + cache := NewPatternCache(patterns) + + for _, tc := range testCases { + cacheResult := cache.IsIgnored(tc.path) + originalResult := isFileIgnored(tc.path, patterns) + + if cacheResult != originalResult { + t.Errorf("Mismatch for path %q: cache=%v, original=%v", tc.path, cacheResult, originalResult) + } + + if cacheResult != tc.expected { + t.Errorf("Path %q: expected %v, got %v", tc.path, tc.expected, cacheResult) + } + } +} diff --git a/internal/github/sync_push.go b/internal/github/sync_push.go index f37aa7b..dd41255 100644 --- a/internal/github/sync_push.go +++ b/internal/github/sync_push.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "sync" "github.com/javanhut/Ivaldi-vcs/internal/cas" @@ -24,12 +25,32 @@ type FileChange struct { Type string // "added", "modified", "deleted" } -// computeFileDeltas compares two commits and returns changed files +// fileHashJob represents a job to compute file hash +type fileHashJob struct { + filePath string + tree *commit.TreeObject +} + +// fileHashResult represents the result of a hash computation +type fileHashResult struct { + filePath string + hash cas.Hash + content []byte + err error +} + +// computeFileDeltas compares two commits and returns changed files using parallel hash computation func (rs *RepoSyncer) computeFileDeltas(parentHash, currentHash cas.Hash) ([]FileChange, error) { commitReader := commit.NewCommitReader(rs.casStore) + // Determine worker count + workerCount := runtime.NumCPU() + if workerCount < 4 { + workerCount = 4 + } + // Read parent commit and tree - var parentFiles map[string]cas.Hash + var parentFiles sync.Map // map[string]cas.Hash if parentHash != (cas.Hash{}) { parentCommit, err := commitReader.ReadCommit(parentHash) if err != nil { @@ -46,16 +67,49 @@ func (rs *RepoSyncer) computeFileDeltas(parentHash, currentHash cas.Hash) ([]Fil return nil, fmt.Errorf("failed to list parent files: %w", err) } - parentFiles = make(map[string]cas.Hash) - for _, filePath := range parentFileList { - content, err := commitReader.GetFileContent(parentTree, filePath) - if err != nil { - continue + // Compute parent hashes in parallel + if len(parentFileList) > 0 { + jobs := make(chan fileHashJob, len(parentFileList)) + results := make(chan fileHashResult, len(parentFileList)) + + var wg sync.WaitGroup + for i := 0; i < workerCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobs { + content, err := commitReader.GetFileContent(job.tree, job.filePath) + if err != nil { + results <- fileHashResult{filePath: job.filePath, err: err} + continue + } + results <- fileHashResult{ + filePath: job.filePath, + hash: cas.SumB3(content), + } + } + }() + } + + // Submit jobs + for _, filePath := range parentFileList { + jobs <- fileHashJob{filePath: filePath, tree: parentTree} + } + close(jobs) + + // Wait for workers and close results + go func() { + wg.Wait() + close(results) + }() + + // Collect results + for result := range results { + if result.err == nil { + parentFiles.Store(result.filePath, result.hash) + } } - parentFiles[filePath] = cas.SumB3(content) } - } else { - parentFiles = make(map[string]cas.Hash) } // Read current commit and tree @@ -74,58 +128,131 @@ func (rs *RepoSyncer) computeFileDeltas(parentHash, currentHash cas.Hash) ([]Fil return nil, fmt.Errorf("failed to list current files: %w", err) } - // Build map of current files - currentFiles := make(map[string][]byte) - for _, filePath := range currentFileList { - content, err := commitReader.GetFileContent(currentTree, filePath) - if err != nil { - continue + // Build map of current files with parallel content reading + var currentFiles sync.Map // map[string][]byte + + if len(currentFileList) > 0 { + jobs := make(chan fileHashJob, len(currentFileList)) + results := make(chan fileHashResult, len(currentFileList)) + + var wg sync.WaitGroup + for i := 0; i < workerCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobs { + content, err := commitReader.GetFileContent(job.tree, job.filePath) + if err != nil { + results <- fileHashResult{filePath: job.filePath, err: err} + continue + } + results <- fileHashResult{ + filePath: job.filePath, + hash: cas.SumB3(content), + content: content, + } + } + }() + } + + // Submit jobs + for _, filePath := range currentFileList { + jobs <- fileHashJob{filePath: filePath, tree: currentTree} + } + close(jobs) + + // Wait for workers and close results + go func() { + wg.Wait() + close(results) + }() + + // Collect results + for result := range results { + if result.err == nil { + currentFiles.Store(result.filePath, result.content) + } } - currentFiles[filePath] = content } // Compute deltas var changes []FileChange + var changesMu sync.Mutex - // Check for added and modified files - for filePath, content := range currentFiles { - currentHash := cas.SumB3(content) - parentHash, existed := parentFiles[filePath] + // Check for added and modified files in parallel + var deltaWg sync.WaitGroup + deltaJobs := make(chan string, len(currentFileList)) - mode := "100644" // regular file - if len(content) > 0 && content[0] == '#' && bytes.Contains(content[:min(100, len(content))], []byte("!/")) { - mode = "100755" - } + for i := 0; i < workerCount; i++ { + deltaWg.Add(1) + go func() { + defer deltaWg.Done() + for filePath := range deltaJobs { + contentVal, ok := currentFiles.Load(filePath) + if !ok { + continue + } + content := contentVal.([]byte) + currHash := cas.SumB3(content) - if !existed { - // File added - changes = append(changes, FileChange{ - Path: filePath, - Content: content, - Mode: mode, - Type: "added", - }) - } else if currentHash != parentHash { - // File modified - changes = append(changes, FileChange{ - Path: filePath, - Content: content, - Mode: mode, - Type: "modified", - }) - } - // If hashes match, file unchanged - skip + mode := "100644" // regular file + if len(content) > 0 && content[0] == '#' && bytes.Contains(content[:min(100, len(content))], []byte("!/")) { + mode = "100755" + } + + parentHashVal, existed := parentFiles.Load(filePath) + var change *FileChange + + if !existed { + // File added + change = &FileChange{ + Path: filePath, + Content: content, + Mode: mode, + Type: "added", + } + } else { + parentH := parentHashVal.(cas.Hash) + if currHash != parentH { + // File modified + change = &FileChange{ + Path: filePath, + Content: content, + Mode: mode, + Type: "modified", + } + } + } + + if change != nil { + changesMu.Lock() + changes = append(changes, *change) + changesMu.Unlock() + } + } + }() } + // Submit delta comparison jobs + for _, filePath := range currentFileList { + deltaJobs <- filePath + } + close(deltaJobs) + deltaWg.Wait() + // Check for deleted files - for filePath := range parentFiles { - if _, exists := currentFiles[filePath]; !exists { + parentFiles.Range(func(key, _ any) bool { + filePath := key.(string) + if _, exists := currentFiles.Load(filePath); !exists { + changesMu.Lock() changes = append(changes, FileChange{ Path: filePath, Type: "deleted", }) + changesMu.Unlock() } - } + return true + }) return changes, nil } @@ -390,50 +517,68 @@ func (rs *RepoSyncer) PushCommit(ctx context.Context, owner, repo, branch string return fmt.Errorf("failed to list files: %w", err) } - // Special case: empty repository requires using Contents API for first commit + // Empty repository case: use parallel Git Data API for better performance if parentSHA == "" { - fmt.Printf("Initial upload to empty repository: uploading %d files using Contents API\n", len(files)) + fmt.Printf("Initial upload to empty repository: uploading %d files in parallel\n", len(files)) - // Create progress bar for initial upload - initialUploadBar := progress.NewUploadBar(len(files), "Uploading initial files") - - // Upload files using Contents API (creates commits automatically) + // Build change list for all files + var initialChanges []FileChange for _, filePath := range files { content, err := commitReader.GetFileContent(tree, filePath) if err != nil { - initialUploadBar.Finish() return fmt.Errorf("failed to get content for %s: %w", filePath, err) } - // Create upload request directly with content (don't use UploadFile helper) - uploadReq := FileUploadRequest{ - Message: commitObj.Message, - Content: base64.StdEncoding.EncodeToString(content), - Branch: branch, + mode := "100644" // regular file + if len(content) > 0 && content[0] == '#' && bytes.Contains(content[:min(100, len(content))], []byte("!/")) { + mode = "100755" } - // Upload file using Contents API - err = rs.client.UploadFile(ctx, owner, repo, filePath, uploadReq) - if err != nil { - initialUploadBar.Finish() - return fmt.Errorf("failed to upload %s: %w", filePath, err) - } + initialChanges = append(initialChanges, FileChange{ + Path: filePath, + Content: content, + Mode: mode, + Type: "added", + }) + } - initialUploadBar.Increment() + // Upload all blobs in parallel + initialTreeEntries, err := rs.createBlobsParallel(ctx, owner, repo, initialChanges) + if err != nil { + return fmt.Errorf("failed to create blobs for empty repo: %w", err) } - initialUploadBar.Finish() - fmt.Printf("Successfully uploaded %d files to empty repository\n", len(files)) + // Create tree on GitHub (no base tree for empty repo) + initialTreeReq := CreateTreeRequest{ + Tree: initialTreeEntries, + } + initialTreeResp, err := rs.client.CreateTree(ctx, owner, repo, initialTreeReq) + if err != nil { + return fmt.Errorf("failed to create tree for empty repo: %w", err) + } - // Get the branch to find the commit SHA created by Contents API - branchInfo, err := rs.client.GetBranch(ctx, owner, repo, branch) + // Create commit on GitHub (no parents for initial commit) + initialCommitReq := CreateCommitRequest{ + Message: commitObj.Message, + Tree: initialTreeResp.SHA, + Parents: []string{}, // Empty for initial commit + } + initialCommitResp, err := rs.client.CreateGitCommit(ctx, owner, repo, initialCommitReq) if err != nil { - logging.Warn("Could not get branch info after upload", "error", err) - return nil + return fmt.Errorf("failed to create commit for empty repo: %w", err) } + // Create branch reference pointing to the new commit + err = rs.client.CreateBranch(ctx, owner, repo, branch, initialCommitResp.SHA) + if err != nil { + return fmt.Errorf("failed to create branch reference: %w", err) + } + + fmt.Printf("Successfully uploaded %d files to empty repository\n", len(files)) + fmt.Printf("Created branch '%s' with initial commit %s\n", branch, initialCommitResp.SHA[:7]) + // Store GitHub commit SHA in timeline - err = rs.updateTimelineWithGitHubSHA(branch, commitHash, branchInfo.Commit.SHA) + err = rs.updateTimelineWithGitHubSHA(branch, commitHash, initialCommitResp.SHA) if err != nil { logging.Warn("Failed to update timeline with GitHub SHA", "error", err) } diff --git a/internal/github/sync_push_test.go b/internal/github/sync_push_test.go new file mode 100644 index 0000000..041314e --- /dev/null +++ b/internal/github/sync_push_test.go @@ -0,0 +1,249 @@ +package github + +import ( + "runtime" + "sync" + "testing" + + "github.com/javanhut/Ivaldi-vcs/internal/cas" +) + +// BenchmarkParallelHashComputation benchmarks parallel vs sequential hash computation +func BenchmarkParallelHashComputation(b *testing.B) { + // Generate test data + fileCount := 500 + files := make(map[string][]byte, fileCount) + for i := 0; i < fileCount; i++ { + content := make([]byte, 1024) // 1KB files + for j := range content { + content[j] = byte((i + j) % 256) + } + files["file"+string(rune('0'+i/100))+string(rune('0'+(i/10)%10))+string(rune('0'+i%10))+".txt"] = content + } + + b.Run("Sequential", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + hashes := make(map[string]cas.Hash) + for path, content := range files { + hashes[path] = cas.SumB3(content) + } + } + }) + + b.Run("Parallel", func(b *testing.B) { + workerCount := runtime.NumCPU() + if workerCount < 4 { + workerCount = 4 + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var hashes sync.Map + jobs := make(chan struct { + path string + content []byte + }, len(files)) + results := make(chan struct { + path string + hash cas.Hash + }, len(files)) + + var wg sync.WaitGroup + for w := 0; w < workerCount; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobs { + results <- struct { + path string + hash cas.Hash + }{job.path, cas.SumB3(job.content)} + } + }() + } + + for path, content := range files { + jobs <- struct { + path string + content []byte + }{path, content} + } + close(jobs) + + go func() { + wg.Wait() + close(results) + }() + + for result := range results { + hashes.Store(result.path, result.hash) + } + } + }) +} + +// BenchmarkDeltaComputation benchmarks change detection +func BenchmarkDeltaComputation(b *testing.B) { + // Generate test data simulating parent and current files + fileCount := 500 + parentFiles := make(map[string]cas.Hash, fileCount) + currentFiles := make(map[string][]byte, fileCount) + + for i := 0; i < fileCount; i++ { + path := "file" + string(rune('0'+i/100)) + string(rune('0'+(i/10)%10)) + string(rune('0'+i%10)) + ".txt" + content := make([]byte, 1024) + for j := range content { + content[j] = byte((i + j) % 256) + } + + // 90% unchanged, 5% modified, 5% new + if i < fileCount*90/100 { + parentFiles[path] = cas.SumB3(content) + currentFiles[path] = content + } else if i < fileCount*95/100 { + parentFiles[path] = cas.SumB3([]byte("old content")) + currentFiles[path] = content // Modified + } else { + currentFiles[path] = content // New file + } + } + + // Add some deleted files + for i := 0; i < 25; i++ { + path := "deleted" + string(rune('0'+i/10)) + string(rune('0'+i%10)) + ".txt" + parentFiles[path] = cas.SumB3([]byte("deleted content")) + } + + b.Run("Sequential", func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + var changes []FileChange + + // Check for added and modified files + for filePath, content := range currentFiles { + currentHash := cas.SumB3(content) + parentHash, existed := parentFiles[filePath] + + if !existed { + changes = append(changes, FileChange{Path: filePath, Type: "added"}) + } else if currentHash != parentHash { + changes = append(changes, FileChange{Path: filePath, Type: "modified"}) + } + } + + // Check for deleted files + for filePath := range parentFiles { + if _, exists := currentFiles[filePath]; !exists { + changes = append(changes, FileChange{Path: filePath, Type: "deleted"}) + } + } + _ = changes + } + }) + + b.Run("Parallel", func(b *testing.B) { + workerCount := runtime.NumCPU() + if workerCount < 4 { + workerCount = 4 + } + + // Convert parentFiles to sync.Map for parallel access + var parentMap sync.Map + for k, v := range parentFiles { + parentMap.Store(k, v) + } + + var currentMap sync.Map + for k, v := range currentFiles { + currentMap.Store(k, v) + } + + filePaths := make([]string, 0, len(currentFiles)) + for path := range currentFiles { + filePaths = append(filePaths, path) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var changes []FileChange + var changesMu sync.Mutex + + var wg sync.WaitGroup + jobs := make(chan string, len(filePaths)) + + for w := 0; w < workerCount; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for filePath := range jobs { + contentVal, _ := currentMap.Load(filePath) + content := contentVal.([]byte) + currentHash := cas.SumB3(content) + parentHashVal, existed := parentMap.Load(filePath) + + var change *FileChange + if !existed { + change = &FileChange{Path: filePath, Type: "added"} + } else { + parentHash := parentHashVal.(cas.Hash) + if currentHash != parentHash { + change = &FileChange{Path: filePath, Type: "modified"} + } + } + + if change != nil { + changesMu.Lock() + changes = append(changes, *change) + changesMu.Unlock() + } + } + }() + } + + for _, path := range filePaths { + jobs <- path + } + close(jobs) + wg.Wait() + + // Check for deleted files + parentMap.Range(func(key, _ any) bool { + filePath := key.(string) + if _, exists := currentMap.Load(filePath); !exists { + changesMu.Lock() + changes = append(changes, FileChange{Path: filePath, Type: "deleted"}) + changesMu.Unlock() + } + return true + }) + _ = changes + } + }) +} + +// TestFileChangeTypes ensures FileChange types are correct +func TestFileChangeTypes(t *testing.T) { + changes := []FileChange{ + {Path: "new.txt", Type: "added"}, + {Path: "modified.txt", Type: "modified"}, + {Path: "deleted.txt", Type: "deleted"}, + } + + expectedTypes := map[string]string{ + "new.txt": "added", + "modified.txt": "modified", + "deleted.txt": "deleted", + } + + for _, change := range changes { + expected, ok := expectedTypes[change.Path] + if !ok { + t.Errorf("Unexpected path: %s", change.Path) + continue + } + if change.Type != expected { + t.Errorf("Path %s: expected type %s, got %s", change.Path, expected, change.Type) + } + } +}