mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-02 18:12:33 +02:00
feat(add): implement multiple file addition with atomic operation
This commit is contained in:
@@ -257,6 +257,153 @@ func (l *Lnk) Add(filePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMultiple adds multiple files or directories to the repository in a single transaction
|
||||
func (l *Lnk) AddMultiple(paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Phase 1: Validate all paths first
|
||||
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 - move to repository and create symlinks
|
||||
var rollbackActions []func() error
|
||||
|
||||
for i, absPath := range absolutePaths {
|
||||
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", 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
|
||||
}
|
||||
|
||||
// createRollbackAction creates a rollback function for a single file operation
|
||||
func (l *Lnk) createRollbackAction(absPath, destPath, relativePath string, info os.FileInfo) func() error {
|
||||
return func() error {
|
||||
_ = os.Remove(absPath)
|
||||
_ = l.removeManagedItem(relativePath)
|
||||
return l.fs.Move(destPath, absPath, info)
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackOperations executes rollback actions in reverse order
|
||||
func (l *Lnk) rollbackOperations(rollbackActions []func() error) {
|
||||
for i := len(rollbackActions) - 1; i >= 0; i-- {
|
||||
_ = rollbackActions[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove removes a symlink and restores the original file or directory
|
||||
func (l *Lnk) Remove(filePath string) error {
|
||||
// Get absolute path
|
||||
|
@@ -31,6 +31,9 @@ func (suite *CoreTestSuite) SetupTest() {
|
||||
err = os.Chdir(tempDir)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Set HOME to temp directory for consistent relative path calculation
|
||||
suite.T().Setenv("HOME", tempDir)
|
||||
|
||||
// Set XDG_CONFIG_HOME to temp directory
|
||||
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
|
||||
@@ -86,8 +89,8 @@ func (suite *CoreTestSuite) TestCoreFileOperations() {
|
||||
// The repository file will preserve the directory structure
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// Find the .bashrc file in the repository (it should be at the relative path)
|
||||
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc")
|
||||
// Find the .bashrc file in the repository (it should be at the relative path from HOME)
|
||||
repoFile := filepath.Join(lnkDir, ".bashrc")
|
||||
suite.FileExists(repoFile)
|
||||
|
||||
// Verify content is preserved
|
||||
@@ -137,8 +140,8 @@ func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
|
||||
// Check that the repository directory preserves the structure
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// The directory should be at the relative path
|
||||
repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir")
|
||||
// The directory should be at the relative path from HOME
|
||||
repoDir := filepath.Join(lnkDir, "testdir")
|
||||
suite.DirExists(repoDir)
|
||||
|
||||
// Remove the directory
|
||||
@@ -820,6 +823,273 @@ func (suite *CoreTestSuite) TestRunBootstrapScriptNotFound() {
|
||||
suite.Contains(err.Error(), "Bootstrap script not found")
|
||||
}
|
||||
|
||||
func (suite *CoreTestSuite) TestAddMultiple() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create multiple test files
|
||||
file1 := filepath.Join(suite.tempDir, "file1.txt")
|
||||
file2 := filepath.Join(suite.tempDir, "file2.txt")
|
||||
file3 := filepath.Join(suite.tempDir, "file3.txt")
|
||||
|
||||
content1 := "content1"
|
||||
content2 := "content2"
|
||||
content3 := "content3"
|
||||
|
||||
err = os.WriteFile(file1, []byte(content1), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(file2, []byte(content2), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(file3, []byte(content3), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test AddMultiple method - should succeed
|
||||
paths := []string{file1, file2, file3}
|
||||
err = suite.lnk.AddMultiple(paths)
|
||||
suite.NoError(err, "AddMultiple should succeed")
|
||||
|
||||
// Verify all files are now symlinks
|
||||
for _, file := range paths {
|
||||
info, err := os.Lstat(file)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "File should be a symlink: %s", file)
|
||||
}
|
||||
|
||||
// Verify all files exist in storage
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
suite.FileExists(filepath.Join(lnkDir, "file1.txt"))
|
||||
suite.FileExists(filepath.Join(lnkDir, "file2.txt"))
|
||||
suite.FileExists(filepath.Join(lnkDir, "file3.txt"))
|
||||
|
||||
// Verify .lnk file contains all entries
|
||||
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||
lnkContent, err := os.ReadFile(lnkFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal("file1.txt\nfile2.txt\nfile3.txt\n", string(lnkContent))
|
||||
|
||||
// Verify Git commit was created
|
||||
commits, err := suite.lnk.GetCommits()
|
||||
suite.NoError(err)
|
||||
suite.T().Logf("Commits: %v", commits)
|
||||
// Should have at least 1 commit for the batch add
|
||||
suite.GreaterOrEqual(len(commits), 1)
|
||||
// The most recent commit should mention multiple files
|
||||
suite.Contains(commits[0], "added 3 files")
|
||||
}
|
||||
|
||||
func (suite *CoreTestSuite) TestAddMultipleWithConflicts() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create test files
|
||||
file1 := filepath.Join(suite.tempDir, "file1.txt")
|
||||
file2 := filepath.Join(suite.tempDir, "file2.txt")
|
||||
file3 := filepath.Join(suite.tempDir, "file3.txt")
|
||||
|
||||
err = os.WriteFile(file1, []byte("content1"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(file2, []byte("content2"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(file3, []byte("content3"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file2 individually first
|
||||
err = suite.lnk.Add(file2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Now try to add all three - should fail due to conflict with file2
|
||||
paths := []string{file1, file2, file3}
|
||||
err = suite.lnk.AddMultiple(paths)
|
||||
suite.Error(err, "AddMultiple should fail due to conflict")
|
||||
suite.Contains(err.Error(), "already managed")
|
||||
|
||||
// Verify no partial changes were made
|
||||
// file1 and file3 should still be regular files, not symlinks
|
||||
info1, err := os.Lstat(file1)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink")
|
||||
|
||||
info3, err := os.Lstat(file3)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink")
|
||||
|
||||
// file2 should still be managed (was added before)
|
||||
info2, err := os.Lstat(file2)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink")
|
||||
}
|
||||
|
||||
func (suite *CoreTestSuite) TestAddMultipleRollback() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create test files - one will be invalid to force rollback
|
||||
file1 := filepath.Join(suite.tempDir, "file1.txt")
|
||||
file2 := filepath.Join(suite.tempDir, "nonexistent.txt") // This doesn't exist
|
||||
file3 := filepath.Join(suite.tempDir, "file3.txt")
|
||||
|
||||
err = os.WriteFile(file1, []byte("content1"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(file3, []byte("content3"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
// Note: file2 is intentionally not created
|
||||
|
||||
// Try to add all files - should fail and rollback
|
||||
paths := []string{file1, file2, file3}
|
||||
err = suite.lnk.AddMultiple(paths)
|
||||
suite.Error(err, "AddMultiple should fail due to nonexistent file")
|
||||
|
||||
// Verify rollback - no files should be symlinks
|
||||
info1, err := os.Lstat(file1)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink after rollback")
|
||||
|
||||
info3, err := os.Lstat(file3)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink after rollback")
|
||||
|
||||
// Verify no files in storage
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
suite.NoFileExists(filepath.Join(lnkDir, "file1.txt"))
|
||||
suite.NoFileExists(filepath.Join(lnkDir, "file3.txt"))
|
||||
|
||||
// Verify .lnk file is empty or doesn't contain these files
|
||||
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||
if _, err := os.Stat(lnkFile); err == nil {
|
||||
lnkContent, err := os.ReadFile(lnkFile)
|
||||
suite.NoError(err)
|
||||
content := string(lnkContent)
|
||||
suite.NotContains(content, "file1.txt")
|
||||
suite.NotContains(content, "file3.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *CoreTestSuite) TestValidateMultiplePaths() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a mix of valid and invalid paths
|
||||
validFile := filepath.Join(suite.tempDir, "valid.txt")
|
||||
err = os.WriteFile(validFile, []byte("content"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
nonexistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||
// Don't create this file
|
||||
|
||||
// Create a valid directory
|
||||
validDir := filepath.Join(suite.tempDir, "validdir")
|
||||
err = os.MkdirAll(validDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test validation fails early with detailed error
|
||||
paths := []string{validFile, nonexistentFile, validDir}
|
||||
err = suite.lnk.AddMultiple(paths)
|
||||
suite.Error(err, "Should fail due to nonexistent file")
|
||||
suite.Contains(err.Error(), "validation failed")
|
||||
suite.Contains(err.Error(), "nonexistent.txt")
|
||||
|
||||
// Verify no partial changes were made
|
||||
info, err := os.Lstat(validFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid file should not be a symlink")
|
||||
|
||||
info, err = os.Lstat(validDir)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid directory should not be a symlink")
|
||||
}
|
||||
|
||||
func (suite *CoreTestSuite) TestAtomicRollbackOnFailure() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create test files
|
||||
file1 := filepath.Join(suite.tempDir, "file1.txt")
|
||||
file2 := filepath.Join(suite.tempDir, "file2.txt")
|
||||
file3 := filepath.Join(suite.tempDir, "file3.txt")
|
||||
|
||||
content1 := "original content 1"
|
||||
content2 := "original content 2"
|
||||
content3 := "original content 3"
|
||||
|
||||
err = os.WriteFile(file1, []byte(content1), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(file2, []byte(content2), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(file3, []byte(content3), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file2 individually first to create a conflict
|
||||
err = suite.lnk.Add(file2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Store original states
|
||||
info1Before, err := os.Lstat(file1)
|
||||
suite.Require().NoError(err)
|
||||
info3Before, err := os.Lstat(file3)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Try to add all files - should fail and rollback completely
|
||||
paths := []string{file1, file2, file3}
|
||||
err = suite.lnk.AddMultiple(paths)
|
||||
suite.Error(err, "Should fail due to conflict with file2")
|
||||
|
||||
// Verify complete rollback
|
||||
info1After, err := os.Lstat(file1)
|
||||
suite.NoError(err)
|
||||
suite.Equal(info1Before.Mode(), info1After.Mode(), "file1 mode should be unchanged")
|
||||
|
||||
info3After, err := os.Lstat(file3)
|
||||
suite.NoError(err)
|
||||
suite.Equal(info3Before.Mode(), info3After.Mode(), "file3 mode should be unchanged")
|
||||
|
||||
// Verify original contents are preserved
|
||||
content1After, err := os.ReadFile(file1)
|
||||
suite.NoError(err)
|
||||
suite.Equal(content1, string(content1After), "file1 content should be preserved")
|
||||
|
||||
content3After, err := os.ReadFile(file3)
|
||||
suite.NoError(err)
|
||||
suite.Equal(content3, string(content3After), "file3 content should be preserved")
|
||||
|
||||
// file2 should still be managed (was added before)
|
||||
info2, err := os.Lstat(file2)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink")
|
||||
}
|
||||
|
||||
func (suite *CoreTestSuite) TestDetailedErrorMessages() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test with multiple types of errors
|
||||
validFile := filepath.Join(suite.tempDir, "valid.txt")
|
||||
err = os.WriteFile(validFile, []byte("content"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
nonexistentFile := filepath.Join(suite.tempDir, "does-not-exist.txt")
|
||||
alreadyManagedFile := filepath.Join(suite.tempDir, "already-managed.txt")
|
||||
err = os.WriteFile(alreadyManagedFile, []byte("managed"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add one file first to create conflict
|
||||
err = suite.lnk.Add(alreadyManagedFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test with nonexistent file
|
||||
paths := []string{validFile, nonexistentFile}
|
||||
err = suite.lnk.AddMultiple(paths)
|
||||
suite.Error(err, "Should fail due to nonexistent file")
|
||||
suite.Contains(err.Error(), "validation failed", "Error should mention validation failure")
|
||||
suite.Contains(err.Error(), "does-not-exist.txt", "Error should include specific filename")
|
||||
|
||||
// Test with already managed file
|
||||
paths = []string{validFile, alreadyManagedFile}
|
||||
err = suite.lnk.AddMultiple(paths)
|
||||
suite.Error(err, "Should fail due to already managed file")
|
||||
suite.Contains(err.Error(), "already managed", "Error should mention already managed")
|
||||
suite.Contains(err.Error(), "already-managed.txt", "Error should include specific filename")
|
||||
}
|
||||
|
||||
func TestCoreSuite(t *testing.T) {
|
||||
suite.Run(t, new(CoreTestSuite))
|
||||
}
|
||||
|
Reference in New Issue
Block a user