mirror of
https://github.com/yarlson/lnk.git
synced 2025-08-30 17:59:47 +02:00
feat: add directory support and .lnk tracking
- Add directory management support alongside individual files - Implement .lnk tracking file to manage files and directories - Update filesystem validation to allow directories - Add MoveDirectory method for handling directory operations - Use git rm --cached to properly handle directory removal - Add comprehensive tests for directory operations - Update README with directory support documentation - All 20 integration tests passing Breaking: None (backward compatible) Resolves: Git limitation of not tracking directories
This commit is contained in:
10
README.md
10
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
|
||||
```
|
||||
|
||||
[](./test) [](https://golang.org) [](LICENSE)
|
||||
[](./test) [](https://golang.org) [](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
|
||||
|
@@ -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
|
||||
// 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
|
||||
// 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.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,22 +203,46 @@ 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
|
||||
// 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 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
return fmt.Errorf("failed to commit changes: %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
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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()
|
||||
|
@@ -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() {
|
||||
|
Reference in New Issue
Block a user