diff --git a/README.md b/README.md index 65dc783..80ddd2c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Git-native dotfiles management that won't break your setup. Zero config, zero bl lnk init && lnk add ~/.vimrc && lnk push ``` -[![Tests](https://img.shields.io/badge/tests-17%20passing-green)](./test) [![Go](https://img.shields.io/badge/go-1.21+-blue)](https://golang.org) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +[![Tests](https://img.shields.io/badge/tests-20%20passing-green)](./test) [![Go](https://img.shields.io/badge/go-1.21+-blue)](https://golang.org) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) ## Why Lnk? @@ -22,8 +22,9 @@ While chezmoi offers 100+ features and Home Manager requires learning Nix, **Lnk - ⚡ **Zero friction**: No YAML configs, no templates, no learning curve - 🔧 **Git-native**: Clean commits, standard workflow, no abstractions - 📦 **Zero dependencies**: Single binary vs Python/Node/Ruby runtimes -- 🚀 **Production ready**: 17 integration tests, proper error handling +- 🚀 **Production ready**: 20 integration tests, proper error handling - 🔄 **Smart sync**: Built-in status tracking and seamless multi-machine workflow +- 📁 **Directory support**: Manage entire config directories or individual files **The market gap**: Tools are either too simple (and unsafe) or too complex (and overwhelming). Lnk is the **Goldilocks solution** – just right for developers who want reliability without complexity. @@ -98,10 +99,11 @@ lnk init -r git@github.com:username/dotfiles.git # With remote - ✅ Protects existing repositories from overwrite - ✅ Validates remote conflicts before changes -### Manage Files +### Manage Files & Directories ```bash lnk add ~/.bashrc ~/.vimrc ~/.tmux.conf # Add multiple files +lnk add ~/.config/nvim ~/.ssh # Add entire directories lnk rm ~/.bashrc # Remove from management ``` @@ -246,7 +248,7 @@ internal/ ### What Makes It Robust -- **17 integration tests** covering edge cases and error conditions +- **20 integration tests** covering edge cases and error conditions - **Zero external dependencies** at runtime - **Atomic operations** with automatic rollback on failure - **Relative symlinks** for cross-platform compatibility diff --git a/internal/core/lnk.go b/internal/core/lnk.go index c94dc17..ac9eb43 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "path/filepath" + "sort" + "strings" "github.com/yarlson/lnk/internal/fs" "github.com/yarlson/lnk/internal/git" @@ -76,9 +78,9 @@ func (l *Lnk) AddRemote(name, url string) error { return nil } -// Add moves a file to the repository and creates a symlink +// Add moves a file or directory to the repository and creates a symlink func (l *Lnk) Add(filePath string) error { - // Validate the file + // Validate the file or directory if err := l.fs.ValidateFileForAdd(filePath); err != nil { return err } @@ -93,30 +95,89 @@ func (l *Lnk) Add(filePath string) error { basename := filepath.Base(absPath) destPath := filepath.Join(l.repoPath, basename) - // Move file to repository - if err := l.fs.MoveFile(absPath, destPath); err != nil { - return fmt.Errorf("failed to move file to repository: %w", err) + // Check if it's a directory or file + info, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("failed to stat path: %w", err) + } + + // Move to repository (handles both files and directories) + if info.IsDir() { + if err := l.fs.MoveDirectory(absPath, destPath); err != nil { + return fmt.Errorf("failed to move directory to repository: %w", err) + } + } else { + if err := l.fs.MoveFile(absPath, destPath); err != nil { + return fmt.Errorf("failed to move file to repository: %w", err) + } } // Create symlink if err := l.fs.CreateSymlink(destPath, absPath); err != nil { - // Try to restore the file if symlink creation fails - _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup + // Try to restore the original if symlink creation fails + if info.IsDir() { + _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup + } else { + _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup + } return fmt.Errorf("failed to create symlink: %w", err) } - // Stage and commit the file - if err := l.git.AddAndCommit(basename, fmt.Sprintf("lnk: added %s", basename)); err != nil { + // Add to .lnk tracking file + if err := l.addManagedItem(absPath); err != nil { + // Try to restore the original state if tracking fails + _ = os.Remove(absPath) // Ignore error in cleanup + if info.IsDir() { + _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup + } else { + _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup + } + return fmt.Errorf("failed to update tracking file: %w", err) + } + + // Add both the item and .lnk file to git in a single commit + if err := l.git.Add(basename); err != nil { + // Try to restore the original state if git add fails + _ = os.Remove(absPath) // Ignore error in cleanup + _ = l.removeManagedItem(absPath) // Ignore error in cleanup + if info.IsDir() { + _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup + } else { + _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup + } + return fmt.Errorf("failed to add item to git: %w", err) + } + + // Add .lnk file to the same commit + if err := l.git.Add(".lnk"); err != nil { + // Try to restore the original state if git add fails + _ = os.Remove(absPath) // Ignore error in cleanup + _ = l.removeManagedItem(absPath) // Ignore error in cleanup + if info.IsDir() { + _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup + } else { + _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup + } + return fmt.Errorf("failed to add .lnk file to git: %w", err) + } + + // Commit both changes together + if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil { // Try to restore the original state if commit fails - _ = os.Remove(absPath) // Ignore error in cleanup - _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup + _ = os.Remove(absPath) // Ignore error in cleanup + _ = l.removeManagedItem(absPath) // Ignore error in cleanup + if info.IsDir() { + _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup + } else { + _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup + } return fmt.Errorf("failed to commit changes: %w", err) } return nil } -// Remove removes a symlink and restores the original file +// Remove removes a symlink and restores the original file or directory func (l *Lnk) Remove(filePath string) error { // Get absolute path absPath, err := filepath.Abs(filePath) @@ -142,24 +203,48 @@ func (l *Lnk) Remove(filePath string) error { basename := filepath.Base(target) + // Check if target is a directory or file + info, err := os.Stat(target) + if err != nil { + return fmt.Errorf("failed to stat target: %w", err) + } + // Remove the symlink if err := os.Remove(absPath); err != nil { return fmt.Errorf("failed to remove symlink: %w", err) } - // Move file back from repository - if err := l.fs.MoveFile(target, absPath); err != nil { - return fmt.Errorf("failed to restore file: %w", err) + // Remove from .lnk tracking file + if err := l.removeManagedItem(absPath); err != nil { + return fmt.Errorf("failed to update tracking file: %w", err) } - // Remove from Git and commit - if err := l.git.RemoveAndCommit(basename, fmt.Sprintf("lnk: removed %s", basename)); err != nil { - // Try to restore the symlink if commit fails - _ = l.fs.MoveFile(absPath, target) // Ignore error in cleanup - _ = l.fs.CreateSymlink(target, absPath) // Ignore error in cleanup + // Remove from Git first (while the item is still in the repository) + if err := l.git.Remove(basename); err != nil { + return fmt.Errorf("failed to remove from git: %w", err) + } + + // Add .lnk file to the same commit + if err := l.git.Add(".lnk"); err != nil { + return fmt.Errorf("failed to add .lnk file to git: %w", err) + } + + // Commit both changes together + if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil { return fmt.Errorf("failed to commit changes: %w", err) } + // Move back from repository (handles both files and directories) + if info.IsDir() { + if err := l.fs.MoveDirectory(target, absPath); err != nil { + return fmt.Errorf("failed to restore directory: %w", err) + } + } else { + if err := l.fs.MoveFile(target, absPath); err != nil { + return fmt.Errorf("failed to restore file: %w", err) + } + } + return nil } @@ -249,30 +334,24 @@ func (l *Lnk) Pull() ([]string, error) { return restored, nil } -// RestoreSymlinks finds all files in the repository and ensures they have proper symlinks +// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks func (l *Lnk) RestoreSymlinks() ([]string, error) { var restored []string - // Read all files in the repository - entries, err := os.ReadDir(l.repoPath) + // Get managed items from .lnk file + managedItems, err := l.getManagedItems() if err != nil { - return nil, fmt.Errorf("failed to read repository directory: %w", err) + return nil, fmt.Errorf("failed to get managed items: %w", err) } - for _, entry := range entries { - // Skip hidden files and directories (like .git) - if entry.Name()[0] == '.' { - continue - } + for _, itemName := range managedItems { + repoItem := filepath.Join(l.repoPath, itemName) - // Skip directories - if entry.IsDir() { - continue + // Check if item exists in repository + if _, err := os.Stat(repoItem); os.IsNotExist(err) { + continue // Skip missing items } - filename := entry.Name() - repoFile := filepath.Join(l.repoPath, filename) - // Determine where the symlink should be // For config files, we'll place them in the user's home directory homeDir, err := os.UserHomeDir() @@ -280,26 +359,26 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) { return nil, fmt.Errorf("failed to get home directory: %w", err) } - symlinkPath := filepath.Join(homeDir, filename) + symlinkPath := filepath.Join(homeDir, itemName) // Check if symlink already exists and is correct - if l.isValidSymlink(symlinkPath, repoFile) { + if l.isValidSymlink(symlinkPath, repoItem) { continue } // Remove existing file/symlink if it exists if _, err := os.Lstat(symlinkPath); err == nil { - if err := os.Remove(symlinkPath); err != nil { - return nil, fmt.Errorf("failed to remove existing file %s: %w", symlinkPath, err) + if err := os.RemoveAll(symlinkPath); err != nil { + return nil, fmt.Errorf("failed to remove existing item %s: %w", symlinkPath, err) } } // Create symlink - if err := l.fs.CreateSymlink(repoFile, symlinkPath); err != nil { - return nil, fmt.Errorf("failed to create symlink for %s: %w", filename, err) + if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil { + return nil, fmt.Errorf("failed to create symlink for %s: %w", itemName, err) } - restored = append(restored, filename) + restored = append(restored, itemName) } return restored, nil @@ -341,3 +420,99 @@ func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool { return targetAbs == expectedAbs } + +// getManagedItems returns the list of managed files and directories from .lnk file +func (l *Lnk) getManagedItems() ([]string, error) { + lnkFile := filepath.Join(l.repoPath, ".lnk") + + // If .lnk file doesn't exist, return empty list + if _, err := os.Stat(lnkFile); os.IsNotExist(err) { + return []string{}, nil + } + + content, err := os.ReadFile(lnkFile) + if err != nil { + return nil, fmt.Errorf("failed to read .lnk file: %w", err) + } + + if len(content) == 0 { + return []string{}, nil + } + + lines := strings.Split(strings.TrimSpace(string(content)), "\n") + var items []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + items = append(items, line) + } + } + + return items, nil +} + +// addManagedItem adds an item to the .lnk tracking file +func (l *Lnk) addManagedItem(itemPath string) error { + // Get current items + items, err := l.getManagedItems() + if err != nil { + return fmt.Errorf("failed to get managed items: %w", err) + } + + // Get the basename for storage + basename := filepath.Base(itemPath) + + // Check if already exists + for _, item := range items { + if item == basename { + return nil // Already managed + } + } + + // Add new item + items = append(items, basename) + + // Sort for consistent ordering + sort.Strings(items) + + return l.writeManagedItems(items) +} + +// removeManagedItem removes an item from the .lnk tracking file +func (l *Lnk) removeManagedItem(itemPath string) error { + // Get current items + items, err := l.getManagedItems() + if err != nil { + return fmt.Errorf("failed to get managed items: %w", err) + } + + // Get the basename for removal + basename := filepath.Base(itemPath) + + // Remove item + var newItems []string + for _, item := range items { + if item != basename { + newItems = append(newItems, item) + } + } + + return l.writeManagedItems(newItems) +} + +// writeManagedItems writes the list of managed items to .lnk file +func (l *Lnk) writeManagedItems(items []string) error { + lnkFile := filepath.Join(l.repoPath, ".lnk") + + content := strings.Join(items, "\n") + if len(items) > 0 { + content += "\n" + } + + err := os.WriteFile(lnkFile, []byte(content), 0644) + if err != nil { + return fmt.Errorf("failed to write .lnk file: %w", err) + } + + return nil +} diff --git a/internal/fs/filesystem.go b/internal/fs/filesystem.go index faa0dd0..345c50b 100644 --- a/internal/fs/filesystem.go +++ b/internal/fs/filesystem.go @@ -15,7 +15,7 @@ func New() *FileSystem { return &FileSystem{} } -// ValidateFileForAdd validates that a file can be added to lnk +// ValidateFileForAdd validates that a file or directory can be added to lnk func (fs *FileSystem) ValidateFileForAdd(filePath string) error { // Check if file exists info, err := os.Stat(filePath) @@ -26,14 +26,9 @@ func (fs *FileSystem) ValidateFileForAdd(filePath string) error { return fmt.Errorf("failed to stat file: %w", err) } - // Check if it's a directory - if info.IsDir() { - return fmt.Errorf("directories are not supported: %s", filePath) - } - - // Check if it's a regular file - if !info.Mode().IsRegular() { - return fmt.Errorf("only regular files are supported: %s", filePath) + // Allow both regular files and directories + if !info.Mode().IsRegular() && !info.IsDir() { + return fmt.Errorf("only regular files and directories are supported: %s", filePath) } return nil @@ -110,3 +105,29 @@ func (fs *FileSystem) CreateSymlink(target, linkPath string) error { return nil } + +// MoveDirectory moves a directory from source to destination recursively +func (fs *FileSystem) MoveDirectory(src, dst string) error { + // Check if source is a directory + info, err := os.Stat(src) + if err != nil { + return fmt.Errorf("failed to stat source: %w", err) + } + + if !info.IsDir() { + return fmt.Errorf("source is not a directory: %s", src) + } + + // Ensure destination parent directory exists + dstParent := filepath.Dir(dst) + if err := os.MkdirAll(dstParent, 0755); err != nil { + return fmt.Errorf("failed to create destination parent directory: %w", err) + } + + // Use os.Rename which works for directories + if err := os.Rename(src, dst); err != nil { + return fmt.Errorf("failed to move directory from %s to %s: %w", src, dst, err) + } + + return nil +} diff --git a/internal/git/git.go b/internal/git/git.go index e836afa..8e801bc 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -130,7 +130,7 @@ func (g *Git) IsLnkRepository() bool { // AddAndCommit stages a file and commits it func (g *Git) AddAndCommit(filename, message string) error { // Stage the file - if err := g.add(filename); err != nil { + if err := g.Add(filename); err != nil { return err } @@ -145,7 +145,7 @@ func (g *Git) AddAndCommit(filename, message string) error { // RemoveAndCommit removes a file from Git and commits the change func (g *Git) RemoveAndCommit(filename, message string) error { // Remove the file from Git - if err := g.remove(filename); err != nil { + if err := g.Remove(filename); err != nil { return err } @@ -157,8 +157,8 @@ func (g *Git) RemoveAndCommit(filename, message string) error { return nil } -// add stages a file -func (g *Git) add(filename string) error { +// Add stages a file +func (g *Git) Add(filename string) error { cmd := exec.Command("git", "add", filename) cmd.Dir = g.repoPath @@ -170,9 +170,21 @@ func (g *Git) add(filename string) error { return nil } -// remove removes a file from Git tracking -func (g *Git) remove(filename string) error { - cmd := exec.Command("git", "rm", filename) +// Remove removes a file from Git tracking +func (g *Git) Remove(filename string) error { + // Check if it's a directory that needs -r flag + fullPath := filepath.Join(g.repoPath, filename) + info, err := os.Stat(fullPath) + + var cmd *exec.Cmd + if err == nil && info.IsDir() { + // Use -r and --cached flags for directories (only remove from git, not filesystem) + cmd = exec.Command("git", "rm", "-r", "--cached", filename) + } else { + // Regular file (only remove from git, not filesystem) + cmd = exec.Command("git", "rm", "--cached", filename) + } + cmd.Dir = g.repoPath output, err := cmd.CombinedOutput() diff --git a/test/integration_test.go b/test/integration_test.go index 11f29bf..7a9a86b 100644 --- a/test/integration_test.go +++ b/test/integration_test.go @@ -169,14 +169,230 @@ func (suite *LnkIntegrationTestSuite) TestAddDirectory() { err := suite.lnk.Init() suite.Require().NoError(err) - // Create a directory + // Create a directory with files testDir := filepath.Join(suite.tempDir, "testdir") err = os.MkdirAll(testDir, 0755) suite.Require().NoError(err) + // Add files to the directory + testFile1 := filepath.Join(testDir, "file1.txt") + err = os.WriteFile(testFile1, []byte("content1"), 0644) + suite.Require().NoError(err) + + testFile2 := filepath.Join(testDir, "file2.txt") + err = os.WriteFile(testFile2, []byte("content2"), 0644) + suite.Require().NoError(err) + + // Add the directory - should now succeed err = suite.lnk.Add(testDir) - suite.Error(err) - suite.Contains(err.Error(), "directories are not supported") + suite.Require().NoError(err) + + // Check that the directory is now a symlink + info, err := os.Lstat(testDir) + suite.Require().NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) + + // Check that the directory exists in the repo + repoDir := filepath.Join(suite.tempDir, "lnk", "testdir") + suite.DirExists(repoDir) + + // Check that files are preserved + repoFile1 := filepath.Join(repoDir, "file1.txt") + repoFile2 := filepath.Join(repoDir, "file2.txt") + suite.FileExists(repoFile1) + suite.FileExists(repoFile2) + + content1, err := os.ReadFile(repoFile1) + suite.Require().NoError(err) + suite.Equal("content1", string(content1)) + + content2, err := os.ReadFile(repoFile2) + suite.Require().NoError(err) + suite.Equal("content2", string(content2)) + + // Check that .lnk file was created and contains the directory + lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") + suite.FileExists(lnkFile) + + lnkContent, err := os.ReadFile(lnkFile) + suite.Require().NoError(err) + suite.Contains(string(lnkContent), "testdir") + + // Check that Git commit was made + commits, err := suite.lnk.GetCommits() + suite.Require().NoError(err) + suite.Len(commits, 1) + suite.Contains(commits[0], "lnk: added testdir") +} + +func (suite *LnkIntegrationTestSuite) TestRemoveDirectory() { + // Initialize and add a directory first + err := suite.lnk.Init() + suite.Require().NoError(err) + + testDir := filepath.Join(suite.tempDir, "testdir") + err = os.MkdirAll(testDir, 0755) + suite.Require().NoError(err) + + testFile := filepath.Join(testDir, "config.txt") + content := "test config" + err = os.WriteFile(testFile, []byte(content), 0644) + suite.Require().NoError(err) + + err = suite.lnk.Add(testDir) + suite.Require().NoError(err) + + // Now remove the directory + err = suite.lnk.Remove(testDir) + suite.Require().NoError(err) + + // Check that the symlink is gone and regular directory is restored + info, err := os.Lstat(testDir) + suite.Require().NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink + suite.True(info.IsDir()) // Is a directory + + // Check that content is preserved + restoredContent, err := os.ReadFile(testFile) + suite.Require().NoError(err) + suite.Equal(content, string(restoredContent)) + + // Check that directory is removed from repo + repoDir := filepath.Join(suite.tempDir, "lnk", "testdir") + suite.NoDirExists(repoDir) + + // Check that .lnk file no longer contains the directory + lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") + if suite.FileExists(lnkFile) { + lnkContent, err := os.ReadFile(lnkFile) + suite.Require().NoError(err) + suite.NotContains(string(lnkContent), "testdir") + } + + // Check that Git commit was made + commits, err := suite.lnk.GetCommits() + suite.Require().NoError(err) + suite.Len(commits, 2) // add + remove + suite.Contains(commits[0], "lnk: removed testdir") + suite.Contains(commits[1], "lnk: added testdir") +} + +func (suite *LnkIntegrationTestSuite) TestLnkFileTracking() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Add a file + testFile := filepath.Join(suite.tempDir, ".bashrc") + err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) + suite.Require().NoError(err) + + err = suite.lnk.Add(testFile) + suite.Require().NoError(err) + + // Add a directory + testDir := filepath.Join(suite.tempDir, ".ssh") + err = os.MkdirAll(testDir, 0700) + suite.Require().NoError(err) + + configFile := filepath.Join(testDir, "config") + err = os.WriteFile(configFile, []byte("Host example.com"), 0600) + suite.Require().NoError(err) + + err = suite.lnk.Add(testDir) + suite.Require().NoError(err) + + // Check .lnk file contains both entries + lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") + suite.FileExists(lnkFile) + + lnkContent, err := os.ReadFile(lnkFile) + suite.Require().NoError(err) + + lines := strings.Split(strings.TrimSpace(string(lnkContent)), "\n") + suite.Len(lines, 2) + suite.Contains(lines, ".bashrc") + suite.Contains(lines, ".ssh") + + // Remove a file and check .lnk is updated + err = suite.lnk.Remove(testFile) + suite.Require().NoError(err) + + lnkContent, err = os.ReadFile(lnkFile) + suite.Require().NoError(err) + + lines = strings.Split(strings.TrimSpace(string(lnkContent)), "\n") + suite.Len(lines, 1) + suite.Contains(lines, ".ssh") + suite.NotContains(lines, ".bashrc") +} + +func (suite *LnkIntegrationTestSuite) TestPullWithDirectories() { + // Initialize repo + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Add remote for pull to work + err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") + suite.Require().NoError(err) + + // Create a directory and .lnk file in the repo directly to simulate a pull + repoDir := filepath.Join(suite.tempDir, "lnk", ".config") + err = os.MkdirAll(repoDir, 0755) + suite.Require().NoError(err) + + configFile := filepath.Join(repoDir, "app.conf") + content := "setting=value" + err = os.WriteFile(configFile, []byte(content), 0644) + suite.Require().NoError(err) + + // Create .lnk file + lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") + err = os.WriteFile(lnkFile, []byte(".config\n"), 0644) + suite.Require().NoError(err) + + // Get home directory for the test + homeDir, err := os.UserHomeDir() + suite.Require().NoError(err) + + targetDir := filepath.Join(homeDir, ".config") + + // Clean up the test directory after the test + defer func() { + _ = os.RemoveAll(targetDir) + }() + + // Create a regular directory in home to simulate conflict scenario + err = os.MkdirAll(targetDir, 0755) + suite.Require().NoError(err) + err = os.WriteFile(filepath.Join(targetDir, "different.conf"), []byte("different"), 0644) + suite.Require().NoError(err) + + // Pull should restore symlinks and handle conflicts + restored, err := suite.lnk.Pull() + // In tests, pull will fail because we don't have real remotes, but that's expected + // We can still test the symlink restoration part + if err != nil { + suite.Contains(err.Error(), "git pull failed") + // Test symlink restoration directly + restored, err = suite.lnk.RestoreSymlinks() + suite.Require().NoError(err) + } + + // Should have restored the symlink + suite.GreaterOrEqual(len(restored), 1) + if len(restored) > 0 { + suite.Equal(".config", restored[0]) + } + + // Check that directory is back to being a symlink + info, err := os.Lstat(targetDir) + suite.Require().NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) + + // Check content is preserved from repo + repoContent, err := os.ReadFile(configFile) + suite.Require().NoError(err) + suite.Equal(content, string(repoContent)) } func (suite *LnkIntegrationTestSuite) TestRemoveNonSymlink() {