23 Commits

Author SHA1 Message Date
Yar Kravtsov
1e2c9704f3 refactor(errors): implement structured error handling for improved debugging 2025-06-03 07:58:21 +03:00
Yar Kravtsov
3cba309c05 refactor(core): simplify Lnk creation with functional options pattern 2025-06-03 06:50:52 +03:00
Yar Kravtsov
3e6b426a19 test(cmd): improve test coverage for file storage and .lnk tracking 2025-05-27 08:33:23 +03:00
Yar Kravtsov
02f342b02b refactor(core): simplify path handling and remove redundant generateRepoName function 2025-05-27 08:00:04 +03:00
Yar Kravtsov
92f2575090 fix: preserve directory structure for common files and fix display paths 2025-05-26 09:23:46 +03:00
Yar Kravtsov
0f74723a03 docs(README): update examples for host-specific configurations and usage patterns 2025-05-26 08:38:21 +03:00
Yar Kravtsov
093cc8ebe7 feat(multihost): add support for host-specific configurations
Implement multihost functionality allowing separate management of common and host-specific dotfiles. Add new commands and flags for handling host-specific files, update core logic for file storage and tracking, and enhance documentation to reflect new capabilities.
2025-05-26 08:26:06 +03:00
Yar Kravtsov
ff3cddc065 docs: Update README.md 2025-05-26 07:58:29 +03:00
Yar Kravtsov
4a275ce4ca feat(cmd): add 'list' command to display managed files
Implements a new 'list' command that shows all files and directories managed by lnk, improving visibility and user experience.

fixes #4
2025-05-26 05:59:33 +03:00
Yar Kravtsov
69c1038f3e Merge pull request #6 from yarlson/alert-autofix-4
Potential fix for code scanning alert no. 4: Workflow does not contain permissions
2025-05-26 05:46:31 +03:00
Yar Kravtsov
c670ac1fd8 Potential fix for code scanning alert no. 4: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-26 05:44:46 +03:00
Yar Kravtsov
27196e3341 docs(README): remove outdated technical details for accuracy 2025-05-25 07:50:47 +03:00
Yar Kravtsov
84c507828d fix(status): detect and report uncommitted changes in repository status, fixes #5 2025-05-25 07:35:16 +03:00
Yar Kravtsov
d02f112200 fix(core): remove unnecessary Windows drive letter check in getRelativePath 2025-05-24 18:13:03 +03:00
Yar Kravtsov
f96bfb6ce0 fix: prevent file loss when multiple files have same basename
Fixes #2: https://github.com/yarlson/lnk/issues/2

Previously, files with the same basename (e.g., a/config.json and b/config.json)
would overwrite each other in the repository, causing data loss. The second file
would completely replace the first, and removing files would fail with 'no such
file or directory' errors.

Changes:
- Store files using unique names based on full relative paths (slashes → underscores)
- Track full relative paths in .lnk file instead of just basenames
- Generate repository names from relative paths to prevent collisions
- Update symlink restoration to work with new path-based system
- Add comprehensive tests for basename collision scenarios

This ensures each file maintains its unique content and can be managed
independently, eliminating the data loss issue.
2025-05-24 18:10:20 +03:00
Yar Kravtsov
7007ec64f2 refactor(test): update test commands to include all packages recursively 2025-05-24 11:39:20 +03:00
Yar Kravtsov
ec6ad6b0d0 refactor(test): update test commands to include all packages 2025-05-24 11:37:57 +03:00
Yar Kravtsov
e7f316ea6e ci: update test command to include all packages in CI and release workflows 2025-05-24 11:37:13 +03:00
Yar Kravtsov
09d67f181e refactor(tests): reorganize test files for improved structure and modularity 2025-05-24 11:35:40 +03:00
Yar Kravtsov
3a34e4fb37 refactor(cmd): centralize output formatting with printf helper function 2025-05-24 11:30:55 +03:00
Yar Kravtsov
fc0b567e9f refactor(cmd): improve testability and error handling in CLI commands 2025-05-24 11:28:16 +03:00
Yar Kravtsov
61a9cc8c88 feat: enhance CLI output with colorful and informative messages 2025-05-24 10:13:00 +03:00
Yar Kravtsov
1e2728fe33 feat(install): enhance installer script robustness and flexibility 2025-05-24 09:56:51 +03:00
23 changed files with 2823 additions and 1119 deletions

View File

@@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [ main ] branches: [ main ]
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -38,7 +41,7 @@ jobs:
run: go vet ./... run: go vet ./...
- name: Test - name: Test
run: go test -v -race -coverprofile=coverage.out ./test run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v3

View File

@@ -23,7 +23,7 @@ jobs:
go-version: '1.24' go-version: '1.24'
- name: Run tests - name: Run tests
run: go test ./test run: go test ./...
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6

View File

@@ -60,19 +60,19 @@ build:
## test: Run tests ## test: Run tests
test: test:
@echo "$(BLUE)Running tests...$(NC)" @echo "$(BLUE)Running tests...$(NC)"
@go test ./test @go test ./...
@echo "$(GREEN)✓ Tests passed$(NC)" @echo "$(GREEN)✓ Tests passed$(NC)"
## test-v: Run tests with verbose output ## test-v: Run tests with verbose output
test-v: test-v:
@echo "$(BLUE)Running tests (verbose)...$(NC)" @echo "$(BLUE)Running tests (verbose)...$(NC)"
@go test -v ./test @go test -v ./...
## test-cover: Run tests with coverage ## test-cover: Run tests with coverage
test-cover: test-cover:
@echo "$(BLUE)Running tests with coverage...$(NC)" @echo "$(BLUE)Running tests with coverage...$(NC)"
@go test -v -cover ./test @go test -v -cover ./...
@go test -coverprofile=coverage.out ./test @go test -coverprofile=coverage.out ./
@go tool cover -html=coverage.out -o coverage.html @go tool cover -html=coverage.out -o coverage.html
@echo "$(GREEN)✓ Coverage report generated: coverage.html$(NC)" @echo "$(GREEN)✓ Coverage report generated: coverage.html$(NC)"

143
README.md
View File

@@ -2,11 +2,12 @@
**Git-native dotfiles management that doesn't suck.** **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 ```bash
lnk init lnk init
lnk add ~/.vimrc ~/.bashrc lnk add ~/.vimrc ~/.bashrc # Common config
lnk add --host work ~/.ssh/config # Host-specific config
lnk push "setup" lnk push "setup"
``` ```
@@ -15,15 +16,21 @@ lnk push "setup"
```bash ```bash
# Quick install (recommended) # Quick install (recommended)
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```
```bash
# Homebrew (macOS/Linux) # Homebrew (macOS/Linux)
brew tap yarlson/lnk brew tap yarlson/lnk
brew install lnk brew install lnk
```
```bash
# Manual download # Manual download
wget https://github.com/yarlson/lnk/releases/latest/download/lnk-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64 wget https://github.com/yarlson/lnk/releases/latest/download/lnk-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64
chmod +x lnk-* && sudo mv lnk-* /usr/local/bin/lnk chmod +x lnk-* && sudo mv lnk-* /usr/local/bin/lnk
```
```bash
# From source # From source
git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/
``` ```
@@ -43,25 +50,83 @@ lnk init -r git@github.com:user/dotfiles.git
### Daily workflow ### Daily workflow
```bash ```bash
# Add files/directories # Add files/directories (common config)
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
# Add host-specific files
lnk add --host laptop ~/.ssh/config
lnk add --host work ~/.gitconfig
# List managed files
lnk list # Common config only
lnk list --host laptop # Laptop-specific config
lnk list --all # All configurations
# Check status # Check status
lnk status lnk status
# Sync changes # Sync changes
lnk push "updated vim config" lnk push "updated vim config"
lnk pull lnk pull # Pull common config
lnk pull --host laptop # Pull laptop-specific config
``` ```
## How it works ## How it works
``` ```
Common files:
Before: ~/.vimrc (file) Before: ~/.vimrc (file)
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink) 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
│ └── .tmux.conf
└── work.lnk/ # Work-specific storage
├── .ssh/
│ └── config
└── .gitconfig
```
### 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 ~/.gitconfig
# 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? ## Why not just Git?
@@ -78,7 +143,13 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
```bash ```bash
lnk init -r git@github.com:you/dotfiles.git lnk init -r git@github.com:you/dotfiles.git
# Add common config (shared across all machines)
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
# Add host-specific config
lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf
lnk push "initial setup" lnk push "initial setup"
``` ```
@@ -86,58 +157,76 @@ lnk push "initial setup"
```bash ```bash
lnk init -r git@github.com:you/dotfiles.git 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 ### Daily edits
```bash ```bash
vim ~/.vimrc # edit normally 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 status # check what changed
lnk push "new plugins" # commit & push 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 ~/.gitconfig
lnk push "work git config"
# Back on laptop
lnk pull # Get updates (work config won't affect laptop)
```
## Commands ## Commands
- `lnk init [-r remote]` - Create repo - `lnk init [-r remote]` - Create repo
- `lnk add <files>` - Move files to repo, create symlinks - `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
- `lnk rm <files>` - Move files back, remove 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 status` - Git status + sync info
- `lnk push [msg]` - Stage all, commit, push - `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 ## Technical bits
- **Single binary** (~8MB, no deps) - **Single binary** (~8MB, no deps)
- **Atomic operations** (rollback on failure)
- **Relative symlinks** (portable) - **Relative symlinks** (portable)
- **XDG compliant** (`~/.config/lnk`) - **XDG compliant** (`~/.config/lnk`)
- **20 integration tests** - **Multihost support** (common + host-specific configs)
- **Git-native** (standard Git repo, no special formats)
## Alternatives ## Alternatives
| Tool | Complexity | Why choose it | | 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 | | chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption | | yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features | | dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only | | 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 ## Contributing
```bash ```bash

View File

@@ -64,11 +64,13 @@ git push origin v1.0.0
## What GoReleaser Does ## What GoReleaser Does
1. **Builds binaries** for multiple platforms: 1. **Builds binaries** for multiple platforms:
- Linux (amd64, arm64) - Linux (amd64, arm64)
- macOS (amd64, arm64) - macOS (amd64, arm64)
- Windows (amd64) - Windows (amd64)
2. **Creates archives** with consistent naming: 2. **Creates archives** with consistent naming:
- `lnk_Linux_x86_64.tar.gz` - `lnk_Linux_x86_64.tar.gz`
- `lnk_Darwin_arm64.tar.gz` - `lnk_Darwin_arm64.tar.gz`
- etc. - etc.
@@ -76,6 +78,7 @@ git push origin v1.0.0
3. **Generates checksums** for verification 3. **Generates checksums** for verification
4. **Creates GitHub release** with: 4. **Creates GitHub release** with:
- Automatic changelog from conventional commits - Automatic changelog from conventional commits
- Installation instructions - Installation instructions
- Download links for all platforms - Download links for all platforms
@@ -112,17 +115,20 @@ ls -la dist/
After a release is published, users can install lnk using multiple methods: After a release is published, users can install lnk using multiple methods:
### 1. Shell Script (Recommended) ### 1. Shell Script (Recommended)
```bash ```bash
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
``` ```
### 2. Homebrew (macOS/Linux) ### 2. Homebrew (macOS/Linux)
```bash ```bash
brew tap yarlson/lnk brew tap yarlson/lnk
brew install lnk brew install lnk
``` ```
### 3. Manual Download ### 3. Manual Download
```bash ```bash
# Download from GitHub releases # Download from GitHub releases
wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz

View File

@@ -1,32 +1,43 @@
package cmd package cmd
import ( import (
"fmt"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
var addCmd = &cobra.Command{ func newAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "add <file>", Use: "add <file>",
Short: "Add a file to lnk management", Short: "✨ Add a file to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.", Long: "Moves a file to the lnk repository and creates a symlink in its place.",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0] filePath := args[0]
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
lnk := core.NewLnk()
if err := lnk.Add(filePath); err != nil { if err := lnk.Add(filePath); err != nil {
return fmt.Errorf("failed to add file: %w", err) return err
} }
basename := filepath.Base(filePath) basename := filepath.Base(filePath)
fmt.Printf("Added %s to lnk\n", basename) 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, filePath)
} 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, filePath)
}
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
return nil return nil
}, },
} }
func init() { cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
rootCmd.AddCommand(addCmd) return cmd
} }

View File

@@ -1,35 +1,44 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
var initCmd = &cobra.Command{ func newInitCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "init", Use: "init",
Short: "Initialize a new lnk repository", Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.", Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote") remote, _ := cmd.Flags().GetString("remote")
lnk := core.NewLnk() lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil { if err := lnk.InitWithRemote(remote); err != nil {
return fmt.Errorf("failed to initialize lnk: %w", err) return err
} }
if remote != "" { if remote != "" {
fmt.Printf("Initialized lnk repository by cloning: %s\n", remote) 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 { } else {
fmt.Println("Initialized lnk repository") 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 return nil
}, },
} }
func init() { cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
initCmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository") return cmd
rootCmd.AddCommand(initCmd)
} }

193
cmd/list.go Normal file
View File

@@ -0,0 +1,193 @@
package cmd
import (
"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,
SilenceErrors: 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 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.NewLnk(core.WithHost(host))
managedItems, err := lnk.List()
if err != nil {
return 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 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 err
}
for _, host := range hosts {
printf(cmd, "\n🖥 \033[1mHost: %s\033[0m", host)
hostLnk := core.NewLnk(core.WithHost(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, 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")
}

View File

@@ -1,36 +1,56 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
var pullCmd = &cobra.Command{ func newPullCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "pull", Use: "pull",
Short: "Pull changes from remote and restore symlinks", Short: "⬇️ Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.", Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk() host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
restored, err := lnk.Pull() restored, err := lnk.Pull()
if err != nil { if err != nil {
return fmt.Errorf("failed to pull changes: %w", err) return err
} }
if len(restored) > 0 { if len(restored) > 0 {
fmt.Printf("Successfully pulled changes and restored %d symlink(s):\n", len(restored)) if host != "" {
for _, file := range restored { printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
fmt.Printf(" - %s\n", file)
}
} else { } else {
fmt.Println("Successfully pulled changes (no symlinks needed restoration)") 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 return nil
}, },
} }
func init() { cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)")
rootCmd.AddCommand(pullCmd) return cmd
} }

View File

@@ -1,17 +1,18 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
var pushCmd = &cobra.Command{ func newPushCmd() *cobra.Command {
return &cobra.Command{
Use: "push [message]", Use: "push [message]",
Short: "Push local changes to remote repository", Short: "🚀 Push local changes to remote repository",
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.", Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
message := "lnk: sync configuration files" message := "lnk: sync configuration files"
if len(args) > 0 { if len(args) > 0 {
@@ -20,14 +21,14 @@ var pushCmd = &cobra.Command{
lnk := core.NewLnk() lnk := core.NewLnk()
if err := lnk.Push(message); err != nil { if err := lnk.Push(message); err != nil {
return fmt.Errorf("failed to push changes: %w", err) return err
} }
fmt.Println("Successfully pushed changes to remote") 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 return nil
}, },
} }
func init() {
rootCmd.AddCommand(pushCmd)
} }

View File

@@ -1,32 +1,43 @@
package cmd package cmd
import ( import (
"fmt"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
var rmCmd = &cobra.Command{ func newRemoveCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "rm <file>", Use: "rm <file>",
Short: "Remove a file from lnk management", Short: "🗑️ Remove a file from lnk management",
Long: "Removes a symlink and restores the original file from the lnk repository.", Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0] filePath := args[0]
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
lnk := core.NewLnk()
if err := lnk.Remove(filePath); err != nil { if err := lnk.Remove(filePath); err != nil {
return fmt.Errorf("failed to remove file: %w", err) return err
} }
basename := filepath.Base(filePath) basename := filepath.Base(filePath)
fmt.Printf("Removed %s from lnk\n", basename) 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 return nil
}, },
} }
func init() { cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)")
rootCmd.AddCommand(rmCmd) return cmd
} }

View File

@@ -12,22 +12,53 @@ var (
buildTime = "unknown" buildTime = "unknown"
) )
var rootCmd = &cobra.Command{ // NewRootCommand creates a new root command (testable)
func NewRootCommand() *cobra.Command {
rootCmd := &cobra.Command{
Use: "lnk", Use: "lnk",
Short: "Dotfiles, linked. No fluff.", Short: "🔗 Dotfiles, linked. No fluff.",
Long: "Lnk is a minimalist CLI tool for managing dotfiles using symlinks and Git.", Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
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 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
🎯 Simple, fast, Git-native, and multi-host ready.`,
SilenceUsage: true,
SilenceErrors: 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 // SetVersion sets the version information for the CLI
func SetVersion(v, bt string) { func SetVersion(v, bt string) {
version = v version = v
buildTime = bt buildTime = bt
rootCmd.Version = fmt.Sprintf("%s (built %s)", version, buildTime)
} }
func Execute() { func Execute() {
rootCmd := NewRootCommand()
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) _, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
} }

751
cmd/root_test.go Normal file
View File

@@ -0,0 +1,751 @@
package cmd
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"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 HOME to temp directory for consistent relative path calculation
suite.T().Setenv("HOME", tempDir)
// Set XDG_CONFIG_HOME to tempDir/.config for config files
suite.T().Setenv("XDG_CONFIG_HOME", filepath.Join(tempDir, ".config"))
// 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, ".config", "lnk")
suite.DirExists(lnkDir)
gitDir := filepath.Join(lnkDir, ".git")
suite.DirExists(gitDir)
}
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 the file exists in repo with preserved directory structure
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
repoFile := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(repoFile)
// Verify content is preserved in storage
storedContent, err := os.ReadFile(repoFile)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent))
// Verify .lnk file contains the correct entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(lnkContent))
}
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 repository is 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")
// Verify both files exist in storage with correct content
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
bashrcStorage := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(bashrcStorage)
bashrcContent, err := os.ReadFile(bashrcStorage)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(bashrcContent))
vimrcStorage := filepath.Join(lnkDir, ".vimrc")
suite.FileExists(vimrcStorage)
vimrcContent, err := os.ReadFile(vimrcStorage)
suite.NoError(err)
suite.Equal("set number", string(vimrcContent))
// Verify .lnk file contains both entries (sorted)
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n.vimrc\n", string(lnkContent))
}
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 or directory not found",
},
{
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", filepath.Join(suite.tempDir, ".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")
// Verify storage and .lnk file
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
bashrcStorage := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(bashrcStorage)
storedContent, err := os.ReadFile(bashrcStorage)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent))
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(lnkContent))
},
},
{
name: "add another file",
args: []string{"add", filepath.Join(suite.tempDir, ".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")
// Verify storage and .lnk file now contains both files
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
vimrcStorage := filepath.Join(lnkDir, ".vimrc")
suite.FileExists(vimrcStorage)
storedContent, err := os.ReadFile(vimrcStorage)
suite.NoError(err)
suite.Equal("set number", string(storedContent))
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n.vimrc\n", string(lnkContent))
},
},
{
name: "remove file",
args: []string{"rm", filepath.Join(suite.tempDir, ".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, ".ssh")
_ = os.MkdirAll(testDir, 0755)
configFile := filepath.Join(testDir, "config")
_ = os.WriteFile(configFile, []byte("Host example.com"), 0644)
// Add the directory
err := suite.runCommand("add", testDir)
suite.NoError(err)
// Check output
output := suite.stdout.String()
suite.Contains(output, "Added .ssh 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 the directory exists in repo with preserved directory structure
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
repoDir := filepath.Join(lnkDir, ".ssh")
suite.DirExists(repoDir)
// Verify directory content is preserved
repoConfigFile := filepath.Join(repoDir, "config")
suite.FileExists(repoConfigFile)
storedContent, err := os.ReadFile(repoConfigFile)
suite.NoError(err)
suite.Equal("Host example.com", string(storedContent))
// Verify .lnk file contains the directory entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".ssh\n", string(lnkContent))
}
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")
// Verify both files exist in storage with correct paths and content
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
storageFileA := filepath.Join(lnkDir, "a", "config.json")
suite.FileExists(storageFileA)
storedContentA, err := os.ReadFile(storageFileA)
suite.NoError(err)
suite.Equal(contentA, string(storedContentA))
storageFileB := filepath.Join(lnkDir, "b", "config.json")
suite.FileExists(storageFileB)
storedContentB, err := os.ReadFile(storageFileB)
suite.NoError(err)
suite.Equal(contentB, string(storedContentB))
// Verify .lnk file contains both entries with correct relative paths
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal("a/config.json\nb/config.json\n", string(lnkContent))
// 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()
// Verify file is stored correctly
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
storageFile := filepath.Join(lnkDir, "a")
suite.FileExists(storageFile)
storedContent, err := os.ReadFile(storageFile)
suite.NoError(err)
suite.Equal("abc", string(storedContent))
// Verify .lnk file contains the entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal("a\n", string(lnkContent))
// Add a remote so status works
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()
// Verify storage paths and .lnk files for both common and host-specific
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
// Verify common file storage and tracking
commonStorage := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(commonStorage)
commonContent, err := os.ReadFile(commonStorage)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(commonContent))
commonLnkFile := filepath.Join(lnkDir, ".lnk")
commonLnkContent, err := os.ReadFile(commonLnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(commonLnkContent))
// Verify host-specific file storage and tracking
hostStorage := filepath.Join(lnkDir, "workstation.lnk", ".vimrc")
suite.FileExists(hostStorage)
hostContent, err := os.ReadFile(hostStorage)
suite.NoError(err)
suite.Equal("set number", string(hostContent))
hostLnkFile := filepath.Join(lnkDir, ".lnk.workstation")
hostLnkContent, err := os.ReadFile(hostLnkFile)
suite.NoError(err)
suite.Equal(".vimrc\n", string(hostLnkContent))
// 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))
}

View File

@@ -1,38 +1,92 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
var statusCmd = &cobra.Command{ func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status", Use: "status",
Short: "Show repository sync 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,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk() lnk := core.NewLnk()
status, err := lnk.Status() status, err := lnk.Status()
if err != nil { if err != nil {
return fmt.Errorf("failed to get status: %w", err) return err
}
if status.Dirty {
displayDirtyStatus(cmd, status)
return nil
} }
if status.Ahead == 0 && status.Behind == 0 { if status.Ahead == 0 && status.Behind == 0 {
fmt.Println("Repository is up to date with remote") displayUpToDateStatus(cmd, status)
} else { return nil
if status.Ahead > 0 {
fmt.Printf("Your branch is ahead of '%s' by %d commit(s)\n", status.Remote, status.Ahead)
}
if status.Behind > 0 {
fmt.Printf("Your branch is behind '%s' by %d commit(s)\n", status.Remote, status.Behind)
}
} }
displaySyncStatus(cmd, status)
return nil 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
View 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...)
}

View File

@@ -17,6 +17,9 @@ REPO="yarlson/lnk"
INSTALL_DIR="/usr/local/bin" INSTALL_DIR="/usr/local/bin"
BINARY_NAME="lnk" BINARY_NAME="lnk"
# Fallback version if redirect fails
FALLBACK_VERSION="v0.0.2"
# Detect OS and architecture # Detect OS and architecture
detect_platform() { detect_platform() {
local os arch local os arch
@@ -45,11 +48,44 @@ detect_platform() {
echo "${os}_${arch}" echo "${os}_${arch}"
} }
# Get the latest release version # Get latest version by following redirect
get_latest_version() { get_latest_version() {
curl -s "https://api.github.com/repos/${REPO}/releases/latest" | \ echo -e "${BLUE}Getting latest release version...${NC}" >&2
grep '"tag_name":' | \
sed -E 's/.*"([^"]+)".*/\1/' # Get redirect location from releases/latest
local redirect_url
redirect_url=$(curl -s -I "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/\r$//' | cut -d' ' -f2-)
if [ -z "$redirect_url" ]; then
echo -e "${YELLOW}⚠ Could not get redirect URL, using fallback version ${FALLBACK_VERSION}${NC}" >&2
echo "$FALLBACK_VERSION"
return 0
fi
# Extract version from redirect URL (format: https://github.com/user/repo/releases/tag/v1.2.3)
local version
version=$(echo "$redirect_url" | sed -E 's|.*/releases/tag/([^/]*)\s*$|\1|')
if [ -z "$version" ] || [ "$version" = "$redirect_url" ]; then
echo -e "${YELLOW}⚠ Could not parse version from redirect URL: $redirect_url${NC}" >&2
echo -e "${YELLOW}Using fallback version ${FALLBACK_VERSION}${NC}" >&2
echo "$FALLBACK_VERSION"
return 0
fi
echo "$version"
}
# Get version to install
get_version() {
# Allow override via environment variable
if [ -n "$LNK_VERSION" ]; then
echo "$LNK_VERSION"
elif [ -n "$1" ]; then
echo "$1"
else
get_latest_version
fi
} }
# Download and install # Download and install
@@ -59,14 +95,9 @@ install_lnk() {
echo -e "${BLUE}🔗 Installing lnk...${NC}" echo -e "${BLUE}🔗 Installing lnk...${NC}"
platform=$(detect_platform) platform=$(detect_platform)
version=$(get_latest_version) version=$(get_version "$1")
if [ -z "$version" ]; then echo -e "${BLUE}Version: ${version}${NC}"
echo -e "${RED}Error: Failed to get latest version${NC}"
exit 1
fi
echo -e "${BLUE}Latest version: ${version}${NC}"
echo -e "${BLUE}Platform: ${platform}${NC}" echo -e "${BLUE}Platform: ${platform}${NC}"
# Download URL # Download URL
@@ -82,6 +113,16 @@ install_lnk() {
# Download the binary # Download the binary
if ! curl -sL "$url" -o "$filename"; then if ! curl -sL "$url" -o "$filename"; then
echo -e "${RED}Error: Failed to download ${url}${NC}" echo -e "${RED}Error: Failed to download ${url}${NC}"
echo -e "${YELLOW}Please check if the release exists at: https://github.com/${REPO}/releases/tag/${version}${NC}"
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
exit 1
fi
# Check if we got an HTML error page instead of the binary
if file "$filename" 2>/dev/null | grep -q "HTML"; then
echo -e "${RED}Error: Downloaded file appears to be an HTML page (404 error)${NC}"
echo -e "${YELLOW}The release ${version} might not exist.${NC}"
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
exit 1 exit 1
fi fi
@@ -107,20 +148,33 @@ install_lnk() {
echo -e "${GREEN}✅ lnk installed successfully!${NC}" echo -e "${GREEN}✅ lnk installed successfully!${NC}"
echo -e "${GREEN}Run 'lnk --help' to get started.${NC}" echo -e "${GREEN}Run 'lnk --help' to get started.${NC}"
# Test the installation
if command -v lnk >/dev/null 2>&1; then
echo -e "${GREEN}Installed version: $(lnk --version)${NC}"
fi
} }
# Check if running with --help # Check if running with --help
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo "Lnk installer script" echo "Lnk installer script"
echo "" echo ""
echo "Usage: curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash" echo "Usage:"
echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash -s v0.0.1"
echo " LNK_VERSION=v0.0.1 curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
echo "" echo ""
echo "This script will:" echo "This script will:"
echo " 1. Detect your OS and architecture" echo " 1. Detect your OS and architecture"
echo " 2. Download the latest lnk release" echo " 2. Auto-detect the latest release by following GitHub redirects"
echo " 3. Install it to /usr/local/bin (requires sudo)" echo " 3. Download and install to /usr/local/bin (requires sudo)"
echo ""
echo "Environment variables:"
echo " LNK_VERSION - Specify version to install (e.g., v0.0.1)"
echo ""
echo "Manual installation: https://github.com/yarlson/lnk/releases"
exit 0 exit 0
fi fi
# Run the installer # Run the installer
install_lnk install_lnk "$1"

View File

@@ -14,18 +14,44 @@ import (
// Lnk represents the main application logic // Lnk represents the main application logic
type Lnk struct { type Lnk struct {
repoPath string repoPath string
host string // Host-specific configuration
git *git.Git git *git.Git
fs *fs.FileSystem fs *fs.FileSystem
} }
// NewLnk creates a new Lnk instance type Option func(*Lnk)
func NewLnk() *Lnk {
// WithHost sets the host for host-specific configuration
func WithHost(host string) Option {
return func(l *Lnk) {
l.host = host
}
}
// NewLnk creates a new Lnk instance with optional configuration
func NewLnk(opts ...Option) *Lnk {
repoPath := getRepoPath() repoPath := getRepoPath()
return &Lnk{ lnk := &Lnk{
repoPath: repoPath, repoPath: repoPath,
host: "",
git: git.New(repoPath), git: git.New(repoPath),
fs: fs.New(), fs: fs.New(),
} }
for _, opt := range opts {
opt(lnk)
}
return lnk
}
// 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 // getRepoPath returns the path to the lnk repository directory
@@ -43,6 +69,48 @@ func getRepoPath() string {
return filepath.Join(xdgConfig, "lnk") return filepath.Join(xdgConfig, "lnk")
} }
// 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 // Init initializes the lnk repository
func (l *Lnk) Init() error { func (l *Lnk) Init() error {
return l.InitWithRemote("") return l.InitWithRemote("")
@@ -68,32 +136,22 @@ func (l *Lnk) InitWithRemote(remoteURL string) error {
return nil return nil
} else { } else {
// It's not a lnk repository, error to prevent data loss // It's not a lnk repository, error to prevent data loss
return fmt.Errorf("directory %s appears to contain an existing Git repository that is not managed by lnk. Please backup or move the existing repository before initializing lnk", l.repoPath) return fmt.Errorf("❌ Directory \033[31m%s\033[0m contains an existing Git repository\n 💡 Please backup or move the existing repository before initializing lnk", l.repoPath)
} }
} }
// No existing repository, initialize Git repository // No existing repository, initialize Git repository
if err := l.git.Init(); err != nil { return l.git.Init()
return fmt.Errorf("failed to initialize git repository: %w", err)
}
return nil
} }
// Clone clones a repository from the given URL // Clone clones a repository from the given URL
func (l *Lnk) Clone(url string) error { func (l *Lnk) Clone(url string) error {
if err := l.git.Clone(url); err != nil { return l.git.Clone(url)
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
} }
// AddRemote adds a remote to the repository // AddRemote adds a remote to the repository
func (l *Lnk) AddRemote(name, url string) error { func (l *Lnk) AddRemote(name, url string) error {
if err := l.git.AddRemote(name, url); err != nil { return l.git.AddRemote(name, url)
return fmt.Errorf("failed to add remote %s: %w", name, err)
}
return nil
} }
// Add moves a file or directory to the repository and creates a symlink // Add moves a file or directory to the repository and creates a symlink
@@ -109,9 +167,32 @@ func (l *Lnk) Add(filePath string) error {
return fmt.Errorf("failed to get absolute path: %w", err) return fmt.Errorf("failed to get absolute path: %w", err)
} }
// Calculate destination path in repo // Get relative path for tracking
basename := filepath.Base(absPath) relativePath, err := getRelativePath(absPath)
destPath := filepath.Join(l.repoPath, basename) if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
// Generate repository path from relative path
storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, relativePath)
// 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 // Check if it's a directory or file
info, err := os.Stat(absPath) info, err := os.Stat(absPath)
@@ -120,76 +201,56 @@ func (l *Lnk) Add(filePath string) error {
} }
// Move to repository (handles both files and directories) // Move to repository (handles both files and directories)
if info.IsDir() { if err := l.fs.Move(absPath, destPath, info); err != nil {
if err := l.fs.MoveDirectory(absPath, destPath); err != nil { return err
return fmt.Errorf("failed to move directory to repository: %w", err)
}
} else {
if err := l.fs.MoveFile(absPath, destPath); err != nil {
return fmt.Errorf("failed to move file to repository: %w", err)
}
} }
// Create symlink // Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil { if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the original if symlink creation fails // Try to restore the original if symlink creation fails
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to create symlink: %w", err)
} }
// Add to .lnk tracking file // Add to .lnk tracking file using relative path
if err := l.addManagedItem(absPath); err != nil { if err := l.addManagedItem(relativePath); err != nil {
// Try to restore the original state if tracking fails // Try to restore the original state if tracking fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to update tracking file: %w", err) return fmt.Errorf("failed to update tracking file: %w", err)
} }
// Add both the item and .lnk file to git in a single commit // 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
// Try to restore the original state if git add fails gitPath := relativePath
_ = os.Remove(absPath) // Ignore error in cleanup if l.host != "" {
_ = l.removeManagedItem(absPath) // Ignore error in cleanup gitPath = filepath.Join(l.host+".lnk", relativePath)
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
} }
return fmt.Errorf("failed to add item to git: %w", err) if err := l.git.Add(gitPath); err != nil {
// Try to restore the original state if git add fails
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
_ = l.fs.Move(destPath, absPath, info)
return err
} }
// Add .lnk file to the same commit // 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 // Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
_ = l.removeManagedItem(absPath) // Ignore error in cleanup _ = l.removeManagedItem(relativePath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to add .lnk file to git: %w", err)
} }
// Commit both changes together // Commit both changes together
basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil { if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
// Try to restore the original state if commit fails // Try to restore the original state if commit fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
_ = l.removeManagedItem(absPath) // Ignore error in cleanup _ = l.removeManagedItem(relativePath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to commit changes: %w", err)
} }
return nil return nil
@@ -208,6 +269,29 @@ func (l *Lnk) Remove(filePath string) error {
return err 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 // Get the target path in the repository
target, err := os.Readlink(absPath) target, err := os.Readlink(absPath)
if err != nil { if err != nil {
@@ -219,8 +303,6 @@ func (l *Lnk) Remove(filePath string) error {
target = filepath.Join(filepath.Dir(absPath), target) target = filepath.Join(filepath.Dir(absPath), target)
} }
basename := filepath.Base(target)
// Check if target is a directory or file // Check if target is a directory or file
info, err := os.Stat(target) info, err := os.Stat(target)
if err != nil { if err != nil {
@@ -232,35 +314,34 @@ func (l *Lnk) Remove(filePath string) error {
return fmt.Errorf("failed to remove symlink: %w", err) return fmt.Errorf("failed to remove symlink: %w", err)
} }
// Remove from .lnk tracking file // Remove from .lnk tracking file using relative path
if err := l.removeManagedItem(absPath); err != nil { if err := l.removeManagedItem(relativePath); err != nil {
return fmt.Errorf("failed to update tracking file: %w", err) return fmt.Errorf("failed to update tracking file: %w", err)
} }
// Remove from Git first (while the item is still in the repository) // Generate the correct git path for removal
if err := l.git.Remove(basename); err != nil { gitPath := relativePath
return fmt.Errorf("failed to remove from git: %w", err) if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
if err := l.git.Remove(gitPath); err != nil {
return err
} }
// Add .lnk file to the same commit // 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) return err
} }
// Commit both changes together // Commit both changes together
basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil { if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
return fmt.Errorf("failed to commit changes: %w", err) return err
} }
// Move back from repository (handles both files and directories) // Move back from repository (handles both files and directories)
if info.IsDir() { if err := l.fs.Move(target, absPath, info); err != nil {
if err := l.fs.MoveDirectory(target, absPath); err != nil { return err
return fmt.Errorf("failed to restore directory: %w", err)
}
} else {
if err := l.fs.MoveFile(target, absPath); err != nil {
return fmt.Errorf("failed to restore file: %w", err)
}
} }
return nil return nil
@@ -276,24 +357,26 @@ type StatusInfo struct {
Ahead int Ahead int
Behind int Behind int
Remote string Remote string
Dirty bool
} }
// Status returns the repository sync status // Status returns the repository sync status
func (l *Lnk) Status() (*StatusInfo, error) { func (l *Lnk) Status() (*StatusInfo, error) {
// Check if repository is initialized // Check if repository is initialized
if !l.git.IsGitRepository() { if !l.git.IsGitRepository() {
return nil, fmt.Errorf("lnk repository not initialized - run 'lnk init' first") return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
} }
gitStatus, err := l.git.GetStatus() gitStatus, err := l.git.GetStatus()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get repository status: %w", err) return nil, err
} }
return &StatusInfo{ return &StatusInfo{
Ahead: gitStatus.Ahead, Ahead: gitStatus.Ahead,
Behind: gitStatus.Behind, Behind: gitStatus.Behind,
Remote: gitStatus.Remote, Remote: gitStatus.Remote,
Dirty: gitStatus.Dirty,
}, nil }, nil
} }
@@ -301,46 +384,42 @@ func (l *Lnk) Status() (*StatusInfo, error) {
func (l *Lnk) Push(message string) error { func (l *Lnk) Push(message string) error {
// Check if repository is initialized // Check if repository is initialized
if !l.git.IsGitRepository() { if !l.git.IsGitRepository() {
return fmt.Errorf("lnk repository not initialized - run 'lnk init' first") return fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
} }
// Check if there are any changes // Check if there are any changes
hasChanges, err := l.git.HasChanges() hasChanges, err := l.git.HasChanges()
if err != nil { if err != nil {
return fmt.Errorf("failed to check for changes: %w", err) return err
} }
if hasChanges { if hasChanges {
// Stage all changes // Stage all changes
if err := l.git.AddAll(); err != nil { if err := l.git.AddAll(); err != nil {
return fmt.Errorf("failed to stage changes: %w", err) return err
} }
// Create a sync commit // Create a sync commit
if err := l.git.Commit(message); err != nil { if err := l.git.Commit(message); err != nil {
return fmt.Errorf("failed to commit changes: %w", err) return err
} }
} }
// Push to remote (this will be a no-op in tests since we don't have real remotes) // Push to remote (this will be a no-op in tests since we don't have real remotes)
// In real usage, this would push to the actual remote repository // In real usage, this would push to the actual remote repository
if err := l.git.Push(); err != nil { return l.git.Push()
return fmt.Errorf("failed to push to remote: %w", err)
}
return nil
} }
// Pull fetches changes from remote and restores symlinks as needed // Pull fetches changes from remote and restores symlinks as needed
func (l *Lnk) Pull() ([]string, error) { func (l *Lnk) Pull() ([]string, error) {
// Check if repository is initialized // Check if repository is initialized
if !l.git.IsGitRepository() { if !l.git.IsGitRepository() {
return nil, fmt.Errorf("lnk repository not initialized - run 'lnk init' first") return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
} }
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes) // Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
if err := l.git.Pull(); err != nil { if err := l.git.Pull(); err != nil {
return nil, fmt.Errorf("failed to pull from remote: %w", err) return nil, err
} }
// Find all managed files in the repository and restore symlinks // Find all managed files in the repository and restore symlinks
@@ -352,9 +431,12 @@ func (l *Lnk) Pull() ([]string, error) {
return restored, nil return restored, nil
} }
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks // List returns the list of files and directories currently managed by lnk
func (l *Lnk) RestoreSymlinks() ([]string, error) { func (l *Lnk) List() ([]string, error) {
var restored []string // 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 // Get managed items from .lnk file
managedItems, err := l.getManagedItems() managedItems, err := l.getManagedItems()
@@ -362,28 +444,48 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
return nil, fmt.Errorf("failed to get managed items: %w", err) return nil, fmt.Errorf("failed to get managed items: %w", err)
} }
for _, itemName := range managedItems { return managedItems, nil
repoItem := filepath.Join(l.repoPath, itemName) }
// 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
storagePath := l.getHostStoragePath()
repoItem := filepath.Join(storagePath, relativePath)
// Check if item exists in repository // Check if item exists in repository
if _, err := os.Stat(repoItem); os.IsNotExist(err) { if _, err := os.Stat(repoItem); os.IsNotExist(err) {
continue // Skip missing items continue // Skip missing items
} }
// Determine where the symlink should be // Determine where the symlink should be created
// For config files, we'll place them in the user's home directory symlinkPath := filepath.Join(homeDir, relativePath)
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
symlinkPath := filepath.Join(homeDir, itemName)
// Check if symlink already exists and is correct // Check if symlink already exists and is correct
if l.isValidSymlink(symlinkPath, repoItem) { if l.isValidSymlink(symlinkPath, repoItem) {
continue 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 // Remove existing file/symlink if it exists
if _, err := os.Lstat(symlinkPath); err == nil { if _, err := os.Lstat(symlinkPath); err == nil {
if err := os.RemoveAll(symlinkPath); err != nil { if err := os.RemoveAll(symlinkPath); err != nil {
@@ -393,10 +495,10 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
// Create symlink // Create symlink
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil { if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
return nil, fmt.Errorf("failed to create symlink for %s: %w", itemName, err) return nil, err
} }
restored = append(restored, itemName) restored = append(restored, relativePath)
} }
return restored, nil return restored, nil
@@ -441,7 +543,7 @@ func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
// getManagedItems returns the list of managed files and directories from .lnk file // getManagedItems returns the list of managed files and directories from .lnk file
func (l *Lnk) getManagedItems() ([]string, error) { 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 .lnk file doesn't exist, return empty list
if _, err := os.Stat(lnkFile); os.IsNotExist(err) { if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
@@ -470,25 +572,22 @@ func (l *Lnk) getManagedItems() ([]string, error) {
} }
// addManagedItem adds an item to the .lnk tracking file // 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 // Get current items
items, err := l.getManagedItems() items, err := l.getManagedItems()
if err != nil { if err != nil {
return fmt.Errorf("failed to get managed items: %w", err) return fmt.Errorf("failed to get managed items: %w", err)
} }
// Get the basename for storage
basename := filepath.Base(itemPath)
// Check if already exists // Check if already exists
for _, item := range items { for _, item := range items {
if item == basename { if item == relativePath {
return nil // Already managed return nil // Already managed
} }
} }
// Add new item // Add new item using relative path
items = append(items, basename) items = append(items, relativePath)
// Sort for consistent ordering // Sort for consistent ordering
sort.Strings(items) sort.Strings(items)
@@ -497,20 +596,17 @@ func (l *Lnk) addManagedItem(itemPath string) error {
} }
// removeManagedItem removes an item from the .lnk tracking file // 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 // Get current items
items, err := l.getManagedItems() items, err := l.getManagedItems()
if err != nil { if err != nil {
return fmt.Errorf("failed to get managed items: %w", err) return fmt.Errorf("failed to get managed items: %w", err)
} }
// Get the basename for removal // Remove item using relative path
basename := filepath.Base(itemPath)
// Remove item
var newItems []string var newItems []string
for _, item := range items { for _, item := range items {
if item != basename { if item != relativePath {
newItems = append(newItems, item) newItems = append(newItems, item)
} }
} }
@@ -520,7 +616,7 @@ func (l *Lnk) removeManagedItem(itemPath string) error {
// writeManagedItems writes the list of managed items to .lnk file // writeManagedItems writes the list of managed items to .lnk file
func (l *Lnk) writeManagedItems(items []string) error { 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") content := strings.Join(items, "\n")
if len(items) > 0 { if len(items) > 0 {

754
internal/core/lnk_test.go Normal file
View File

@@ -0,0 +1,754 @@
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 preserve the directory structure
lnkDir := filepath.Join(suite.tempDir, "lnk")
// Find the .bashrc file in the repository (it should be at the relative path)
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc")
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 the repository directory preserves the structure
lnkDir := filepath.Join(suite.tempDir, "lnk")
// The directory should be at the relative path
repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir")
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 or directory not found")
// 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 repository is 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 := NewLnk(WithHost("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 := NewLnk(WithHost("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 := NewLnk(WithHost("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))
}

119
internal/fs/errors.go Normal file
View File

@@ -0,0 +1,119 @@
package fs
import "fmt"
// ANSI color codes for consistent formatting
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorBold = "\033[1m"
)
// formatError creates a consistently formatted error message with ❌ prefix
func formatError(message string, args ...interface{}) string {
return fmt.Sprintf("❌ "+message, args...)
}
// formatPath formats a file path with red color
func formatPath(path string) string {
return fmt.Sprintf("%s%s%s", colorRed, path, colorReset)
}
// formatCommand formats a command with bold styling
func formatCommand(command string) string {
return fmt.Sprintf("%s%s%s", colorBold, command, colorReset)
}
// FileNotExistsError represents an error when a file does not exist
type FileNotExistsError struct {
Path string
Err error
}
func (e *FileNotExistsError) Error() string {
return formatError("File or directory not found: %s", formatPath(e.Path))
}
func (e *FileNotExistsError) Unwrap() error {
return e.Err
}
// FileCheckError represents an error when failing to check a file
type FileCheckError struct {
Err error
}
func (e *FileCheckError) Error() string {
return formatError("Unable to access file. Please check file permissions and try again.")
}
func (e *FileCheckError) Unwrap() error {
return e.Err
}
// UnsupportedFileTypeError represents an error when a file type is not supported
type UnsupportedFileTypeError struct {
Path string
}
func (e *UnsupportedFileTypeError) Error() string {
return formatError("Cannot manage this type of file: %s\n 💡 lnk can only manage regular files and directories", formatPath(e.Path))
}
func (e *UnsupportedFileTypeError) Unwrap() error {
return nil
}
// NotManagedByLnkError represents an error when a file is not managed by lnk
type NotManagedByLnkError struct {
Path string
}
func (e *NotManagedByLnkError) Error() string {
return formatError("File is not managed by lnk: %s\n 💡 Use %s to manage this file first",
formatPath(e.Path), formatCommand("lnk add"))
}
func (e *NotManagedByLnkError) Unwrap() error {
return nil
}
// SymlinkReadError represents an error when failing to read a symlink
type SymlinkReadError struct {
Err error
}
func (e *SymlinkReadError) Error() string {
return formatError("Unable to read symlink. The file may be corrupted or have invalid permissions.")
}
func (e *SymlinkReadError) Unwrap() error {
return e.Err
}
// DirectoryCreationError represents an error when failing to create a directory
type DirectoryCreationError struct {
Operation string
Err error
}
func (e *DirectoryCreationError) Error() string {
return formatError("Failed to create directory. Please check permissions and available disk space.")
}
func (e *DirectoryCreationError) Unwrap() error {
return e.Err
}
// RelativePathCalculationError represents an error when failing to calculate relative path
type RelativePathCalculationError struct {
Err error
}
func (e *RelativePathCalculationError) Error() string {
return formatError("Unable to create symlink due to path configuration issues. Please check file locations.")
}
func (e *RelativePathCalculationError) Unwrap() error {
return e.Err
}

View File

@@ -1,7 +1,6 @@
package fs package fs
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -17,18 +16,19 @@ func New() *FileSystem {
// ValidateFileForAdd validates that a file or directory can be added to lnk // ValidateFileForAdd validates that a file or directory can be added to lnk
func (fs *FileSystem) ValidateFileForAdd(filePath string) error { func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
// Check if file exists // Check if file exists and get its info
info, err := os.Stat(filePath) info, err := os.Stat(filePath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", filePath) return &FileNotExistsError{Path: filePath, Err: err}
} }
return fmt.Errorf("failed to stat file: %w", err)
return &FileCheckError{Err: err}
} }
// Allow both regular files and directories // Allow both regular files and directories
if !info.Mode().IsRegular() && !info.IsDir() { if !info.Mode().IsRegular() && !info.IsDir() {
return fmt.Errorf("only regular files and directories are supported: %s", filePath) return &UnsupportedFileTypeError{Path: filePath}
} }
return nil return nil
@@ -36,98 +36,79 @@ func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk // ValidateSymlinkForRemove validates that a symlink can be removed from lnk
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error { func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
// Check if file exists // Check if file exists and is a symlink
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", filePath) return &FileNotExistsError{Path: filePath, Err: err}
} }
return fmt.Errorf("failed to stat file: %w", err)
return &FileCheckError{Err: err}
} }
// Check if it's a symlink
if info.Mode()&os.ModeSymlink == 0 { if info.Mode()&os.ModeSymlink == 0 {
return fmt.Errorf("file is not managed by lnk: %s", filePath) return &NotManagedByLnkError{Path: filePath}
} }
// Check if symlink points to the repository // Get symlink target and resolve to absolute path
target, err := os.Readlink(filePath) target, err := os.Readlink(filePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to read symlink: %w", err) return &SymlinkReadError{Err: err}
} }
// Convert relative path to absolute if needed
if !filepath.IsAbs(target) { if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(filePath), target) target = filepath.Join(filepath.Dir(filePath), target)
} }
// Clean the path to resolve any .. or . components // Clean paths and check if target is inside the repository
target = filepath.Clean(target) target = filepath.Clean(target)
repoPath = filepath.Clean(repoPath) repoPath = filepath.Clean(repoPath)
// Check if target is inside the repository
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath { if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
return fmt.Errorf("file is not managed by lnk: %s", filePath) return &NotManagedByLnkError{Path: filePath}
} }
return nil return nil
} }
// Move moves a file or directory from source to destination based on the file info
func (fs *FileSystem) Move(src, dst string, info os.FileInfo) error {
if info.IsDir() {
return fs.MoveDirectory(src, dst)
}
return fs.MoveFile(src, dst)
}
// MoveFile moves a file from source to destination // MoveFile moves a file from source to destination
func (fs *FileSystem) MoveFile(src, dst string) error { func (fs *FileSystem) MoveFile(src, dst string) error {
// Ensure destination directory exists // Ensure destination directory exists
dstDir := filepath.Dir(dst) if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
if err := os.MkdirAll(dstDir, 0755); err != nil { return &DirectoryCreationError{Operation: "destination directory", Err: err}
return fmt.Errorf("failed to create destination directory: %w", err)
} }
// Move the file // Move the file
if err := os.Rename(src, dst); err != nil { return os.Rename(src, dst)
return fmt.Errorf("failed to move file from %s to %s: %w", src, dst, err)
}
return nil
} }
// CreateSymlink creates a relative symlink from target to linkPath // CreateSymlink creates a relative symlink from target to linkPath
func (fs *FileSystem) CreateSymlink(target, linkPath string) error { func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
// Calculate relative path from linkPath to target // Calculate relative path from linkPath to target
linkDir := filepath.Dir(linkPath) relTarget, err := filepath.Rel(filepath.Dir(linkPath), target)
relTarget, err := filepath.Rel(linkDir, target)
if err != nil { if err != nil {
return fmt.Errorf("failed to calculate relative path: %w", err) return &RelativePathCalculationError{Err: err}
} }
// Create the symlink // Create the symlink
if err := os.Symlink(relTarget, linkPath); err != nil { return os.Symlink(relTarget, linkPath)
return fmt.Errorf("failed to create symlink: %w", err)
}
return nil
} }
// MoveDirectory moves a directory from source to destination recursively // MoveDirectory moves a directory from source to destination recursively
func (fs *FileSystem) MoveDirectory(src, dst string) error { func (fs *FileSystem) MoveDirectory(src, dst string) error {
// Check if source is a directory
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("failed to stat source: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("source is not a directory: %s", src)
}
// Ensure destination parent directory exists // Ensure destination parent directory exists
dstParent := filepath.Dir(dst) if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
if err := os.MkdirAll(dstParent, 0755); err != nil { return &DirectoryCreationError{Operation: "destination parent directory", Err: err}
return fmt.Errorf("failed to create destination parent directory: %w", err)
} }
// Use os.Rename which works for directories // Move the directory
if err := os.Rename(src, dst); err != nil { return os.Rename(src, dst)
return fmt.Errorf("failed to move directory from %s to %s: %w", src, dst, err)
}
return nil
} }

218
internal/git/errors.go Normal file
View File

@@ -0,0 +1,218 @@
package git
import "fmt"
// ANSI color codes for consistent formatting
const (
colorReset = "\033[0m"
colorBold = "\033[1m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
)
// formatError creates a consistently formatted error message with ❌ prefix
func formatError(message string, args ...interface{}) string {
return fmt.Sprintf("❌ "+message, args...)
}
// formatURL formats a URL with styling
func formatURL(url string) string {
return fmt.Sprintf("%s%s%s", colorBold, url, colorReset)
}
// formatRemote formats a remote name with styling
func formatRemote(remote string) string {
return fmt.Sprintf("%s%s%s", colorGreen, remote, colorReset)
}
// GitInitError represents an error during git initialization
type GitInitError struct {
Output string
Err error
}
func (e *GitInitError) Error() string {
return formatError("Failed to initialize git repository. Please ensure git is installed and try again.")
}
func (e *GitInitError) Unwrap() error {
return e.Err
}
// BranchSetupError represents an error setting up the default branch
type BranchSetupError struct {
Err error
}
func (e *BranchSetupError) Error() string {
return formatError("Failed to set up the default branch. Please check your git installation.")
}
func (e *BranchSetupError) Unwrap() error {
return e.Err
}
// RemoteExistsError represents an error when a remote already exists with different URL
type RemoteExistsError struct {
Remote string
ExistingURL string
NewURL string
}
func (e *RemoteExistsError) Error() string {
return formatError("Remote %s is already configured with a different repository (%s). Cannot add %s.",
formatRemote(e.Remote), formatURL(e.ExistingURL), formatURL(e.NewURL))
}
func (e *RemoteExistsError) Unwrap() error {
return nil
}
// GitCommandError represents a generic git command execution error
type GitCommandError struct {
Command string
Output string
Err error
}
func (e *GitCommandError) Error() string {
// Provide user-friendly messages based on common command types
switch e.Command {
case "add":
return formatError("Failed to stage files for commit. Please check file permissions and try again.")
case "commit":
return formatError("Failed to create commit. Please ensure you have staged changes and try again.")
case "remote add":
return formatError("Failed to add remote repository. Please check the repository URL and try again.")
case "rm":
return formatError("Failed to remove file from git tracking. Please check if the file exists and try again.")
case "log":
return formatError("Failed to retrieve commit history.")
case "remote":
return formatError("Failed to retrieve remote repository information.")
case "clone":
return formatError("Failed to clone repository. Please check the repository URL and your network connection.")
default:
return formatError("Git operation failed. Please check your repository state and try again.")
}
}
func (e *GitCommandError) Unwrap() error {
return e.Err
}
// NoRemoteError represents an error when no remote is configured
type NoRemoteError struct{}
func (e *NoRemoteError) Error() string {
return formatError("No remote repository is configured. Please add a remote repository first.")
}
func (e *NoRemoteError) Unwrap() error {
return nil
}
// RemoteNotFoundError represents an error when a specific remote is not found
type RemoteNotFoundError struct {
Remote string
Err error
}
func (e *RemoteNotFoundError) Error() string {
return formatError("Remote repository %s is not configured.", formatRemote(e.Remote))
}
func (e *RemoteNotFoundError) Unwrap() error {
return e.Err
}
// GitConfigError represents an error with git configuration
type GitConfigError struct {
Setting string
Err error
}
func (e *GitConfigError) Error() string {
return formatError("Failed to configure git settings. Please check your git installation.")
}
func (e *GitConfigError) Unwrap() error {
return e.Err
}
// UncommittedChangesError represents an error checking for uncommitted changes
type UncommittedChangesError struct {
Err error
}
func (e *UncommittedChangesError) Error() string {
return formatError("Failed to check repository status. Please verify your git repository is valid.")
}
func (e *UncommittedChangesError) Unwrap() error {
return e.Err
}
// DirectoryRemovalError represents an error removing a directory
type DirectoryRemovalError struct {
Path string
Err error
}
func (e *DirectoryRemovalError) Error() string {
return formatError("Failed to prepare directory for operation. Please check directory permissions.")
}
func (e *DirectoryRemovalError) Unwrap() error {
return e.Err
}
// DirectoryCreationError represents an error creating a directory
type DirectoryCreationError struct {
Path string
Err error
}
func (e *DirectoryCreationError) Error() string {
return formatError("Failed to create directory. Please check permissions and available disk space.")
}
func (e *DirectoryCreationError) Unwrap() error {
return e.Err
}
// PushError represents an error during git push operation
type PushError struct {
Reason string
Output string
Err error
}
func (e *PushError) Error() string {
if e.Reason != "" {
return formatError("Cannot push changes: %s", e.Reason)
}
return formatError("Failed to push changes to remote repository. Please check your network connection and repository permissions.")
}
func (e *PushError) Unwrap() error {
return e.Err
}
// PullError represents an error during git pull operation
type PullError struct {
Reason string
Output string
Err error
}
func (e *PullError) Error() string {
if e.Reason != "" {
return formatError("Cannot pull changes: %s", e.Reason)
}
return formatError("Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts.")
}
func (e *PullError) Unwrap() error {
return e.Err
}

View File

@@ -34,7 +34,7 @@ func (g *Git) Init() error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output)) return &GitInitError{Output: string(output), Err: err}
} }
// Set the default branch to main // Set the default branch to main
@@ -42,7 +42,7 @@ func (g *Git) Init() error {
cmd.Dir = g.repoPath cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set default branch to main: %w", err) return &BranchSetupError{Err: err}
} }
} }
@@ -60,7 +60,7 @@ func (g *Git) AddRemote(name, url string) error {
return nil return nil
} }
// Different URL, error // Different URL, error
return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url) return &RemoteExistsError{Remote: name, ExistingURL: existingURL, NewURL: url}
} }
// Remote doesn't exist, add it // Remote doesn't exist, add it
@@ -69,7 +69,7 @@ func (g *Git) AddRemote(name, url string) error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output)) return &GitCommandError{Command: "remote add", Output: string(output), Err: err}
} }
return nil return nil
@@ -164,7 +164,7 @@ func (g *Git) Add(filename string) error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output)) return &GitCommandError{Command: "add", Output: string(output), Err: err}
} }
return nil return nil
@@ -189,7 +189,7 @@ func (g *Git) Remove(filename string) error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output)) return &GitCommandError{Command: "rm", Output: string(output), Err: err}
} }
return nil return nil
@@ -207,7 +207,7 @@ func (g *Git) Commit(message string) error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output)) return &GitCommandError{Command: "commit", Output: string(output), Err: err}
} }
return nil return nil
@@ -223,7 +223,7 @@ func (g *Git) ensureGitConfig() error {
cmd = exec.Command("git", "config", "user.name", "Lnk User") cmd = exec.Command("git", "config", "user.name", "Lnk User")
cmd.Dir = g.repoPath cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set git user.name: %w", err) return &GitConfigError{Setting: "user.name", Err: err}
} }
} }
@@ -235,7 +235,7 @@ func (g *Git) ensureGitConfig() error {
cmd = exec.Command("git", "config", "user.email", "lnk@localhost") cmd = exec.Command("git", "config", "user.email", "lnk@localhost")
cmd.Dir = g.repoPath cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set git user.email: %w", err) return &GitConfigError{Setting: "user.email", Err: err}
} }
} }
@@ -260,7 +260,7 @@ func (g *Git) GetCommits() ([]string, error) {
if strings.Contains(outputStr, "does not have any commits yet") { if strings.Contains(outputStr, "does not have any commits yet") {
return []string{}, nil return []string{}, nil
} }
return nil, fmt.Errorf("git log failed: %w", err) return nil, &GitCommandError{Command: "log", Output: outputStr, Err: err}
} }
commits := strings.Split(strings.TrimSpace(string(output)), "\n") commits := strings.Split(strings.TrimSpace(string(output)), "\n")
@@ -282,18 +282,18 @@ func (g *Git) GetRemoteInfo() (string, error) {
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to list remotes: %w", err) return "", &GitCommandError{Command: "remote", Output: string(output), Err: err}
} }
remotes := strings.Split(strings.TrimSpace(string(output)), "\n") remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(remotes) == 0 || remotes[0] == "" { if len(remotes) == 0 || remotes[0] == "" {
return "", fmt.Errorf("no remote configured") return "", &NoRemoteError{}
} }
// Use the first remote // Use the first remote
url, err = g.getRemoteURL(remotes[0]) url, err = g.getRemoteURL(remotes[0])
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get remote URL: %w", err) return "", &RemoteNotFoundError{Remote: remotes[0], Err: err}
} }
} }
@@ -305,6 +305,7 @@ type StatusInfo struct {
Ahead int Ahead int
Behind int Behind int
Remote string Remote string
Dirty bool
} }
// GetStatus returns the repository status relative to remote // GetStatus returns the repository status relative to remote
@@ -315,6 +316,12 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
return nil, err return nil, err
} }
// Check for uncommitted changes
dirty, err := g.HasChanges()
if err != nil {
return nil, &UncommittedChangesError{Err: err}
}
// Get the remote tracking branch // Get the remote tracking branch
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
cmd.Dir = g.repoPath cmd.Dir = g.repoPath
@@ -327,6 +334,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
Ahead: g.getAheadCount(remoteBranch), Ahead: g.getAheadCount(remoteBranch),
Behind: 0, // Can't be behind if no upstream Behind: 0, // Can't be behind if no upstream
Remote: remoteBranch, Remote: remoteBranch,
Dirty: dirty,
}, nil }, nil
} }
@@ -336,6 +344,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
Ahead: g.getAheadCount(remoteBranch), Ahead: g.getAheadCount(remoteBranch),
Behind: g.getBehindCount(remoteBranch), Behind: g.getBehindCount(remoteBranch),
Remote: remoteBranch, Remote: remoteBranch,
Dirty: dirty,
}, nil }, nil
} }
@@ -401,7 +410,7 @@ func (g *Git) HasChanges() (bool, error) {
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return false, fmt.Errorf("git status failed: %w", err) return false, &GitCommandError{Command: "status", Output: string(output), Err: err}
} }
return len(strings.TrimSpace(string(output))) > 0, nil return len(strings.TrimSpace(string(output))) > 0, nil
@@ -414,7 +423,7 @@ func (g *Git) AddAll() error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output)) return &GitCommandError{Command: "add", Output: string(output), Err: err}
} }
return nil return nil
@@ -425,7 +434,7 @@ func (g *Git) Push() error {
// First ensure we have a remote configured // First ensure we have a remote configured
_, err := g.GetRemoteInfo() _, err := g.GetRemoteInfo()
if err != nil { if err != nil {
return fmt.Errorf("cannot push: %w", err) return &PushError{Reason: err.Error(), Err: err}
} }
cmd := exec.Command("git", "push", "-u", "origin", "main") cmd := exec.Command("git", "push", "-u", "origin", "main")
@@ -433,7 +442,7 @@ func (g *Git) Push() error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output)) return &PushError{Output: string(output), Err: err}
} }
return nil return nil
@@ -444,7 +453,7 @@ func (g *Git) Pull() error {
// First ensure we have a remote configured // First ensure we have a remote configured
_, err := g.GetRemoteInfo() _, err := g.GetRemoteInfo()
if err != nil { if err != nil {
return fmt.Errorf("cannot pull: %w", err) return &PullError{Reason: err.Error(), Err: err}
} }
cmd := exec.Command("git", "pull", "origin", "main") cmd := exec.Command("git", "pull", "origin", "main")
@@ -452,7 +461,7 @@ func (g *Git) Pull() error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output)) return &PullError{Output: string(output), Err: err}
} }
return nil return nil
@@ -462,20 +471,20 @@ func (g *Git) Pull() error {
func (g *Git) Clone(url string) error { func (g *Git) Clone(url string) error {
// Remove the directory if it exists to ensure clean clone // Remove the directory if it exists to ensure clean clone
if err := os.RemoveAll(g.repoPath); err != nil { if err := os.RemoveAll(g.repoPath); err != nil {
return fmt.Errorf("failed to remove existing directory: %w", err) return &DirectoryRemovalError{Path: g.repoPath, Err: err}
} }
// Create parent directory // Create parent directory
parentDir := filepath.Dir(g.repoPath) parentDir := filepath.Dir(g.repoPath)
if err := os.MkdirAll(parentDir, 0755); err != nil { if err := os.MkdirAll(parentDir, 0755); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err) return &DirectoryCreationError{Path: parentDir, Err: err}
} }
// Clone the repository // Clone the repository
cmd := exec.Command("git", "clone", url, g.repoPath) cmd := exec.Command("git", "clone", url, g.repoPath)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output)) return &GitCommandError{Command: "clone", Output: string(output), Err: err}
} }
// Set up upstream tracking for main branch // Set up upstream tracking for main branch

View File

@@ -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(), "appears to contain 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))
}