mirror of
https://github.com/yarlson/lnk.git
synced 2025-08-31 18:01:41 +02:00
feat: initial Lnk CLI implementation
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal 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
118
README.md
Normal 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
32
cmd/add.go
Normal 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
26
cmd/init.go
Normal 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
32
cmd/rm.go
Normal 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
21
cmd/root.go
Normal 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
16
go.mod
Normal 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
18
go.sum
Normal 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
149
internal/core/lnk.go
Normal 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
112
internal/fs/filesystem.go
Normal 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
165
internal/git/git.go
Normal 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
7
main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "github.com/yarlson/lnk/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
207
test/integration_test.go
Normal file
207
test/integration_test.go
Normal 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))
|
||||
}
|
Reference in New Issue
Block a user