From 9088bbda500105106a385f0f58fbb1b25ab9fb89 Mon Sep 17 00:00:00 2001 From: Yar Kravtsov Date: Sat, 24 May 2025 06:17:52 +0300 Subject: [PATCH] feat: initial Lnk CLI implementation --- .gitignore | 38 +++++++ README.md | 118 ++++++++++++++++++++++ cmd/add.go | 32 ++++++ cmd/init.go | 26 +++++ cmd/rm.go | 32 ++++++ cmd/root.go | 21 ++++ go.mod | 16 +++ go.sum | 18 ++++ internal/core/lnk.go | 149 +++++++++++++++++++++++++++ internal/fs/filesystem.go | 112 +++++++++++++++++++++ internal/git/git.go | 165 ++++++++++++++++++++++++++++++ main.go | 7 ++ test/integration_test.go | 207 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 941 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/add.go create mode 100644 cmd/init.go create mode 100644 cmd/rm.go create mode 100644 cmd/root.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/core/lnk.go create mode 100644 internal/fs/filesystem.go create mode 100644 internal/git/git.go create mode 100644 main.go create mode 100644 test/integration_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2564452 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +lnk + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work +go.work.sum + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# Temporary files +*.tmp +*.log \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fb5a6e --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# Lnk + +**Dotfiles, linked. No fluff.** + +Lnk is a minimalist CLI tool for managing dotfiles using symlinks and Git. It moves files into a managed repository directory, replaces them with symlinks, and commits changes to Git. That's it—no templating, no secrets, no config file. + +## Features + +- **Simple**: Just three commands: `init`, `add`, and `rm` +- **Git-based**: Automatically commits changes with descriptive messages +- **Symlink management**: Creates relative symlinks for portability +- **XDG compliant**: Uses `$XDG_CONFIG_HOME/lnk` or `~/.config/lnk` +- **No configuration**: Works out of the box + +## Installation + +### From Source + +```bash +git clone https://github.com/yarlson/lnk.git +cd lnk +go build -o lnk . +sudo mv lnk /usr/local/bin/ +``` + +## Usage + +### Initialize a repository + +```bash +lnk init +``` + +This creates `$XDG_CONFIG_HOME/lnk` (or `~/.config/lnk`) and initializes a Git repository. + +### Add a file + +```bash +lnk add ~/.bashrc +``` + +This: +1. Moves `~/.bashrc` to `$XDG_CONFIG_HOME/lnk/.bashrc` +2. Creates a symlink from `~/.bashrc` to the repository +3. Commits the change with message "lnk: added .bashrc" + +### Remove a file + +```bash +lnk rm ~/.bashrc +``` + +This: +1. Removes the symlink `~/.bashrc` +2. Moves the file back from the repository to `~/.bashrc` +3. Removes it from Git tracking and commits with message "lnk: removed .bashrc" + +## Examples + +```bash +# Initialize lnk +lnk init + +# Add some dotfiles +lnk add ~/.bashrc +lnk add ~/.vimrc +lnk add ~/.gitconfig + +# Remove a file from management +lnk rm ~/.vimrc + +# Your files are now managed in ~/.config/lnk with Git history +cd ~/.config/lnk +git log --oneline +``` + +## Error Handling + +- Adding a nonexistent file: exits with error +- Adding a directory: exits with "directories are not supported" +- Removing a non-symlink: exits with "file is not managed by lnk" +- Git operations show stderr output on failure + +## Development + +### Running Tests + +```bash +go test -v ./test +``` + +The project uses integration tests that test real file and Git operations in isolated temporary directories. + +### Project Structure + +``` +├── cmd/ # Cobra CLI commands +│ ├── root.go # Root command +│ ├── init.go # Init command +│ ├── add.go # Add command +│ └── rm.go # Remove command +├── internal/ +│ ├── core/ # Core business logic +│ │ └── lnk.go +│ ├── fs/ # File system operations +│ │ └── filesystem.go +│ └── git/ # Git operations +│ └── git.go +├── test/ # Integration tests +│ └── integration_test.go +├── main.go # Entry point +├── README.md # Documentation +└── go.mod # Dependencies +``` + +## License + +MIT License - see LICENSE file for details. diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..92afb16 --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" +) + +var addCmd = &cobra.Command{ + Use: "add ", + Short: "Add a file to lnk management", + Long: "Moves a file to the lnk repository and creates a symlink in its place.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + filePath := args[0] + + lnk := core.NewLnk() + if err := lnk.Add(filePath); err != nil { + return fmt.Errorf("failed to add file: %w", err) + } + + basename := filepath.Base(filePath) + fmt.Printf("Added %s to lnk\n", basename) + return nil + }, +} + +func init() { + rootCmd.AddCommand(addCmd) +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..c8df04a --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize a new lnk repository", + Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.", + RunE: func(cmd *cobra.Command, args []string) error { + lnk := core.NewLnk() + if err := lnk.Init(); err != nil { + return fmt.Errorf("failed to initialize lnk: %w", err) + } + fmt.Println("Initialized lnk repository") + return nil + }, +} + +func init() { + rootCmd.AddCommand(initCmd) +} diff --git a/cmd/rm.go b/cmd/rm.go new file mode 100644 index 0000000..7f2acd3 --- /dev/null +++ b/cmd/rm.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/yarlson/lnk/internal/core" +) + +var rmCmd = &cobra.Command{ + Use: "rm ", + Short: "Remove a file from lnk management", + Long: "Removes a symlink and restores the original file from the lnk repository.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + filePath := args[0] + + lnk := core.NewLnk() + if err := lnk.Remove(filePath); err != nil { + return fmt.Errorf("failed to remove file: %w", err) + } + + basename := filepath.Base(filePath) + fmt.Printf("Removed %s from lnk\n", basename) + return nil + }, +} + +func init() { + rootCmd.AddCommand(rmCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..5560762 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "lnk", + Short: "Dotfiles, linked. No fluff.", + Long: "Lnk is a minimalist CLI tool for managing dotfiles using symlinks and Git.", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..52ad0c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/yarlson/lnk + +go 1.24 + +require ( + github.com/spf13/cobra v1.9.1 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4695b18 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/core/lnk.go b/internal/core/lnk.go new file mode 100644 index 0000000..a8bac29 --- /dev/null +++ b/internal/core/lnk.go @@ -0,0 +1,149 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/yarlson/lnk/internal/fs" + "github.com/yarlson/lnk/internal/git" +) + +// Lnk represents the main application logic +type Lnk struct { + repoPath string + git *git.Git + fs *fs.FileSystem +} + +// NewLnk creates a new Lnk instance +func NewLnk() *Lnk { + repoPath := getRepoPath() + return &Lnk{ + repoPath: repoPath, + git: git.New(repoPath), + fs: fs.New(), + } +} + +// getRepoPath returns the path to the lnk repository directory +func getRepoPath() string { + xdgConfig := os.Getenv("XDG_CONFIG_HOME") + if xdgConfig == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + // Fallback to current directory if we can't get home + xdgConfig = "." + } else { + xdgConfig = filepath.Join(homeDir, ".config") + } + } + return filepath.Join(xdgConfig, "lnk") +} + +// Init initializes the lnk repository +func (l *Lnk) Init() error { + // Create the repository directory + if err := os.MkdirAll(l.repoPath, 0755); err != nil { + return fmt.Errorf("failed to create lnk directory: %w", err) + } + + // Initialize Git repository + if err := l.git.Init(); err != nil { + return fmt.Errorf("failed to initialize git repository: %w", err) + } + + return nil +} + +// Add moves a file to the repository and creates a symlink +func (l *Lnk) Add(filePath string) error { + // Validate the file + if err := l.fs.ValidateFileForAdd(filePath); err != nil { + return err + } + + // Get absolute path + absPath, err := filepath.Abs(filePath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Calculate destination path in repo + 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) + } + + // 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) + 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 { + // Try to restore the original state if commit fails + os.Remove(absPath) + l.fs.MoveFile(destPath, absPath) + return fmt.Errorf("failed to commit changes: %w", err) + } + + return nil +} + +// Remove removes a symlink and restores the original file +func (l *Lnk) Remove(filePath string) error { + // Get absolute path + absPath, err := filepath.Abs(filePath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Validate that this is a symlink managed by lnk + if err := l.fs.ValidateSymlinkForRemove(absPath, l.repoPath); err != nil { + return err + } + + // Get the target path in the repository + target, err := os.Readlink(absPath) + if err != nil { + return fmt.Errorf("failed to read symlink: %w", err) + } + + // Convert relative path to absolute if needed + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(absPath), target) + } + + basename := filepath.Base(target) + + // 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 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) + l.fs.CreateSymlink(target, absPath) + return fmt.Errorf("failed to commit changes: %w", err) + } + + return nil +} + +// GetCommits returns the list of commits for testing purposes +func (l *Lnk) GetCommits() ([]string, error) { + return l.git.GetCommits() +} diff --git a/internal/fs/filesystem.go b/internal/fs/filesystem.go new file mode 100644 index 0000000..faa0dd0 --- /dev/null +++ b/internal/fs/filesystem.go @@ -0,0 +1,112 @@ +package fs + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// FileSystem handles file system operations +type FileSystem struct{} + +// New creates a new FileSystem instance +func New() *FileSystem { + return &FileSystem{} +} + +// ValidateFileForAdd validates that a file can be added to lnk +func (fs *FileSystem) ValidateFileForAdd(filePath string) error { + // Check if file exists + info, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("file does not exist: %s", filePath) + } + 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) + } + + return nil +} + +// ValidateSymlinkForRemove validates that a symlink can be removed from lnk +func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error { + // Check if file exists + info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("file does not exist: %s", filePath) + } + return fmt.Errorf("failed to stat file: %w", err) + } + + // Check if it's a symlink + if info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("file is not managed by lnk: %s", filePath) + } + + // Check if symlink points to the repository + target, err := os.Readlink(filePath) + if err != nil { + return fmt.Errorf("failed to read symlink: %w", err) + } + + // Convert relative path to absolute if needed + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(filePath), target) + } + + // Clean the path to resolve any .. or . components + target = filepath.Clean(target) + repoPath = filepath.Clean(repoPath) + + // Check if target is inside the repository + if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath { + return fmt.Errorf("file is not managed by lnk: %s", filePath) + } + + return nil +} + +// MoveFile moves a file from source to destination +func (fs *FileSystem) MoveFile(src, dst string) error { + // Ensure destination directory exists + dstDir := filepath.Dir(dst) + if err := os.MkdirAll(dstDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Move the file + if err := os.Rename(src, dst); err != nil { + return fmt.Errorf("failed to move file from %s to %s: %w", src, dst, err) + } + + return nil +} + +// CreateSymlink creates a relative symlink from target to linkPath +func (fs *FileSystem) CreateSymlink(target, linkPath string) error { + // Calculate relative path from linkPath to target + linkDir := filepath.Dir(linkPath) + relTarget, err := filepath.Rel(linkDir, target) + if err != nil { + return fmt.Errorf("failed to calculate relative path: %w", err) + } + + // Create the symlink + if err := os.Symlink(relTarget, linkPath); err != nil { + return fmt.Errorf("failed to create symlink: %w", err) + } + + return nil +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..3b3d249 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,165 @@ +package git + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Git handles Git operations +type Git struct { + repoPath string +} + +// New creates a new Git instance +func New(repoPath string) *Git { + return &Git{ + repoPath: repoPath, + } +} + +// Init initializes a new Git repository +func (g *Git) Init() error { + cmd := exec.Command("git", "init") + cmd.Dir = g.repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +// 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 { + return err + } + + // Commit the changes + if err := g.commit(message); err != nil { + return err + } + + return nil +} + +// 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 { + return err + } + + // Commit the changes + if err := g.commit(message); err != nil { + return err + } + + return nil +} + +// add stages a file +func (g *Git) add(filename string) error { + cmd := exec.Command("git", "add", filename) + cmd.Dir = g.repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +// remove removes a file from Git tracking +func (g *Git) remove(filename string) error { + cmd := exec.Command("git", "rm", filename) + cmd.Dir = g.repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +// commit creates a commit with the given message +func (g *Git) commit(message string) error { + // Configure git user if not already configured + if err := g.ensureGitConfig(); err != nil { + return err + } + + cmd := exec.Command("git", "commit", "-m", message) + cmd.Dir = g.repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +// ensureGitConfig ensures that git user.name and user.email are configured +func (g *Git) ensureGitConfig() error { + // Check if user.name is configured + cmd := exec.Command("git", "config", "user.name") + cmd.Dir = g.repoPath + if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 { + // Set a default user.name + cmd = exec.Command("git", "config", "user.name", "Lnk User") + cmd.Dir = g.repoPath + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to set git user.name: %w", err) + } + } + + // Check if user.email is configured + cmd = exec.Command("git", "config", "user.email") + cmd.Dir = g.repoPath + if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 { + // Set a default user.email + cmd = exec.Command("git", "config", "user.email", "lnk@localhost") + cmd.Dir = g.repoPath + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to set git user.email: %w", err) + } + } + + return nil +} + +// GetCommits returns the list of commit messages for testing purposes +func (g *Git) GetCommits() ([]string, error) { + // Check if .git directory exists + gitDir := filepath.Join(g.repoPath, ".git") + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + return []string{}, nil + } + + cmd := exec.Command("git", "log", "--oneline", "--format=%s") + cmd.Dir = g.repoPath + + output, err := cmd.Output() + if err != nil { + // If there are no commits yet, return empty slice + if strings.Contains(string(output), "does not have any commits yet") { + return []string{}, nil + } + return nil, fmt.Errorf("git log failed: %w", err) + } + + commits := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(commits) == 1 && commits[0] == "" { + return []string{}, nil + } + + return commits, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..652c17b --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/yarlson/lnk/cmd" + +func main() { + cmd.Execute() +} diff --git a/test/integration_test.go b/test/integration_test.go new file mode 100644 index 0000000..9de4dc2 --- /dev/null +++ b/test/integration_test.go @@ -0,0 +1,207 @@ +package test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/yarlson/lnk/internal/core" +) + +type LnkIntegrationTestSuite struct { + suite.Suite + tempDir string + originalDir string + lnk *core.Lnk +} + +func (suite *LnkIntegrationTestSuite) SetupTest() { + // Create temporary directory for each test + tempDir, err := os.MkdirTemp("", "lnk-test-*") + suite.Require().NoError(err) + suite.tempDir = tempDir + + // Change to temp directory + originalDir, err := os.Getwd() + suite.Require().NoError(err) + suite.originalDir = originalDir + + err = os.Chdir(tempDir) + suite.Require().NoError(err) + + // Set XDG_CONFIG_HOME to temp directory + suite.T().Setenv("XDG_CONFIG_HOME", tempDir) + + // Initialize Lnk instance + suite.lnk = core.NewLnk() +} + +func (suite *LnkIntegrationTestSuite) TearDownTest() { + // Return to original directory + err := os.Chdir(suite.originalDir) + suite.Require().NoError(err) + + // Clean up temp directory + err = os.RemoveAll(suite.tempDir) + suite.Require().NoError(err) +} + +func (suite *LnkIntegrationTestSuite) TestInit() { + // Test that init creates the directory and Git repo + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Check that the lnk directory was created + lnkDir := filepath.Join(suite.tempDir, "lnk") + suite.DirExists(lnkDir) + + // Check that Git repo was initialized + gitDir := filepath.Join(lnkDir, ".git") + suite.DirExists(gitDir) + + // Verify it's a non-bare repo + configPath := filepath.Join(gitDir, "config") + suite.FileExists(configPath) +} + +func (suite *LnkIntegrationTestSuite) TestAddFile() { + // Initialize first + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create a test file + testFile := filepath.Join(suite.tempDir, ".bashrc") + content := "export PATH=$PATH:/usr/local/bin" + err = os.WriteFile(testFile, []byte(content), 0644) + suite.Require().NoError(err) + + // Add the file + err = suite.lnk.Add(testFile) + suite.Require().NoError(err) + + // Check that the original file is now a symlink + info, err := os.Lstat(testFile) + suite.Require().NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) + + // Check that the file exists in the repo + repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc") + suite.FileExists(repoFile) + + // Check that the content is preserved + repoContent, err := os.ReadFile(repoFile) + suite.Require().NoError(err) + suite.Equal(content, string(repoContent)) + + // Check that symlink points to the correct location + linkTarget, err := os.Readlink(testFile) + suite.Require().NoError(err) + expectedTarget, err := filepath.Rel(filepath.Dir(testFile), repoFile) + suite.Require().NoError(err) + suite.Equal(expectedTarget, linkTarget) + + // 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 .bashrc") +} + +func (suite *LnkIntegrationTestSuite) TestRemoveFile() { + // Initialize and add a file first + err := suite.lnk.Init() + suite.Require().NoError(err) + + testFile := filepath.Join(suite.tempDir, ".vimrc") + content := "set number" + err = os.WriteFile(testFile, []byte(content), 0644) + suite.Require().NoError(err) + + err = suite.lnk.Add(testFile) + suite.Require().NoError(err) + + // Now remove the file + err = suite.lnk.Remove(testFile) + suite.Require().NoError(err) + + // Check that the symlink is gone and regular file is restored + info, err := os.Lstat(testFile) + suite.Require().NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink + + // Check that content is preserved + restoredContent, err := os.ReadFile(testFile) + suite.Require().NoError(err) + suite.Equal(content, string(restoredContent)) + + // Check that file is removed from repo + repoFile := filepath.Join(suite.tempDir, "lnk", ".vimrc") + suite.NoFileExists(repoFile) + + // 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 .vimrc") + suite.Contains(commits[1], "lnk: added .vimrc") +} + +func (suite *LnkIntegrationTestSuite) TestAddNonexistentFile() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + err = suite.lnk.Add("/nonexistent/file") + suite.Error(err) + suite.Contains(err.Error(), "file does not exist") +} + +func (suite *LnkIntegrationTestSuite) TestAddDirectory() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create a directory + testDir := filepath.Join(suite.tempDir, "testdir") + err = os.MkdirAll(testDir, 0755) + suite.Require().NoError(err) + + err = suite.lnk.Add(testDir) + suite.Error(err) + suite.Contains(err.Error(), "directories are not supported") +} + +func (suite *LnkIntegrationTestSuite) TestRemoveNonSymlink() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create a regular file (not managed by lnk) + testFile := filepath.Join(suite.tempDir, ".regularfile") + err = os.WriteFile(testFile, []byte("content"), 0644) + suite.Require().NoError(err) + + err = suite.lnk.Remove(testFile) + suite.Error(err) + suite.Contains(err.Error(), "file is not managed by lnk") +} + +func (suite *LnkIntegrationTestSuite) TestXDGConfigHomeFallback() { + // Test fallback to ~/.config/lnk when XDG_CONFIG_HOME is not set + suite.T().Setenv("XDG_CONFIG_HOME", "") + + homeDir := filepath.Join(suite.tempDir, "home") + err := os.MkdirAll(homeDir, 0755) + suite.Require().NoError(err) + suite.T().Setenv("HOME", homeDir) + + lnk := core.NewLnk() + err = lnk.Init() + suite.Require().NoError(err) + + // Check that the lnk directory was created under ~/.config/lnk + expectedDir := filepath.Join(homeDir, ".config", "lnk") + suite.DirExists(expectedDir) +} + +func TestLnkIntegrationSuite(t *testing.T) { + suite.Run(t, new(LnkIntegrationTestSuite)) +}