diff --git a/cmd/add.go b/cmd/add.go index c3d3839..fdbebd6 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -17,23 +17,46 @@ func newAddCmd() *cobra.Command { SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { host, _ := cmd.Flags().GetString("host") + recursive, _ := cmd.Flags().GetBool("recursive") lnk := core.NewLnk(core.WithHost(host)) - // Use appropriate method based on number of files - if len(args) == 1 { - // Single file - use existing Add method for backward compatibility - if err := lnk.Add(args[0]); err != nil { + // Handle recursive mode + if recursive { + // Create progress callback for CLI display + progressCallback := func(current, total int, currentFile string) { + printf(cmd, "\r⏳ Processing %d/%d: %s", current, total, currentFile) + } + + if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil { return err } + + // Clear progress line and show completion + printf(cmd, "\r") } else { - // Multiple files - use AddMultiple for atomic operation - if err := lnk.AddMultiple(args); err != nil { - return err + // Use appropriate method based on number of files + if len(args) == 1 { + // Single file - use existing Add method for backward compatibility + if err := lnk.Add(args[0]); err != nil { + return err + } + } else { + // Multiple files - use AddMultiple for atomic operation + if err := lnk.AddMultiple(args); err != nil { + return err + } } } // Display results - if len(args) == 1 { + if recursive { + // Recursive mode - show different message + if host != "" { + printf(cmd, "✨ \033[1mAdded files recursively to lnk (host: %s)\033[0m\n", host) + } else { + printf(cmd, "✨ \033[1mAdded files recursively to lnk\033[0m\n") + } + } else if len(args) == 1 { // Single file - maintain existing output format for backward compatibility filePath := args[0] basename := filepath.Base(filePath) @@ -69,5 +92,6 @@ func newAddCmd() *cobra.Command { } cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)") + cmd.Flags().BoolP("recursive", "r", false, "Add directory contents individually instead of the directory as a whole") return cmd } diff --git a/cmd/root_test.go b/cmd/root_test.go index 9f24026..e738a07 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1012,6 +1012,113 @@ func (suite *CLITestSuite) TestAddCommandMixedTypes() { suite.FileExists(filepath.Join(lnkDir, ".config", "git", "config")) } +func (suite *CLITestSuite) TestAddCommandRecursiveFlag() { + // Initialize repository + err := suite.runCommand("init") + suite.Require().NoError(err) + suite.stdout.Reset() + + // Create a directory with nested files + testDir := filepath.Join(suite.tempDir, ".config", "zed") + err = os.MkdirAll(testDir, 0755) + suite.Require().NoError(err) + + // Create nested files + settingsFile := filepath.Join(testDir, "settings.json") + err = os.WriteFile(settingsFile, []byte(`{"theme": "dark"}`), 0644) + suite.Require().NoError(err) + + keymapFile := filepath.Join(testDir, "keymap.json") + err = os.WriteFile(keymapFile, []byte(`{"ctrl+s": "save"}`), 0644) + suite.Require().NoError(err) + + // Create a subdirectory with files + themesDir := filepath.Join(testDir, "themes") + err = os.MkdirAll(themesDir, 0755) + suite.Require().NoError(err) + + themeFile := filepath.Join(themesDir, "custom.json") + err = os.WriteFile(themeFile, []byte(`{"colors": {}}`), 0644) + suite.Require().NoError(err) + + // Test recursive flag - should process directory contents individually + err = suite.runCommand("add", "--recursive", testDir) + suite.NoError(err, "Adding directory recursively should succeed") + + // Check output shows multiple files were processed + output := suite.stdout.String() + suite.Contains(output, "Added") // Should show some success message + + // Verify individual files are now symlinks (not the directory itself) + info, err := os.Lstat(settingsFile) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "settings.json should be a symlink") + + info, err = os.Lstat(keymapFile) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "keymap.json should be a symlink") + + info, err = os.Lstat(themeFile) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "custom.json should be a symlink") + + // The directory itself should NOT be a symlink + info, err = os.Lstat(testDir) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "Directory should not be a symlink") + + // Verify files exist individually in storage + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "settings.json")) + suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "keymap.json")) + suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "themes", "custom.json")) +} + +func (suite *CLITestSuite) TestAddCommandRecursiveMultipleDirs() { + // Initialize repository + err := suite.runCommand("init") + suite.Require().NoError(err) + suite.stdout.Reset() + + // Create two directories with files + dir1 := filepath.Join(suite.tempDir, "dir1") + dir2 := filepath.Join(suite.tempDir, "dir2") + err = os.MkdirAll(dir1, 0755) + suite.Require().NoError(err) + err = os.MkdirAll(dir2, 0755) + suite.Require().NoError(err) + + // Create files in each directory + file1 := filepath.Join(dir1, "file1.txt") + file2 := filepath.Join(dir2, "file2.txt") + err = os.WriteFile(file1, []byte("content1"), 0644) + suite.Require().NoError(err) + err = os.WriteFile(file2, []byte("content2"), 0644) + suite.Require().NoError(err) + + // Test recursive flag with multiple directories + err = suite.runCommand("add", "--recursive", dir1, dir2) + suite.NoError(err, "Adding multiple directories recursively should succeed") + + // Verify both files are symlinks + info, err := os.Lstat(file1) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "file1.txt should be a symlink") + + info, err = os.Lstat(file2) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "file2.txt should be a symlink") + + // Verify directories are not symlinks + info, err = os.Lstat(dir1) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "dir1 should not be a symlink") + + info, err = os.Lstat(dir2) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "dir2 should not be a symlink") +} + func TestCLISuite(t *testing.T) { suite.Run(t, new(CLITestSuite)) } diff --git a/internal/core/lnk.go b/internal/core/lnk.go index cbab637..a49ca86 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -827,3 +827,263 @@ func (l *Lnk) RunBootstrapScript(scriptName string) error { return nil } + +// walkDirectory walks through a directory and returns all regular files +func (l *Lnk) walkDirectory(dirPath string) ([]string, error) { + var files []string + + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories - we only want files + if info.IsDir() { + return nil + } + + // Handle symlinks: include them as files if they point to regular files + if info.Mode()&os.ModeSymlink != 0 { + // For symlinks, we'll include them but the AddMultiple logic + // will handle validation appropriately + files = append(files, path) + return nil + } + + // Include regular files + if info.Mode().IsRegular() { + files = append(files, path) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to walk directory %s: %w", dirPath, err) + } + + return files, nil +} + +// ProgressCallback defines the signature for progress reporting callbacks +type ProgressCallback func(current, total int, currentFile string) + +// AddRecursiveWithProgress adds directory contents individually with progress reporting +func (l *Lnk) AddRecursiveWithProgress(paths []string, progress ProgressCallback) error { + var allFiles []string + + for _, path := range paths { + // Get absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to get absolute path for %s: %w", path, err) + } + + // Check if it's a directory + info, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("failed to stat %s: %w", path, err) + } + + if info.IsDir() { + // Walk directory to get all files + files, err := l.walkDirectory(absPath) + if err != nil { + return fmt.Errorf("failed to walk directory %s: %w", path, err) + } + allFiles = append(allFiles, files...) + } else { + // It's a regular file, add it directly + allFiles = append(allFiles, absPath) + } + } + + // Use AddMultiple for batch processing + if len(allFiles) == 0 { + return fmt.Errorf("no files found to add") + } + + // Apply progress threshold: only show progress for >10 files + const progressThreshold = 10 + if len(allFiles) > progressThreshold && progress != nil { + return l.addMultipleWithProgress(allFiles, progress) + } + + // For small operations, use regular AddMultiple without progress + return l.AddMultiple(allFiles) +} + +// addMultipleWithProgress adds multiple files with progress reporting +func (l *Lnk) addMultipleWithProgress(paths []string, progress ProgressCallback) error { + if len(paths) == 0 { + return nil + } + + // Phase 1: Validate all paths first (same as AddMultiple) + var relativePaths []string + var absolutePaths []string + var infos []os.FileInfo + + for _, filePath := range paths { + // Validate the file or directory + if err := l.fs.ValidateFileForAdd(filePath); err != nil { + return fmt.Errorf("validation failed for %s: %w", filePath, err) + } + + // Get absolute path + absPath, err := filepath.Abs(filePath) + if err != nil { + return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err) + } + + // Get relative path for tracking + relativePath, err := getRelativePath(absPath) + if err != nil { + return fmt.Errorf("failed to get relative path for %s: %w", filePath, err) + } + + // Check if this relative path is already managed + managedItems, err := l.getManagedItems() + if err != nil { + return fmt.Errorf("failed to get managed items: %w", err) + } + for _, item := range managedItems { + if item == relativePath { + return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath) + } + } + + // Get file info + info, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("failed to stat path %s: %w", filePath, err) + } + + relativePaths = append(relativePaths, relativePath) + absolutePaths = append(absolutePaths, absPath) + infos = append(infos, info) + } + + // Phase 2: Process all files with progress reporting + var rollbackActions []func() error + total := len(absolutePaths) + + for i, absPath := range absolutePaths { + // Report progress + if progress != nil { + progress(i+1, total, filepath.Base(absPath)) + } + + relativePath := relativePaths[i] + info := infos[i] + + // Generate repository path from relative path + storagePath := l.getHostStoragePath() + destPath := filepath.Join(storagePath, relativePath) + + // Ensure destination directory exists + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + // Rollback previous operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Move to repository + if err := l.fs.Move(absPath, destPath, info); err != nil { + // Rollback previous operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to move %s: %w", absPath, err) + } + + // Create symlink + if err := l.fs.CreateSymlink(destPath, absPath); err != nil { + // Try to restore the file we just moved, then rollback others + _ = l.fs.Move(destPath, absPath, info) + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to create symlink for %s: %w", absPath, err) + } + + // Add to tracking + if err := l.addManagedItem(relativePath); err != nil { + // Restore this file and rollback others + _ = os.Remove(absPath) + _ = l.fs.Move(destPath, absPath, info) + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err) + } + + // Add rollback action for this file + rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info) + rollbackActions = append(rollbackActions, rollbackAction) + } + + // Phase 3: Git operations - add all files and create single commit + for i, relativePath := range relativePaths { + // For host-specific files, we need to add the relative path from repo root + gitPath := relativePath + if l.host != "" { + gitPath = filepath.Join(l.host+".lnk", relativePath) + } + if err := l.git.Add(gitPath); err != nil { + // Rollback all operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err) + } + } + + // Add .lnk file to the same commit + if err := l.git.Add(l.getLnkFileName()); err != nil { + // Rollback all operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to add tracking file to git: %w", err) + } + + // Commit all changes together + commitMessage := fmt.Sprintf("lnk: added %d files recursively", len(paths)) + if err := l.git.Commit(commitMessage); err != nil { + // Rollback all operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to commit changes: %w", err) + } + + return nil +} + +// AddRecursive adds directory contents individually instead of the directory as a whole +func (l *Lnk) AddRecursive(paths []string) error { + var allFiles []string + + for _, path := range paths { + // Get absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to get absolute path for %s: %w", path, err) + } + + // Check if it's a directory + info, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("failed to stat %s: %w", path, err) + } + + if info.IsDir() { + // Walk directory to get all files + files, err := l.walkDirectory(absPath) + if err != nil { + return fmt.Errorf("failed to walk directory %s: %w", path, err) + } + allFiles = append(allFiles, files...) + } else { + // It's a regular file, add it directly + allFiles = append(allFiles, absPath) + } + } + + // Use AddMultiple for batch processing + if len(allFiles) == 0 { + return fmt.Errorf("no files found to add") + } + + return l.AddMultiple(allFiles) +} diff --git a/internal/core/lnk_test.go b/internal/core/lnk_test.go index 6d6ba00..046f891 100644 --- a/internal/core/lnk_test.go +++ b/internal/core/lnk_test.go @@ -1090,6 +1090,254 @@ func (suite *CoreTestSuite) TestDetailedErrorMessages() { suite.Contains(err.Error(), "already-managed.txt", "Error should include specific filename") } +// Task 2.2: Directory Walking Logic Tests + +func (suite *CoreTestSuite) TestWalkDirectory() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create nested directory structure + configDir := filepath.Join(suite.tempDir, ".config", "myapp") + err = os.MkdirAll(configDir, 0755) + suite.Require().NoError(err) + + themeDir := filepath.Join(configDir, "themes") + err = os.MkdirAll(themeDir, 0755) + suite.Require().NoError(err) + + // Create files in different levels + file1 := filepath.Join(configDir, "config.json") + file2 := filepath.Join(configDir, "settings.json") + file3 := filepath.Join(themeDir, "dark.json") + file4 := filepath.Join(themeDir, "light.json") + + suite.Require().NoError(os.WriteFile(file1, []byte("config"), 0644)) + suite.Require().NoError(os.WriteFile(file2, []byte("settings"), 0644)) + suite.Require().NoError(os.WriteFile(file3, []byte("dark theme"), 0644)) + suite.Require().NoError(os.WriteFile(file4, []byte("light theme"), 0644)) + + // Call walkDirectory method (which doesn't exist yet) + files, err := suite.lnk.walkDirectory(configDir) + suite.Require().NoError(err, "walkDirectory should succeed") + + // Should find all 4 files + suite.Len(files, 4, "Should find all files in nested structure") + + // Check that all expected files are found (order may vary) + expectedFiles := []string{file1, file2, file3, file4} + for _, expectedFile := range expectedFiles { + suite.Contains(files, expectedFile, "Should include file %s", expectedFile) + } +} + +func (suite *CoreTestSuite) TestWalkDirectoryIncludesHiddenFiles() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create directory with hidden files and directories + testDir := filepath.Join(suite.tempDir, "test-hidden") + err = os.MkdirAll(testDir, 0755) + suite.Require().NoError(err) + + hiddenDir := filepath.Join(testDir, ".hidden") + err = os.MkdirAll(hiddenDir, 0755) + suite.Require().NoError(err) + + // Create regular and hidden files + regularFile := filepath.Join(testDir, "regular.txt") + hiddenFile := filepath.Join(testDir, ".hidden-file") + hiddenDirFile := filepath.Join(hiddenDir, "file-in-hidden.txt") + + suite.Require().NoError(os.WriteFile(regularFile, []byte("regular"), 0644)) + suite.Require().NoError(os.WriteFile(hiddenFile, []byte("hidden"), 0644)) + suite.Require().NoError(os.WriteFile(hiddenDirFile, []byte("in hidden dir"), 0644)) + + // Call walkDirectory method + files, err := suite.lnk.walkDirectory(testDir) + suite.Require().NoError(err, "walkDirectory should succeed with hidden files") + + // Should find all files including hidden ones + suite.Len(files, 3, "Should find all files including hidden ones") + suite.Contains(files, regularFile, "Should include regular file") + suite.Contains(files, hiddenFile, "Should include hidden file") + suite.Contains(files, hiddenDirFile, "Should include file in hidden directory") +} + +func (suite *CoreTestSuite) TestWalkDirectorySymlinkHandling() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create directory structure + testDir := filepath.Join(suite.tempDir, "test-symlinks") + err = os.MkdirAll(testDir, 0755) + suite.Require().NoError(err) + + // Create a regular file + regularFile := filepath.Join(testDir, "regular.txt") + suite.Require().NoError(os.WriteFile(regularFile, []byte("regular"), 0644)) + + // Create a symlink to the regular file + symlinkFile := filepath.Join(testDir, "link-to-regular.txt") + err = os.Symlink(regularFile, symlinkFile) + suite.Require().NoError(err) + + // Call walkDirectory method + files, err := suite.lnk.walkDirectory(testDir) + suite.Require().NoError(err, "walkDirectory should handle symlinks") + + // Should include both regular file and properly handle symlink + // (exact behavior depends on implementation - could include symlink as file) + suite.GreaterOrEqual(len(files), 1, "Should find at least the regular file") + suite.Contains(files, regularFile, "Should include regular file") + + // The symlink handling behavior will be defined in implementation + // For now, we just ensure no errors occur +} + +func (suite *CoreTestSuite) TestWalkDirectoryEmptyDirs() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create directory structure with empty directories + testDir := filepath.Join(suite.tempDir, "test-empty") + err = os.MkdirAll(testDir, 0755) + suite.Require().NoError(err) + + // Create empty subdirectories + emptyDir1 := filepath.Join(testDir, "empty1") + emptyDir2 := filepath.Join(testDir, "empty2") + err = os.MkdirAll(emptyDir1, 0755) + suite.Require().NoError(err) + err = os.MkdirAll(emptyDir2, 0755) + suite.Require().NoError(err) + + // Create one file in a non-empty directory + nonEmptyDir := filepath.Join(testDir, "non-empty") + err = os.MkdirAll(nonEmptyDir, 0755) + suite.Require().NoError(err) + + testFile := filepath.Join(nonEmptyDir, "test.txt") + suite.Require().NoError(os.WriteFile(testFile, []byte("content"), 0644)) + + // Call walkDirectory method + files, err := suite.lnk.walkDirectory(testDir) + suite.Require().NoError(err, "walkDirectory should skip empty directories") + + // Should only find the one file, not empty directories + suite.Len(files, 1, "Should only find files, not empty directories") + suite.Contains(files, testFile, "Should include the actual file") +} + +// Task 2.3: Progress Indication System Tests + +func (suite *CoreTestSuite) TestProgressReporting() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create directory with multiple files to test progress reporting + testDir := filepath.Join(suite.tempDir, "progress-test") + err = os.MkdirAll(testDir, 0755) + suite.Require().NoError(err) + + // Create 15 files to exceed threshold + expectedFiles := 15 + for i := 0; i < expectedFiles; i++ { + file := filepath.Join(testDir, fmt.Sprintf("file%d.txt", i)) + suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644)) + } + + // Track progress calls + var progressCalls []struct { + Current int + Total int + CurrentFile string + } + + progressCallback := func(current, total int, currentFile string) { + progressCalls = append(progressCalls, struct { + Current int + Total int + CurrentFile string + }{ + Current: current, + Total: total, + CurrentFile: currentFile, + }) + } + + // Call AddRecursiveWithProgress method (which doesn't exist yet) + err = suite.lnk.AddRecursiveWithProgress([]string{testDir}, progressCallback) + suite.Require().NoError(err, "AddRecursiveWithProgress should succeed") + + // Verify progress was reported + suite.Greater(len(progressCalls), 0, "Progress callback should be called") + suite.Equal(expectedFiles, len(progressCalls), "Should have progress calls for each file") + + // Verify progress order and totals + for i, call := range progressCalls { + suite.Equal(i+1, call.Current, "Current count should increment") + suite.Equal(expectedFiles, call.Total, "Total should be consistent") + suite.NotEmpty(call.CurrentFile, "CurrentFile should be provided") + } +} + +func (suite *CoreTestSuite) TestProgressThreshold() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Test with few files (under threshold) + smallDir := filepath.Join(suite.tempDir, "small-test") + err = os.MkdirAll(smallDir, 0755) + suite.Require().NoError(err) + + // Create only 5 files (under 10 threshold) + for i := 0; i < 5; i++ { + file := filepath.Join(smallDir, fmt.Sprintf("small%d.txt", i)) + suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644)) + } + + // Track progress calls for small operation + smallProgressCalls := 0 + smallCallback := func(current, total int, currentFile string) { + smallProgressCalls++ + } + + err = suite.lnk.AddRecursiveWithProgress([]string{smallDir}, smallCallback) + suite.Require().NoError(err, "AddRecursiveWithProgress should succeed for small operation") + + // Should NOT call progress for small operations + suite.Equal(0, smallProgressCalls, "Progress should not be called for operations under threshold") + + // Test with many files (over threshold) + largeDir := filepath.Join(suite.tempDir, "large-test") + err = os.MkdirAll(largeDir, 0755) + suite.Require().NoError(err) + + // Create 15 files (over 10 threshold) + for i := 0; i < 15; i++ { + file := filepath.Join(largeDir, fmt.Sprintf("large%d.txt", i)) + suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644)) + } + + // Track progress calls for large operation + largeProgressCalls := 0 + largeCallback := func(current, total int, currentFile string) { + largeProgressCalls++ + } + + err = suite.lnk.AddRecursiveWithProgress([]string{largeDir}, largeCallback) + suite.Require().NoError(err, "AddRecursiveWithProgress should succeed for large operation") + + // Should call progress for large operations + suite.Equal(15, largeProgressCalls, "Progress should be called for operations over threshold") +} + func TestCoreSuite(t *testing.T) { suite.Run(t, new(CoreTestSuite)) }