feat(add): implement recursive file addition with progress tracking

This commit is contained in:
Yar Kravtsov
2025-07-29 08:47:14 +03:00
parent 36d76c881c
commit a6852e5ad5
4 changed files with 647 additions and 8 deletions

View File

@@ -17,8 +17,23 @@ 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))
// 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 {
// Use appropriate method based on number of files
if len(args) == 1 {
// Single file - use existing Add method for backward compatibility
@@ -31,9 +46,17 @@ func newAddCmd() *cobra.Command {
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
}

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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))
}