feat: initial Lnk CLI implementation

This commit is contained in:
Yar Kravtsov
2025-05-24 06:17:52 +03:00
commit 9088bbda50
13 changed files with 941 additions and 0 deletions

38
.gitignore vendored Normal file
View File

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

118
README.md Normal file
View File

@@ -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.

32
cmd/add.go Normal file
View File

@@ -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 <file>",
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)
}

26
cmd/init.go Normal file
View File

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

32
cmd/rm.go Normal file
View File

@@ -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 <file>",
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)
}

21
cmd/root.go Normal file
View File

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

16
go.mod Normal file
View File

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

18
go.sum Normal file
View File

@@ -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=

149
internal/core/lnk.go Normal file
View File

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

112
internal/fs/filesystem.go Normal file
View File

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

165
internal/git/git.go Normal file
View File

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

7
main.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "github.com/yarlson/lnk/cmd"
func main() {
cmd.Execute()
}

207
test/integration_test.go Normal file
View File

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