mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-24 21:11:27 +02:00
Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
093cc8ebe7 | ||
|
ff3cddc065 | ||
|
4a275ce4ca | ||
|
69c1038f3e | ||
|
c670ac1fd8 | ||
|
27196e3341 | ||
|
84c507828d | ||
|
d02f112200 | ||
|
f96bfb6ce0 | ||
|
7007ec64f2 | ||
|
ec6ad6b0d0 | ||
|
e7f316ea6e | ||
|
09d67f181e | ||
|
3a34e4fb37 | ||
|
fc0b567e9f |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -38,7 +41,7 @@ jobs:
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -v -race -coverprofile=coverage.out ./test
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./test
|
||||
run: go test ./...
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
|
8
Makefile
8
Makefile
@@ -60,19 +60,19 @@ build:
|
||||
## test: Run tests
|
||||
test:
|
||||
@echo "$(BLUE)Running tests...$(NC)"
|
||||
@go test ./test
|
||||
@go test ./...
|
||||
@echo "$(GREEN)✓ Tests passed$(NC)"
|
||||
|
||||
## test-v: Run tests with verbose output
|
||||
test-v:
|
||||
@echo "$(BLUE)Running tests (verbose)...$(NC)"
|
||||
@go test -v ./test
|
||||
@go test -v ./...
|
||||
|
||||
## test-cover: Run tests with coverage
|
||||
test-cover:
|
||||
@echo "$(BLUE)Running tests with coverage...$(NC)"
|
||||
@go test -v -cover ./test
|
||||
@go test -coverprofile=coverage.out ./test
|
||||
@go test -v -cover ./...
|
||||
@go test -coverprofile=coverage.out ./
|
||||
@go tool cover -html=coverage.out -o coverage.html
|
||||
@echo "$(GREEN)✓ Coverage report generated: coverage.html$(NC)"
|
||||
|
||||
|
139
README.md
139
README.md
@@ -2,11 +2,12 @@
|
||||
|
||||
**Git-native dotfiles management that doesn't suck.**
|
||||
|
||||
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. That's it.
|
||||
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
|
||||
|
||||
```bash
|
||||
lnk init
|
||||
lnk add ~/.vimrc ~/.bashrc
|
||||
lnk add ~/.vimrc ~/.bashrc # Common config
|
||||
lnk add --host work ~/.ssh/config # Host-specific config
|
||||
lnk push "setup"
|
||||
```
|
||||
|
||||
@@ -49,25 +50,85 @@ lnk init -r git@github.com:user/dotfiles.git
|
||||
### Daily workflow
|
||||
|
||||
```bash
|
||||
# Add files/directories
|
||||
# Add files/directories (common config)
|
||||
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
||||
|
||||
# Add host-specific files
|
||||
lnk add --host laptop ~/.ssh/config
|
||||
lnk add --host work ~/.aws/credentials
|
||||
|
||||
# List managed files
|
||||
lnk list # Common config only
|
||||
lnk list --host laptop # Laptop-specific config
|
||||
lnk list --all # All configurations
|
||||
|
||||
# Check status
|
||||
lnk status
|
||||
|
||||
# Sync changes
|
||||
lnk push "updated vim config"
|
||||
lnk pull
|
||||
lnk pull # Pull common config
|
||||
lnk pull --host laptop # Pull laptop-specific config
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Common files:
|
||||
Before: ~/.vimrc (file)
|
||||
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
|
||||
|
||||
Host-specific files:
|
||||
Before: ~/.ssh/config (file)
|
||||
After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
|
||||
```
|
||||
|
||||
Your files live in `~/.config/lnk` (a Git repo). Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||
|
||||
## Multihost Support
|
||||
|
||||
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
|
||||
|
||||
### File Organization
|
||||
|
||||
```
|
||||
~/.config/lnk/
|
||||
├── .lnk # Tracks common files
|
||||
├── .lnk.laptop # Tracks laptop-specific files
|
||||
├── .lnk.work # Tracks work-specific files
|
||||
├── .vimrc # Common file
|
||||
├── .gitconfig # Common file
|
||||
├── laptop.lnk/ # Laptop-specific storage
|
||||
│ ├── .ssh/
|
||||
│ │ └── config
|
||||
│ └── .aws/
|
||||
│ └── credentials
|
||||
└── work.lnk/ # Work-specific storage
|
||||
├── .ssh/
|
||||
│ └── config
|
||||
└── .company/
|
||||
└── config
|
||||
```
|
||||
|
||||
### Usage Patterns
|
||||
|
||||
```bash
|
||||
# Common config (shared everywhere)
|
||||
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig
|
||||
|
||||
# Host-specific config (unique per machine)
|
||||
lnk add --host $(hostname) ~/.ssh/config
|
||||
lnk add --host work ~/.aws/credentials
|
||||
|
||||
# List configurations
|
||||
lnk list # Common only
|
||||
lnk list --host work # Work host only
|
||||
lnk list --all # Everything
|
||||
|
||||
# Pull configurations
|
||||
lnk pull # Common config
|
||||
lnk pull --host work # Work-specific config
|
||||
```
|
||||
|
||||
## Why not just Git?
|
||||
|
||||
@@ -84,7 +145,13 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
|
||||
|
||||
```bash
|
||||
lnk init -r git@github.com:you/dotfiles.git
|
||||
|
||||
# Add common config (shared across all machines)
|
||||
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
||||
|
||||
# Add host-specific config
|
||||
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||
|
||||
lnk push "initial setup"
|
||||
```
|
||||
|
||||
@@ -92,58 +159,76 @@ lnk push "initial setup"
|
||||
|
||||
```bash
|
||||
lnk init -r git@github.com:you/dotfiles.git
|
||||
lnk pull # auto-creates symlinks
|
||||
|
||||
# Pull common config
|
||||
lnk pull
|
||||
|
||||
# Pull host-specific config (if it exists)
|
||||
lnk pull --host $(hostname)
|
||||
```
|
||||
|
||||
### Daily edits
|
||||
|
||||
```bash
|
||||
vim ~/.vimrc # edit normally
|
||||
lnk list # see common config
|
||||
lnk list --host $(hostname) # see host-specific config
|
||||
lnk list --all # see everything
|
||||
lnk status # check what changed
|
||||
lnk push "new plugins" # commit & push
|
||||
```
|
||||
|
||||
### Multi-machine workflow
|
||||
|
||||
```bash
|
||||
# On your laptop
|
||||
lnk add --host laptop ~/.ssh/config
|
||||
lnk add ~/.vimrc # Common config
|
||||
lnk push "laptop ssh config"
|
||||
|
||||
# On your work machine
|
||||
lnk pull # Get common config
|
||||
lnk add --host work ~/.aws/credentials
|
||||
lnk push "work aws config"
|
||||
|
||||
# Back on laptop
|
||||
lnk pull # Get updates (work config won't affect laptop)
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `lnk init [-r remote]` - Create repo
|
||||
- `lnk add <files>` - Move files to repo, create symlinks
|
||||
- `lnk rm <files>` - Move files back, remove symlinks
|
||||
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
|
||||
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
||||
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
||||
- `lnk status` - Git status + sync info
|
||||
- `lnk push [msg]` - Stage all, commit, push
|
||||
- `lnk pull` - Pull + restore missing symlinks
|
||||
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
|
||||
|
||||
### Command Options
|
||||
|
||||
- `--host HOST` - Manage files for specific host (default: common configuration)
|
||||
- `--all` - Show all configurations (common + all hosts) when listing
|
||||
- `-r, --remote URL` - Clone from remote URL when initializing
|
||||
|
||||
## Technical bits
|
||||
|
||||
- **Single binary** (~8MB, no deps)
|
||||
- **Atomic operations** (rollback on failure)
|
||||
- **Relative symlinks** (portable)
|
||||
- **XDG compliant** (`~/.config/lnk`)
|
||||
- **20 integration tests**
|
||||
- **Multihost support** (common + host-specific configs)
|
||||
- **Git-native** (standard Git repo, no special formats)
|
||||
|
||||
## Alternatives
|
||||
|
||||
| Tool | Complexity | Why choose it |
|
||||
| ------- | ---------- | ------------------------------------- |
|
||||
| **lnk** | Minimal | Just works, no config, Git-native |
|
||||
| ------- | ---------- | -------------------------------------------- |
|
||||
| **lnk** | Minimal | Just works, no config, Git-native, multihost |
|
||||
| chezmoi | High | Templates, encryption, cross-platform |
|
||||
| yadm | Medium | Git power user, encryption |
|
||||
| dotbot | Low | YAML config, basic features |
|
||||
| stow | Low | Perl, symlink only |
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: What if I already have dotfiles in Git?**
|
||||
A: `git clone your-repo ~/.config/lnk && lnk add ~/.vimrc` (adopts existing files)
|
||||
|
||||
**Q: How do I handle machine-specific configs?**
|
||||
A: Git branches, or just don't manage machine-specific files with lnk
|
||||
|
||||
**Q: Windows support?**
|
||||
A: Symlinks work on Windows 10+, but untested
|
||||
|
||||
**Q: Production ready?**
|
||||
A: I use it daily. It won't break your files. API might change (pre-1.0).
|
||||
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
|
27
cmd/add.go
27
cmd/add.go
@@ -8,7 +8,8 @@ import (
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
var addCmd = &cobra.Command{
|
||||
func newAddCmd() *cobra.Command {
|
||||
cmd := &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.",
|
||||
@@ -16,20 +17,32 @@ var addCmd = &cobra.Command{
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath := args[0]
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
}
|
||||
|
||||
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("✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||
fmt.Printf(" 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
|
||||
fmt.Printf(" 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||
if host != "" {
|
||||
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, basename)
|
||||
} else {
|
||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
|
||||
}
|
||||
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(addCmd)
|
||||
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
|
||||
return cmd
|
||||
}
|
||||
|
30
cmd/init.go
30
cmd/init.go
@@ -7,7 +7,8 @@ import (
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
func newInitCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "🎯 Initialize a new lnk repository",
|
||||
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
||||
@@ -21,25 +22,24 @@ var initCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
if remote != "" {
|
||||
fmt.Printf("🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||
fmt.Printf(" 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||
fmt.Printf(" 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||
fmt.Printf("\n💡 \033[33mNext steps:\033[0m\n")
|
||||
fmt.Printf(" • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
||||
fmt.Printf(" • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
||||
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
||||
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
||||
} else {
|
||||
fmt.Printf("🎯 \033[1mInitialized empty lnk repository\033[0m\n")
|
||||
fmt.Printf(" 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||
fmt.Printf("\n💡 \033[33mNext steps:\033[0m\n")
|
||||
fmt.Printf(" • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
|
||||
fmt.Printf(" • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
|
||||
printf(cmd, "🎯 \033[1mInitialized empty lnk repository\033[0m\n")
|
||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||
printf(cmd, " • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
|
||||
printf(cmd, " • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
initCmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
||||
rootCmd.AddCommand(initCmd)
|
||||
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
||||
return cmd
|
||||
}
|
||||
|
193
cmd/list.go
Normal file
193
cmd/list.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "📋 List files managed by lnk",
|
||||
Long: "Display all files and directories currently managed by lnk.",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
all, _ := cmd.Flags().GetBool("all")
|
||||
|
||||
if host != "" {
|
||||
// Show specific host configuration
|
||||
return listHostConfig(cmd, host)
|
||||
}
|
||||
|
||||
if all {
|
||||
// Show all configurations (common + all hosts)
|
||||
return listAllConfigs(cmd)
|
||||
}
|
||||
|
||||
// Default: show common configuration
|
||||
return listCommonConfig(cmd)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("host", "H", "", "List files for specific host")
|
||||
cmd.Flags().BoolP("all", "a", false, "List files for all hosts and common configuration")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listCommonConfig(cmd *cobra.Command) error {
|
||||
lnk := core.NewLnk()
|
||||
managedItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list managed items: %w", err)
|
||||
}
|
||||
|
||||
if len(managedItems) == 0 {
|
||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n")
|
||||
printf(cmd, " 💡 Use \033[1mlnk add <file>\033[0m to start managing files\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n\n")
|
||||
|
||||
for _, item := range managedItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||
lnk := core.NewLnkWithHost(host)
|
||||
managedItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list managed items for host %s: %w", host, err)
|
||||
}
|
||||
|
||||
if len(managedItems) == 0 {
|
||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host)
|
||||
printf(cmd, " 💡 Use \033[1mlnk add --host %s <file>\033[0m to start managing files\n", host)
|
||||
return nil
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n\n")
|
||||
|
||||
for _, item := range managedItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func listAllConfigs(cmd *cobra.Command) error {
|
||||
// List common configuration
|
||||
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
||||
|
||||
lnk := core.NewLnk()
|
||||
commonItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list common managed items: %w", err)
|
||||
}
|
||||
|
||||
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
|
||||
if len(commonItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
|
||||
if len(commonItems) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
} else {
|
||||
for _, item := range commonItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
}
|
||||
}
|
||||
|
||||
// Find all host-specific configurations
|
||||
hosts, err := findHostConfigs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find host configurations: %w", err)
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||
|
||||
hostLnk := core.NewLnkWithHost(host)
|
||||
hostItems, err := hostLnk.List()
|
||||
if err != nil {
|
||||
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
printf(cmd, " (\033[36m%d item", len(hostItems))
|
||||
if len(hostItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
|
||||
if len(hostItems) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
} else {
|
||||
for _, item := range hostItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func findHostConfigs() ([]string, error) {
|
||||
repoPath := getRepoPath()
|
||||
|
||||
// Check if repo exists
|
||||
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(repoPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read repository directory: %w", err)
|
||||
}
|
||||
|
||||
var hosts []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
// Look for .lnk.<hostname> files
|
||||
if strings.HasPrefix(name, ".lnk.") && name != ".lnk" {
|
||||
host := strings.TrimPrefix(name, ".lnk.")
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
}
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func getRepoPath() string {
|
||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdgConfig == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
xdgConfig = "."
|
||||
} else {
|
||||
xdgConfig = filepath.Join(homeDir, ".config")
|
||||
}
|
||||
}
|
||||
return filepath.Join(xdgConfig, "lnk")
|
||||
}
|
51
cmd/pull.go
51
cmd/pull.go
@@ -7,39 +7,56 @@ import (
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
var pullCmd = &cobra.Command{
|
||||
func newPullCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "pull",
|
||||
Short: "⬇️ Pull changes from remote and restore symlinks",
|
||||
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
lnk := core.NewLnk()
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
}
|
||||
|
||||
restored, err := lnk.Pull()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull changes: %w", err)
|
||||
}
|
||||
|
||||
if len(restored) > 0 {
|
||||
fmt.Printf("⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
fmt.Printf(" 🔗 Restored \033[1m%d symlink", len(restored))
|
||||
if len(restored) > 1 {
|
||||
fmt.Printf("s")
|
||||
}
|
||||
fmt.Printf("\033[0m:\n")
|
||||
for _, file := range restored {
|
||||
fmt.Printf(" ✨ \033[36m%s\033[0m\n", file)
|
||||
}
|
||||
fmt.Printf("\n 🎉 Your dotfiles are synced and ready!\n")
|
||||
if host != "" {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||
} else {
|
||||
fmt.Printf("⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
fmt.Printf(" ✅ All symlinks already in place\n")
|
||||
fmt.Printf(" 🎉 Everything is up to date!\n")
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
}
|
||||
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
||||
if len(restored) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m:\n")
|
||||
for _, file := range restored {
|
||||
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
|
||||
}
|
||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||
} else {
|
||||
if host != "" {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||
} else {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
}
|
||||
printf(cmd, " ✅ All symlinks already in place\n")
|
||||
printf(cmd, " 🎉 Everything is up to date!\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(pullCmd)
|
||||
cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)")
|
||||
return cmd
|
||||
}
|
||||
|
14
cmd/push.go
14
cmd/push.go
@@ -7,7 +7,8 @@ import (
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
var pushCmd = &cobra.Command{
|
||||
func newPushCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "push [message]",
|
||||
Short: "🚀 Push local changes to remote repository",
|
||||
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
||||
@@ -24,14 +25,11 @@ var pushCmd = &cobra.Command{
|
||||
return fmt.Errorf("failed to push changes: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
||||
fmt.Printf(" 💾 Commit: \033[90m%s\033[0m\n", message)
|
||||
fmt.Printf(" 📡 Synced to remote\n")
|
||||
fmt.Printf(" ✨ Your dotfiles are up to date!\n")
|
||||
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
||||
printf(cmd, " 💾 Commit: \033[90m%s\033[0m\n", message)
|
||||
printf(cmd, " 📡 Synced to remote\n")
|
||||
printf(cmd, " ✨ Your dotfiles are up to date!\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(pushCmd)
|
||||
}
|
||||
|
27
cmd/rm.go
27
cmd/rm.go
@@ -8,7 +8,8 @@ import (
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
var rmCmd = &cobra.Command{
|
||||
func newRemoveCmd() *cobra.Command {
|
||||
cmd := &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.",
|
||||
@@ -16,20 +17,32 @@ var rmCmd = &cobra.Command{
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath := args[0]
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
}
|
||||
|
||||
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("🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
||||
fmt.Printf(" ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
|
||||
fmt.Printf(" 📄 Original file restored\n")
|
||||
if host != "" {
|
||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host)
|
||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath)
|
||||
} else {
|
||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
|
||||
}
|
||||
printf(cmd, " 📄 Original file restored\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(rmCmd)
|
||||
cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)")
|
||||
return cmd
|
||||
}
|
||||
|
29
cmd/root.go
29
cmd/root.go
@@ -12,33 +12,50 @@ var (
|
||||
buildTime = "unknown"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
// NewRootCommand creates a new root command (testable)
|
||||
func NewRootCommand() *cobra.Command {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lnk",
|
||||
Short: "🔗 Dotfiles, linked. No fluff.",
|
||||
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
||||
|
||||
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
||||
That's it.
|
||||
Supports both common configurations and host-specific setups.
|
||||
|
||||
✨ Examples:
|
||||
lnk init # Fresh start
|
||||
lnk init -r <repo-url> # Clone existing dotfiles
|
||||
lnk add ~/.vimrc ~/.bashrc # Start managing files
|
||||
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
||||
lnk add --host work ~/.ssh/config # Manage host-specific files
|
||||
lnk list --all # Show all configurations
|
||||
lnk pull --host work # Pull host-specific changes
|
||||
lnk push "setup complete" # Sync to remote
|
||||
lnk pull # Get latest changes
|
||||
|
||||
🎯 Simple, fast, and Git-native.`,
|
||||
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||
SilenceUsage: true,
|
||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
rootCmd.AddCommand(newInitCmd())
|
||||
rootCmd.AddCommand(newAddCmd())
|
||||
rootCmd.AddCommand(newRemoveCmd())
|
||||
rootCmd.AddCommand(newListCmd())
|
||||
rootCmd.AddCommand(newStatusCmd())
|
||||
rootCmd.AddCommand(newPushCmd())
|
||||
rootCmd.AddCommand(newPullCmd())
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// SetVersion sets the version information for the CLI
|
||||
func SetVersion(v, bt string) {
|
||||
version = v
|
||||
buildTime = bt
|
||||
rootCmd.Version = fmt.Sprintf("%s (built %s)", version, buildTime)
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
rootCmd := NewRootCommand()
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
640
cmd/root_test.go
Normal file
640
cmd/root_test.go
Normal file
@@ -0,0 +1,640 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type CLITestSuite struct {
|
||||
suite.Suite
|
||||
tempDir string
|
||||
originalDir string
|
||||
stdout *bytes.Buffer
|
||||
stderr *bytes.Buffer
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) SetupTest() {
|
||||
// Create temp directory and change to it
|
||||
tempDir, err := os.MkdirTemp("", "lnk-cli-test-*")
|
||||
suite.Require().NoError(err)
|
||||
suite.tempDir = tempDir
|
||||
|
||||
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)
|
||||
|
||||
// Capture output
|
||||
suite.stdout = &bytes.Buffer{}
|
||||
suite.stderr = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TearDownTest() {
|
||||
err := os.Chdir(suite.originalDir)
|
||||
suite.Require().NoError(err)
|
||||
err = os.RemoveAll(suite.tempDir)
|
||||
suite.Require().NoError(err)
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) runCommand(args ...string) error {
|
||||
rootCmd := NewRootCommand()
|
||||
rootCmd.SetOut(suite.stdout)
|
||||
rootCmd.SetErr(suite.stderr)
|
||||
rootCmd.SetArgs(args)
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestInitCommand() {
|
||||
err := suite.runCommand("init")
|
||||
suite.NoError(err)
|
||||
|
||||
// Check output
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Initialized empty lnk repository")
|
||||
suite.Contains(output, "Location:")
|
||||
suite.Contains(output, "Next steps:")
|
||||
suite.Contains(output, "lnk add <file>")
|
||||
|
||||
// Verify actual effect
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
suite.DirExists(lnkDir)
|
||||
|
||||
gitDir := filepath.Join(lnkDir, ".git")
|
||||
suite.DirExists(gitDir)
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestInitWithRemote() {
|
||||
err := suite.runCommand("init", "-r", "https://github.com/user/dotfiles.git")
|
||||
// This will fail because we don't have a real remote, but that's expected
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "git clone failed")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestAddCommand() {
|
||||
// Initialize first
|
||||
err := suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Create test file
|
||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test add command
|
||||
err = suite.runCommand("add", testFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// Check output
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Added .bashrc to lnk")
|
||||
suite.Contains(output, "→")
|
||||
suite.Contains(output, "sync to remote")
|
||||
|
||||
// Verify symlink was created
|
||||
info, err := os.Lstat(testFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
|
||||
// Verify some file exists in repo with .bashrc in the name
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
entries, err := os.ReadDir(lnkDir)
|
||||
suite.NoError(err)
|
||||
|
||||
found := false
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".bashrc") && entry.Name() != ".lnk" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
suite.True(found, "Repository should contain a file with .bashrc in the name")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestRemoveCommand() {
|
||||
// Setup: init and add a file
|
||||
_ = suite.runCommand("init")
|
||||
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
||||
_ = os.WriteFile(testFile, []byte("set number"), 0644)
|
||||
_ = suite.runCommand("add", testFile)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test remove command
|
||||
err := suite.runCommand("rm", testFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// Check output
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Removed .vimrc from lnk")
|
||||
suite.Contains(output, "→")
|
||||
suite.Contains(output, "Original file restored")
|
||||
|
||||
// Verify symlink is gone and regular file is restored
|
||||
info, err := os.Lstat(testFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
||||
|
||||
// Verify content is preserved
|
||||
content, err := os.ReadFile(testFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal("set number", string(content))
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestStatusCommand() {
|
||||
// Initialize first
|
||||
err := suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test status without remote - should fail
|
||||
err = suite.runCommand("status")
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "no remote configured")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestListCommand() {
|
||||
// Test list without init - should fail
|
||||
err := suite.runCommand("list")
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "Lnk repository not initialized")
|
||||
|
||||
// Initialize first
|
||||
err = suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test list with no managed files
|
||||
err = suite.runCommand("list")
|
||||
suite.NoError(err)
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "No files currently managed by lnk")
|
||||
suite.Contains(output, "lnk add <file>")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// 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.runCommand("add", testFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test list with one managed file
|
||||
err = suite.runCommand("list")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Files managed by lnk")
|
||||
suite.Contains(output, "1 item")
|
||||
suite.Contains(output, ".bashrc")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Add another file
|
||||
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||
err = os.WriteFile(testFile2, []byte("set number"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = suite.runCommand("add", testFile2)
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test list with multiple managed files
|
||||
err = suite.runCommand("list")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Files managed by lnk")
|
||||
suite.Contains(output, "2 items")
|
||||
suite.Contains(output, ".bashrc")
|
||||
suite.Contains(output, ".vimrc")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestErrorHandling() {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
errContains string
|
||||
outContains string
|
||||
}{
|
||||
{
|
||||
name: "add nonexistent file",
|
||||
args: []string{"add", "/nonexistent/file"},
|
||||
wantErr: true,
|
||||
errContains: "File does not exist",
|
||||
},
|
||||
{
|
||||
name: "status without init",
|
||||
args: []string{"status"},
|
||||
wantErr: true,
|
||||
errContains: "Lnk repository not initialized",
|
||||
},
|
||||
{
|
||||
name: "help command",
|
||||
args: []string{"--help"},
|
||||
wantErr: false,
|
||||
outContains: "Lnk - Git-native dotfiles management",
|
||||
},
|
||||
{
|
||||
name: "version command",
|
||||
args: []string{"--version"},
|
||||
wantErr: false,
|
||||
outContains: "lnk version",
|
||||
},
|
||||
{
|
||||
name: "init help",
|
||||
args: []string{"init", "--help"},
|
||||
wantErr: false,
|
||||
outContains: "Creates the lnk directory",
|
||||
},
|
||||
{
|
||||
name: "add help",
|
||||
args: []string{"add", "--help"},
|
||||
wantErr: false,
|
||||
outContains: "Moves a file to the lnk repository",
|
||||
},
|
||||
{
|
||||
name: "list help",
|
||||
args: []string{"list", "--help"},
|
||||
wantErr: false,
|
||||
outContains: "Display all files and directories",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
suite.stdout.Reset()
|
||||
suite.stderr.Reset()
|
||||
|
||||
err := suite.runCommand(tt.args...)
|
||||
|
||||
if tt.wantErr {
|
||||
suite.Error(err, "Expected error for %s", tt.name)
|
||||
if tt.errContains != "" {
|
||||
suite.Contains(err.Error(), tt.errContains, "Wrong error message for %s", tt.name)
|
||||
}
|
||||
} else {
|
||||
suite.NoError(err, "Unexpected error for %s", tt.name)
|
||||
}
|
||||
|
||||
if tt.outContains != "" {
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, tt.outContains, "Expected output not found for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestCompleteWorkflow() {
|
||||
// Test realistic user workflow
|
||||
steps := []struct {
|
||||
name string
|
||||
args []string
|
||||
setup func()
|
||||
verify func(output string)
|
||||
}{
|
||||
{
|
||||
name: "initialize repository",
|
||||
args: []string{"init"},
|
||||
verify: func(output string) {
|
||||
suite.Contains(output, "Initialized empty lnk repository")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add config file",
|
||||
args: []string{"add", ".bashrc"},
|
||||
setup: func() {
|
||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||
_ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||
},
|
||||
verify: func(output string) {
|
||||
suite.Contains(output, "Added .bashrc to lnk")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add another file",
|
||||
args: []string{"add", ".vimrc"},
|
||||
setup: func() {
|
||||
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
||||
_ = os.WriteFile(testFile, []byte("set number"), 0644)
|
||||
},
|
||||
verify: func(output string) {
|
||||
suite.Contains(output, "Added .vimrc to lnk")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove file",
|
||||
args: []string{"rm", ".vimrc"},
|
||||
verify: func(output string) {
|
||||
suite.Contains(output, "Removed .vimrc from lnk")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
suite.Run(step.name, func() {
|
||||
if step.setup != nil {
|
||||
step.setup()
|
||||
}
|
||||
|
||||
suite.stdout.Reset()
|
||||
suite.stderr.Reset()
|
||||
|
||||
err := suite.runCommand(step.args...)
|
||||
suite.NoError(err, "Step %s failed: %v", step.name, err)
|
||||
|
||||
output := suite.stdout.String()
|
||||
if step.verify != nil {
|
||||
step.verify(output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestRemoveUnmanagedFile() {
|
||||
// Initialize repository
|
||||
_ = suite.runCommand("init")
|
||||
|
||||
// Create a regular file (not managed by lnk)
|
||||
testFile := filepath.Join(suite.tempDir, ".regularfile")
|
||||
_ = os.WriteFile(testFile, []byte("content"), 0644)
|
||||
|
||||
// Try to remove it
|
||||
err := suite.runCommand("rm", testFile)
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "File is not managed by lnk")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestAddDirectory() {
|
||||
// Initialize repository
|
||||
_ = suite.runCommand("init")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Create a directory with files
|
||||
testDir := filepath.Join(suite.tempDir, ".config")
|
||||
_ = os.MkdirAll(testDir, 0755)
|
||||
configFile := filepath.Join(testDir, "app.conf")
|
||||
_ = os.WriteFile(configFile, []byte("setting=value"), 0644)
|
||||
|
||||
// Add the directory
|
||||
err := suite.runCommand("add", testDir)
|
||||
suite.NoError(err)
|
||||
|
||||
// Check output
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Added .config to lnk")
|
||||
|
||||
// Verify directory is now a symlink
|
||||
info, err := os.Lstat(testDir)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
|
||||
// Verify some directory exists in repo with .config in the name
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
entries, err := os.ReadDir(lnkDir)
|
||||
suite.NoError(err)
|
||||
|
||||
found := false
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".config") && entry.Name() != ".lnk" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
suite.True(found, "Repository should contain a directory with .config in the name")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestSameBasenameFilesBug() {
|
||||
// Initialize repository
|
||||
err := suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Create two directories with files having the same basename
|
||||
dirA := filepath.Join(suite.tempDir, "a")
|
||||
dirB := filepath.Join(suite.tempDir, "b")
|
||||
err = os.MkdirAll(dirA, 0755)
|
||||
suite.Require().NoError(err)
|
||||
err = os.MkdirAll(dirB, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create files with same basename but different content
|
||||
fileA := filepath.Join(dirA, "config.json")
|
||||
fileB := filepath.Join(dirB, "config.json")
|
||||
contentA := `{"name": "config_a"}`
|
||||
contentB := `{"name": "config_b"}`
|
||||
|
||||
err = os.WriteFile(fileA, []byte(contentA), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(fileB, []byte(contentB), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add first file
|
||||
err = suite.runCommand("add", fileA)
|
||||
suite.NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Verify first file content is preserved
|
||||
content, err := os.ReadFile(fileA)
|
||||
suite.NoError(err)
|
||||
suite.Equal(contentA, string(content), "First file should preserve its original content")
|
||||
|
||||
// Add second file with same basename - this should work correctly
|
||||
err = suite.runCommand("add", fileB)
|
||||
suite.NoError(err, "Adding second file with same basename should work")
|
||||
|
||||
// CORRECT BEHAVIOR: Both files should preserve their original content
|
||||
contentAfterAddA, err := os.ReadFile(fileA)
|
||||
suite.NoError(err)
|
||||
contentAfterAddB, err := os.ReadFile(fileB)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal(contentA, string(contentAfterAddA), "First file should keep its original content")
|
||||
suite.Equal(contentB, string(contentAfterAddB), "Second file should keep its original content")
|
||||
|
||||
// Both files should be removable independently
|
||||
suite.stdout.Reset()
|
||||
err = suite.runCommand("rm", fileA)
|
||||
suite.NoError(err, "First file should be removable")
|
||||
|
||||
// Verify output shows removal
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Removed config.json from lnk")
|
||||
|
||||
// Verify first file is restored with correct content
|
||||
restoredContentA, err := os.ReadFile(fileA)
|
||||
suite.NoError(err)
|
||||
suite.Equal(contentA, string(restoredContentA), "Restored first file should have original content")
|
||||
|
||||
// Second file should still be removable without errors
|
||||
suite.stdout.Reset()
|
||||
err = suite.runCommand("rm", fileB)
|
||||
suite.NoError(err, "Second file should also be removable without errors")
|
||||
|
||||
// Verify second file is restored with correct content
|
||||
restoredContentB, err := os.ReadFile(fileB)
|
||||
suite.NoError(err)
|
||||
suite.Equal(contentB, string(restoredContentB), "Restored second file should have original content")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestStatusDirtyRepo() {
|
||||
// Initialize repository
|
||||
err := suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Add and commit a file
|
||||
testFile := filepath.Join(suite.tempDir, "a")
|
||||
err = os.WriteFile(testFile, []byte("abc"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.runCommand("add", testFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Add a remote so status works
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test/dotfiles.git")
|
||||
cmd.Dir = lnkDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Status should show clean but ahead
|
||||
err = suite.runCommand("status")
|
||||
suite.NoError(err)
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "1 commit ahead")
|
||||
suite.NotContains(output, "uncommitted changes")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Now edit the managed file (simulating the issue scenario)
|
||||
err = os.WriteFile(testFile, []byte("def"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Status should now detect dirty state and NOT say "up to date"
|
||||
err = suite.runCommand("status")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Repository has uncommitted changes")
|
||||
suite.NotContains(output, "Repository is up to date")
|
||||
suite.Contains(output, "lnk push")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestMultihostCommands() {
|
||||
// Initialize repository
|
||||
err := suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Create test files
|
||||
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
|
||||
err = os.WriteFile(testFile1, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||
err = os.WriteFile(testFile2, []byte("set number"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file to common configuration
|
||||
err = suite.runCommand("add", testFile1)
|
||||
suite.NoError(err)
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Added .bashrc to lnk")
|
||||
suite.NotContains(output, "host:")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Add file to host-specific configuration
|
||||
err = suite.runCommand("add", "--host", "workstation", testFile2)
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Added .vimrc to lnk (host: workstation)")
|
||||
suite.Contains(output, "workstation.lnk")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test list command - common only
|
||||
err = suite.runCommand("list")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Files managed by lnk (common)")
|
||||
suite.Contains(output, ".bashrc")
|
||||
suite.NotContains(output, ".vimrc")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test list command - specific host
|
||||
err = suite.runCommand("list", "--host", "workstation")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Files managed by lnk (host: workstation)")
|
||||
suite.Contains(output, ".vimrc")
|
||||
suite.NotContains(output, ".bashrc")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test list command - all configurations
|
||||
err = suite.runCommand("list", "--all")
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "All configurations managed by lnk")
|
||||
suite.Contains(output, "Common configuration")
|
||||
suite.Contains(output, "Host: workstation")
|
||||
suite.Contains(output, ".bashrc")
|
||||
suite.Contains(output, ".vimrc")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test remove from host-specific
|
||||
err = suite.runCommand("rm", "--host", "workstation", testFile2)
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Removed .vimrc from lnk (host: workstation)")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Test remove from common
|
||||
err = suite.runCommand("rm", testFile1)
|
||||
suite.NoError(err)
|
||||
output = suite.stdout.String()
|
||||
suite.Contains(output, "Removed .bashrc from lnk")
|
||||
suite.NotContains(output, "host:")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Verify files are restored
|
||||
info1, err := os.Lstat(testFile1)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
|
||||
|
||||
info2, err := os.Lstat(testFile2)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestMultihostErrorHandling() {
|
||||
// Initialize repository
|
||||
err := suite.runCommand("init")
|
||||
suite.Require().NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Try to remove from non-existent host config
|
||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.runCommand("rm", "--host", "nonexistent", testFile)
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "File is not managed by lnk")
|
||||
|
||||
// Try to list non-existent host config
|
||||
err = suite.runCommand("list", "--host", "nonexistent")
|
||||
suite.NoError(err) // Should not error, just show empty
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)")
|
||||
}
|
||||
|
||||
func TestCLISuite(t *testing.T) {
|
||||
suite.Run(t, new(CLITestSuite))
|
||||
}
|
@@ -7,10 +7,11 @@ import (
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
func newStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "📊 Show repository sync status",
|
||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
|
||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
lnk := core.NewLnk()
|
||||
@@ -19,40 +20,74 @@ var statusCmd = &cobra.Command{
|
||||
return fmt.Errorf("failed to get status: %w", err)
|
||||
}
|
||||
|
||||
if status.Dirty {
|
||||
displayDirtyStatus(cmd, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Ahead == 0 && status.Behind == 0 {
|
||||
fmt.Printf("✅ \033[1;32mRepository is up to date\033[0m\n")
|
||||
fmt.Printf(" 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
||||
} else {
|
||||
fmt.Printf("📊 \033[1mRepository Status\033[0m\n")
|
||||
fmt.Printf(" 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||
fmt.Printf("\n")
|
||||
|
||||
if status.Ahead > 0 {
|
||||
commitText := "commit"
|
||||
if status.Ahead > 1 {
|
||||
commitText = "commits"
|
||||
}
|
||||
fmt.Printf(" ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
||||
}
|
||||
if status.Behind > 0 {
|
||||
commitText := "commit"
|
||||
if status.Behind > 1 {
|
||||
commitText = "commits"
|
||||
}
|
||||
fmt.Printf(" ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
|
||||
}
|
||||
|
||||
if status.Ahead > 0 && status.Behind == 0 {
|
||||
fmt.Printf("\n💡 Run \033[1mlnk push\033[0m to sync your changes")
|
||||
} else if status.Behind > 0 {
|
||||
fmt.Printf("\n💡 Run \033[1mlnk pull\033[0m to get latest changes")
|
||||
}
|
||||
displayUpToDateStatus(cmd, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
displaySyncStatus(cmd, status)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||
|
||||
if status.Ahead == 0 && status.Behind == 0 {
|
||||
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||
return
|
||||
}
|
||||
|
||||
printf(cmd, "\n")
|
||||
displayAheadBehindInfo(cmd, status, true)
|
||||
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||
}
|
||||
|
||||
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
||||
}
|
||||
|
||||
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||
printf(cmd, "\n")
|
||||
|
||||
displayAheadBehindInfo(cmd, status, false)
|
||||
|
||||
if status.Ahead > 0 && status.Behind == 0 {
|
||||
printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes\n")
|
||||
} else if status.Behind > 0 {
|
||||
printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes\n")
|
||||
}
|
||||
}
|
||||
|
||||
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
||||
if status.Ahead > 0 {
|
||||
commitText := getCommitText(status.Ahead)
|
||||
if isDirty {
|
||||
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m (excluding uncommitted changes)\n", status.Ahead, commitText)
|
||||
} else {
|
||||
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
||||
}
|
||||
}
|
||||
|
||||
if status.Behind > 0 {
|
||||
commitText := getCommitText(status.Behind)
|
||||
printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
|
||||
}
|
||||
}
|
||||
|
||||
func getCommitText(count int) string {
|
||||
if count == 1 {
|
||||
return "commit"
|
||||
}
|
||||
return "commits"
|
||||
}
|
||||
|
12
cmd/utils.go
Normal file
12
cmd/utils.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// printf is a helper function to simplify output formatting in commands
|
||||
func printf(cmd *cobra.Command, format string, args ...interface{}) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...)
|
||||
}
|
@@ -14,20 +14,42 @@ import (
|
||||
// Lnk represents the main application logic
|
||||
type Lnk struct {
|
||||
repoPath string
|
||||
host string // Host-specific configuration
|
||||
git *git.Git
|
||||
fs *fs.FileSystem
|
||||
}
|
||||
|
||||
// NewLnk creates a new Lnk instance
|
||||
// NewLnk creates a new Lnk instance for common configuration
|
||||
func NewLnk() *Lnk {
|
||||
repoPath := getRepoPath()
|
||||
return &Lnk{
|
||||
repoPath: repoPath,
|
||||
host: "", // Empty host means common configuration
|
||||
git: git.New(repoPath),
|
||||
fs: fs.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewLnkWithHost creates a new Lnk instance for host-specific configuration
|
||||
func NewLnkWithHost(host string) *Lnk {
|
||||
repoPath := getRepoPath()
|
||||
return &Lnk{
|
||||
repoPath: repoPath,
|
||||
host: host,
|
||||
git: git.New(repoPath),
|
||||
fs: fs.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentHostname returns the current system hostname
|
||||
func GetCurrentHostname() (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||
}
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
// getRepoPath returns the path to the lnk repository directory
|
||||
func getRepoPath() string {
|
||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
@@ -43,6 +65,62 @@ func getRepoPath() string {
|
||||
return filepath.Join(xdgConfig, "lnk")
|
||||
}
|
||||
|
||||
// generateRepoName creates a repository path from a relative path
|
||||
func generateRepoName(relativePath string, host string) string {
|
||||
if host != "" {
|
||||
// For host-specific files, preserve the directory structure
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// For common files, replace slashes and backslashes with underscores to create valid filename
|
||||
repoName := strings.ReplaceAll(relativePath, "/", "_")
|
||||
repoName = strings.ReplaceAll(repoName, "\\", "_")
|
||||
|
||||
return repoName
|
||||
}
|
||||
|
||||
// getHostStoragePath returns the storage path for host-specific or common files
|
||||
func (l *Lnk) getHostStoragePath() string {
|
||||
if l.host == "" {
|
||||
// Common configuration - store in root of repo
|
||||
return l.repoPath
|
||||
}
|
||||
// Host-specific configuration - store in host subdirectory
|
||||
return filepath.Join(l.repoPath, l.host+".lnk")
|
||||
}
|
||||
|
||||
// getLnkFileName returns the appropriate .lnk tracking file name
|
||||
func (l *Lnk) getLnkFileName() string {
|
||||
if l.host == "" {
|
||||
return ".lnk"
|
||||
}
|
||||
return ".lnk." + l.host
|
||||
}
|
||||
|
||||
// getRelativePath converts an absolute path to a relative path from home directory
|
||||
func getRelativePath(absPath string) (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if the file is under home directory
|
||||
relPath, err := filepath.Rel(homeDir, absPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
// If the relative path starts with "..", the file is outside home directory
|
||||
// In this case, use the absolute path as relative (without the leading slash)
|
||||
if strings.HasPrefix(relPath, "..") {
|
||||
// Use absolute path but remove leading slash and drive letter (for cross-platform)
|
||||
cleanPath := strings.TrimPrefix(absPath, "/")
|
||||
return cleanPath, nil
|
||||
}
|
||||
|
||||
return relPath, nil
|
||||
}
|
||||
|
||||
// Init initializes the lnk repository
|
||||
func (l *Lnk) Init() error {
|
||||
return l.InitWithRemote("")
|
||||
@@ -109,9 +187,33 @@ func (l *Lnk) Add(filePath string) error {
|
||||
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)
|
||||
// Get relative path for tracking
|
||||
relativePath, err := getRelativePath(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
// Generate repository path from relative path
|
||||
repoName := generateRepoName(relativePath, l.host)
|
||||
storagePath := l.getHostStoragePath()
|
||||
destPath := filepath.Join(storagePath, repoName)
|
||||
|
||||
// Ensure destination directory exists (including parent directories for host-specific files)
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if this relative path is already managed
|
||||
managedItems, err := l.getManagedItems()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get managed items: %w", err)
|
||||
}
|
||||
for _, item := range managedItems {
|
||||
if item == relativePath {
|
||||
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a directory or file
|
||||
info, err := os.Stat(absPath)
|
||||
@@ -141,8 +243,8 @@ func (l *Lnk) Add(filePath string) error {
|
||||
return fmt.Errorf("failed to create symlink: %w", err)
|
||||
}
|
||||
|
||||
// Add to .lnk tracking file
|
||||
if err := l.addManagedItem(absPath); err != nil {
|
||||
// Add to .lnk tracking file using relative path
|
||||
if err := l.addManagedItem(relativePath); err != nil {
|
||||
// Try to restore the original state if tracking fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
@@ -154,10 +256,15 @@ func (l *Lnk) Add(filePath string) error {
|
||||
}
|
||||
|
||||
// Add both the item and .lnk file to git in a single commit
|
||||
if err := l.git.Add(basename); err != nil {
|
||||
// For host-specific files, we need to add the relative path from repo root
|
||||
gitPath := repoName
|
||||
if l.host != "" {
|
||||
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||
}
|
||||
if err := l.git.Add(gitPath); 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
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
@@ -167,10 +274,10 @@ func (l *Lnk) Add(filePath string) error {
|
||||
}
|
||||
|
||||
// Add .lnk file to the same commit
|
||||
if err := l.git.Add(".lnk"); err != nil {
|
||||
if err := l.git.Add(l.getLnkFileName()); 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
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
@@ -180,10 +287,11 @@ func (l *Lnk) Add(filePath string) error {
|
||||
}
|
||||
|
||||
// Commit both changes together
|
||||
basename := filepath.Base(relativePath)
|
||||
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
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
@@ -208,6 +316,29 @@ func (l *Lnk) Remove(filePath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get relative path for tracking
|
||||
relativePath, err := getRelativePath(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
// Check if this relative path is managed
|
||||
managedItems, err := l.getManagedItems()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get managed items: %w", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range managedItems {
|
||||
if item == relativePath {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||
}
|
||||
|
||||
// Get the target path in the repository
|
||||
target, err := os.Readlink(absPath)
|
||||
if err != nil {
|
||||
@@ -219,8 +350,6 @@ func (l *Lnk) Remove(filePath string) error {
|
||||
target = filepath.Join(filepath.Dir(absPath), target)
|
||||
}
|
||||
|
||||
basename := filepath.Base(target)
|
||||
|
||||
// Check if target is a directory or file
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
@@ -232,22 +361,28 @@ func (l *Lnk) Remove(filePath string) error {
|
||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
||||
}
|
||||
|
||||
// Remove from .lnk tracking file
|
||||
if err := l.removeManagedItem(absPath); err != nil {
|
||||
// Remove from .lnk tracking file using relative path
|
||||
if err := l.removeManagedItem(relativePath); 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 {
|
||||
// Generate the correct git path for removal
|
||||
repoName := generateRepoName(relativePath, l.host)
|
||||
gitPath := repoName
|
||||
if l.host != "" {
|
||||
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||
}
|
||||
if err := l.git.Remove(gitPath); 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 {
|
||||
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
||||
}
|
||||
|
||||
// Commit both changes together
|
||||
basename := filepath.Base(relativePath)
|
||||
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
|
||||
return fmt.Errorf("failed to commit changes: %w", err)
|
||||
}
|
||||
@@ -276,6 +411,7 @@ type StatusInfo struct {
|
||||
Ahead int
|
||||
Behind int
|
||||
Remote string
|
||||
Dirty bool
|
||||
}
|
||||
|
||||
// Status returns the repository sync status
|
||||
@@ -294,6 +430,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
|
||||
Ahead: gitStatus.Ahead,
|
||||
Behind: gitStatus.Behind,
|
||||
Remote: gitStatus.Remote,
|
||||
Dirty: gitStatus.Dirty,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -352,9 +489,12 @@ func (l *Lnk) Pull() ([]string, error) {
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks
|
||||
func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||
var restored []string
|
||||
// List returns the list of files and directories currently managed by lnk
|
||||
func (l *Lnk) List() ([]string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
}
|
||||
|
||||
// Get managed items from .lnk file
|
||||
managedItems, err := l.getManagedItems()
|
||||
@@ -362,28 +502,49 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||
return nil, fmt.Errorf("failed to get managed items: %w", err)
|
||||
}
|
||||
|
||||
for _, itemName := range managedItems {
|
||||
repoItem := filepath.Join(l.repoPath, itemName)
|
||||
return managedItems, nil
|
||||
}
|
||||
|
||||
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks
|
||||
func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||
var restored []string
|
||||
|
||||
// Get managed items from .lnk file (now containing relative paths)
|
||||
managedItems, err := l.getManagedItems()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get managed items: %w", err)
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
for _, relativePath := range managedItems {
|
||||
// Generate repository name from relative path
|
||||
repoName := generateRepoName(relativePath, l.host)
|
||||
storagePath := l.getHostStoragePath()
|
||||
repoItem := filepath.Join(storagePath, repoName)
|
||||
|
||||
// Check if item exists in repository
|
||||
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
|
||||
continue // Skip missing items
|
||||
}
|
||||
|
||||
// Determine where the symlink should be
|
||||
// For config files, we'll place them in the user's home directory
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
symlinkPath := filepath.Join(homeDir, itemName)
|
||||
// Determine where the symlink should be created
|
||||
symlinkPath := filepath.Join(homeDir, relativePath)
|
||||
|
||||
// Check if symlink already exists and is correct
|
||||
if l.isValidSymlink(symlinkPath, repoItem) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
symlinkDir := filepath.Dir(symlinkPath)
|
||||
if err := os.MkdirAll(symlinkDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", symlinkDir, err)
|
||||
}
|
||||
|
||||
// Remove existing file/symlink if it exists
|
||||
if _, err := os.Lstat(symlinkPath); err == nil {
|
||||
if err := os.RemoveAll(symlinkPath); err != nil {
|
||||
@@ -393,10 +554,10 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||
|
||||
// Create symlink
|
||||
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to create symlink for %s: %w", itemName, err)
|
||||
return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err)
|
||||
}
|
||||
|
||||
restored = append(restored, itemName)
|
||||
restored = append(restored, relativePath)
|
||||
}
|
||||
|
||||
return restored, nil
|
||||
@@ -441,7 +602,7 @@ func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
|
||||
|
||||
// getManagedItems returns the list of managed files and directories from .lnk file
|
||||
func (l *Lnk) getManagedItems() ([]string, error) {
|
||||
lnkFile := filepath.Join(l.repoPath, ".lnk")
|
||||
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
|
||||
|
||||
// If .lnk file doesn't exist, return empty list
|
||||
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
|
||||
@@ -470,25 +631,22 @@ func (l *Lnk) getManagedItems() ([]string, error) {
|
||||
}
|
||||
|
||||
// addManagedItem adds an item to the .lnk tracking file
|
||||
func (l *Lnk) addManagedItem(itemPath string) error {
|
||||
func (l *Lnk) addManagedItem(relativePath 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 {
|
||||
if item == relativePath {
|
||||
return nil // Already managed
|
||||
}
|
||||
}
|
||||
|
||||
// Add new item
|
||||
items = append(items, basename)
|
||||
// Add new item using relative path
|
||||
items = append(items, relativePath)
|
||||
|
||||
// Sort for consistent ordering
|
||||
sort.Strings(items)
|
||||
@@ -497,20 +655,17 @@ func (l *Lnk) addManagedItem(itemPath string) error {
|
||||
}
|
||||
|
||||
// removeManagedItem removes an item from the .lnk tracking file
|
||||
func (l *Lnk) removeManagedItem(itemPath string) error {
|
||||
func (l *Lnk) removeManagedItem(relativePath 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
|
||||
// Remove item using relative path
|
||||
var newItems []string
|
||||
for _, item := range items {
|
||||
if item != basename {
|
||||
if item != relativePath {
|
||||
newItems = append(newItems, item)
|
||||
}
|
||||
}
|
||||
@@ -520,7 +675,7 @@ func (l *Lnk) removeManagedItem(itemPath string) error {
|
||||
|
||||
// writeManagedItems writes the list of managed items to .lnk file
|
||||
func (l *Lnk) writeManagedItems(items []string) error {
|
||||
lnkFile := filepath.Join(l.repoPath, ".lnk")
|
||||
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
|
||||
|
||||
content := strings.Join(items, "\n")
|
||||
if len(items) > 0 {
|
||||
|
770
internal/core/lnk_test.go
Normal file
770
internal/core/lnk_test.go
Normal file
@@ -0,0 +1,770 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type CoreTestSuite struct {
|
||||
suite.Suite
|
||||
tempDir string
|
||||
originalDir string
|
||||
lnk *Lnk
|
||||
}
|
||||
|
||||
func (suite *CoreTestSuite) 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 = NewLnk()
|
||||
}
|
||||
|
||||
func (suite *CoreTestSuite) 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)
|
||||
}
|
||||
|
||||
// Test core initialization functionality
|
||||
func (suite *CoreTestSuite) TestCoreInit() {
|
||||
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)
|
||||
}
|
||||
|
||||
// Test core add/remove functionality with files
|
||||
func (suite *CoreTestSuite) TestCoreFileOperations() {
|
||||
// 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)
|
||||
|
||||
// Verify symlink and repo file
|
||||
info, err := os.Lstat(testFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
|
||||
// The repository file will have a generated name based on the relative path
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
entries, err := os.ReadDir(lnkDir)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
var repoFile string
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".bashrc") && entry.Name() != ".lnk" {
|
||||
repoFile = filepath.Join(lnkDir, entry.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
suite.NotEmpty(repoFile, "Repository should contain a file with .bashrc in the name")
|
||||
suite.FileExists(repoFile)
|
||||
|
||||
// Verify content is preserved
|
||||
repoContent, err := os.ReadFile(repoFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(content, string(repoContent))
|
||||
|
||||
// Test remove
|
||||
err = suite.lnk.Remove(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify 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
|
||||
|
||||
// Verify content is preserved
|
||||
restoredContent, err := os.ReadFile(testFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(content, string(restoredContent))
|
||||
}
|
||||
|
||||
// Test core add/remove functionality with directories
|
||||
func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a directory with files
|
||||
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)
|
||||
|
||||
// Add the directory
|
||||
err = suite.lnk.Add(testDir)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify directory is now a symlink
|
||||
info, err := os.Lstat(testDir)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
|
||||
// Check that some repository directory exists with testdir in the name
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
entries, err := os.ReadDir(lnkDir)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
var repoDir string
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), "testdir") && entry.Name() != ".lnk" {
|
||||
repoDir = filepath.Join(lnkDir, entry.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
suite.NotEmpty(repoDir, "Repository should contain a directory with testdir in the name")
|
||||
suite.DirExists(repoDir)
|
||||
|
||||
// Remove the directory
|
||||
err = suite.lnk.Remove(testDir)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify 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
|
||||
|
||||
// Verify content is preserved
|
||||
restoredContent, err := os.ReadFile(testFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(content, string(restoredContent))
|
||||
}
|
||||
|
||||
// Test .lnk file tracking functionality
|
||||
func (suite *CoreTestSuite) TestLnkFileTracking() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add multiple items
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
// The .lnk file now contains relative paths, not basenames
|
||||
// Check that the content contains references to .bashrc and .ssh
|
||||
content := string(lnkContent)
|
||||
suite.Contains(content, ".bashrc", ".lnk file should contain reference to .bashrc")
|
||||
suite.Contains(content, ".ssh", ".lnk file should contain reference to .ssh")
|
||||
|
||||
// Remove one item and verify tracking 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)
|
||||
|
||||
content = string(lnkContent)
|
||||
suite.Contains(content, ".ssh", ".lnk file should still contain reference to .ssh")
|
||||
suite.NotContains(content, ".bashrc", ".lnk file should not contain reference to .bashrc after removal")
|
||||
}
|
||||
|
||||
// Test XDG_CONFIG_HOME fallback
|
||||
func (suite *CoreTestSuite) 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 := 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)
|
||||
}
|
||||
|
||||
// Test symlink restoration (pull functionality)
|
||||
func (suite *CoreTestSuite) TestSymlinkRestoration() {
|
||||
_ = suite.lnk.Init()
|
||||
|
||||
// Create a file in the repo directly (simulating a pull)
|
||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
||||
content := "export PATH=$PATH:/usr/local/bin"
|
||||
err := os.WriteFile(repoFile, []byte(content), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create .lnk file to track it
|
||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
||||
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Get home directory for the test
|
||||
homeDir, err := os.UserHomeDir()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
targetFile := filepath.Join(homeDir, ".bashrc")
|
||||
|
||||
// Clean up the test file after the test
|
||||
defer func() {
|
||||
_ = os.Remove(targetFile)
|
||||
}()
|
||||
|
||||
// Test symlink restoration
|
||||
restored, err := suite.lnk.RestoreSymlinks()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Should have restored the symlink
|
||||
suite.Len(restored, 1)
|
||||
suite.Equal(".bashrc", restored[0])
|
||||
|
||||
// Check that file is now a symlink
|
||||
info, err := os.Lstat(targetFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
}
|
||||
|
||||
// Test error conditions
|
||||
func (suite *CoreTestSuite) TestErrorConditions() {
|
||||
// Test add nonexistent file
|
||||
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")
|
||||
|
||||
// Test remove unmanaged file
|
||||
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")
|
||||
|
||||
// Test status without remote
|
||||
_, err = suite.lnk.Status()
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "no remote configured")
|
||||
}
|
||||
|
||||
// Test git operations
|
||||
func (suite *CoreTestSuite) TestGitOperations() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add a file to create a commit
|
||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||
content := "export PATH=$PATH:/usr/local/bin"
|
||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.lnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// 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")
|
||||
|
||||
// Test add remote
|
||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test status with remote
|
||||
status, err := suite.lnk.Status()
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(1, status.Ahead)
|
||||
suite.Equal(0, status.Behind)
|
||||
}
|
||||
|
||||
// Test edge case: files with same basename from different directories should be handled properly
|
||||
func (suite *CoreTestSuite) TestSameBasenameFilesOverwrite() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create two directories with files having the same basename
|
||||
dirA := filepath.Join(suite.tempDir, "a")
|
||||
dirB := filepath.Join(suite.tempDir, "b")
|
||||
err = os.MkdirAll(dirA, 0755)
|
||||
suite.Require().NoError(err)
|
||||
err = os.MkdirAll(dirB, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create files with same basename but different content
|
||||
fileA := filepath.Join(dirA, "config.json")
|
||||
fileB := filepath.Join(dirB, "config.json")
|
||||
contentA := `{"name": "config_a"}`
|
||||
contentB := `{"name": "config_b"}`
|
||||
|
||||
err = os.WriteFile(fileA, []byte(contentA), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(fileB, []byte(contentB), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add first file
|
||||
err = suite.lnk.Add(fileA)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify first file is managed correctly and preserves content
|
||||
info, err := os.Lstat(fileA)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
|
||||
symlinkContentA, err := os.ReadFile(fileA)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(contentA, string(symlinkContentA), "First file should preserve its original content")
|
||||
|
||||
// Add second file - this should work without overwriting the first
|
||||
err = suite.lnk.Add(fileB)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify second file is managed
|
||||
info, err = os.Lstat(fileB)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
|
||||
// CORRECT BEHAVIOR: Both files should preserve their original content
|
||||
symlinkContentA, err = os.ReadFile(fileA)
|
||||
suite.Require().NoError(err)
|
||||
symlinkContentB, err := os.ReadFile(fileB)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
suite.Equal(contentA, string(symlinkContentA), "First file should keep its original content")
|
||||
suite.Equal(contentB, string(symlinkContentB), "Second file should keep its original content")
|
||||
|
||||
// Both files should be removable independently
|
||||
err = suite.lnk.Remove(fileA)
|
||||
suite.Require().NoError(err, "First file should be removable")
|
||||
|
||||
// First file should be restored with correct content
|
||||
info, err = os.Lstat(fileA)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore
|
||||
|
||||
restoredContentA, err := os.ReadFile(fileA)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(contentA, string(restoredContentA), "Restored file should have original content")
|
||||
|
||||
// Second file should still be manageable and removable
|
||||
err = suite.lnk.Remove(fileB)
|
||||
suite.Require().NoError(err, "Second file should also be removable without errors")
|
||||
|
||||
// Second file should be restored with correct content
|
||||
info, err = os.Lstat(fileB)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore
|
||||
|
||||
restoredContentB, err := os.ReadFile(fileB)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(contentB, string(restoredContentB), "Second restored file should have original content")
|
||||
}
|
||||
|
||||
// Test another variant: adding files with same basename should work correctly
|
||||
func (suite *CoreTestSuite) TestSameBasenameSequentialAdd() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create subdirectories in different locations
|
||||
configDir := filepath.Join(suite.tempDir, "config")
|
||||
backupDir := filepath.Join(suite.tempDir, "backup")
|
||||
err = os.MkdirAll(configDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
err = os.MkdirAll(backupDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create files with same basename (.bashrc)
|
||||
configBashrc := filepath.Join(configDir, ".bashrc")
|
||||
backupBashrc := filepath.Join(backupDir, ".bashrc")
|
||||
|
||||
originalContent := "export PATH=/usr/local/bin:$PATH"
|
||||
backupContent := "export PATH=/opt/bin:$PATH"
|
||||
|
||||
err = os.WriteFile(configBashrc, []byte(originalContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
err = os.WriteFile(backupBashrc, []byte(backupContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add first .bashrc
|
||||
err = suite.lnk.Add(configBashrc)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add second .bashrc - should work without overwriting the first
|
||||
err = suite.lnk.Add(backupBashrc)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Check .lnk tracking file should track both properly
|
||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
||||
lnkContent, err := os.ReadFile(lnkFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Both entries should be tracked and distinguishable
|
||||
content := string(lnkContent)
|
||||
suite.Contains(content, ".bashrc", "Both .bashrc files should be tracked")
|
||||
|
||||
// Both files should maintain their distinct content
|
||||
content1, err := os.ReadFile(configBashrc)
|
||||
suite.Require().NoError(err)
|
||||
content2, err := os.ReadFile(backupBashrc)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
suite.Equal(originalContent, string(content1), "First file should keep original content")
|
||||
suite.Equal(backupContent, string(content2), "Second file should keep its distinct content")
|
||||
|
||||
// Both should be removable independently
|
||||
err = suite.lnk.Remove(configBashrc)
|
||||
suite.Require().NoError(err, "First .bashrc should be removable")
|
||||
|
||||
err = suite.lnk.Remove(backupBashrc)
|
||||
suite.Require().NoError(err, "Second .bashrc should be removable")
|
||||
}
|
||||
|
||||
// Test dirty repository status detection
|
||||
func (suite *CoreTestSuite) TestStatusDetectsDirtyRepo() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add and commit a file
|
||||
testFile := filepath.Join(suite.tempDir, "a")
|
||||
err = os.WriteFile(testFile, []byte("abc"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.lnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add a remote so status works
|
||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Check status - should be clean but ahead of remote
|
||||
status, err := suite.lnk.Status()
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(1, status.Ahead)
|
||||
suite.Equal(0, status.Behind)
|
||||
suite.False(status.Dirty, "Repository should not be dirty after commit")
|
||||
|
||||
// Now edit the managed file (simulating the issue scenario)
|
||||
err = os.WriteFile(testFile, []byte("def"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Check status again - should detect dirty state
|
||||
status, err = suite.lnk.Status()
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(1, status.Ahead)
|
||||
suite.Equal(0, status.Behind)
|
||||
suite.True(status.Dirty, "Repository should be dirty after editing managed file")
|
||||
}
|
||||
|
||||
// Test list functionality
|
||||
func (suite *CoreTestSuite) TestListManagedItems() {
|
||||
// Test list without init - should fail
|
||||
_, err := suite.lnk.List()
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "Lnk repository not initialized")
|
||||
|
||||
// Initialize repository
|
||||
err = suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test list with no managed files
|
||||
items, err := suite.lnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Empty(items)
|
||||
|
||||
// Add a 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)
|
||||
|
||||
err = suite.lnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test list with one managed file
|
||||
items, err = suite.lnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(items, 1)
|
||||
suite.Contains(items[0], ".bashrc")
|
||||
|
||||
// Add a directory
|
||||
testDir := filepath.Join(suite.tempDir, ".config")
|
||||
err = os.MkdirAll(testDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
configFile := filepath.Join(testDir, "app.conf")
|
||||
err = os.WriteFile(configFile, []byte("setting=value"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.lnk.Add(testDir)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test list with multiple managed items
|
||||
items, err = suite.lnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(items, 2)
|
||||
|
||||
// Check that both items are present
|
||||
found := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
if strings.Contains(item, ".bashrc") {
|
||||
found[".bashrc"] = true
|
||||
}
|
||||
if strings.Contains(item, ".config") {
|
||||
found[".config"] = true
|
||||
}
|
||||
}
|
||||
suite.True(found[".bashrc"], "Should contain .bashrc")
|
||||
suite.True(found[".config"], "Should contain .config")
|
||||
|
||||
// Remove one item and verify list is updated
|
||||
err = suite.lnk.Remove(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
items, err = suite.lnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(items, 1)
|
||||
suite.Contains(items[0], ".config")
|
||||
}
|
||||
|
||||
// Test multihost functionality
|
||||
func (suite *CoreTestSuite) TestMultihostFileOperations() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create test files for different hosts
|
||||
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
|
||||
content1 := "export PATH=$PATH:/usr/local/bin"
|
||||
err = os.WriteFile(testFile1, []byte(content1), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||
content2 := "set number"
|
||||
err = os.WriteFile(testFile2, []byte(content2), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file to common configuration
|
||||
commonLnk := NewLnk()
|
||||
err = commonLnk.Add(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file to host-specific configuration
|
||||
hostLnk := NewLnkWithHost("workstation")
|
||||
err = hostLnk.Add(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify both files are symlinks
|
||||
info1, err := os.Lstat(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink)
|
||||
|
||||
info2, err := os.Lstat(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink)
|
||||
|
||||
// Verify common configuration tracking
|
||||
commonItems, err := commonLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(commonItems, 1)
|
||||
suite.Contains(commonItems[0], ".bashrc")
|
||||
|
||||
// Verify host-specific configuration tracking
|
||||
hostItems, err := hostLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(hostItems, 1)
|
||||
suite.Contains(hostItems[0], ".vimrc")
|
||||
|
||||
// Verify files are stored in correct locations
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// Common file should be in root
|
||||
commonFile := filepath.Join(lnkDir, ".lnk")
|
||||
suite.FileExists(commonFile)
|
||||
|
||||
// Host-specific file should be in host subdirectory
|
||||
hostDir := filepath.Join(lnkDir, "workstation.lnk")
|
||||
suite.DirExists(hostDir)
|
||||
|
||||
hostTrackingFile := filepath.Join(lnkDir, ".lnk.workstation")
|
||||
suite.FileExists(hostTrackingFile)
|
||||
|
||||
// Test removal
|
||||
err = commonLnk.Remove(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = hostLnk.Remove(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify files are restored
|
||||
info1, err = os.Lstat(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
|
||||
|
||||
info2, err = os.Lstat(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
|
||||
}
|
||||
|
||||
// Test hostname detection
|
||||
func (suite *CoreTestSuite) TestHostnameDetection() {
|
||||
hostname, err := GetCurrentHostname()
|
||||
suite.NoError(err)
|
||||
suite.NotEmpty(hostname)
|
||||
}
|
||||
|
||||
// Test host-specific symlink restoration
|
||||
func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create files directly in host-specific storage (simulating a pull)
|
||||
hostLnk := NewLnkWithHost("testhost")
|
||||
|
||||
// Ensure host storage directory exists
|
||||
hostStoragePath := hostLnk.getHostStoragePath()
|
||||
err = os.MkdirAll(hostStoragePath, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a file in host storage
|
||||
repoFile := filepath.Join(hostStoragePath, ".bashrc")
|
||||
content := "export HOST=testhost"
|
||||
err = os.WriteFile(repoFile, []byte(content), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create host tracking file
|
||||
trackingFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
|
||||
err = os.WriteFile(trackingFile, []byte(".bashrc\n"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Get home directory for the test
|
||||
homeDir, err := os.UserHomeDir()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
targetFile := filepath.Join(homeDir, ".bashrc")
|
||||
|
||||
// Clean up the test file after the test
|
||||
defer func() {
|
||||
_ = os.Remove(targetFile)
|
||||
}()
|
||||
|
||||
// Test symlink restoration
|
||||
restored, err := hostLnk.RestoreSymlinks()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Should have restored the symlink
|
||||
suite.Len(restored, 1)
|
||||
suite.Equal(".bashrc", restored[0])
|
||||
|
||||
// Check that file is now a symlink
|
||||
info, err := os.Lstat(targetFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
}
|
||||
|
||||
// Test that common and host-specific configurations don't interfere
|
||||
func (suite *CoreTestSuite) TestMultihostIsolation() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create same file for common and host-specific
|
||||
testFile := filepath.Join(suite.tempDir, ".gitconfig")
|
||||
commonContent := "[user]\n\tname = Common User"
|
||||
err = os.WriteFile(testFile, []byte(commonContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add to common
|
||||
commonLnk := NewLnk()
|
||||
err = commonLnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Remove and recreate with different content
|
||||
err = commonLnk.Remove(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
hostContent := "[user]\n\tname = Work User"
|
||||
err = os.WriteFile(testFile, []byte(hostContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add to host-specific
|
||||
hostLnk := NewLnkWithHost("work")
|
||||
err = hostLnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify tracking files are separate
|
||||
commonItems, err := commonLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(commonItems, 0) // Should be empty after removal
|
||||
|
||||
hostItems, err := hostLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(hostItems, 1)
|
||||
suite.Contains(hostItems[0], ".gitconfig")
|
||||
|
||||
// Verify content is correct
|
||||
symlinkContent, err := os.ReadFile(testFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(hostContent, string(symlinkContent))
|
||||
}
|
||||
|
||||
func TestCoreSuite(t *testing.T) {
|
||||
suite.Run(t, new(CoreTestSuite))
|
||||
}
|
@@ -305,6 +305,7 @@ type StatusInfo struct {
|
||||
Ahead int
|
||||
Behind int
|
||||
Remote string
|
||||
Dirty bool
|
||||
}
|
||||
|
||||
// GetStatus returns the repository status relative to remote
|
||||
@@ -315,6 +316,12 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
dirty, err := g.HasChanges()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check for uncommitted changes: %w", err)
|
||||
}
|
||||
|
||||
// Get the remote tracking branch
|
||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||
cmd.Dir = g.repoPath
|
||||
@@ -327,6 +334,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
||||
Ahead: g.getAheadCount(remoteBranch),
|
||||
Behind: 0, // Can't be behind if no upstream
|
||||
Remote: remoteBranch,
|
||||
Dirty: dirty,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -336,6 +344,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
||||
Ahead: g.getAheadCount(remoteBranch),
|
||||
Behind: g.getBehindCount(remoteBranch),
|
||||
Remote: remoteBranch,
|
||||
Dirty: dirty,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@@ -1,718 +0,0 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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)
|
||||
|
||||
// Verify the default branch is set to 'main'
|
||||
cmd := exec.Command("git", "symbolic-ref", "HEAD")
|
||||
cmd.Dir = lnkDir
|
||||
output, err := cmd.Output()
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal("refs/heads/main", strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
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 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.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() {
|
||||
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 (suite *LnkIntegrationTestSuite) TestInitWithRemote() {
|
||||
// Test that init with remote adds the origin remote
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
remoteURL := "https://github.com/user/dotfiles.git"
|
||||
err = suite.lnk.AddRemote("origin", remoteURL)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify the remote was added by checking git config
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
||||
cmd.Dir = lnkDir
|
||||
|
||||
output, err := cmd.Output()
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(remoteURL, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
func (suite *LnkIntegrationTestSuite) TestInitIdempotent() {
|
||||
// Test that running init multiple times is safe
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// Add a file to the repo to ensure it's not lost
|
||||
testFile := filepath.Join(lnkDir, "test.txt")
|
||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Run init again - should be idempotent
|
||||
err = suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// File should still exist
|
||||
suite.FileExists(testFile)
|
||||
content, err := os.ReadFile(testFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal("test content", string(content))
|
||||
}
|
||||
|
||||
func (suite *LnkIntegrationTestSuite) TestInitWithExistingRemote() {
|
||||
// Test init with remote when remote already exists (same URL)
|
||||
remoteURL := "https://github.com/user/dotfiles.git"
|
||||
|
||||
// First init with remote
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
err = suite.lnk.AddRemote("origin", remoteURL)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Init again with same remote should be idempotent
|
||||
err = suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
err = suite.lnk.AddRemote("origin", remoteURL)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify remote is still correct
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
||||
cmd.Dir = lnkDir
|
||||
output, err := cmd.Output()
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(remoteURL, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
func (suite *LnkIntegrationTestSuite) TestInitWithDifferentRemote() {
|
||||
// Test init with different remote when remote already exists
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add first remote
|
||||
err = suite.lnk.AddRemote("origin", "https://github.com/user/dotfiles.git")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Try to add different remote - should error
|
||||
err = suite.lnk.AddRemote("origin", "https://github.com/user/other-repo.git")
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "already exists with different URL")
|
||||
}
|
||||
|
||||
func (suite *LnkIntegrationTestSuite) TestInitWithNonLnkRepo() {
|
||||
// Test init when directory contains a non-lnk Git repository
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
err := os.MkdirAll(lnkDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a non-lnk git repo in the lnk directory
|
||||
cmd := exec.Command("git", "init")
|
||||
cmd.Dir = lnkDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add some content to make it look like a real repo
|
||||
testFile := filepath.Join(lnkDir, "important-file.txt")
|
||||
err = os.WriteFile(testFile, []byte("important data"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Configure git and commit
|
||||
cmd = exec.Command("git", "config", "user.name", "Test User")
|
||||
cmd.Dir = lnkDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cmd = exec.Command("git", "config", "user.email", "test@example.com")
|
||||
cmd.Dir = lnkDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cmd = exec.Command("git", "add", "important-file.txt")
|
||||
cmd.Dir = lnkDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
cmd = exec.Command("git", "commit", "-m", "important commit")
|
||||
cmd.Dir = lnkDir
|
||||
err = cmd.Run()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Now try to init lnk - should error to protect existing repo
|
||||
err = suite.lnk.Init()
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "contains an existing Git repository")
|
||||
|
||||
// Verify the original file is still there
|
||||
suite.FileExists(testFile)
|
||||
}
|
||||
|
||||
// TestSyncStatus tests the status command functionality
|
||||
func (suite *LnkIntegrationTestSuite) TestSyncStatus() {
|
||||
// Initialize repo with remote
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add a file to create some local changes
|
||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||
content := "export PATH=$PATH:/usr/local/bin"
|
||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.lnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Get status - should show 1 commit ahead
|
||||
status, err := suite.lnk.Status()
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(1, status.Ahead)
|
||||
suite.Equal(0, status.Behind)
|
||||
suite.Equal("origin/main", status.Remote)
|
||||
}
|
||||
|
||||
// TestSyncPush tests the push command functionality
|
||||
func (suite *LnkIntegrationTestSuite) TestSyncPush() {
|
||||
// Initialize repo
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add remote for push to work
|
||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add a file
|
||||
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)
|
||||
|
||||
// Add another file for a second commit
|
||||
testFile2 := filepath.Join(suite.tempDir, ".gitconfig")
|
||||
content2 := "[user]\n name = Test User"
|
||||
err = os.WriteFile(testFile2, []byte(content2), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.lnk.Add(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Modify one of the files to create uncommitted changes
|
||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".vimrc")
|
||||
modifiedContent := "set number\nset relativenumber"
|
||||
err = os.WriteFile(repoFile, []byte(modifiedContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Push should stage all changes and create a sync commit
|
||||
message := "Updated configuration files"
|
||||
err = suite.lnk.Push(message)
|
||||
// In tests, push will fail because we don't have real remotes, but that's expected
|
||||
// The important part is that it stages and commits changes
|
||||
if err != nil {
|
||||
suite.Contains(err.Error(), "git push failed")
|
||||
}
|
||||
|
||||
// Check that a sync commit was made (even if push failed)
|
||||
commits, err := suite.lnk.GetCommits()
|
||||
suite.Require().NoError(err)
|
||||
suite.GreaterOrEqual(len(commits), 3) // at least 2 add commits + 1 sync commit
|
||||
suite.Contains(commits[0], message) // Latest commit should contain our message
|
||||
}
|
||||
|
||||
// TestSyncPull tests the pull command functionality
|
||||
func (suite *LnkIntegrationTestSuite) TestSyncPull() {
|
||||
// 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)
|
||||
|
||||
// Pull should attempt to pull from remote (will fail in tests but that's expected)
|
||||
_, err = suite.lnk.Pull()
|
||||
// In tests, pull will fail because we don't have real remotes, but that's expected
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "git pull failed")
|
||||
|
||||
// Test RestoreSymlinks functionality separately
|
||||
// Create a file in the repo directly
|
||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
||||
content := "export PATH=$PATH:/usr/local/bin"
|
||||
err = os.WriteFile(repoFile, []byte(content), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test that RestoreSymlinks can be called (even if it doesn't restore anything in this test setup)
|
||||
restored, err := suite.lnk.RestoreSymlinks()
|
||||
suite.Require().NoError(err)
|
||||
// In this test setup, it might not restore anything, and that's okay for Phase 1
|
||||
suite.GreaterOrEqual(len(restored), 0)
|
||||
}
|
||||
|
||||
// TestSyncStatusNoRemote tests status when no remote is configured
|
||||
func (suite *LnkIntegrationTestSuite) TestSyncStatusNoRemote() {
|
||||
// Initialize repo without remote
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Status should indicate no remote
|
||||
_, err = suite.lnk.Status()
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "no remote configured")
|
||||
}
|
||||
|
||||
// TestSyncPushWithModifiedFiles tests push when files are modified
|
||||
func (suite *LnkIntegrationTestSuite) TestSyncPushWithModifiedFiles() {
|
||||
// Initialize repo and add a file
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add remote for push to work
|
||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||
content := "export PATH=$PATH:/usr/local/bin"
|
||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.lnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Modify the file in the repo (simulate editing managed file)
|
||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
||||
modifiedContent := "export PATH=$PATH:/usr/local/bin\nexport EDITOR=vim"
|
||||
err = os.WriteFile(repoFile, []byte(modifiedContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Push should detect and commit the changes
|
||||
message := "Updated bashrc with editor setting"
|
||||
err = suite.lnk.Push(message)
|
||||
// In tests, push will fail because we don't have real remotes, but that's expected
|
||||
if err != nil {
|
||||
suite.Contains(err.Error(), "git push failed")
|
||||
}
|
||||
|
||||
// Check that changes were committed (even if push failed)
|
||||
commits, err := suite.lnk.GetCommits()
|
||||
suite.Require().NoError(err)
|
||||
suite.GreaterOrEqual(len(commits), 2) // add commit + sync commit
|
||||
suite.Contains(commits[0], message)
|
||||
}
|
||||
|
||||
func TestLnkIntegrationSuite(t *testing.T) {
|
||||
suite.Run(t, new(LnkIntegrationTestSuite))
|
||||
}
|
Reference in New Issue
Block a user