22 Commits

Author SHA1 Message Date
Yar Kravtsov
c718055f26 feat(core): refactor to clean architecture and improve error handling 2025-06-01 11:20:08 +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
34 changed files with 6567 additions and 2087 deletions

View File

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

View File

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

View File

@@ -60,19 +60,19 @@ build:
## test: Run tests
test:
@echo "$(BLUE)Running tests...$(NC)"
@go test ./test
@go test ./...
@echo "$(GREEN)✓ Tests passed$(NC)"
## test-v: Run tests with verbose output
test-v:
@echo "$(BLUE)Running tests (verbose)...$(NC)"
@go test -v ./test
@go test -v ./...
## test-cover: Run tests with coverage
test-cover:
@echo "$(BLUE)Running tests with coverage...$(NC)"
@go test -v -cover ./test
@go test -coverprofile=coverage.out ./test
@go test -v -cover ./...
@go test -coverprofile=coverage.out ./
@go tool cover -html=coverage.out -o coverage.html
@echo "$(GREEN)✓ Coverage report generated: coverage.html$(NC)"
@@ -183,4 +183,4 @@ goreleaser-snapshot: goreleaser-check
@echo "$(GREEN)✓ Snapshot release built in dist/$(NC)"
# Default target
all: check build
all: check build

159
README.md
View File

@@ -2,11 +2,12 @@
**Git-native dotfiles management that doesn't suck.**
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. That's it.
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
```bash
lnk init
lnk add ~/.vimrc ~/.bashrc
lnk add ~/.vimrc ~/.bashrc # Common config
lnk add --host work ~/.ssh/config # Host-specific config
lnk push "setup"
```
@@ -15,15 +16,21 @@ lnk push "setup"
```bash
# Quick install (recommended)
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```
```bash
# Homebrew (macOS/Linux)
brew tap yarlson/lnk
brew install lnk
```
```bash
# Manual download
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
```
```bash
# From source
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
```bash
# Add files/directories
# Add files/directories (common config)
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
# Add host-specific files
lnk add --host laptop ~/.ssh/config
lnk add --host work ~/.gitconfig
# List managed files
lnk list # Common config only
lnk list --host laptop # Laptop-specific config
lnk list --all # All configurations
# Check status
lnk status
# Sync changes
lnk push "updated vim config"
lnk pull
lnk pull # Pull common config
lnk pull --host laptop # Pull laptop-specific config
```
## How it works
```
Common files:
Before: ~/.vimrc (file)
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
Host-specific files:
Before: ~/.ssh/config (file)
After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
```
Your files live in `~/.config/lnk` (a Git repo). Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
## Multihost Support
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
### File Organization
```
~/.config/lnk/
├── .lnk # Tracks common files
├── .lnk.laptop # Tracks laptop-specific files
├── .lnk.work # Tracks work-specific files
├── .vimrc # Common file
├── .gitconfig # Common file
├── laptop.lnk/ # Laptop-specific storage
│ ├── .ssh/
│ │ └── config
│ └── .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?
@@ -78,7 +143,13 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
```bash
lnk init -r git@github.com:you/dotfiles.git
# Add common config (shared across all machines)
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
# Add host-specific config
lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf
lnk push "initial setup"
```
@@ -86,57 +157,75 @@ lnk push "initial setup"
```bash
lnk init -r git@github.com:you/dotfiles.git
lnk pull # auto-creates symlinks
# Pull common config
lnk pull
# Pull host-specific config (if it exists)
lnk pull --host $(hostname)
```
### Daily edits
```bash
vim ~/.vimrc # edit normally
lnk status # check what changed
lnk push "new plugins" # commit & push
vim ~/.vimrc # edit normally
lnk list # see common config
lnk list --host $(hostname) # see host-specific config
lnk list --all # see everything
lnk status # check what changed
lnk push "new plugins" # commit & push
```
### Multi-machine workflow
```bash
# On your laptop
lnk add --host laptop ~/.ssh/config
lnk add ~/.vimrc # Common config
lnk push "laptop ssh config"
# On your work machine
lnk pull # Get common config
lnk add --host work ~/.gitconfig
lnk push "work git config"
# Back on laptop
lnk pull # Get updates (work config won't affect laptop)
```
## Commands
- `lnk init [-r remote]` - Create repo
- `lnk add <files>` - Move files to repo, create symlinks
- `lnk rm <files>` - Move files back, remove symlinks
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
- `lnk list [--host HOST] [--all]` - List files managed by lnk
- `lnk status` - Git status + sync info
- `lnk push [msg]` - Stage all, commit, push
- `lnk pull` - Pull + restore missing symlinks
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
### Command Options
- `--host HOST` - Manage files for specific host (default: common configuration)
- `--all` - Show all configurations (common + all hosts) when listing
- `-r, --remote URL` - Clone from remote URL when initializing
## Technical bits
- **Single binary** (~8MB, no deps)
- **Atomic operations** (rollback on failure)
- **Relative symlinks** (portable)
- **XDG compliant** (`~/.config/lnk`)
- **20 integration tests**
- **Multihost support** (common + host-specific configs)
- **Git-native** (standard Git repo, no special formats)
## Alternatives
| Tool | Complexity | Why choose it |
| ------- | ---------- | ------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native |
| chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only |
## FAQ
**Q: What if I already have dotfiles in Git?**
A: `git clone your-repo ~/.config/lnk && lnk add ~/.vimrc` (adopts existing files)
**Q: How do I handle machine-specific configs?**
A: Git branches, or just don't manage machine-specific files with lnk
**Q: Windows support?**
A: Symlinks work on Windows 10+, but untested
**Q: Production ready?**
A: I use it daily. It won't break your files. API might change (pre-1.0).
| Tool | Complexity | Why choose it |
| ------- | ---------- | -------------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native, multihost |
| chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only |
## Contributing

View File

@@ -64,11 +64,13 @@ git push origin v1.0.0
## What GoReleaser Does
1. **Builds binaries** for multiple platforms:
- Linux (amd64, arm64)
- macOS (amd64, arm64)
- Windows (amd64)
2. **Creates archives** with consistent naming:
- `lnk_Linux_x86_64.tar.gz`
- `lnk_Darwin_arm64.tar.gz`
- etc.
@@ -76,6 +78,7 @@ git push origin v1.0.0
3. **Generates checksums** for verification
4. **Creates GitHub release** with:
- Automatic changelog from conventional commits
- Installation instructions
- Download links for all platforms
@@ -112,17 +115,20 @@ ls -la dist/
After a release is published, users can install lnk using multiple methods:
### 1. Shell Script (Recommended)
```bash
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```
### 2. Homebrew (macOS/Linux)
```bash
brew tap yarlson/lnk
brew install lnk
```
### 3. Manual Download
```bash
# Download from GitHub releases
wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz
@@ -181,4 +187,4 @@ The Homebrew formula is automatically maintained in the [homebrew-lnk](https://g
1. Check that the GITHUB_TOKEN has access to the homebrew-lnk repository
2. Verify the repository name and owner in `.goreleaser.yml`
3. Check the release workflow logs for Homebrew-related errors
4. Ensure the homebrew-lnk repository exists and is accessible
4. Ensure the homebrew-lnk repository exists and is accessible

View File

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

View File

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

195
cmd/list.go Normal file
View File

@@ -0,0 +1,195 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/service"
)
func newListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "📋 List files managed by lnk",
Long: "Display all files and directories currently managed by lnk.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host")
all, _ := cmd.Flags().GetBool("all")
if host != "" {
// Show specific host configuration
return listHostConfig(cmd, host)
}
if all {
// Show all configurations (common + all hosts)
return listAllConfigs(cmd)
}
// Default: show common configuration
return listCommonConfig(cmd)
},
}
cmd.Flags().StringP("host", "H", "", "List files for specific host")
cmd.Flags().BoolP("all", "a", false, "List files for all hosts and common configuration")
return cmd
}
func listCommonConfig(cmd *cobra.Command) error {
ctx := context.Background()
lnkService, err := service.New()
if err != nil {
return wrapServiceError("initialize lnk service", err)
}
managedFiles, err := lnkService.ListManagedFiles(ctx, "")
if err != nil {
return formatError(err)
}
if len(managedFiles) == 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(managedFiles))
if len(managedFiles) > 1 {
printf(cmd, "s")
}
printf(cmd, "\033[0m):\n\n")
for _, file := range managedFiles {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
}
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
return nil
}
func listHostConfig(cmd *cobra.Command, host string) error {
ctx := context.Background()
lnkService, err := service.New()
if err != nil {
return wrapServiceError("initialize lnk service", err)
}
managedFiles, err := lnkService.ListManagedFiles(ctx, host)
if err != nil {
return formatError(err)
}
if len(managedFiles) == 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(managedFiles))
if len(managedFiles) > 1 {
printf(cmd, "s")
}
printf(cmd, "\033[0m):\n\n")
for _, file := range managedFiles {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
}
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
return nil
}
func listAllConfigs(cmd *cobra.Command) error {
ctx := context.Background()
lnkService, err := service.New()
if err != nil {
return wrapServiceError("initialize lnk service", err)
}
// List common configuration
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
commonFiles, err := lnkService.ListManagedFiles(ctx, "")
if err != nil {
return formatError(err)
}
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonFiles))
if len(commonFiles) > 1 {
printf(cmd, "s")
}
printf(cmd, "\033[0m):\n")
if len(commonFiles) == 0 {
printf(cmd, " \033[90m(no files)\033[0m\n")
} else {
for _, file := range commonFiles {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
}
}
// Find all host-specific configurations
hosts, err := findHostConfigs(lnkService)
if err != nil {
return formatError(err)
}
for _, host := range hosts {
printf(cmd, "\n🖥 \033[1mHost: %s\033[0m", host)
hostFiles, err := lnkService.ListManagedFiles(ctx, host)
if err != nil {
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
continue
}
printf(cmd, " (\033[36m%d item", len(hostFiles))
if len(hostFiles) > 1 {
printf(cmd, "s")
}
printf(cmd, "\033[0m):\n")
if len(hostFiles) == 0 {
printf(cmd, " \033[90m(no files)\033[0m\n")
} else {
for _, file := range hostFiles {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
}
}
}
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
return nil
}
func findHostConfigs(service *service.Service) ([]string, error) {
repoPath := service.GetRepoPath()
// Check if repo exists
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
return []string{}, nil
}
entries, err := os.ReadDir(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to read repository directory: %w", err)
}
var hosts []string
for _, entry := range entries {
name := entry.Name()
// Look for .lnk.<hostname> files
if strings.HasPrefix(name, ".lnk.") && name != ".lnk" {
host := strings.TrimPrefix(name, ".lnk.")
hosts = append(hosts, host)
}
}
return hosts, nil
}

View File

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

View File

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

View File

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

View File

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

799
cmd/root_test.go Normal file
View File

@@ -0,0 +1,799 @@
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 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 does not exist",
},
{
name: "status without init",
args: []string{"status"},
wantErr: true,
errContains: "Lnk repository not initialized",
},
{
name: "help command",
args: []string{"--help"},
wantErr: false,
outContains: "Lnk - Git-native dotfiles management",
},
{
name: "version command",
args: []string{"--version"},
wantErr: false,
outContains: "lnk version",
},
{
name: "init help",
args: []string{"init", "--help"},
wantErr: false,
outContains: "Creates the lnk directory",
},
{
name: "add help",
args: []string{"add", "--help"},
wantErr: false,
outContains: "Moves a file to the lnk repository",
},
{
name: "list help",
args: []string{"list", "--help"},
wantErr: false,
outContains: "Display all files and directories",
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
suite.stdout.Reset()
suite.stderr.Reset()
err := suite.runCommand(tt.args...)
if tt.wantErr {
suite.Error(err, "Expected error for %s", tt.name)
if tt.errContains != "" {
suite.Contains(err.Error(), tt.errContains, "Wrong error message for %s", tt.name)
}
} else {
suite.NoError(err, "Unexpected error for %s", tt.name)
}
if tt.outContains != "" {
output := suite.stdout.String()
suite.Contains(output, tt.outContains, "Expected output not found for %s", tt.name)
}
})
}
}
func (suite *CLITestSuite) TestCompleteWorkflow() {
// Test realistic user workflow
steps := []struct {
name string
args []string
setup func()
verify func(output string)
}{
{
name: "initialize repository",
args: []string{"init"},
verify: func(output string) {
suite.Contains(output, "Initialized empty lnk repository")
},
},
{
name: "add config file",
args: []string{"add", 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(), "not a symlink")
}
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) TestRemoveDirectory() {
// Initialize repository
_ = suite.runCommand("init")
suite.stdout.Reset()
// Create a directory with files
testDir := filepath.Join(suite.tempDir, ".config", "aerospace")
_ = os.MkdirAll(testDir, 0755)
configFile := filepath.Join(testDir, "aerospace.toml")
_ = os.WriteFile(configFile, []byte("# Aerospace config"), 0644)
// Add the directory
err := suite.runCommand("add", testDir)
suite.NoError(err)
suite.stdout.Reset()
// Verify directory is now a symlink
info, err := os.Lstat(testDir)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
// Remove the directory
err = suite.runCommand("rm", testDir)
suite.NoError(err, "Should be able to remove directory without error")
// Check output
output := suite.stdout.String()
suite.Contains(output, "Removed aerospace from lnk")
suite.Contains(output, "Original file restored")
// Verify directory is no longer a symlink
info, err = os.Lstat(testDir)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
// Verify directory content is preserved
content, err := os.ReadFile(configFile)
suite.NoError(err)
suite.Equal("# Aerospace config", string(content))
// Verify directory is removed from tracking
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal("", string(lnkContent), ".lnk file should be empty after removing directory")
}
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(), "not a symlink")
// 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,110 @@
package cmd
import (
"fmt"
"context"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
"github.com/yarlson/lnk/internal/models"
"github.com/yarlson/lnk/internal/service"
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
status, err := lnk.Status()
if err != nil {
return fmt.Errorf("failed to get status: %w", err)
}
func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "📊 Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnkService, err := service.New()
if err != nil {
return wrapServiceError("initialize lnk service", err)
}
if status.Ahead == 0 && status.Behind == 0 {
fmt.Println("Repository is up to date with remote")
ctx := context.Background()
status, err := lnkService.GetStatus(ctx)
if err != nil {
return formatError(err)
}
if status.Dirty {
displayDirtyStatus(cmd, status)
return nil
}
if status.Ahead == 0 && status.Behind == 0 {
displayUpToDateStatus(cmd, status)
return nil
}
displaySyncStatus(cmd, status)
return nil
},
}
}
func displayDirtyStatus(cmd *cobra.Command, status *models.SyncStatus) {
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status))
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 *models.SyncStatus) {
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", getRemoteDisplay(status))
}
func displaySyncStatus(cmd *cobra.Command, status *models.SyncStatus) {
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status))
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 *models.SyncStatus, 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 {
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)
}
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
}
}
return nil
},
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 init() {
rootCmd.AddCommand(statusCmd)
func getCommitText(count int) string {
if count == 1 {
return "commit"
}
return "commits"
}
func getRemoteDisplay(status *models.SyncStatus) string {
if status.HasRemote && status.RemoteBranch != "" {
return status.RemoteBranch
}
if status.HasRemote && status.RemoteURL != "" {
return status.RemoteURL
}
return "no remote configured"
}

175
cmd/utils.go Normal file
View File

@@ -0,0 +1,175 @@
package cmd
import (
stderrors "errors"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/errors"
)
// 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...)
}
// formatError provides user-friendly error formatting while preserving specific error messages for tests
func formatError(err error) error {
if err == nil {
return nil
}
// Handle typed LnkError first
var lnkErr *errors.LnkError
if stderrors.As(err, &lnkErr) {
return formatLnkError(lnkErr)
}
// Handle other error patterns with improved messages
errMsg := err.Error()
// Git-related errors
if strings.Contains(errMsg, "git") {
if strings.Contains(errMsg, "no remote configured") {
return fmt.Errorf("🚫 no remote configured\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
}
if strings.Contains(errMsg, "authentication") || strings.Contains(errMsg, "permission denied") {
return fmt.Errorf("🔐 \033[31mGit authentication failed\033[0m\n 💡 Check your SSH keys or credentials: \033[36mhttps://docs.github.com/en/authentication\033[0m")
}
if strings.Contains(errMsg, "not found") && strings.Contains(errMsg, "remote") {
return fmt.Errorf("🌐 \033[31mRemote repository not found\033[0m\n 💡 Verify the repository URL is correct and you have access")
}
}
// Service initialization errors
if strings.Contains(errMsg, "failed to initialize lnk service") {
return fmt.Errorf("⚠️ \033[31mFailed to initialize lnk\033[0m\n 💡 This is likely a system configuration issue. Please check permissions and try again.")
}
// Return original error for unhandled cases to maintain test compatibility
return err
}
// formatLnkError formats typed LnkError instances with user-friendly messages
func formatLnkError(lnkErr *errors.LnkError) error {
switch lnkErr.Code {
case errors.ErrorCodeFileNotFound:
// Preserve "File does not exist" for test compatibility but add consistent colors
if path, ok := lnkErr.Context["path"].(string); ok {
return fmt.Errorf("❌ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", path)
}
return fmt.Errorf("❌ \033[31mFile does not exist\033[0m\n 💡 Check the file path and try again")
case errors.ErrorCodeRepoNotInitialized:
// Preserve "Lnk repository not initialized" for test compatibility but add consistent colors
return fmt.Errorf("📦 \033[31mLnk repository not initialized\033[0m\n 💡 Run \033[1mlnk init\033[0m to get started")
case errors.ErrorCodeNotSymlink:
// Preserve "not a symlink" for test compatibility but add consistent colors
return fmt.Errorf("🔗 \033[31mnot a symlink\033[0m\n 💡 Only files managed by lnk can be removed. Use \033[1mlnk list\033[0m to see managed files")
case errors.ErrorCodeFileAlreadyManaged:
if path, ok := lnkErr.Context["path"].(string); ok {
return fmt.Errorf("✨ \033[33mFile is already managed by lnk:\033[0m \033[36m%s\033[0m\n 💡 Use \033[1mlnk list\033[0m to see all managed files", path)
}
return fmt.Errorf("✨ \033[33mFile is already managed by lnk\033[0m\n 💡 Use \033[1mlnk list\033[0m to see all managed files")
case errors.ErrorCodeNoRemoteConfigured:
// Preserve "no remote configured" for test compatibility but add consistent colors
return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
case errors.ErrorCodePermissionDenied:
if path, ok := lnkErr.Context["path"].(string); ok {
return fmt.Errorf("🔒 \033[31mPermission denied:\033[0m \033[36m%s\033[0m\n 💡 Check file permissions or run with appropriate privileges", path)
}
return fmt.Errorf("🔒 \033[31mPermission denied\033[0m\n 💡 Check file permissions or run with appropriate privileges")
case errors.ErrorCodeGitOperation:
// Check if this is a "no remote configured" case by examining the underlying error first
if lnkErr.Cause != nil && strings.Contains(lnkErr.Cause.Error(), "no remote configured") {
return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
}
operation := lnkErr.Context["operation"]
if op, ok := operation.(string); ok {
switch op {
case "get_status", "status":
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
case "push_to_remote", "push":
return fmt.Errorf("🚀 \033[31mGit operation failed\033[0m\n 💡 Check your internet connection and Git credentials\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
case "pull_from_remote", "pull":
return fmt.Errorf("⬇️ \033[31mGit operation failed\033[0m\n 💡 Check your internet connection and resolve any conflicts\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
case "clone_repository", "clone":
return fmt.Errorf("📥 \033[31mGit operation failed\033[0m\n 💡 Check the repository URL and your access permissions\n 💡 Ensure you have the correct SSH keys or credentials")
case "commit_changes", "commit":
return fmt.Errorf("💾 \033[31mGit operation failed\033[0m\n 💡 Check if you have Git user.name and user.email configured\n 💡 Run \033[1mgit config --global user.name \"Your Name\"\033[0m")
default:
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
}
}
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
case errors.ErrorCodeFileSystemOperation:
operation := lnkErr.Context["operation"]
path := lnkErr.Context["path"]
// Determine user-friendly message based on operation and underlying cause
if op, ok := operation.(string); ok {
switch op {
case "stat_symlink", "check_file_exists":
// Use consistent "File does not exist" messaging
if pathStr, pathOk := path.(string); pathOk {
return fmt.Errorf("❌ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", pathStr)
}
return fmt.Errorf("❌ \033[31mFile does not exist\033[0m\n 💡 Check the file path and try again")
case "move_file":
return fmt.Errorf("📁 \033[31mFile operation failed\033[0m\n 💡 Check file permissions and available disk space")
case "create_symlink":
return fmt.Errorf("🔗 \033[31mFile operation failed\033[0m\n 💡 Check directory permissions and ensure target file exists")
case "remove_symlink", "remove_file":
return fmt.Errorf("🗑️ \033[31mFile operation failed\033[0m\n 💡 Check file permissions and ensure file exists")
case "read_symlink":
return fmt.Errorf("🔗 \033[31mFile operation failed\033[0m\n 💡 The symlink may be broken or you don't have permission to read it")
case "resolve_path", "get_relative_path":
if pathStr, pathOk := path.(string); pathOk {
return fmt.Errorf("📂 \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", pathStr)
}
return fmt.Errorf("📂 \033[31mInvalid file path\033[0m\n 💡 Check the file path and try again")
case "create_dest_dir", "create_repo_dir":
return fmt.Errorf("📁 \033[31mFile operation failed\033[0m\n 💡 Check permissions and available disk space")
default:
// Don't expose cryptic operation names - give generic but helpful message
return fmt.Errorf("💽 \033[31mFile operation failed\033[0m\n 💡 Check file permissions, paths, and available disk space")
}
}
return fmt.Errorf("💽 \033[31mFile operation failed\033[0m\n 💡 Check file permissions and available disk space")
case errors.ErrorCodeInvalidPath:
if path, ok := lnkErr.Context["path"].(string); ok {
return fmt.Errorf("📂 \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", path)
}
return fmt.Errorf("📂 \033[31mInvalid file path\033[0m\n 💡 Check the file path and try again")
default:
// For unknown LnkError types, preserve original message but add context
return fmt.Errorf("⚠️ \033[31m%s\033[0m", lnkErr.Error())
}
}
// wrapServiceError wraps service errors with consistent messaging while preserving specific errors for tests
func wrapServiceError(operation string, err error) error {
if err == nil {
return nil
}
// For typed errors, format them nicely
var lnkErr *errors.LnkError
if stderrors.As(err, &lnkErr) {
return formatLnkError(lnkErr)
}
// For other errors, provide operation context but preserve original message for tests
return fmt.Errorf("failed to %s: %w", operation, err)
}

View File

@@ -17,6 +17,9 @@ REPO="yarlson/lnk"
INSTALL_DIR="/usr/local/bin"
BINARY_NAME="lnk"
# Fallback version if redirect fails
FALLBACK_VERSION="v0.0.2"
# Detect OS and architecture
detect_platform() {
local os arch
@@ -45,11 +48,44 @@ detect_platform() {
echo "${os}_${arch}"
}
# Get the latest release version
# Get latest version by following redirect
get_latest_version() {
curl -s "https://api.github.com/repos/${REPO}/releases/latest" | \
grep '"tag_name":' | \
sed -E 's/.*"([^"]+)".*/\1/'
echo -e "${BLUE}Getting latest release version...${NC}" >&2
# 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
@@ -59,14 +95,9 @@ install_lnk() {
echo -e "${BLUE}🔗 Installing lnk...${NC}"
platform=$(detect_platform)
version=$(get_latest_version)
version=$(get_version "$1")
if [ -z "$version" ]; then
echo -e "${RED}Error: Failed to get latest version${NC}"
exit 1
fi
echo -e "${BLUE}Latest version: ${version}${NC}"
echo -e "${BLUE}Version: ${version}${NC}"
echo -e "${BLUE}Platform: ${platform}${NC}"
# Download URL
@@ -82,6 +113,16 @@ install_lnk() {
# Download the binary
if ! curl -sL "$url" -o "$filename"; then
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
fi
@@ -107,20 +148,33 @@ install_lnk() {
echo -e "${GREEN}✅ lnk installed successfully!${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
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo "Lnk installer script"
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 "This script will:"
echo " 1. Detect your OS and architecture"
echo " 2. Download the latest lnk release"
echo " 3. Install it to /usr/local/bin (requires sudo)"
echo " 2. Auto-detect the latest release by following GitHub redirects"
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
fi
# Run the installer
install_lnk
install_lnk "$1"

232
internal/config/config.go Normal file
View File

@@ -0,0 +1,232 @@
package config
import (
"context"
"os"
"sort"
"strings"
"time"
"github.com/yarlson/lnk/internal/errors"
"github.com/yarlson/lnk/internal/fs"
"github.com/yarlson/lnk/internal/models"
"github.com/yarlson/lnk/internal/pathresolver"
)
// Config implements the service.ConfigManager interface
type Config struct {
fileManager *fs.FileManager
pathResolver *pathresolver.Resolver
}
// New creates a new ConfigManager instance
func New(fileManager *fs.FileManager, pathResolver *pathresolver.Resolver) *Config {
return &Config{
fileManager: fileManager,
pathResolver: pathResolver,
}
}
// LoadHostConfig loads the configuration for a specific host
func (cm *Config) LoadHostConfig(ctx context.Context, repoPath, host string) (*models.HostConfig, error) {
managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host)
if err != nil {
return nil, err
}
return &models.HostConfig{
Name: host,
ManagedFiles: managedFiles,
LastUpdate: time.Now(),
}, nil
}
// SaveHostConfig saves the configuration for a specific host
func (cm *Config) SaveHostConfig(ctx context.Context, repoPath string, config *models.HostConfig) error {
// Convert managed files to relative paths for storage
var relativePaths []string
for _, file := range config.ManagedFiles {
relativePaths = append(relativePaths, file.RelativePath)
}
// Sort for consistent ordering
sort.Strings(relativePaths)
return cm.writeManagedItems(ctx, repoPath, config.Name, relativePaths)
}
// AddManagedFileToHost adds a managed file to a host's configuration
func (cm *Config) AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error {
// Get current managed files
managedFiles, err := cm.getManagedItems(ctx, repoPath, host)
if err != nil {
return err
}
// Check if already exists
for _, item := range managedFiles {
if item == file.RelativePath {
return nil // Already managed
}
}
// Add new item
managedFiles = append(managedFiles, file.RelativePath)
// Sort for consistent ordering
sort.Strings(managedFiles)
return cm.writeManagedItems(ctx, repoPath, host, managedFiles)
}
// RemoveManagedFileFromHost removes a managed file from a host's configuration
func (cm *Config) RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error {
// Get current managed files
managedFiles, err := cm.getManagedItems(ctx, repoPath, host)
if err != nil {
return err
}
// Remove item
var newManagedFiles []string
for _, item := range managedFiles {
if item != relativePath {
newManagedFiles = append(newManagedFiles, item)
}
}
return cm.writeManagedItems(ctx, repoPath, host, newManagedFiles)
}
// ListManagedFiles returns all files managed by a specific host
func (cm *Config) ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) {
relativePaths, err := cm.getManagedItems(ctx, repoPath, host)
if err != nil {
return nil, err
}
var managedFiles []models.ManagedFile
for _, relativePath := range relativePaths {
// Get file storage path
fileStoragePath, err := cm.pathResolver.GetFileStoragePathInRepo(repoPath, host, relativePath)
if err != nil {
return nil, errors.NewConfigNotFoundError(host).
WithContext("relative_path", relativePath)
}
// Get original path (where symlink should be)
originalPath, err := cm.pathResolver.GetAbsolutePathInHome(relativePath)
if err != nil {
return nil, errors.NewInvalidPathError(relativePath, "cannot convert to absolute path")
}
// Check if file exists and get info
var isDirectory bool
var mode os.FileMode
if exists, err := cm.fileManager.Exists(ctx, fileStoragePath); err == nil && exists {
if info, err := cm.fileManager.Stat(ctx, fileStoragePath); err == nil {
isDirectory = info.IsDir()
mode = info.Mode()
}
}
managedFile := models.ManagedFile{
OriginalPath: originalPath,
RepoPath: fileStoragePath,
RelativePath: relativePath,
Host: host,
IsDirectory: isDirectory,
Mode: mode,
}
managedFiles = append(managedFiles, managedFile)
}
return managedFiles, nil
}
// GetManagedFile retrieves a specific managed file by relative path
func (cm *Config) GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) {
managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host)
if err != nil {
return nil, err
}
for _, file := range managedFiles {
if file.RelativePath == relativePath {
return &file, nil
}
}
return nil, errors.NewFileNotFoundError(relativePath)
}
// ConfigExists checks if a configuration file exists for the host
func (cm *Config) ConfigExists(ctx context.Context, repoPath, host string) (bool, error) {
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
if err != nil {
return false, err
}
return cm.fileManager.Exists(ctx, trackingFilePath)
}
// getManagedItems returns the list of managed files and directories from .lnk file
// This is the core method that reads the plain text format
func (cm *Config) getManagedItems(ctx context.Context, repoPath, host string) ([]string, error) {
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
if err != nil {
return nil, errors.NewConfigNotFoundError(host).
WithContext("repo_path", repoPath)
}
// If .lnk file doesn't exist, return empty list
exists, err := cm.fileManager.Exists(ctx, trackingFilePath)
if err != nil {
return nil, errors.NewFileSystemOperationError("check_exists", trackingFilePath, err)
}
if !exists {
return []string{}, nil
}
content, err := cm.fileManager.ReadFile(ctx, trackingFilePath)
if err != nil {
return nil, errors.NewFileSystemOperationError("read", trackingFilePath, err)
}
if len(content) == 0 {
return []string{}, nil
}
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
var items []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
items = append(items, line)
}
}
return items, nil
}
// writeManagedItems writes the list of managed items to .lnk file
// This maintains the plain text line-by-line format for compatibility
func (cm *Config) writeManagedItems(ctx context.Context, repoPath, host string, items []string) error {
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
if err != nil {
return errors.NewConfigNotFoundError(host).
WithContext("repo_path", repoPath)
}
content := strings.Join(items, "\n")
if len(items) > 0 {
content += "\n"
}
if err := cm.fileManager.WriteFile(ctx, trackingFilePath, []byte(content), 0644); err != nil {
return errors.NewFileSystemOperationError("write", trackingFilePath, err)
}
return nil
}

View File

@@ -0,0 +1,278 @@
package config
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/yarlson/lnk/internal/errors"
"github.com/yarlson/lnk/internal/fs"
"github.com/yarlson/lnk/internal/models"
"github.com/yarlson/lnk/internal/pathresolver"
)
type ConfigTestSuite struct {
suite.Suite
tempDir string
configManager *Config
fileManager *fs.FileManager
pathResolver *pathresolver.Resolver
ctx context.Context
}
func (suite *ConfigTestSuite) SetupTest() {
// Create temp directory for testing
tempDir, err := os.MkdirTemp("", "lnk_config_test_*")
suite.Require().NoError(err)
suite.tempDir = tempDir
// Create file manager and path resolver
suite.fileManager = fs.New()
suite.pathResolver = pathresolver.New()
// Create config manager
suite.configManager = New(suite.fileManager, suite.pathResolver)
// Create context
suite.ctx = context.Background()
}
func (suite *ConfigTestSuite) TearDownTest() {
err := os.RemoveAll(suite.tempDir)
suite.Require().NoError(err)
}
func (suite *ConfigTestSuite) TestAddAndListManagedFiles() {
repoPath := filepath.Join(suite.tempDir, "repo")
host := "testhost"
// Create a managed file
managedFile := models.ManagedFile{
RelativePath: ".vimrc",
Host: host,
IsDirectory: false,
}
// Add managed file
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
suite.NoError(err)
// List managed files
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
suite.NoError(err)
suite.Len(files, 1)
suite.Equal(".vimrc", files[0].RelativePath)
suite.Equal(host, files[0].Host)
// Verify tracking file was created
trackingPath, err := suite.pathResolver.GetTrackingFilePath(repoPath, host)
suite.NoError(err)
exists, err := suite.fileManager.Exists(suite.ctx, trackingPath)
suite.NoError(err)
suite.True(exists)
// Read tracking file content
content, err := suite.fileManager.ReadFile(suite.ctx, trackingPath)
suite.NoError(err)
expectedContent := ".vimrc\n"
suite.Equal(expectedContent, string(content))
}
func (suite *ConfigTestSuite) TestAddDuplicateFile() {
repoPath := filepath.Join(suite.tempDir, "repo")
host := "testhost"
managedFile := models.ManagedFile{
RelativePath: ".bashrc",
Host: host,
}
// Add file twice
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
suite.NoError(err)
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
suite.NoError(err)
// Should still have only one file
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
suite.NoError(err)
suite.Len(files, 1)
}
func (suite *ConfigTestSuite) TestRemoveManagedFile() {
repoPath := filepath.Join(suite.tempDir, "repo")
host := "testhost"
// Add two managed files
file1 := models.ManagedFile{RelativePath: ".vimrc", Host: host}
file2 := models.ManagedFile{RelativePath: ".bashrc", Host: host}
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file1)
suite.NoError(err)
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file2)
suite.NoError(err)
// Remove one file
err = suite.configManager.RemoveManagedFileFromHost(suite.ctx, repoPath, host, ".vimrc")
suite.NoError(err)
// Should have only one file left
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
suite.NoError(err)
suite.Len(files, 1)
suite.Equal(".bashrc", files[0].RelativePath)
}
func (suite *ConfigTestSuite) TestLoadAndSaveHostConfig() {
repoPath := filepath.Join(suite.tempDir, "repo")
host := "workstation"
// Create host config with managed files
config := &models.HostConfig{
Name: host,
ManagedFiles: []models.ManagedFile{
{RelativePath: ".vimrc", Host: host},
{RelativePath: ".bashrc", Host: host},
},
LastUpdate: time.Now(),
}
// Save config
err := suite.configManager.SaveHostConfig(suite.ctx, repoPath, config)
suite.NoError(err)
// Load config
loadedConfig, err := suite.configManager.LoadHostConfig(suite.ctx, repoPath, host)
suite.NoError(err)
suite.Equal(host, loadedConfig.Name)
suite.Len(loadedConfig.ManagedFiles, 2)
// Check files are sorted
suite.Equal(".bashrc", loadedConfig.ManagedFiles[0].RelativePath)
suite.Equal(".vimrc", loadedConfig.ManagedFiles[1].RelativePath)
}
func (suite *ConfigTestSuite) TestGetManagedFile() {
repoPath := filepath.Join(suite.tempDir, "repo")
host := "testhost"
managedFile := models.ManagedFile{
RelativePath: ".gitconfig",
Host: host,
}
// Add managed file
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
suite.NoError(err)
// Get specific managed file
file, err := suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".gitconfig")
suite.NoError(err)
suite.Equal(".gitconfig", file.RelativePath)
// Try to get non-existent file
_, err = suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".nonexistent")
suite.Error(err)
suite.True(errors.NewFileNotFoundError("").Is(err))
}
func (suite *ConfigTestSuite) TestConfigExists() {
repoPath := filepath.Join(suite.tempDir, "repo")
host := "testhost"
// Initially should not exist
exists, err := suite.configManager.ConfigExists(suite.ctx, repoPath, host)
suite.NoError(err)
suite.False(exists)
// Add a managed file
managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host}
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
suite.NoError(err)
// Now should exist
exists, err = suite.configManager.ConfigExists(suite.ctx, repoPath, host)
suite.NoError(err)
suite.True(exists)
}
func (suite *ConfigTestSuite) TestEmptyConfig() {
repoPath := filepath.Join(suite.tempDir, "repo")
host := "emptyhost"
// List files from non-existent config
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
suite.NoError(err)
suite.Len(files, 0)
}
func (suite *ConfigTestSuite) TestCommonAndHostConfigs() {
repoPath := filepath.Join(suite.tempDir, "repo")
// Add file to common config (empty host)
commonFile := models.ManagedFile{RelativePath: ".bashrc", Host: ""}
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "", commonFile)
suite.NoError(err)
// Add file to host-specific config
hostFile := models.ManagedFile{RelativePath: ".vimrc", Host: "workstation"}
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "workstation", hostFile)
suite.NoError(err)
// List common files
commonFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "")
suite.NoError(err)
suite.Len(commonFiles, 1)
suite.Equal(".bashrc", commonFiles[0].RelativePath)
// List host files
hostFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "workstation")
suite.NoError(err)
suite.Len(hostFiles, 1)
suite.Equal(".vimrc", hostFiles[0].RelativePath)
}
func (suite *ConfigTestSuite) TestFileWithMetadata() {
repoPath := filepath.Join(suite.tempDir, "repo")
host := "testhost"
// Create actual file in repository storage area
hostStoragePath := filepath.Join(repoPath, host+".lnk")
testFilePath := filepath.Join(hostStoragePath, ".vimrc")
err := suite.fileManager.WriteFile(suite.ctx, testFilePath, []byte("set number"), 0644)
suite.NoError(err)
// Add managed file
managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host}
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
suite.NoError(err)
// List files should include metadata
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
suite.NoError(err)
suite.Len(files, 1)
file := files[0]
suite.False(file.IsDirectory)
suite.NotZero(file.Mode)
// Expected paths
expectedRepoPath := testFilePath
suite.Equal(expectedRepoPath, file.RepoPath)
}
func TestConfigSuite(t *testing.T) {
suite.Run(t, new(ConfigTestSuite))
}

View File

@@ -1,536 +0,0 @@
package core
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/yarlson/lnk/internal/fs"
"github.com/yarlson/lnk/internal/git"
)
// Lnk represents the main application logic
type Lnk struct {
repoPath string
git *git.Git
fs *fs.FileSystem
}
// NewLnk creates a new Lnk instance
func NewLnk() *Lnk {
repoPath := getRepoPath()
return &Lnk{
repoPath: repoPath,
git: git.New(repoPath),
fs: fs.New(),
}
}
// getRepoPath returns the path to the lnk repository directory
func getRepoPath() string {
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
if xdgConfig == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
// Fallback to current directory if we can't get home
xdgConfig = "."
} else {
xdgConfig = filepath.Join(homeDir, ".config")
}
}
return filepath.Join(xdgConfig, "lnk")
}
// Init initializes the lnk repository
func (l *Lnk) Init() error {
return l.InitWithRemote("")
}
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
func (l *Lnk) InitWithRemote(remoteURL string) error {
if remoteURL != "" {
// Clone from remote
return l.Clone(remoteURL)
}
// Create the repository directory
if err := os.MkdirAll(l.repoPath, 0755); err != nil {
return fmt.Errorf("failed to create lnk directory: %w", err)
}
// Check if there's already a Git repository
if l.git.IsGitRepository() {
// Repository exists, check if it's a lnk repository
if l.git.IsLnkRepository() {
// It's a lnk repository, init is idempotent - do nothing
return nil
} else {
// 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)
}
}
// No existing repository, initialize Git repository
if err := l.git.Init(); err != nil {
return fmt.Errorf("failed to initialize git repository: %w", err)
}
return nil
}
// Clone clones a repository from the given URL
func (l *Lnk) Clone(url string) error {
if err := l.git.Clone(url); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
}
// AddRemote adds a remote to the repository
func (l *Lnk) AddRemote(name, url string) error {
if err := l.git.AddRemote(name, url); err != nil {
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
func (l *Lnk) Add(filePath string) error {
// Validate the file or directory
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
return err
}
// Get absolute path
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Calculate destination path in repo
basename := filepath.Base(absPath)
destPath := filepath.Join(l.repoPath, basename)
// Check if it's a directory or file
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat path: %w", err)
}
// Move to repository (handles both files and directories)
if info.IsDir() {
if err := l.fs.MoveDirectory(absPath, destPath); err != nil {
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
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the original if symlink creation fails
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 create symlink: %w", err)
}
// Add to .lnk tracking file
if err := l.addManagedItem(absPath); err != nil {
// Try to restore the original state if tracking fails
_ = os.Remove(absPath) // Ignore error in cleanup
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 update tracking file: %w", err)
}
// Add both the item and .lnk file to git in a single commit
if err := l.git.Add(basename); err != nil {
// Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup
_ = l.removeManagedItem(absPath) // Ignore error in cleanup
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)
}
// Add .lnk file to the same commit
if err := l.git.Add(".lnk"); err != nil {
// Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup
_ = l.removeManagedItem(absPath) // Ignore error in cleanup
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 .lnk file to git: %w", err)
}
// Commit both changes together
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
// Try to restore the original state if commit fails
_ = os.Remove(absPath) // Ignore error in cleanup
_ = l.removeManagedItem(absPath) // Ignore error in cleanup
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 commit changes: %w", err)
}
return nil
}
// Remove removes a symlink and restores the original file or directory
func (l *Lnk) Remove(filePath string) error {
// Get absolute path
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Validate that this is a symlink managed by lnk
if err := l.fs.ValidateSymlinkForRemove(absPath, l.repoPath); err != nil {
return err
}
// Get the target path in the repository
target, err := os.Readlink(absPath)
if err != nil {
return fmt.Errorf("failed to read symlink: %w", err)
}
// Convert relative path to absolute if needed
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(absPath), target)
}
basename := filepath.Base(target)
// Check if target is a directory or file
info, err := os.Stat(target)
if err != nil {
return fmt.Errorf("failed to stat target: %w", err)
}
// Remove the symlink
if err := os.Remove(absPath); err != nil {
return fmt.Errorf("failed to remove symlink: %w", err)
}
// Remove from .lnk tracking file
if err := l.removeManagedItem(absPath); err != nil {
return fmt.Errorf("failed to update tracking file: %w", err)
}
// Remove from Git first (while the item is still in the repository)
if err := l.git.Remove(basename); err != nil {
return fmt.Errorf("failed to remove from git: %w", err)
}
// Add .lnk file to the same commit
if err := l.git.Add(".lnk"); err != nil {
return fmt.Errorf("failed to add .lnk file to git: %w", err)
}
// Commit both changes together
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
// Move back from repository (handles both files and directories)
if info.IsDir() {
if err := l.fs.MoveDirectory(target, absPath); err != nil {
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
}
// GetCommits returns the list of commits for testing purposes
func (l *Lnk) GetCommits() ([]string, error) {
return l.git.GetCommits()
}
// StatusInfo contains repository sync status information
type StatusInfo struct {
Ahead int
Behind int
Remote string
}
// Status returns the repository sync status
func (l *Lnk) Status() (*StatusInfo, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return nil, fmt.Errorf("lnk repository not initialized - run 'lnk init' first")
}
gitStatus, err := l.git.GetStatus()
if err != nil {
return nil, fmt.Errorf("failed to get repository status: %w", err)
}
return &StatusInfo{
Ahead: gitStatus.Ahead,
Behind: gitStatus.Behind,
Remote: gitStatus.Remote,
}, nil
}
// Push stages all changes and creates a sync commit, then pushes to remote
func (l *Lnk) Push(message string) error {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return fmt.Errorf("lnk repository not initialized - run 'lnk init' first")
}
// Check if there are any changes
hasChanges, err := l.git.HasChanges()
if err != nil {
return fmt.Errorf("failed to check for changes: %w", err)
}
if hasChanges {
// Stage all changes
if err := l.git.AddAll(); err != nil {
return fmt.Errorf("failed to stage changes: %w", err)
}
// Create a sync commit
if err := l.git.Commit(message); err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
}
// 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
if err := l.git.Push(); err != nil {
return fmt.Errorf("failed to push to remote: %w", err)
}
return nil
}
// Pull fetches changes from remote and restores symlinks as needed
func (l *Lnk) Pull() ([]string, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return nil, fmt.Errorf("lnk repository not initialized - run 'lnk init' first")
}
// 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 {
return nil, fmt.Errorf("failed to pull from remote: %w", err)
}
// Find all managed files in the repository and restore symlinks
restored, err := l.RestoreSymlinks()
if err != nil {
return nil, fmt.Errorf("failed to restore symlinks: %w", err)
}
return restored, nil
}
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks
func (l *Lnk) RestoreSymlinks() ([]string, error) {
var restored []string
// Get managed items from .lnk file
managedItems, err := l.getManagedItems()
if err != nil {
return nil, fmt.Errorf("failed to get managed items: %w", err)
}
for _, itemName := range managedItems {
repoItem := filepath.Join(l.repoPath, itemName)
// Check if item exists in repository
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
continue // Skip missing items
}
// Determine where the symlink should be
// For config files, we'll place them in the user's home directory
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
symlinkPath := filepath.Join(homeDir, itemName)
// Check if symlink already exists and is correct
if l.isValidSymlink(symlinkPath, repoItem) {
continue
}
// Remove existing file/symlink if it exists
if _, err := os.Lstat(symlinkPath); err == nil {
if err := os.RemoveAll(symlinkPath); err != nil {
return nil, fmt.Errorf("failed to remove existing item %s: %w", symlinkPath, err)
}
}
// Create symlink
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
return nil, fmt.Errorf("failed to create symlink for %s: %w", itemName, err)
}
restored = append(restored, itemName)
}
return restored, nil
}
// isValidSymlink checks if the given path is a symlink pointing to the expected target
func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
info, err := os.Lstat(symlinkPath)
if err != nil {
return false
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink == 0 {
return false
}
// Check if it points to the correct target
target, err := os.Readlink(symlinkPath)
if err != nil {
return false
}
// Convert relative path to absolute if needed
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(symlinkPath), target)
}
// Clean both paths for comparison
targetAbs, err := filepath.Abs(target)
if err != nil {
return false
}
expectedAbs, err := filepath.Abs(expectedTarget)
if err != nil {
return false
}
return targetAbs == expectedAbs
}
// getManagedItems returns the list of managed files and directories from .lnk file
func (l *Lnk) getManagedItems() ([]string, error) {
lnkFile := filepath.Join(l.repoPath, ".lnk")
// If .lnk file doesn't exist, return empty list
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
return []string{}, nil
}
content, err := os.ReadFile(lnkFile)
if err != nil {
return nil, fmt.Errorf("failed to read .lnk file: %w", err)
}
if len(content) == 0 {
return []string{}, nil
}
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
var items []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
items = append(items, line)
}
}
return items, nil
}
// addManagedItem adds an item to the .lnk tracking file
func (l *Lnk) addManagedItem(itemPath string) error {
// Get current items
items, err := l.getManagedItems()
if err != nil {
return fmt.Errorf("failed to get managed items: %w", err)
}
// Get the basename for storage
basename := filepath.Base(itemPath)
// Check if already exists
for _, item := range items {
if item == basename {
return nil // Already managed
}
}
// Add new item
items = append(items, basename)
// Sort for consistent ordering
sort.Strings(items)
return l.writeManagedItems(items)
}
// removeManagedItem removes an item from the .lnk tracking file
func (l *Lnk) removeManagedItem(itemPath string) error {
// Get current items
items, err := l.getManagedItems()
if err != nil {
return fmt.Errorf("failed to get managed items: %w", err)
}
// Get the basename for removal
basename := filepath.Base(itemPath)
// Remove item
var newItems []string
for _, item := range items {
if item != basename {
newItems = append(newItems, item)
}
}
return l.writeManagedItems(newItems)
}
// writeManagedItems writes the list of managed items to .lnk file
func (l *Lnk) writeManagedItems(items []string) error {
lnkFile := filepath.Join(l.repoPath, ".lnk")
content := strings.Join(items, "\n")
if len(items) > 0 {
content += "\n"
}
err := os.WriteFile(lnkFile, []byte(content), 0644)
if err != nil {
return fmt.Errorf("failed to write .lnk file: %w", err)
}
return nil
}

241
internal/errors/errors.go Normal file
View File

@@ -0,0 +1,241 @@
package errors
import (
"errors"
"fmt"
)
// Standard error variables
var (
// ErrFileNotFound indicates a file or directory was not found
ErrFileNotFound = errors.New("file not found")
// ErrFileAlreadyManaged indicates a file is already being managed by lnk
ErrFileAlreadyManaged = errors.New("file already managed")
// ErrNotSymlink indicates the file is not a symbolic link
ErrNotSymlink = errors.New("not a symlink")
// ErrRepoNotInitialized indicates the lnk repository has not been initialized
ErrRepoNotInitialized = errors.New("repository not initialized")
// ErrNoRemoteConfigured indicates no Git remote has been configured
ErrNoRemoteConfigured = errors.New("no remote configured")
// ErrOperationAborted indicates an operation was aborted by the user
ErrOperationAborted = errors.New("operation aborted")
// ErrConfigNotFound indicates a configuration file was not found
ErrConfigNotFound = errors.New("configuration not found")
// ErrInvalidPath indicates an invalid file path was provided
ErrInvalidPath = errors.New("invalid path")
// ErrPermissionDenied indicates insufficient permissions for the operation
ErrPermissionDenied = errors.New("permission denied")
// ErrGitOperation indicates a Git operation failed
ErrGitOperation = errors.New("git operation failed")
// ErrFileSystemOperation indicates a file system operation failed
ErrFileSystemOperation = errors.New("file system operation failed")
)
// ErrorCode represents different types of errors that can occur
type ErrorCode int
const (
// ErrorCodeUnknown represents an unknown error
ErrorCodeUnknown ErrorCode = iota
// ErrorCodeFileNotFound represents file not found errors
ErrorCodeFileNotFound
// ErrorCodeFileAlreadyManaged represents file already managed errors
ErrorCodeFileAlreadyManaged
// ErrorCodeNotSymlink represents not a symlink errors
ErrorCodeNotSymlink
// ErrorCodeRepoNotInitialized represents repository not initialized errors
ErrorCodeRepoNotInitialized
// ErrorCodeNoRemoteConfigured represents no remote configured errors
ErrorCodeNoRemoteConfigured
// ErrorCodeOperationAborted represents operation aborted errors
ErrorCodeOperationAborted
// ErrorCodeConfigNotFound represents configuration not found errors
ErrorCodeConfigNotFound
// ErrorCodeInvalidPath represents invalid path errors
ErrorCodeInvalidPath
// ErrorCodePermissionDenied represents permission denied errors
ErrorCodePermissionDenied
// ErrorCodeGitOperation represents Git operation errors
ErrorCodeGitOperation
// ErrorCodeFileSystemOperation represents file system operation errors
ErrorCodeFileSystemOperation
)
// String returns a string representation of the error code
func (e ErrorCode) String() string {
switch e {
case ErrorCodeFileNotFound:
return "FILE_NOT_FOUND"
case ErrorCodeFileAlreadyManaged:
return "FILE_ALREADY_MANAGED"
case ErrorCodeNotSymlink:
return "NOT_SYMLINK"
case ErrorCodeRepoNotInitialized:
return "REPO_NOT_INITIALIZED"
case ErrorCodeNoRemoteConfigured:
return "NO_REMOTE_CONFIGURED"
case ErrorCodeOperationAborted:
return "OPERATION_ABORTED"
case ErrorCodeConfigNotFound:
return "CONFIG_NOT_FOUND"
case ErrorCodeInvalidPath:
return "INVALID_PATH"
case ErrorCodePermissionDenied:
return "PERMISSION_DENIED"
case ErrorCodeGitOperation:
return "GIT_OPERATION"
case ErrorCodeFileSystemOperation:
return "FILE_SYSTEM_OPERATION"
default:
return "UNKNOWN"
}
}
// LnkError represents a structured error with additional context
type LnkError struct {
// Code represents the type of error
Code ErrorCode
// Message is the human-readable error message
Message string
// Cause is the underlying error that caused this error
Cause error
// Context provides additional context about when/where the error occurred
Context map[string]interface{}
}
// Error implements the error interface
func (e *LnkError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
return e.Message
}
// Unwrap returns the underlying cause error for Go 1.13+ error handling
func (e *LnkError) Unwrap() error {
return e.Cause
}
// Is implements error comparison for Go 1.13+ error handling
func (e *LnkError) Is(target error) bool {
if lnkErr, ok := target.(*LnkError); ok {
return e.Code == lnkErr.Code
}
return errors.Is(e.Cause, target)
}
// WithContext adds context information to the error
func (e *LnkError) WithContext(key string, value interface{}) *LnkError {
if e.Context == nil {
e.Context = make(map[string]interface{})
}
e.Context[key] = value
return e
}
// NewLnkError creates a new LnkError with the given code and message
func NewLnkError(code ErrorCode, message string) *LnkError {
return &LnkError{
Code: code,
Message: message,
Context: make(map[string]interface{}),
}
}
// WrapError wraps an existing error with LnkError context
func WrapError(code ErrorCode, message string, cause error) *LnkError {
return &LnkError{
Code: code,
Message: message,
Cause: cause,
Context: make(map[string]interface{}),
}
}
// Helper functions for creating common errors
// NewFileNotFoundError creates a file not found error
func NewFileNotFoundError(path string) *LnkError {
return NewLnkError(ErrorCodeFileNotFound, fmt.Sprintf("❌ File does not exist: \033[31m%s\033[0m", path)).
WithContext("path", path)
}
// NewFileAlreadyManagedError creates a file already managed error
func NewFileAlreadyManagedError(path string) *LnkError {
return NewLnkError(ErrorCodeFileAlreadyManaged, fmt.Sprintf("file already managed: %s", path)).
WithContext("path", path)
}
// NewNotSymlinkError creates a not symlink error
func NewNotSymlinkError(path string) *LnkError {
return NewLnkError(ErrorCodeNotSymlink, fmt.Sprintf("not a symlink: %s", path)).
WithContext("path", path)
}
// NewRepoNotInitializedError creates a repository not initialized error
func NewRepoNotInitializedError(repoPath string) *LnkError {
return NewLnkError(ErrorCodeRepoNotInitialized, "Lnk repository not initialized").
WithContext("repo_path", repoPath)
}
// NewNoRemoteConfiguredError creates a no remote configured error
func NewNoRemoteConfiguredError() *LnkError {
return NewLnkError(ErrorCodeNoRemoteConfigured, "no git remote configured")
}
// NewConfigNotFoundError creates a configuration not found error
func NewConfigNotFoundError(host string) *LnkError {
return NewLnkError(ErrorCodeConfigNotFound, fmt.Sprintf("configuration not found for host: %s", host)).
WithContext("host", host)
}
// NewInvalidPathError creates an invalid path error
func NewInvalidPathError(path string, reason string) *LnkError {
return NewLnkError(ErrorCodeInvalidPath, fmt.Sprintf("invalid path %s: %s", path, reason)).
WithContext("path", path).
WithContext("reason", reason)
}
// NewPermissionDeniedError creates a permission denied error
func NewPermissionDeniedError(operation, path string) *LnkError {
return NewLnkError(ErrorCodePermissionDenied, fmt.Sprintf("permission denied for %s: %s", operation, path)).
WithContext("operation", operation).
WithContext("path", path)
}
// NewGitOperationError creates a Git operation error
func NewGitOperationError(operation string, cause error) *LnkError {
return WrapError(ErrorCodeGitOperation, fmt.Sprintf("git %s failed", operation), cause).
WithContext("operation", operation)
}
// NewFileSystemOperationError creates a file system operation error
func NewFileSystemOperationError(operation, path string, cause error) *LnkError {
return WrapError(ErrorCodeFileSystemOperation, fmt.Sprintf("file system %s failed for %s", operation, path), cause).
WithContext("operation", operation).
WithContext("path", path)
}

View File

@@ -0,0 +1,126 @@
package errors
import (
"errors"
"testing"
"github.com/stretchr/testify/suite"
)
type ErrorsTestSuite struct {
suite.Suite
}
func (suite *ErrorsTestSuite) TestErrorCodeString() {
tests := []struct {
code ErrorCode
expected string
}{
{ErrorCodeFileNotFound, "FILE_NOT_FOUND"},
{ErrorCodeFileAlreadyManaged, "FILE_ALREADY_MANAGED"},
{ErrorCodeNotSymlink, "NOT_SYMLINK"},
{ErrorCodeRepoNotInitialized, "REPO_NOT_INITIALIZED"},
{ErrorCodeNoRemoteConfigured, "NO_REMOTE_CONFIGURED"},
{ErrorCodeOperationAborted, "OPERATION_ABORTED"},
{ErrorCodeConfigNotFound, "CONFIG_NOT_FOUND"},
{ErrorCodeInvalidPath, "INVALID_PATH"},
{ErrorCodePermissionDenied, "PERMISSION_DENIED"},
{ErrorCodeGitOperation, "GIT_OPERATION"},
{ErrorCodeFileSystemOperation, "FILE_SYSTEM_OPERATION"},
{ErrorCodeUnknown, "UNKNOWN"},
}
for _, tt := range tests {
suite.Run(tt.expected, func() {
result := tt.code.String()
suite.Equal(tt.expected, result)
})
}
}
func (suite *ErrorsTestSuite) TestLnkErrorError() {
suite.Run("without_cause", func() {
err := NewLnkError(ErrorCodeFileNotFound, "test file not found")
expected := "test file not found"
suite.Equal(expected, err.Error())
})
suite.Run("with_cause", func() {
cause := errors.New("underlying error")
err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause)
expected := "file operation failed: underlying error"
suite.Equal(expected, err.Error())
})
}
func (suite *ErrorsTestSuite) TestLnkErrorUnwrap() {
cause := errors.New("underlying error")
err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause)
unwrapped := err.Unwrap()
suite.Equal(cause, unwrapped)
}
func (suite *ErrorsTestSuite) TestLnkErrorIs() {
err1 := NewLnkError(ErrorCodeFileNotFound, "file not found")
err2 := NewLnkError(ErrorCodeFileNotFound, "another file not found")
err3 := NewLnkError(ErrorCodeFileAlreadyManaged, "file already managed")
// Same error code should match
suite.True(errors.Is(err1, err2), "expected errors with same code to match")
// Different error codes should not match
suite.False(errors.Is(err1, err3), "expected errors with different codes to not match")
// Test with wrapped errors
cause := errors.New("io error")
wrappedErr := WrapError(ErrorCodeFileSystemOperation, "wrapped", cause)
suite.True(errors.Is(wrappedErr, cause), "expected wrapped error to match its cause")
}
func (suite *ErrorsTestSuite) TestLnkErrorWithContext() {
err := NewLnkError(ErrorCodeFileNotFound, "file not found")
err = err.WithContext("path", "/test/file.txt")
err = err.WithContext("operation", "read")
suite.Equal("/test/file.txt", err.Context["path"])
suite.Equal("read", err.Context["operation"])
}
func (suite *ErrorsTestSuite) TestNewFileNotFoundError() {
path := "/test/file.txt"
err := NewFileNotFoundError(path)
suite.Equal(ErrorCodeFileNotFound, err.Code)
suite.Equal(path, err.Context["path"])
}
func (suite *ErrorsTestSuite) TestNewFileAlreadyManagedError() {
path := "/test/file.txt"
err := NewFileAlreadyManagedError(path)
suite.Equal(ErrorCodeFileAlreadyManaged, err.Code)
suite.Equal(path, err.Context["path"])
}
func (suite *ErrorsTestSuite) TestNewRepoNotInitializedError() {
repoPath := "/test/repo"
err := NewRepoNotInitializedError(repoPath)
suite.Equal(ErrorCodeRepoNotInitialized, err.Code)
suite.Equal(repoPath, err.Context["repo_path"])
}
func (suite *ErrorsTestSuite) TestNewGitOperationError() {
operation := "push"
cause := errors.New("network error")
err := NewGitOperationError(operation, cause)
suite.Equal(ErrorCodeGitOperation, err.Code)
suite.Equal(cause, err.Cause)
suite.Equal(operation, err.Context["operation"])
}
func TestErrorsSuite(t *testing.T) {
suite.Run(t, new(ErrorsTestSuite))
}

254
internal/fs/filemanager.go Normal file
View File

@@ -0,0 +1,254 @@
package fs
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/yarlson/lnk/internal/errors"
)
// FileManager implements the models.FileManager interface
type FileManager struct{}
// New creates a new FileManager instance
func New() *FileManager {
return &FileManager{}
}
// Exists checks if a file or directory exists
func (fm *FileManager) Exists(ctx context.Context, path string) (bool, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return false, ctx.Err()
default:
}
_, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, errors.NewFileSystemOperationError("stat", path, err)
}
return true, nil
}
// IsDirectory checks if the path points to a directory
func (fm *FileManager) IsDirectory(ctx context.Context, path string) (bool, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return false, ctx.Err()
default:
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, errors.NewFileNotFoundError(path)
}
return false, errors.NewFileSystemOperationError("stat", path, err)
}
return info.IsDir(), nil
}
// Move moves a file or directory from src to dst
func (fm *FileManager) Move(ctx context.Context, src, dst string) error {
// Check for context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Ensure destination directory exists
dstDir := filepath.Dir(dst)
if err := fm.MkdirAll(ctx, dstDir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Check for context cancellation before move
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Move the file or directory
if err := os.Rename(src, dst); err != nil {
return errors.NewFileSystemOperationError("move", src, err).
WithContext("destination", dst)
}
return nil
}
// CreateSymlink creates a symlink pointing from linkPath to target
func (fm *FileManager) CreateSymlink(ctx context.Context, target, linkPath string) error {
// Check for context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Calculate relative path from linkPath to target
linkDir := filepath.Dir(linkPath)
relTarget, err := filepath.Rel(linkDir, target)
if err != nil {
return errors.NewFileSystemOperationError("calculate_relative_path", linkPath, err).
WithContext("target", target)
}
// Create the symlink
if err := os.Symlink(relTarget, linkPath); err != nil {
return errors.NewFileSystemOperationError("create_symlink", linkPath, err).
WithContext("target", relTarget)
}
return nil
}
// Remove removes a file or directory
func (fm *FileManager) Remove(ctx context.Context, path string) error {
// Check for context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := os.RemoveAll(path); err != nil {
return errors.NewFileSystemOperationError("remove", path, err)
}
return nil
}
// ReadFile reads the contents of a file
func (fm *FileManager) ReadFile(ctx context.Context, path string) ([]byte, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.NewFileNotFoundError(path)
}
return nil, errors.NewFileSystemOperationError("read", path, err)
}
return data, nil
}
// WriteFile writes data to a file with the given permissions
func (fm *FileManager) WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode) error {
// Check for context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Ensure parent directory exists
dir := filepath.Dir(path)
if err := fm.MkdirAll(ctx, dir, 0755); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
}
// Check for context cancellation before write
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := os.WriteFile(path, data, perm); err != nil {
return errors.NewFileSystemOperationError("write", path, err)
}
return nil
}
// MkdirAll creates a directory and all necessary parent directories
func (fm *FileManager) MkdirAll(ctx context.Context, path string, perm os.FileMode) error {
// Check for context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := os.MkdirAll(path, perm); err != nil {
return errors.NewFileSystemOperationError("mkdir", path, err)
}
return nil
}
// Readlink returns the target of a symbolic link
func (fm *FileManager) Readlink(ctx context.Context, path string) (string, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
target, err := os.Readlink(path)
if err != nil {
if os.IsNotExist(err) {
return "", errors.NewFileNotFoundError(path)
}
return "", errors.NewFileSystemOperationError("readlink", path, err)
}
return target, nil
}
// Lstat returns file info without following symbolic links
func (fm *FileManager) Lstat(ctx context.Context, path string) (os.FileInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
info, err := os.Lstat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.NewFileNotFoundError(path)
}
return nil, errors.NewFileSystemOperationError("lstat", path, err)
}
return info, nil
}
// Stat returns file info, following symbolic links
func (fm *FileManager) Stat(ctx context.Context, path string) (os.FileInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.NewFileNotFoundError(path)
}
return nil, errors.NewFileSystemOperationError("stat", path, err)
}
return info, nil
}

View File

@@ -0,0 +1,261 @@
package fs
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/yarlson/lnk/internal/errors"
)
type FileManagerTestSuite struct {
suite.Suite
tempDir string
fileManager *FileManager
ctx context.Context
}
func (suite *FileManagerTestSuite) SetupTest() {
// Create temp directory for testing
tempDir, err := os.MkdirTemp("", "lnk_test_*")
suite.Require().NoError(err)
suite.tempDir = tempDir
// Create file manager
suite.fileManager = New()
// Create context
suite.ctx = context.Background()
}
func (suite *FileManagerTestSuite) TearDownTest() {
err := os.RemoveAll(suite.tempDir)
suite.Require().NoError(err)
}
func (suite *FileManagerTestSuite) TestExists() {
// Test existing file
testFile := filepath.Join(suite.tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test"), 0644)
suite.Require().NoError(err)
exists, err := suite.fileManager.Exists(suite.ctx, testFile)
suite.NoError(err)
suite.True(exists)
// Test non-existing file
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
exists, err = suite.fileManager.Exists(suite.ctx, nonExistentFile)
suite.NoError(err)
suite.False(exists)
}
func (suite *FileManagerTestSuite) TestExistsWithCancellation() {
// Create cancelled context
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := suite.fileManager.Exists(ctx, "/any/path")
suite.Equal(context.Canceled, err)
}
func (suite *FileManagerTestSuite) TestIsDirectory() {
// Test directory
isDir, err := suite.fileManager.IsDirectory(suite.ctx, suite.tempDir)
suite.NoError(err)
suite.True(isDir)
// Test file
testFile := filepath.Join(suite.tempDir, "test.txt")
err = os.WriteFile(testFile, []byte("test"), 0644)
suite.Require().NoError(err)
isDir, err = suite.fileManager.IsDirectory(suite.ctx, testFile)
suite.NoError(err)
suite.False(isDir)
// Test non-existing file
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
_, err = suite.fileManager.IsDirectory(suite.ctx, nonExistentFile)
suite.Error(err)
// Check that it's a models error
suite.True(errors.NewFileNotFoundError("").Is(err))
}
func (suite *FileManagerTestSuite) TestMove() {
// Create test file
srcFile := filepath.Join(suite.tempDir, "source.txt")
testContent := []byte("test content")
err := os.WriteFile(srcFile, testContent, 0644)
suite.Require().NoError(err)
// Test moving file
dstFile := filepath.Join(suite.tempDir, "subdir", "destination.txt")
err = suite.fileManager.Move(suite.ctx, srcFile, dstFile)
suite.NoError(err)
// Verify source doesn't exist
_, err = os.Stat(srcFile)
suite.True(os.IsNotExist(err))
// Verify destination exists with correct content
content, err := os.ReadFile(dstFile)
suite.NoError(err)
suite.Equal(string(testContent), string(content))
}
func (suite *FileManagerTestSuite) TestCreateSymlink() {
// Create target file
targetFile := filepath.Join(suite.tempDir, "target.txt")
err := os.WriteFile(targetFile, []byte("test"), 0644)
suite.Require().NoError(err)
// Create symlink
linkFile := filepath.Join(suite.tempDir, "link.txt")
err = suite.fileManager.CreateSymlink(suite.ctx, targetFile, linkFile)
suite.NoError(err)
// Verify symlink exists and points to target
info, err := os.Lstat(linkFile)
suite.NoError(err)
suite.NotZero(info.Mode() & os.ModeSymlink)
// Verify symlink target
target, err := os.Readlink(linkFile)
suite.NoError(err)
expectedTarget := "target.txt" // Should be relative
suite.Equal(expectedTarget, target)
}
func (suite *FileManagerTestSuite) TestReadWriteFile() {
// Test writing file
testFile := filepath.Join(suite.tempDir, "subdir", "test.txt")
testContent := []byte("test content")
err := suite.fileManager.WriteFile(suite.ctx, testFile, testContent, 0644)
suite.NoError(err)
// Test reading file
content, err := suite.fileManager.ReadFile(suite.ctx, testFile)
suite.NoError(err)
suite.Equal(string(testContent), string(content))
// Test reading non-existent file
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
_, err = suite.fileManager.ReadFile(suite.ctx, nonExistentFile)
suite.Error(err)
suite.True(errors.NewFileNotFoundError("").Is(err))
}
func (suite *FileManagerTestSuite) TestRemove() {
// Create test file
testFile := filepath.Join(suite.tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test"), 0644)
suite.Require().NoError(err)
// Remove file
err = suite.fileManager.Remove(suite.ctx, testFile)
suite.NoError(err)
// Verify file doesn't exist
_, err = os.Stat(testFile)
suite.True(os.IsNotExist(err))
// Test removing non-existent file (should not error)
err = suite.fileManager.Remove(suite.ctx, testFile)
suite.NoError(err)
}
func (suite *FileManagerTestSuite) TestMkdirAll() {
// Create nested directory
nestedDir := filepath.Join(suite.tempDir, "a", "b", "c")
err := suite.fileManager.MkdirAll(suite.ctx, nestedDir, 0755)
suite.NoError(err)
// Verify directory exists
info, err := os.Stat(nestedDir)
suite.NoError(err)
suite.True(info.IsDir())
}
func (suite *FileManagerTestSuite) TestReadlink() {
// Create target file
targetFile := filepath.Join(suite.tempDir, "target.txt")
err := os.WriteFile(targetFile, []byte("test"), 0644)
suite.Require().NoError(err)
// Create symlink
linkFile := filepath.Join(suite.tempDir, "link.txt")
err = os.Symlink("target.txt", linkFile)
suite.Require().NoError(err)
// Test reading symlink
target, err := suite.fileManager.Readlink(suite.ctx, linkFile)
suite.NoError(err)
suite.Equal("target.txt", target)
// Test reading non-symlink
_, err = suite.fileManager.Readlink(suite.ctx, targetFile)
suite.Error(err)
}
func (suite *FileManagerTestSuite) TestStatAndLstat() {
// Create target file
targetFile := filepath.Join(suite.tempDir, "target.txt")
err := os.WriteFile(targetFile, []byte("test"), 0644)
suite.Require().NoError(err)
// Create symlink
linkFile := filepath.Join(suite.tempDir, "link.txt")
err = os.Symlink("target.txt", linkFile)
suite.Require().NoError(err)
// Test Stat on regular file
info, err := suite.fileManager.Stat(suite.ctx, targetFile)
suite.NoError(err)
suite.False(info.IsDir())
// Test Stat on symlink (should follow link)
info, err = suite.fileManager.Stat(suite.ctx, linkFile)
suite.NoError(err)
suite.False(info.IsDir())
// Test Lstat on symlink (should not follow link)
info, err = suite.fileManager.Lstat(suite.ctx, linkFile)
suite.NoError(err)
suite.NotZero(info.Mode() & os.ModeSymlink)
// Test on non-existent file
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
_, err = suite.fileManager.Stat(suite.ctx, nonExistentFile)
suite.Error(err)
suite.True(errors.NewFileNotFoundError("").Is(err))
}
func (suite *FileManagerTestSuite) TestContextCancellation() {
// Test with timeout context
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Allow time for context to expire
time.Sleep(1 * time.Millisecond)
// Test various operations with cancelled context
_, err := suite.fileManager.Exists(ctx, "/any/path")
suite.Equal(context.DeadlineExceeded, err)
_, err = suite.fileManager.IsDirectory(ctx, "/any/path")
suite.Equal(context.DeadlineExceeded, err)
err = suite.fileManager.Move(ctx, "/src", "/dst")
suite.Equal(context.DeadlineExceeded, err)
}
func TestFileManagerSuite(t *testing.T) {
suite.Run(t, new(FileManagerTestSuite))
}

View File

@@ -1,133 +0,0 @@
package fs
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// FileSystem handles file system operations
type FileSystem struct{}
// New creates a new FileSystem instance
func New() *FileSystem {
return &FileSystem{}
}
// ValidateFileForAdd validates that a file or directory can be added to lnk
func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
// Check if file exists
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", filePath)
}
return fmt.Errorf("failed to stat file: %w", err)
}
// Allow both regular files and directories
if !info.Mode().IsRegular() && !info.IsDir() {
return fmt.Errorf("only regular files and directories are supported: %s", filePath)
}
return nil
}
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
// Check if file exists
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", filePath)
}
return fmt.Errorf("failed to stat file: %w", err)
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink == 0 {
return fmt.Errorf("file is not managed by lnk: %s", filePath)
}
// Check if symlink points to the repository
target, err := os.Readlink(filePath)
if err != nil {
return fmt.Errorf("failed to read symlink: %w", err)
}
// Convert relative path to absolute if needed
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(filePath), target)
}
// Clean the path to resolve any .. or . components
target = filepath.Clean(target)
repoPath = filepath.Clean(repoPath)
// Check if target is inside the repository
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
return fmt.Errorf("file is not managed by lnk: %s", filePath)
}
return nil
}
// MoveFile moves a file from source to destination
func (fs *FileSystem) MoveFile(src, dst string) error {
// Ensure destination directory exists
dstDir := filepath.Dir(dst)
if err := os.MkdirAll(dstDir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Move the file
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("failed to move file from %s to %s: %w", src, dst, err)
}
return nil
}
// CreateSymlink creates a relative symlink from target to linkPath
func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
// Calculate relative path from linkPath to target
linkDir := filepath.Dir(linkPath)
relTarget, err := filepath.Rel(linkDir, target)
if err != nil {
return fmt.Errorf("failed to calculate relative path: %w", err)
}
// Create the symlink
if err := os.Symlink(relTarget, linkPath); err != nil {
return fmt.Errorf("failed to create symlink: %w", err)
}
return nil
}
// MoveDirectory moves a directory from source to destination recursively
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
dstParent := filepath.Dir(dst)
if err := os.MkdirAll(dstParent, 0755); err != nil {
return fmt.Errorf("failed to create destination parent directory: %w", err)
}
// Use os.Rename which works for directories
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("failed to move directory from %s to %s: %w", src, dst, err)
}
return nil
}

View File

@@ -1,499 +0,0 @@
package git
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Git handles Git operations
type Git struct {
repoPath string
}
// New creates a new Git instance
func New(repoPath string) *Git {
return &Git{
repoPath: repoPath,
}
}
// Init initializes a new Git repository
func (g *Git) Init() error {
// Try using git init -b main first (Git 2.28+)
cmd := exec.Command("git", "init", "-b", "main")
cmd.Dir = g.repoPath
_, err := cmd.CombinedOutput()
if err != nil {
// Fallback to regular init + branch rename for older Git versions
cmd = exec.Command("git", "init")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output))
}
// Set the default branch to main
cmd = exec.Command("git", "symbolic-ref", "HEAD", "refs/heads/main")
cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set default branch to main: %w", err)
}
}
return nil
}
// AddRemote adds a remote to the repository
func (g *Git) AddRemote(name, url string) error {
// Check if remote already exists
existingURL, err := g.getRemoteURL(name)
if err == nil {
// Remote exists, check if URL matches
if existingURL == url {
// Same URL, idempotent - do nothing
return nil
}
// Different URL, error
return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url)
}
// Remote doesn't exist, add it
cmd := exec.Command("git", "remote", "add", name, url)
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output))
}
return nil
}
// getRemoteURL returns the URL for a remote, or error if not found
func (g *Git) getRemoteURL(name string) (string, error) {
cmd := exec.Command("git", "remote", "get-url", name)
cmd.Dir = g.repoPath
output, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
// IsGitRepository checks if the directory contains a Git repository
func (g *Git) IsGitRepository() bool {
gitDir := filepath.Join(g.repoPath, ".git")
_, err := os.Stat(gitDir)
return err == nil
}
// IsLnkRepository checks if the repository appears to be managed by lnk
func (g *Git) IsLnkRepository() bool {
if !g.IsGitRepository() {
return false
}
// Check if this looks like a lnk repository
// We consider it a lnk repo if:
// 1. It has no commits (fresh repo), OR
// 2. All commits start with "lnk:" pattern
commits, err := g.GetCommits()
if err != nil {
return false
}
// If no commits, it's a fresh repo - could be lnk
if len(commits) == 0 {
return true
}
// If all commits start with "lnk:", it's definitely ours
// If ANY commit doesn't start with "lnk:", it's probably not ours
for _, commit := range commits {
if !strings.HasPrefix(commit, "lnk:") {
return false
}
}
return true
}
// AddAndCommit stages a file and commits it
func (g *Git) AddAndCommit(filename, message string) error {
// Stage the file
if err := g.Add(filename); err != nil {
return err
}
// Commit the changes
if err := g.Commit(message); err != nil {
return err
}
return nil
}
// RemoveAndCommit removes a file from Git and commits the change
func (g *Git) RemoveAndCommit(filename, message string) error {
// Remove the file from Git
if err := g.Remove(filename); err != nil {
return err
}
// Commit the changes
if err := g.Commit(message); err != nil {
return err
}
return nil
}
// Add stages a file
func (g *Git) Add(filename string) error {
cmd := exec.Command("git", "add", filename)
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
}
return nil
}
// Remove removes a file from Git tracking
func (g *Git) Remove(filename string) error {
// Check if it's a directory that needs -r flag
fullPath := filepath.Join(g.repoPath, filename)
info, err := os.Stat(fullPath)
var cmd *exec.Cmd
if err == nil && info.IsDir() {
// Use -r and --cached flags for directories (only remove from git, not filesystem)
cmd = exec.Command("git", "rm", "-r", "--cached", filename)
} else {
// Regular file (only remove from git, not filesystem)
cmd = exec.Command("git", "rm", "--cached", filename)
}
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output))
}
return nil
}
// Commit creates a commit with the given message
func (g *Git) Commit(message string) error {
// Configure git user if not already configured
if err := g.ensureGitConfig(); err != nil {
return err
}
cmd := exec.Command("git", "commit", "-m", message)
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output))
}
return nil
}
// ensureGitConfig ensures that git user.name and user.email are configured
func (g *Git) ensureGitConfig() error {
// Check if user.name is configured
cmd := exec.Command("git", "config", "user.name")
cmd.Dir = g.repoPath
if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 {
// Set a default user.name
cmd = exec.Command("git", "config", "user.name", "Lnk User")
cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set git user.name: %w", err)
}
}
// Check if user.email is configured
cmd = exec.Command("git", "config", "user.email")
cmd.Dir = g.repoPath
if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 {
// Set a default user.email
cmd = exec.Command("git", "config", "user.email", "lnk@localhost")
cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set git user.email: %w", err)
}
}
return nil
}
// GetCommits returns the list of commit messages for testing purposes
func (g *Git) GetCommits() ([]string, error) {
// Check if .git directory exists
gitDir := filepath.Join(g.repoPath, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
return []string{}, nil
}
cmd := exec.Command("git", "log", "--oneline", "--format=%s")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
// If there are no commits yet, return empty slice
outputStr := string(output)
if strings.Contains(outputStr, "does not have any commits yet") {
return []string{}, nil
}
return nil, fmt.Errorf("git log failed: %w", err)
}
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(commits) == 1 && commits[0] == "" {
return []string{}, nil
}
return commits, nil
}
// GetRemoteInfo returns information about the default remote
func (g *Git) GetRemoteInfo() (string, error) {
// First try to get origin remote
url, err := g.getRemoteURL("origin")
if err != nil {
// If origin doesn't exist, try to get any remote
cmd := exec.Command("git", "remote")
cmd.Dir = g.repoPath
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to list remotes: %w", err)
}
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(remotes) == 0 || remotes[0] == "" {
return "", fmt.Errorf("no remote configured")
}
// Use the first remote
url, err = g.getRemoteURL(remotes[0])
if err != nil {
return "", fmt.Errorf("failed to get remote URL: %w", err)
}
}
return url, nil
}
// StatusInfo contains repository status information
type StatusInfo struct {
Ahead int
Behind int
Remote string
}
// GetStatus returns the repository status relative to remote
func (g *Git) GetStatus() (*StatusInfo, error) {
// Check if we have a remote
_, err := g.GetRemoteInfo()
if err != nil {
return nil, err
}
// Get the remote tracking branch
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
cmd.Dir = g.repoPath
output, err := cmd.Output()
if err != nil {
// No upstream branch set, assume origin/main
remoteBranch := "origin/main"
return &StatusInfo{
Ahead: g.getAheadCount(remoteBranch),
Behind: 0, // Can't be behind if no upstream
Remote: remoteBranch,
}, nil
}
remoteBranch := strings.TrimSpace(string(output))
return &StatusInfo{
Ahead: g.getAheadCount(remoteBranch),
Behind: g.getBehindCount(remoteBranch),
Remote: remoteBranch,
}, nil
}
// getAheadCount returns how many commits ahead of remote
func (g *Git) getAheadCount(remoteBranch string) int {
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch))
cmd.Dir = g.repoPath
output, err := cmd.Output()
if err != nil {
// If remote branch doesn't exist, count all local commits
cmd = exec.Command("git", "rev-list", "--count", "HEAD")
cmd.Dir = g.repoPath
output, err = cmd.Output()
if err != nil {
return 0
}
}
count := strings.TrimSpace(string(output))
if count == "" {
return 0
}
// Convert to int
var ahead int
if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil {
return 0
}
return ahead
}
// getBehindCount returns how many commits behind remote
func (g *Git) getBehindCount(remoteBranch string) int {
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch))
cmd.Dir = g.repoPath
output, err := cmd.Output()
if err != nil {
return 0
}
count := strings.TrimSpace(string(output))
if count == "" {
return 0
}
// Convert to int
var behind int
if _, err := fmt.Sscanf(count, "%d", &behind); err != nil {
return 0
}
return behind
}
// HasChanges checks if there are uncommitted changes
func (g *Git) HasChanges() (bool, error) {
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = g.repoPath
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
return len(strings.TrimSpace(string(output))) > 0, nil
}
// AddAll stages all changes in the repository
func (g *Git) AddAll() error {
cmd := exec.Command("git", "add", "-A")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
}
return nil
}
// Push pushes changes to remote
func (g *Git) Push() error {
// First ensure we have a remote configured
_, err := g.GetRemoteInfo()
if err != nil {
return fmt.Errorf("cannot push: %w", err)
}
cmd := exec.Command("git", "push", "-u", "origin", "main")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output))
}
return nil
}
// Pull pulls changes from remote
func (g *Git) Pull() error {
// First ensure we have a remote configured
_, err := g.GetRemoteInfo()
if err != nil {
return fmt.Errorf("cannot pull: %w", err)
}
cmd := exec.Command("git", "pull", "origin", "main")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output))
}
return nil
}
// Clone clones a repository from the given URL
func (g *Git) Clone(url string) error {
// Remove the directory if it exists to ensure clean clone
if err := os.RemoveAll(g.repoPath); err != nil {
return fmt.Errorf("failed to remove existing directory: %w", err)
}
// Create parent directory
parentDir := filepath.Dir(g.repoPath)
if err := os.MkdirAll(parentDir, 0755); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
}
// Clone the repository
cmd := exec.Command("git", "clone", url, g.repoPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output))
}
// Set up upstream tracking for main branch
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/main", "main")
cmd.Dir = g.repoPath
_, err = cmd.CombinedOutput()
if err != nil {
// If main doesn't exist, try master
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/master", "master")
cmd.Dir = g.repoPath
_, err = cmd.CombinedOutput()
if err != nil {
// If that also fails, try to set upstream for current branch
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/HEAD")
cmd.Dir = g.repoPath
_, _ = cmd.CombinedOutput() // Ignore error as this is best effort
}
}
return nil
}

547
internal/git/gitmanager.go Normal file
View File

@@ -0,0 +1,547 @@
package git
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/yarlson/lnk/internal/errors"
"github.com/yarlson/lnk/internal/models"
)
// GitManager implements the models.GitManager interface
type GitManager struct{}
// New creates a new GitManager instance
func New() *GitManager {
return &GitManager{}
}
// Init initializes a new Git repository at repoPath
func (g *GitManager) Init(ctx context.Context, repoPath string) error {
// Try using git init -b main first (Git 2.28+)
cmd := exec.CommandContext(ctx, "git", "init", "-b", "main")
cmd.Dir = repoPath
_, err := cmd.CombinedOutput()
if err != nil {
// Fallback to regular init + branch rename for older Git versions
cmd = exec.CommandContext(ctx, "git", "init")
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return errors.NewGitOperationError("init", fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output)))
}
// Set the default branch to main
cmd = exec.CommandContext(ctx, "git", "symbolic-ref", "HEAD", "refs/heads/main")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
return errors.NewGitOperationError("init", fmt.Errorf("failed to set default branch to main: %w", err))
}
}
return nil
}
// Clone clones a repository from url to repoPath
func (g *GitManager) Clone(ctx context.Context, repoPath, url string) error {
// Remove the directory if it exists to ensure clean clone
if err := os.RemoveAll(repoPath); err != nil {
return errors.NewFileSystemOperationError("remove_existing_dir", repoPath,
fmt.Errorf("failed to remove existing directory: %w", err))
}
// Create parent directory
parentDir := filepath.Dir(repoPath)
if err := os.MkdirAll(parentDir, 0755); err != nil {
return errors.NewFileSystemOperationError("create_parent_dir", parentDir,
fmt.Errorf("failed to create parent directory: %w", err))
}
// Clone the repository
cmd := exec.CommandContext(ctx, "git", "clone", url, repoPath)
output, err := cmd.CombinedOutput()
if err != nil {
return errors.NewGitOperationError("clone", fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output)))
}
// Set up upstream tracking for main branch
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/main", "main")
cmd.Dir = repoPath
_, err = cmd.CombinedOutput()
if err != nil {
// If main doesn't exist, try master
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/master", "master")
cmd.Dir = repoPath
_, err = cmd.CombinedOutput()
if err != nil {
// If that also fails, try to set upstream for current branch
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/HEAD")
cmd.Dir = repoPath
_, _ = cmd.CombinedOutput() // Ignore error as this is best effort
}
}
return nil
}
// Add stages files for commit
func (g *GitManager) Add(ctx context.Context, repoPath string, files ...string) error {
args := append([]string{"add"}, files...)
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return errors.NewGitOperationError("add", fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output)))
}
return nil
}
// Remove removes files from Git tracking
func (g *GitManager) Remove(ctx context.Context, repoPath string, files ...string) error {
for _, filename := range files {
// Check if it's a directory in the repository by checking the actual repo path
fullPath := filepath.Join(repoPath, filename)
info, err := os.Stat(fullPath)
var cmd *exec.Cmd
useRecursive := false
if err == nil && info.IsDir() {
useRecursive = true
}
if useRecursive {
// Use -r and --cached flags for directories (only remove from git, not fs)
cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename)
} else {
// Regular file (only remove from git, not fs)
cmd = exec.CommandContext(ctx, "git", "rm", "--cached", filename)
}
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
// If we tried without -r and got a "recursively without -r" error, try with -r
if !useRecursive && strings.Contains(string(output), "recursively without -r") {
cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename)
cmd.Dir = repoPath
output, err = cmd.CombinedOutput()
}
if err != nil {
return errors.NewGitOperationError("remove", fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output)))
}
}
}
return nil
}
// Commit creates a commit with the given message
func (g *GitManager) Commit(ctx context.Context, repoPath, message string) error {
// Configure git user if not already configured
if err := g.ensureGitConfig(ctx, repoPath); err != nil {
return err
}
cmd := exec.CommandContext(ctx, "git", "commit", "-m", message)
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return errors.NewGitOperationError("commit", fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output)))
}
return nil
}
// Push pushes changes to the remote repository
func (g *GitManager) Push(ctx context.Context, repoPath string) error {
// First ensure we have a remote configured
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
if err != nil {
return errors.NewGitOperationError("push", fmt.Errorf("cannot push: %w", err))
}
cmd := exec.CommandContext(ctx, "git", "push", "-u", "origin", "main")
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return errors.NewGitOperationError("push", fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output)))
}
return nil
}
// Pull pulls changes from the remote repository
func (g *GitManager) Pull(ctx context.Context, repoPath string) error {
// First ensure we have a remote configured
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
if err != nil {
return errors.NewGitOperationError("pull", fmt.Errorf("cannot pull: %w", err))
}
cmd := exec.CommandContext(ctx, "git", "pull", "origin", "main")
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return errors.NewGitOperationError("pull", fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output)))
}
return nil
}
// Status returns the current Git status
func (g *GitManager) Status(ctx context.Context, repoPath string) (*models.SyncStatus, error) {
// First check if we have a remote configured - this should match old behavior
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
if err != nil {
// If origin doesn't exist, check if we have any remotes at all
cmd := exec.CommandContext(ctx, "git", "remote")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return nil, errors.NewGitOperationError("list_remotes", err)
}
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(remotes) == 0 || remotes[0] == "" {
return nil, errors.NewGitOperationError("status", fmt.Errorf("no remote configured"))
}
}
// Get current branch
currentBranch, err := g.getCurrentBranch(ctx, repoPath)
if err != nil {
return nil, errors.NewGitOperationError("get_current_branch", err)
}
// Check for uncommitted changes
dirty, err := g.HasChanges(ctx, repoPath)
if err != nil {
return nil, errors.NewGitOperationError("check_changes", err)
}
// Get the remote URL
remoteURL, err := g.GetRemoteURL(ctx, repoPath, "origin")
hasRemote := err == nil
// Initialize status with basic information
status := &models.SyncStatus{
CurrentBranch: currentBranch,
Dirty: dirty,
HasRemote: hasRemote,
RemoteURL: remoteURL,
}
// If no remote, we can't determine ahead/behind counts
if !hasRemote {
return status, nil
}
// Get the remote tracking branch
remoteBranch, err := g.getRemoteTrackingBranch(ctx, repoPath)
if err != nil {
// No upstream branch set, assume origin/main
remoteBranch = "origin/main"
}
status.RemoteBranch = remoteBranch
// Get ahead/behind counts
status.Ahead = g.getAheadCount(ctx, repoPath, remoteBranch)
status.Behind = g.getBehindCount(ctx, repoPath, remoteBranch)
// Get last commit hash
lastCommitHash, err := g.getLastCommitHash(ctx, repoPath)
if err == nil {
status.LastCommitHash = lastCommitHash
}
return status, nil
}
// IsRepository checks if the path is a Git repository
func (g *GitManager) IsRepository(ctx context.Context, repoPath string) (bool, error) {
gitDir := filepath.Join(repoPath, ".git")
_, err := os.Stat(gitDir)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, errors.NewFileSystemOperationError("check_git_dir", gitDir, err)
}
return true, nil
}
// HasChanges checks if there are uncommitted changes
func (g *GitManager) HasChanges(ctx context.Context, repoPath string) (bool, error) {
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return false, errors.NewGitOperationError("status", fmt.Errorf("git status failed: %w", err))
}
return len(strings.TrimSpace(string(output))) > 0, nil
}
// AddRemote adds a remote to the repository
func (g *GitManager) AddRemote(ctx context.Context, repoPath, name, url string) error {
// Check if remote already exists
existingURL, err := g.GetRemoteURL(ctx, repoPath, name)
if err == nil {
// Remote exists, check if URL matches
if existingURL == url {
// Same URL, idempotent - do nothing
return nil
}
// Different URL, error
return errors.NewGitOperationError("add_remote",
fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url))
}
// Remote doesn't exist, add it
cmd := exec.CommandContext(ctx, "git", "remote", "add", name, url)
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return errors.NewGitOperationError("add_remote", fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output)))
}
return nil
}
// GetRemoteURL returns the URL of a remote
func (g *GitManager) GetRemoteURL(ctx context.Context, repoPath, name string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", name)
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return "", errors.NewGitOperationError("get_remote_url", fmt.Errorf("failed to get remote URL for %s: %w", name, err))
}
return strings.TrimSpace(string(output)), nil
}
// IsLnkRepository checks if the repository appears to be managed by lnk
func (g *GitManager) IsLnkRepository(ctx context.Context, repoPath string) (bool, error) {
isRepo, err := g.IsRepository(ctx, repoPath)
if err != nil {
return false, err
}
if !isRepo {
return false, nil
}
// Check if this looks like a lnk repository
// We consider it a lnk repo if:
// 1. It has no commits (fresh repo), OR
// 2. All commits start with "lnk:" pattern
commits, err := g.getCommits(ctx, repoPath)
if err != nil {
return false, errors.NewGitOperationError("get_commits", err)
}
// If no commits, it's a fresh repo - could be lnk
if len(commits) == 0 {
return true, nil
}
// If all commits start with "lnk:", it's definitely ours
// If ANY commit doesn't start with "lnk:", it's probably not ours
for _, commit := range commits {
if !strings.HasPrefix(commit, "lnk:") {
return false, nil
}
}
return true, nil
}
// Helper methods
// ensureGitConfig configures git user if not already configured
func (g *GitManager) ensureGitConfig(ctx context.Context, repoPath string) error {
// Check if user.name is configured
cmd := exec.CommandContext(ctx, "git", "config", "user.name")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
// Set default user.name
cmd = exec.CommandContext(ctx, "git", "config", "user.name", "lnk")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
return errors.NewGitOperationError("config_user_name", fmt.Errorf("failed to set git user.name: %w", err))
}
}
// Check if user.email is configured
cmd = exec.CommandContext(ctx, "git", "config", "user.email")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
// Set default user.email
cmd = exec.CommandContext(ctx, "git", "config", "user.email", "lnk@local")
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
return errors.NewGitOperationError("config_user_email", fmt.Errorf("failed to set git user.email: %w", err))
}
}
return nil
}
// getCurrentBranch returns the current branch name
func (g *GitManager) getCurrentBranch(ctx context.Context, repoPath string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = repoPath
output, err := cmd.CombinedOutput()
if err != nil {
// For empty repositories, HEAD might not exist yet, default to main
errStr := string(output)
if strings.Contains(errStr, "fatal: ambiguous argument 'HEAD'") ||
strings.Contains(errStr, "unknown revision") ||
strings.Contains(errStr, "not a valid ref") ||
strings.Contains(errStr, "bad revision") {
return "main", nil
}
return "", fmt.Errorf("failed to get current branch: %w", err)
}
branch := strings.TrimSpace(string(output))
// If the branch is HEAD (detached state), try to get the default branch
if branch == "HEAD" {
return "main", nil
}
return branch, nil
}
// getRemoteTrackingBranch returns the remote tracking branch
func (g *GitManager) getRemoteTrackingBranch(ctx context.Context, repoPath string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("no upstream branch set: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
// getAheadCount returns how many commits ahead of remote
func (g *GitManager) getAheadCount(ctx context.Context, repoPath, remoteBranch string) int {
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch))
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
// If remote branch doesn't exist, count all local commits
cmd = exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD")
cmd.Dir = repoPath
output, err = cmd.Output()
if err != nil {
return 0
}
}
count := strings.TrimSpace(string(output))
if count == "" {
return 0
}
// Convert to int
var ahead int
if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil {
return 0
}
return ahead
}
// getBehindCount returns how many commits behind remote
func (g *GitManager) getBehindCount(ctx context.Context, repoPath, remoteBranch string) int {
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch))
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return 0
}
count := strings.TrimSpace(string(output))
if count == "" {
return 0
}
// Convert to int
var behind int
if _, err := fmt.Sscanf(count, "%d", &behind); err != nil {
return 0
}
return behind
}
// getLastCommitHash returns the hash of the last commit
func (g *GitManager) getLastCommitHash(ctx context.Context, repoPath string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get last commit hash: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
// getCommits returns commit messages
func (g *GitManager) getCommits(ctx context.Context, repoPath string) ([]string, error) {
cmd := exec.CommandContext(ctx, "git", "log", "--pretty=format:%s")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
// If there are no commits, git log will fail
// Use CombinedOutput to get both stdout and stderr to check the error message
cmd = exec.CommandContext(ctx, "git", "log", "--pretty=format:%s")
cmd.Dir = repoPath
combinedOutput, _ := cmd.CombinedOutput()
errStr := string(combinedOutput)
if strings.Contains(errStr, "does not have any commits yet") ||
strings.Contains(errStr, "bad default revision") ||
strings.Contains(errStr, "unknown revision") ||
strings.Contains(errStr, "ambiguous argument") {
return []string{}, nil
}
return nil, fmt.Errorf("failed to get commits: %w", err)
}
outputStr := strings.TrimSpace(string(output))
if outputStr == "" {
return []string{}, nil
}
commitMessages := strings.Split(outputStr, "\n")
return commitMessages, nil
}

View File

@@ -0,0 +1,307 @@
package git
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/suite"
)
type GitManagerTestSuite struct {
suite.Suite
tempDir string
gitManager *GitManager
ctx context.Context
}
func (suite *GitManagerTestSuite) SetupTest() {
// Create temp directory for testing
tempDir, err := os.MkdirTemp("", "lnk_git_test_*")
suite.Require().NoError(err)
suite.tempDir = tempDir
// Create git manager
suite.gitManager = New()
// Create context
suite.ctx = context.Background()
}
func (suite *GitManagerTestSuite) TearDownTest() {
err := os.RemoveAll(suite.tempDir)
suite.Require().NoError(err)
}
// Helper function to check if file exists
func (suite *GitManagerTestSuite) fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func (suite *GitManagerTestSuite) TestInit() {
repoPath := filepath.Join(suite.tempDir, "test-repo")
// Create the directory
err := os.MkdirAll(repoPath, 0755)
suite.Require().NoError(err)
// Test init
err = suite.gitManager.Init(suite.ctx, repoPath)
suite.NoError(err)
// Verify repository was created
isRepo, err := suite.gitManager.IsRepository(suite.ctx, repoPath)
suite.NoError(err)
suite.True(isRepo)
}
func (suite *GitManagerTestSuite) TestAddCommit() {
repoPath := filepath.Join(suite.tempDir, "test-repo")
// Create and initialize repository
err := os.MkdirAll(repoPath, 0755)
suite.Require().NoError(err)
err = suite.gitManager.Init(suite.ctx, repoPath)
suite.Require().NoError(err)
// Create a test file
testFile := filepath.Join(repoPath, "test.txt")
err = os.WriteFile(testFile, []byte("test content"), 0644)
suite.Require().NoError(err)
// Test adding file
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
suite.NoError(err)
// Test commit
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit")
suite.NoError(err)
// Verify no uncommitted changes
hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath)
suite.NoError(err)
suite.False(hasChanges)
}
func (suite *GitManagerTestSuite) TestStatus() {
repoPath := filepath.Join(suite.tempDir, "test-repo")
// Create and initialize repository
err := os.MkdirAll(repoPath, 0755)
suite.Require().NoError(err)
err = suite.gitManager.Init(suite.ctx, repoPath)
suite.Require().NoError(err)
// Test status on empty repository should fail with no remote configured
_, err = suite.gitManager.Status(suite.ctx, repoPath)
suite.Error(err)
suite.Contains(err.Error(), "no remote configured")
// Add a remote to make status work
testURL := "https://github.com/test/repo.git"
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
suite.Require().NoError(err)
// Test status with remote configured but no commits
status, err := suite.gitManager.Status(suite.ctx, repoPath)
suite.NoError(err)
suite.Equal("main", status.CurrentBranch)
suite.False(status.Dirty)
suite.True(status.HasRemote)
// Create and commit a file
testFile := filepath.Join(repoPath, "test.txt")
err = os.WriteFile(testFile, []byte("test content"), 0644)
suite.Require().NoError(err)
// Test dirty status
status, err = suite.gitManager.Status(suite.ctx, repoPath)
suite.NoError(err)
suite.True(status.Dirty)
// Add and commit
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
suite.Require().NoError(err)
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit")
suite.Require().NoError(err)
// Test clean status
status, err = suite.gitManager.Status(suite.ctx, repoPath)
suite.NoError(err)
suite.False(status.Dirty)
suite.NotEmpty(status.LastCommitHash)
}
func (suite *GitManagerTestSuite) TestRemoteOperations() {
repoPath := filepath.Join(suite.tempDir, "test-repo")
// Create and initialize repository
err := os.MkdirAll(repoPath, 0755)
suite.Require().NoError(err)
err = suite.gitManager.Init(suite.ctx, repoPath)
suite.Require().NoError(err)
// Test adding remote
testURL := "https://github.com/test/repo.git"
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
suite.NoError(err)
// Test getting remote URL
remoteURL, err := suite.gitManager.GetRemoteURL(suite.ctx, repoPath, "origin")
suite.NoError(err)
suite.Equal(testURL, remoteURL)
// Test idempotent add (same URL)
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
suite.NoError(err)
// Test adding remote with different URL should fail
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", "https://github.com/different/repo.git")
suite.Error(err)
}
func (suite *GitManagerTestSuite) TestIsLnkRepository() {
tests := []struct {
name string
setup func(string) error
expected bool
}{
{
name: "not_a_repository",
setup: func(path string) error {
return os.MkdirAll(path, 0755)
},
expected: false,
},
{
name: "empty_git_repository",
setup: func(path string) error {
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
return suite.gitManager.Init(suite.ctx, path)
},
expected: true,
},
{
name: "repository_with_lnk_commits",
setup: func(path string) error {
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
if err := suite.gitManager.Init(suite.ctx, path); err != nil {
return err
}
// Create and commit a file with lnk prefix
testFile := filepath.Join(path, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
return err
}
if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil {
return err
}
return suite.gitManager.Commit(suite.ctx, path, "lnk: add test file")
},
expected: true,
},
{
name: "repository_with_non-lnk_commits",
setup: func(path string) error {
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
if err := suite.gitManager.Init(suite.ctx, path); err != nil {
return err
}
// Create and commit a file without lnk prefix
testFile := filepath.Join(path, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
return err
}
if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil {
return err
}
return suite.gitManager.Commit(suite.ctx, path, "regular commit")
},
expected: false,
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
repoPath := filepath.Join(suite.tempDir, tt.name)
err := tt.setup(repoPath)
suite.Require().NoError(err)
isLnk, err := suite.gitManager.IsLnkRepository(suite.ctx, repoPath)
suite.NoError(err)
suite.Equal(tt.expected, isLnk)
})
}
}
func (suite *GitManagerTestSuite) TestContextCancellation() {
repoPath := filepath.Join(suite.tempDir, "test-repo")
err := os.MkdirAll(repoPath, 0755)
suite.Require().NoError(err)
// Test context cancellation
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// This should fail due to context timeout
err = suite.gitManager.Init(ctx, repoPath)
suite.Error(err)
// Verify the error is context-related
suite.NotNil(ctx.Err())
}
func (suite *GitManagerTestSuite) TestRemove() {
repoPath := filepath.Join(suite.tempDir, "test-repo")
// Create and initialize repository
err := os.MkdirAll(repoPath, 0755)
suite.Require().NoError(err)
err = suite.gitManager.Init(suite.ctx, repoPath)
suite.Require().NoError(err)
// Create and add files
testFile := filepath.Join(repoPath, "test.txt")
err = os.WriteFile(testFile, []byte("test content"), 0644)
suite.Require().NoError(err)
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
suite.Require().NoError(err)
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: add test file")
suite.Require().NoError(err)
// Test removing file
err = suite.gitManager.Remove(suite.ctx, repoPath, "test.txt")
suite.NoError(err)
// Verify file is removed from git but still exists on fs
suite.True(suite.fileExists(testFile))
// Verify repository has changes
hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath)
suite.NoError(err)
suite.True(hasChanges)
}
func TestGitManagerSuite(t *testing.T) {
suite.Run(t, new(GitManagerTestSuite))
}

108
internal/models/models.go Normal file
View File

@@ -0,0 +1,108 @@
package models
import (
"os"
"time"
)
// ManagedFile represents a file or directory managed by lnk
type ManagedFile struct {
// ID for potential future database use
ID string `json:"id,omitempty"`
// OriginalPath is the original absolute path where the file was located
OriginalPath string `json:"original_path"`
// RepoPath is the path within the lnk repository
RepoPath string `json:"repo_path"`
// RelativePath is the path relative to the home directory (or absolute for files outside home)
RelativePath string `json:"relative_path"`
// Host is the hostname where this file is managed
Host string `json:"host"`
// IsDirectory indicates whether this is a directory
IsDirectory bool `json:"is_directory"`
// SymlinkTarget is the current symlink target (if the original location is now a symlink)
SymlinkTarget string `json:"symlink_target,omitempty"`
// AddedAt is when the file was first added to lnk
AddedAt time.Time `json:"added_at,omitempty"`
// UpdatedAt is when the file was last updated
UpdatedAt time.Time `json:"updated_at,omitempty"`
// Mode stores the file permissions
Mode os.FileMode `json:"mode,omitempty"`
}
// RepositoryConfig represents the lnk repository settings
type RepositoryConfig struct {
// Path is the absolute path to the lnk repository
Path string `json:"path"`
// DefaultRemote is the default Git remote for sync operations
DefaultRemote string `json:"default_remote,omitempty"`
// Created is when the repository was created
Created time.Time `json:"created,omitempty"`
// LastSync is when the repository was last synced
LastSync time.Time `json:"last_sync,omitempty"`
}
// HostConfig represents configuration specific to a host
type HostConfig struct {
// Name is the hostname
Name string `json:"name"`
// ManagedFiles is the list of files managed on this host
ManagedFiles []ManagedFile `json:"managed_files"`
// LastUpdate is when this host configuration was last updated
LastUpdate time.Time `json:"last_update,omitempty"`
}
// SyncStatus represents Git repository sync status
type SyncStatus struct {
// Ahead is the number of commits ahead of remote
Ahead int `json:"ahead"`
// Behind is the number of commits behind remote
Behind int `json:"behind"`
// CurrentBranch is the currently checked out branch
CurrentBranch string `json:"current_branch"`
// RemoteBranch is the remote tracking branch
RemoteBranch string `json:"remote_branch"`
// RemoteURL is the URL of the remote repository
RemoteURL string `json:"remote_url"`
// Dirty indicates if there are uncommitted changes
Dirty bool `json:"dirty"`
// LastCommitHash is the hash of the last commit
LastCommitHash string `json:"last_commit_hash"`
// HasRemote indicates if a remote is configured
HasRemote bool `json:"has_remote"`
}
// IsClean returns true if the repository is clean (no uncommitted changes)
func (s *SyncStatus) IsClean() bool {
return !s.Dirty
}
// IsSynced returns true if the repository is in sync with remote (ahead=0, behind=0)
func (s *SyncStatus) IsSynced() bool {
return s.Ahead == 0 && s.Behind == 0
}
// NeedsSync returns true if the repository needs to be synced with remote
func (s *SyncStatus) NeedsSync() bool {
return s.Ahead > 0 || s.Behind > 0
}

View File

@@ -0,0 +1,185 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/suite"
)
type ModelsTestSuite struct {
suite.Suite
}
func (suite *ModelsTestSuite) TestManagedFile() {
now := time.Now()
file := ManagedFile{
ID: "test-id",
OriginalPath: "/home/user/.vimrc",
RepoPath: "/home/user/.config/lnk/.vimrc",
RelativePath: ".vimrc",
Host: "workstation",
IsDirectory: false,
AddedAt: now,
UpdatedAt: now,
}
suite.Equal("test-id", file.ID)
suite.Equal("/home/user/.vimrc", file.OriginalPath)
suite.Equal("workstation", file.Host)
}
func (suite *ModelsTestSuite) TestRepositoryConfig() {
now := time.Now()
config := RepositoryConfig{
Path: "/home/user/.config/lnk",
DefaultRemote: "origin",
Created: now,
LastSync: now,
}
suite.Equal("/home/user/.config/lnk", config.Path)
suite.Equal("origin", config.DefaultRemote)
}
func (suite *ModelsTestSuite) TestHostConfig() {
now := time.Now()
managedFile := ManagedFile{
RelativePath: ".vimrc",
Host: "workstation",
}
config := HostConfig{
Name: "workstation",
ManagedFiles: []ManagedFile{managedFile},
LastUpdate: now,
}
suite.Equal("workstation", config.Name)
suite.Len(config.ManagedFiles, 1)
suite.Equal(".vimrc", config.ManagedFiles[0].RelativePath)
}
func (suite *ModelsTestSuite) TestSyncStatusIsClean() {
tests := []struct {
name string
dirty bool
expected bool
}{
{
name: "clean_repository",
dirty: false,
expected: true,
},
{
name: "dirty_repository",
dirty: true,
expected: false,
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
status := SyncStatus{Dirty: tt.dirty}
result := status.IsClean()
suite.Equal(tt.expected, result)
})
}
}
func (suite *ModelsTestSuite) TestSyncStatusIsSynced() {
tests := []struct {
name string
ahead int
behind int
expected bool
}{
{
name: "fully_synced",
ahead: 0,
behind: 0,
expected: true,
},
{
name: "ahead_of_remote",
ahead: 2,
behind: 0,
expected: false,
},
{
name: "behind_remote",
ahead: 0,
behind: 3,
expected: false,
},
{
name: "diverged",
ahead: 1,
behind: 2,
expected: false,
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
status := SyncStatus{
Ahead: tt.ahead,
Behind: tt.behind,
}
result := status.IsSynced()
suite.Equal(tt.expected, result)
})
}
}
func (suite *ModelsTestSuite) TestSyncStatusNeedsSync() {
tests := []struct {
name string
ahead int
behind int
expected bool
}{
{
name: "fully_synced",
ahead: 0,
behind: 0,
expected: false,
},
{
name: "ahead_of_remote",
ahead: 2,
behind: 0,
expected: true,
},
{
name: "behind_remote",
ahead: 0,
behind: 3,
expected: true,
},
{
name: "diverged",
ahead: 1,
behind: 2,
expected: true,
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
status := SyncStatus{
Ahead: tt.ahead,
Behind: tt.behind,
}
result := status.NeedsSync()
suite.Equal(tt.expected, result)
})
}
}
func TestModelsSuite(t *testing.T) {
suite.Run(t, new(ModelsTestSuite))
}

View File

@@ -0,0 +1,153 @@
package pathresolver
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Resolver implements the models.PathResolver interface
type Resolver struct{}
// New creates a new PathResolver instance
func New() *Resolver {
return &Resolver{}
}
// GetRepoStoragePath returns the base path where lnk repositories are stored
// This is based on XDG Base Directory specification
func (r *Resolver) GetRepoStoragePath() (string, error) {
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
if xdgConfig == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
xdgConfig = filepath.Join(homeDir, ".config")
}
return filepath.Join(xdgConfig, "lnk"), nil
}
// GetFileStoragePathInRepo returns the path where a file should be stored in the repository
func (r *Resolver) GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error) {
hostPath, err := r.GetHostStoragePath(repoPath, host)
if err != nil {
return "", err
}
return filepath.Join(hostPath, relativePath), nil
}
// GetTrackingFilePath returns the path to the tracking file for a host
func (r *Resolver) GetTrackingFilePath(repoPath, host string) (string, error) {
var fileName string
if host == "" {
// Common configuration
fileName = ".lnk"
} else {
// Host-specific configuration
fileName = ".lnk." + host
}
return filepath.Join(repoPath, fileName), nil
}
// GetHomePath returns the user's home directory path
func (r *Resolver) GetHomePath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return homeDir, nil
}
// GetRelativePathFromHome converts an absolute path to relative from home directory
// This is migrated from the original getRelativePath function
func (r *Resolver) GetRelativePathFromHome(absPath string) (string, error) {
homeDir, err := r.GetHomePath()
if err != nil {
return "", 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
}
// GetAbsolutePathInHome converts a relative path to absolute within home directory
func (r *Resolver) GetAbsolutePathInHome(relPath string) (string, error) {
homeDir, err := r.GetHomePath()
if err != nil {
return "", err
}
// If the relative path looks like an absolute path (starts with / or drive letter),
// it's probably a file outside home directory
if filepath.IsAbs(relPath) {
return relPath, nil
}
// If it starts with a drive letter on Windows or looks like an absolute path,
// treat it as absolute
if len(relPath) > 0 && !strings.HasPrefix(relPath, ".") {
// Check if it looks like an absolute path stored without leading slash
// This handles paths like "etc/hosts" which should become "/etc/hosts"
if strings.HasPrefix(relPath, "etc/") ||
strings.HasPrefix(relPath, "usr/") ||
strings.HasPrefix(relPath, "var/") ||
strings.HasPrefix(relPath, "opt/") ||
strings.HasPrefix(relPath, "tmp/") {
// Reconstruct the absolute path
return "/" + relPath, nil
}
// Windows drive patterns like "C:" or contains drive separator
if strings.Contains(relPath, ":") {
return relPath, nil
}
}
return filepath.Join(homeDir, relPath), nil
}
// GetHostStoragePath returns the directory where files for a host are stored
// This is migrated from the original getHostStoragePath method
func (r *Resolver) GetHostStoragePath(repoPath, host string) (string, error) {
if host == "" {
// Common configuration - store in root of repo
return repoPath, nil
}
// Host-specific configuration - store in host subdirectory
return filepath.Join(repoPath, host+".lnk"), nil
}
// IsUnderHome checks if a path is under the home directory
func (r *Resolver) IsUnderHome(path string) (bool, error) {
homeDir, err := r.GetHomePath()
if err != nil {
return false, err
}
// Clean both paths to handle relative components like .. and .
cleanPath := filepath.Clean(path)
cleanHome := filepath.Clean(homeDir)
// Get relative path
relPath, err := filepath.Rel(cleanHome, cleanPath)
if err != nil {
return false, nil // If we can't get relative path, assume not under home
}
// If relative path starts with "..", it's outside home directory
return !strings.HasPrefix(relPath, ".."), nil
}

View File

@@ -0,0 +1,250 @@
package pathresolver
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/suite"
)
type ResolverTestSuite struct {
suite.Suite
resolver *Resolver
}
func (suite *ResolverTestSuite) SetupTest() {
suite.resolver = New()
}
func (suite *ResolverTestSuite) TestGetRepoStoragePath() {
// Test with XDG_CONFIG_HOME set
originalXDG := os.Getenv("XDG_CONFIG_HOME")
defer os.Setenv("XDG_CONFIG_HOME", originalXDG)
suite.Run("with_XDG_CONFIG_HOME_set", func() {
testXDG := "/test/config"
os.Setenv("XDG_CONFIG_HOME", testXDG)
path, err := suite.resolver.GetRepoStoragePath()
suite.NoError(err)
expected := filepath.Join(testXDG, "lnk")
suite.Equal(expected, path)
})
suite.Run("without_XDG_CONFIG_HOME", func() {
os.Unsetenv("XDG_CONFIG_HOME")
path, err := suite.resolver.GetRepoStoragePath()
suite.NoError(err)
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, ".config", "lnk")
suite.Equal(expected, path)
})
}
func (suite *ResolverTestSuite) TestGetTrackingFilePath() {
repoPath := "/test/repo"
tests := []struct {
name string
host string
expected string
}{
{
name: "common_config",
host: "",
expected: filepath.Join(repoPath, ".lnk"),
},
{
name: "host-specific_config",
host: "myhost",
expected: filepath.Join(repoPath, ".lnk.myhost"),
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
path, err := suite.resolver.GetTrackingFilePath(repoPath, tt.host)
suite.NoError(err)
suite.Equal(tt.expected, path)
})
}
}
func (suite *ResolverTestSuite) TestGetHostStoragePath() {
repoPath := "/test/repo"
tests := []struct {
name string
host string
expected string
}{
{
name: "common_config",
host: "",
expected: repoPath,
},
{
name: "host-specific_config",
host: "myhost",
expected: filepath.Join(repoPath, "myhost.lnk"),
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
path, err := suite.resolver.GetHostStoragePath(repoPath, tt.host)
suite.NoError(err)
suite.Equal(tt.expected, path)
})
}
}
func (suite *ResolverTestSuite) TestGetRelativePathFromHome() {
homeDir, err := os.UserHomeDir()
suite.Require().NoError(err)
tests := []struct {
name string
absPath string
expected string
}{
{
name: "file_in_home",
absPath: filepath.Join(homeDir, "Documents", "test.txt"),
expected: filepath.Join("Documents", "test.txt"),
},
{
name: "file_outside_home",
absPath: "/etc/hosts",
expected: "etc/hosts",
},
{
name: "home_directory_itself",
absPath: homeDir,
expected: ".",
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
result, err := suite.resolver.GetRelativePathFromHome(tt.absPath)
suite.NoError(err)
suite.Equal(tt.expected, result)
})
}
}
func (suite *ResolverTestSuite) TestGetAbsolutePathInHome() {
homeDir, err := os.UserHomeDir()
suite.Require().NoError(err)
tests := []struct {
name string
relPath string
expected string
}{
{
name: "relative_path_in_home",
relPath: filepath.Join("Documents", "test.txt"),
expected: filepath.Join(homeDir, "Documents", "test.txt"),
},
{
name: "already_absolute_path",
relPath: "/etc/hosts",
expected: "/etc/hosts",
},
{
name: "absolute-like_path_without_leading_slash",
relPath: "etc/hosts",
expected: "/etc/hosts",
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
result, err := suite.resolver.GetAbsolutePathInHome(tt.relPath)
suite.NoError(err)
suite.Equal(tt.expected, result)
})
}
}
func (suite *ResolverTestSuite) TestIsUnderHome() {
homeDir, err := os.UserHomeDir()
suite.Require().NoError(err)
tests := []struct {
name string
path string
expected bool
}{
{
name: "file_in_home",
path: filepath.Join(homeDir, "Documents", "test.txt"),
expected: true,
},
{
name: "file_outside_home",
path: "/etc/hosts",
expected: false,
},
{
name: "home_directory_itself",
path: homeDir,
expected: true,
},
{
name: "parent_of_home",
path: filepath.Dir(homeDir),
expected: false,
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
result, err := suite.resolver.IsUnderHome(tt.path)
suite.NoError(err)
suite.Equal(tt.expected, result)
})
}
}
func (suite *ResolverTestSuite) TestGetFileStoragePathInRepo() {
repoPath := "/test/repo"
tests := []struct {
name string
host string
relativePath string
expected string
}{
{
name: "common_config_file",
host: "",
relativePath: "Documents/test.txt",
expected: filepath.Join(repoPath, "Documents", "test.txt"),
},
{
name: "host-specific_file",
host: "myhost",
relativePath: "Documents/test.txt",
expected: filepath.Join(repoPath, "myhost.lnk", "Documents", "test.txt"),
},
}
for _, tt := range tests {
suite.Run(tt.name, func() {
result, err := suite.resolver.GetFileStoragePathInRepo(repoPath, tt.host, tt.relativePath)
suite.NoError(err)
suite.Equal(tt.expected, result)
})
}
}
func TestResolverSuite(t *testing.T) {
suite.Run(t, new(ResolverTestSuite))
}

823
internal/service/service.go Normal file
View File

@@ -0,0 +1,823 @@
package service
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/yarlson/lnk/internal/config"
"github.com/yarlson/lnk/internal/errors"
"github.com/yarlson/lnk/internal/fs"
"github.com/yarlson/lnk/internal/git"
"github.com/yarlson/lnk/internal/models"
"github.com/yarlson/lnk/internal/pathresolver"
)
// FileManager handles file system operations
type FileManager interface {
Exists(ctx context.Context, path string) (bool, error)
Move(ctx context.Context, src, dst string) error
CreateSymlink(ctx context.Context, target, linkPath string) error
Remove(ctx context.Context, path string) error
MkdirAll(ctx context.Context, path string, perm os.FileMode) error
Readlink(ctx context.Context, path string) (string, error)
Lstat(ctx context.Context, path string) (os.FileInfo, error)
Stat(ctx context.Context, path string) (os.FileInfo, error)
}
// ConfigManager handles configuration persistence (reading and writing .lnk files)
type ConfigManager interface {
AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error
RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error
ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error)
GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error)
}
// GitManager handles Git operations
type GitManager interface {
Init(ctx context.Context, repoPath string) error
Clone(ctx context.Context, repoPath, url string) error
Add(ctx context.Context, repoPath string, files ...string) error
Remove(ctx context.Context, repoPath string, files ...string) error
Commit(ctx context.Context, repoPath, message string) error
Push(ctx context.Context, repoPath string) error
Pull(ctx context.Context, repoPath string) error
Status(ctx context.Context, repoPath string) (*models.SyncStatus, error)
IsRepository(ctx context.Context, repoPath string) (bool, error)
HasChanges(ctx context.Context, repoPath string) (bool, error)
IsLnkRepository(ctx context.Context, repoPath string) (bool, error)
}
// PathResolver handles path resolution and manipulation
type PathResolver interface {
GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error)
GetTrackingFilePath(repoPath, host string) (string, error)
GetHomePath() (string, error)
GetRelativePathFromHome(absPath string) (string, error)
GetAbsolutePathInHome(relPath string) (string, error)
}
// Service encapsulates the business logic for lnk operations
type Service struct {
fileManager FileManager
gitManager GitManager // May be nil for some operations
configManager ConfigManager
pathResolver PathResolver
repoPath string
}
// New creates a new Service instance with default dependencies
func New() (*Service, error) {
// Initialize adapters
fileManager := fs.New()
gitManager := git.New()
pathResolver := pathresolver.New()
configManager := config.New(fileManager, pathResolver)
// Get repository path
repoPath, err := pathResolver.GetRepoStoragePath()
if err != nil {
return nil, errors.NewInvalidPathError("", "failed to determine repository storage path").
WithContext("error", err.Error())
}
return &Service{
fileManager: fileManager,
gitManager: gitManager,
configManager: configManager,
pathResolver: pathResolver,
repoPath: repoPath,
}, nil
}
// NewLnkServiceWithDeps creates a new Service instance with provided dependencies (for testing)
func NewLnkServiceWithDeps(
fileManager FileManager,
gitManager GitManager,
configManager ConfigManager,
pathResolver PathResolver,
repoPath string,
) *Service {
return &Service{
fileManager: fileManager,
gitManager: gitManager,
configManager: configManager,
pathResolver: pathResolver,
repoPath: repoPath,
}
}
// ListManagedFiles returns the list of files managed by lnk for a specific host
// If host is empty, returns common configuration files
func (s *Service) ListManagedFiles(ctx context.Context, host string) ([]models.ManagedFile, error) {
// Check if the repository exists
exists, err := s.fileManager.Exists(ctx, s.repoPath)
if err != nil {
return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
}
if !exists {
return nil, errors.NewRepoNotInitializedError(s.repoPath)
}
// Use the config manager to list managed files
managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host)
if err != nil {
return nil, err // ConfigManager already returns properly typed errors
}
return managedFiles, nil
}
// GetStatus returns the Git repository status
// Returns an error if the repository is not initialized or GitManager is not available
func (s *Service) GetStatus(ctx context.Context) (*models.SyncStatus, error) {
// Check if GitManager is available
if s.gitManager == nil {
return nil, errors.NewGitOperationError("get_status",
fmt.Errorf("git manager not available"))
}
// Check if the repository exists
exists, err := s.fileManager.Exists(ctx, s.repoPath)
if err != nil {
return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
}
if !exists {
return nil, errors.NewRepoNotInitializedError(s.repoPath)
}
// Check if it's a Git repository
isRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
if err != nil {
return nil, errors.NewGitOperationError("check_git_repo", err)
}
if !isRepo {
return nil, errors.NewRepoNotInitializedError(s.repoPath).
WithContext("reason", "directory exists but is not a git repository")
}
// Get Git status
status, err := s.gitManager.Status(ctx, s.repoPath)
if err != nil {
return nil, err // GitManager already returns properly typed errors
}
return status, nil
}
// GetRepoPath returns the repository path
func (s *Service) GetRepoPath() string {
return s.repoPath
}
// IsRepositoryInitialized checks if the lnk repository has been initialized
func (s *Service) IsRepositoryInitialized(ctx context.Context) (bool, error) {
// Check if repository directory exists
exists, err := s.fileManager.Exists(ctx, s.repoPath)
if err != nil {
return false, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
}
if !exists {
return false, nil
}
// Check if it's a Git repository (if GitManager is available)
if s.gitManager != nil {
isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
if err != nil {
return false, errors.NewGitOperationError("check_git_repo", err)
}
return isGitRepo, nil
}
// If no GitManager, just check if the directory exists
return true, nil
}
// InitializeRepository initializes a new lnk repository, optionally cloning from a remote URL
func (s *Service) InitializeRepository(ctx context.Context, remoteURL string) error {
// Check if GitManager is available
if s.gitManager == nil {
return errors.NewGitOperationError("initialize_repository",
fmt.Errorf("git manager not available"))
}
if remoteURL != "" {
// Clone from remote
return s.cloneRepository(ctx, remoteURL)
}
// Initialize empty repository
return s.initEmptyRepository(ctx)
}
// cloneRepository clones a repository from the given URL
func (s *Service) cloneRepository(ctx context.Context, remoteURL string) error {
// Clone using GitManager
if err := s.gitManager.Clone(ctx, s.repoPath, remoteURL); err != nil {
return errors.NewGitOperationError("clone_repository", err).
WithContext("remote_url", remoteURL).
WithContext("repo_path", s.repoPath)
}
return nil
}
// initEmptyRepository initializes an empty Git repository
func (s *Service) initEmptyRepository(ctx context.Context) error {
// Check if repository directory already exists
exists, err := s.fileManager.Exists(ctx, s.repoPath)
if err != nil {
return errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
}
if exists {
// Check if it's already a Git repository
isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
if err != nil {
return errors.NewGitOperationError("check_git_repo", err)
}
if isGitRepo {
// Check if it's a lnk repository
isLnkRepo, err := s.gitManager.IsLnkRepository(ctx, s.repoPath)
if err != nil {
return errors.NewGitOperationError("check_lnk_repo", err)
}
if isLnkRepo {
// It's already a lnk repository, init is idempotent
return nil
} else {
// It's not a lnk repository, error to prevent data loss
return errors.NewRepoNotInitializedError(s.repoPath).
WithContext("reason", "directory contains an existing non-lnk Git repository")
}
}
}
// Create the repository directory if it doesn't exist
if !exists {
if err := s.fileManager.MkdirAll(ctx, s.repoPath, 0755); err != nil {
return errors.NewFileSystemOperationError("create_repo_dir", s.repoPath, err)
}
}
// Initialize Git repository
if err := s.gitManager.Init(ctx, s.repoPath); err != nil {
// Clean up directory if we created it
if !exists {
_ = s.fileManager.Remove(ctx, s.repoPath) // Ignore cleanup errors
}
return errors.NewGitOperationError("init_git_repo", err).
WithContext("repo_path", s.repoPath)
}
return nil
}
// AddFile adds a file or directory to lnk management for the specified host
// This involves moving the file to the repository, creating a symlink, updating tracking, and committing to Git
func (s *Service) AddFile(ctx context.Context, filePath, host string) (*models.ManagedFile, error) {
// Check if GitManager is available
if s.gitManager == nil {
return nil, errors.NewGitOperationError("add_file",
fmt.Errorf("git manager not available"))
}
// Get absolute path
absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath)
if err != nil {
// If it fails, try as-is (might be already absolute)
var pathErr error
absPath, pathErr = filepath.Abs(filePath)
if pathErr != nil {
return nil, errors.NewFileSystemOperationError("resolve_path", filePath, err)
}
}
// Validate that the file exists and is accessible (check this FIRST like the old implementation)
exists, err := s.fileManager.Exists(ctx, absPath)
if err != nil {
return nil, errors.NewFileSystemOperationError("check_file_exists", absPath, err)
}
if !exists {
return nil, errors.NewFileNotFoundError(absPath)
}
// Check if repository is initialized (after file existence check)
initialized, err := s.IsRepositoryInitialized(ctx)
if err != nil {
return nil, err
}
if !initialized {
return nil, errors.NewRepoNotInitializedError(s.repoPath)
}
// Get file information to determine if it's a directory
fileInfo, err := s.fileManager.Stat(ctx, absPath)
if err != nil {
return nil, errors.NewFileSystemOperationError("stat_file", absPath, err)
}
// Get relative path for tracking
relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath)
if err != nil {
return nil, errors.NewFileSystemOperationError("get_relative_path", absPath, err)
}
// Check if file is already managed
existingFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath)
if err == nil && existingFile != nil {
return nil, errors.NewFileAlreadyManagedError(relativePath)
}
// Create managed file model
managedFile := models.ManagedFile{
OriginalPath: absPath,
RelativePath: relativePath,
Host: host,
IsDirectory: fileInfo.IsDir(),
}
// Get storage path in repository
storagePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, relativePath)
if err != nil {
return nil, errors.NewFileSystemOperationError("get_storage_path", relativePath, err)
}
managedFile.RepoPath = storagePath
// Execute the file addition with rollback support
if err := s.executeFileAddition(ctx, &managedFile); err != nil {
return nil, err
}
return &managedFile, nil
}
// executeFileAddition performs the actual file addition with rollback logic
func (s *Service) executeFileAddition(ctx context.Context, file *models.ManagedFile) error {
var rollbackActions []func() error
// Helper function to add rollback action
addRollback := func(action func() error) {
rollbackActions = append([]func() error{action}, rollbackActions...)
}
// Execute rollback if any step fails
defer func() {
if len(rollbackActions) > 0 {
for _, action := range rollbackActions {
_ = action() // Ignore rollback errors
}
}
}()
// Step 1: Create destination directory
destDir := filepath.Dir(file.RepoPath)
if err := s.fileManager.MkdirAll(ctx, destDir, 0755); err != nil {
return errors.NewFileSystemOperationError("create_dest_dir", destDir, err)
}
// Step 2: Move file to repository
if err := s.fileManager.Move(ctx, file.OriginalPath, file.RepoPath); err != nil {
return errors.NewFileSystemOperationError("move_file", file.OriginalPath, err)
}
// Add rollback for move operation
addRollback(func() error {
return s.fileManager.Move(context.Background(), file.RepoPath, file.OriginalPath)
})
// Step 3: Create symlink
if err := s.fileManager.CreateSymlink(ctx, file.RepoPath, file.OriginalPath); err != nil {
return errors.NewFileSystemOperationError("create_symlink", file.OriginalPath, err)
}
// Add rollback for symlink creation
addRollback(func() error {
return s.fileManager.Remove(context.Background(), file.OriginalPath)
})
// Step 4: Add to config tracking
if err := s.configManager.AddManagedFileToHost(ctx, s.repoPath, file.Host, *file); err != nil {
return err // ConfigManager returns properly typed errors
}
// Add rollback for config update
addRollback(func() error {
return s.configManager.RemoveManagedFileFromHost(context.Background(),
s.repoPath, file.Host, file.RelativePath)
})
// Step 5: Add file to Git
gitPath := file.RelativePath
if file.Host != "" {
gitPath = filepath.Join(file.Host+".lnk", file.RelativePath)
}
if err := s.gitManager.Add(ctx, s.repoPath, gitPath); err != nil {
return errors.NewGitOperationError("add_file_to_git", err)
}
// Step 6: Add config file to Git
trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, file.Host)
if err != nil {
return errors.NewFileSystemOperationError("get_tracking_file", "", err)
}
// Get relative path of tracking file from repo root
trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile)
if err != nil {
return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err)
}
if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil {
return errors.NewGitOperationError("add_tracking_file_to_git", err)
}
// Step 7: Commit changes
basename := filepath.Base(file.RelativePath)
commitMessage := fmt.Sprintf("lnk: added %s", basename)
if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil {
return errors.NewGitOperationError("commit_changes", err)
}
// If we reach here, everything succeeded - clear rollback actions
rollbackActions = nil
return nil
}
// RemoveFile removes a file or directory from lnk management for the specified host
// This involves removing the symlink, restoring the original file, updating tracking, and committing to Git
func (s *Service) RemoveFile(ctx context.Context, filePath, host string) error {
// Check if GitManager is available
if s.gitManager == nil {
return errors.NewGitOperationError("remove_file",
fmt.Errorf("git manager not available"))
}
// Check if repository is initialized
initialized, err := s.IsRepositoryInitialized(ctx)
if err != nil {
return err
}
if !initialized {
return errors.NewRepoNotInitializedError(s.repoPath)
}
// Get absolute path
absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath)
if err != nil {
// If it fails, try as-is (might be already absolute)
var pathErr error
absPath, pathErr = filepath.Abs(filePath)
if pathErr != nil {
return errors.NewFileSystemOperationError("resolve_path", filePath, err)
}
}
// Validate that this is a symlink
linkInfo, err := s.fileManager.Lstat(ctx, absPath)
if err != nil {
if os.IsNotExist(err) {
return errors.NewFileNotFoundError(absPath)
}
return errors.NewFileSystemOperationError("stat_symlink", absPath, err)
}
if linkInfo.Mode()&os.ModeSymlink == 0 {
return errors.NewNotSymlinkError(absPath)
}
// Get symlink target
target, err := s.fileManager.Readlink(ctx, absPath)
if err != nil {
return errors.NewFileSystemOperationError("read_symlink", absPath, err)
}
// Convert relative symlink target to absolute path
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(absPath), target)
}
// Validate that the target exists in our repository
targetAbs, err := filepath.Abs(target)
if err != nil {
return errors.NewFileSystemOperationError("resolve_target", target, err)
}
repoPathAbs, err := filepath.Abs(s.repoPath)
if err != nil {
return errors.NewFileSystemOperationError("resolve_repo_path", s.repoPath, err)
}
if !strings.HasPrefix(targetAbs, repoPathAbs) {
return errors.NewInvalidPathError(targetAbs, "symlink target is not in lnk repository")
}
// Get relative path for tracking
relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath)
if err != nil {
return errors.NewFileSystemOperationError("get_relative_path", absPath, err)
}
// Check if this file is actually managed
managedFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath)
if err != nil || managedFile == nil {
return errors.NewLnkError(errors.ErrorCodeFileNotFound, fmt.Sprintf("file is not managed by lnk: %s", relativePath))
}
// Get target file info to determine if it's a directory
targetInfo, err := s.fileManager.Stat(ctx, targetAbs)
if err != nil {
return errors.NewFileSystemOperationError("stat_target", targetAbs, err)
}
// Execute the file removal with rollback support
return s.executeFileRemoval(ctx, absPath, targetAbs, relativePath, host, targetInfo.IsDir())
}
// executeFileRemoval performs the actual file removal with rollback logic
func (s *Service) executeFileRemoval(ctx context.Context, symlinkPath, targetPath, relativePath, host string, isDirectory bool) error {
var rollbackActions []func() error
// Helper function to add rollback action
addRollback := func(action func() error) {
rollbackActions = append([]func() error{action}, rollbackActions...)
}
// Execute rollback if any step fails
defer func() {
if len(rollbackActions) > 0 {
for _, action := range rollbackActions {
_ = action() // Ignore rollback errors
}
}
}()
// Step 1: Remove the symlink
if err := s.fileManager.Remove(ctx, symlinkPath); err != nil {
return errors.NewFileSystemOperationError("remove_symlink", symlinkPath, err)
}
// Add rollback for symlink removal
addRollback(func() error {
return s.fileManager.CreateSymlink(context.Background(), targetPath, symlinkPath)
})
// Step 2: Move file back from repository to original location
if err := s.fileManager.Move(ctx, targetPath, symlinkPath); err != nil {
return errors.NewFileSystemOperationError("restore_file", targetPath, err)
}
// Add rollback for file restoration
addRollback(func() error {
return s.fileManager.Move(context.Background(), symlinkPath, targetPath)
})
// Step 3: Remove from config tracking
if err := s.configManager.RemoveManagedFileFromHost(ctx, s.repoPath, host, relativePath); err != nil {
return err // ConfigManager returns properly typed errors
}
// Add rollback for config update
managedFile := models.ManagedFile{
OriginalPath: symlinkPath,
RelativePath: relativePath,
RepoPath: targetPath,
Host: host,
IsDirectory: isDirectory,
}
addRollback(func() error {
return s.configManager.AddManagedFileToHost(context.Background(), s.repoPath, host, managedFile)
})
// Step 4: Remove file from Git
gitPath := relativePath
if host != "" {
gitPath = filepath.Join(host+".lnk", relativePath)
}
if err := s.gitManager.Remove(ctx, s.repoPath, gitPath); err != nil {
return err
}
// Step 5: Add config file to Git (to commit the tracking change)
trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, host)
if err != nil {
return errors.NewFileSystemOperationError("get_tracking_file", "", err)
}
// Get relative path of tracking file from repo root
trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile)
if err != nil {
return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err)
}
if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil {
return errors.NewGitOperationError("add_tracking_file_to_git", err)
}
// Step 6: Commit changes
basename := filepath.Base(relativePath)
commitMessage := fmt.Sprintf("lnk: removed %s", basename)
if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil {
return errors.NewGitOperationError("commit_changes", err)
}
// If we reach here, everything succeeded - clear rollback actions
rollbackActions = nil
return nil
}
// PushChanges stages all changes and pushes to remote repository
func (s *Service) PushChanges(ctx context.Context, message string) error {
// Check if GitManager is available
if s.gitManager == nil {
return errors.NewGitOperationError("push_changes",
fmt.Errorf("git manager not available"))
}
// Check if repository is initialized
initialized, err := s.IsRepositoryInitialized(ctx)
if err != nil {
return err
}
if !initialized {
return errors.NewRepoNotInitializedError(s.repoPath)
}
// Check if there are any changes to commit
hasChanges, err := s.gitManager.HasChanges(ctx, s.repoPath)
if err != nil {
return errors.NewGitOperationError("check_changes", err)
}
if hasChanges {
// Add all changes (equivalent to git add .)
if err := s.gitManager.Add(ctx, s.repoPath, "."); err != nil {
return errors.NewGitOperationError("stage_changes", err)
}
// Create a sync commit
if err := s.gitManager.Commit(ctx, s.repoPath, message); err != nil {
return errors.NewGitOperationError("commit_changes", err)
}
}
// Push to remote
if err := s.gitManager.Push(ctx, s.repoPath); err != nil {
return errors.NewGitOperationError("push_to_remote", err)
}
return nil
}
// PullChanges pulls changes from remote and restores symlinks for the specified host
func (s *Service) PullChanges(ctx context.Context, host string) ([]models.ManagedFile, error) {
// Check if GitManager is available
if s.gitManager == nil {
return nil, errors.NewGitOperationError("pull_changes",
fmt.Errorf("git manager not available"))
}
// Check if repository is initialized
initialized, err := s.IsRepositoryInitialized(ctx)
if err != nil {
return nil, err
}
if !initialized {
return nil, errors.NewRepoNotInitializedError(s.repoPath)
}
// Pull changes from remote
if err := s.gitManager.Pull(ctx, s.repoPath); err != nil {
return nil, errors.NewGitOperationError("pull_from_remote", err)
}
// Restore symlinks for the specified host
restored, err := s.RestoreSymlinksForHost(ctx, host)
if err != nil {
return nil, err
}
return restored, nil
}
// RestoreSymlinksForHost restores symlinks for all managed files for the specified host
func (s *Service) RestoreSymlinksForHost(ctx context.Context, host string) ([]models.ManagedFile, error) {
// Check if repository is initialized
initialized, err := s.IsRepositoryInitialized(ctx)
if err != nil {
return nil, err
}
if !initialized {
return nil, errors.NewRepoNotInitializedError(s.repoPath)
}
// Get list of managed files for this host
managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host)
if err != nil {
return nil, err
}
var restored []models.ManagedFile
homeDir, err := s.pathResolver.GetHomePath()
if err != nil {
return nil, errors.NewFileSystemOperationError("get_home_dir", "", err)
}
for _, managedFile := range managedFiles {
// Determine symlink path (where the symlink should be created)
symlinkPath := filepath.Join(homeDir, managedFile.RelativePath)
// Determine repository file path (what the symlink should point to)
repoFilePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, managedFile.RelativePath)
if err != nil {
continue // Skip files with path resolution issues
}
// Check if repository file exists
repoExists, err := s.fileManager.Exists(ctx, repoFilePath)
if err != nil || !repoExists {
continue // Skip missing files
}
// Check if symlink already exists and is correct
if s.isValidSymlink(ctx, symlinkPath, repoFilePath) {
continue // Skip files that are already correctly symlinked
}
// Ensure parent directory exists
symlinkDir := filepath.Dir(symlinkPath)
if err := s.fileManager.MkdirAll(ctx, symlinkDir, 0755); err != nil {
continue // Skip files with directory creation issues
}
// Remove existing file/symlink if it exists
exists, err := s.fileManager.Exists(ctx, symlinkPath)
if err == nil && exists {
if err := s.fileManager.Remove(ctx, symlinkPath); err != nil {
continue // Skip files that can't be removed
}
}
// Create symlink
if err := s.fileManager.CreateSymlink(ctx, repoFilePath, symlinkPath); err != nil {
continue // Skip files with symlink creation issues
}
// Update the managed file with current paths
restoredFile := managedFile
restoredFile.OriginalPath = symlinkPath
restoredFile.RepoPath = repoFilePath
restored = append(restored, restoredFile)
}
return restored, nil
}
// isValidSymlink checks if the given path is a symlink pointing to the expected target
func (s *Service) isValidSymlink(ctx context.Context, symlinkPath, expectedTarget string) bool {
info, err := s.fileManager.Lstat(ctx, symlinkPath)
if err != nil {
return false
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink == 0 {
return false
}
// Check if it points to the correct target
target, err := s.fileManager.Readlink(ctx, symlinkPath)
if err != nil {
return false
}
// Convert relative path to absolute if needed
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(symlinkPath), target)
}
// Clean both paths for comparison
targetAbs, err := filepath.Abs(target)
if err != nil {
return false
}
expectedAbs, err := filepath.Abs(expectedTarget)
if err != nil {
return false
}
return targetAbs == expectedAbs
}

File diff suppressed because it is too large Load Diff

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