1 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
36 changed files with 5333 additions and 4683 deletions

View File

@@ -1,17 +0,0 @@
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/optimizing-pr-creation-version-updates#setting-up-a-cooldown-period-for-dependency-updates
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#groups--
groups:
actions:
# Combine all images of last week
patterns: ["*"]
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly

View File

@@ -9,9 +9,6 @@ on:
permissions:
contents: read
env:
GO_VERSION: '1.24'
jobs:
test:
runs-on: ubuntu-latest
@@ -22,10 +19,10 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
go-version: '1.24'
- name: Cache Go modules
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
@@ -47,9 +44,9 @@ jobs:
run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v3
with:
files: ./coverage.out
file: ./coverage.out
lint:
runs-on: ubuntu-latest
@@ -60,10 +57,10 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
go-version: '1.24'
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v3
with:
version: latest
@@ -77,7 +74,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
go-version: '1.24'
- name: Build
run: go build -v ./...

3
.gitignore vendored
View File

@@ -44,6 +44,3 @@ desktop.ini
# GoReleaser artifacts
goreleaser/
*.md
!/README.md
!/RELEASE.md

167
README.md
View File

@@ -2,17 +2,11 @@
**Git-native dotfiles management that doesn't suck.**
Lnk makes managing your dotfiles straightforward, no tedious setups, no complex configurations. Just tell Lnk what files you want tracked, and it'll automatically move them into a tidy Git repository under `~/.config/lnk`. It then creates clean, portable symlinks back to their original locations. Easy.
Why bother with Lnk instead of plain old Git or other dotfile managers? Unlike traditional methods, Lnk automates the boring parts: safely relocating files, handling host-specific setups, bulk operations for multiple files, recursive directory processing, and even running your custom bootstrap scripts automatically. This means fewer manual steps and less chance of accidentally overwriting something important.
With Lnk, your dotfiles setup stays organized and effortlessly portable, letting you spend more time doing real work, not wrestling with configuration files.
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 -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Multiple files at once
lnk add --recursive ~/.config/nvim # Process directory contents
lnk add --dry-run ~/.tmux.conf # Preview changes first
lnk init
lnk add ~/.vimrc ~/.bashrc # Common config
lnk add --host work ~/.ssh/config # Host-specific config
lnk push "setup"
```
@@ -49,32 +43,19 @@ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv
# Fresh start
lnk init
# With existing repo (runs bootstrap automatically)
# With existing repo
lnk init -r git@github.com:user/dotfiles.git
# Skip automatic bootstrap
lnk init -r git@github.com:user/dotfiles.git --no-bootstrap
# Run bootstrap script manually
lnk bootstrap
```
### Daily workflow
```bash
# Add multiple files at once (common config)
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
# Add files/directories (common config)
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
# Add directory contents individually
lnk add --recursive ~/.config/nvim ~/.config/zsh
# Preview changes before applying
lnk add --dry-run ~/.config/git/config
lnk add --dry-run --recursive ~/.config/kitty
# Add host-specific files (supports bulk operations)
lnk add --host laptop ~/.ssh/config ~/.aws/credentials
lnk add --host work ~/.gitconfig ~/.ssh/config
# Add host-specific files
lnk add --host laptop ~/.ssh/config
lnk add --host work ~/.gitconfig
# List managed files
lnk list # Common config only
@@ -104,44 +85,6 @@ After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
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.
## Bootstrap Support
Lnk automatically runs bootstrap scripts when cloning dotfiles repositories, making it easy to set up your development environment. Just add a `bootstrap.sh` file to your dotfiles repo.
### Examples
**Simple bootstrap script:**
```bash
#!/bin/bash
# bootstrap.sh
echo "Setting up development environment..."
# Install Homebrew (macOS)
if ! command -v brew &> /dev/null; then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Install packages
brew install git vim tmux
echo "✅ Setup complete!"
```
**Usage:**
```bash
# Automatic bootstrap on clone
lnk init -r git@github.com:you/dotfiles.git
# → Clones repo and runs bootstrap script automatically
# Skip bootstrap if needed
lnk init -r git@github.com:you/dotfiles.git --no-bootstrap
# Run bootstrap manually later
lnk bootstrap
```
## Multihost Support
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
@@ -168,19 +111,12 @@ Lnk supports both **common configurations** (shared across all machines) and **h
### Usage Patterns
```bash
# Common config (shared everywhere) - supports multiple files
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
# Common config (shared everywhere)
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig
# Process directory contents individually
lnk add --recursive ~/.config/nvim ~/.config/zsh
# Preview operations before making changes
lnk add --dry-run ~/.config/alacritty/alacritty.yml
lnk add --dry-run --recursive ~/.config/i3
# Host-specific config (unique per machine) - supports bulk operations
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
lnk add --host work ~/.gitconfig ~/.npmrc
# Host-specific config (unique per machine)
lnk add --host $(hostname) ~/.ssh/config
lnk add --host work ~/.gitconfig
# List configurations
lnk list # Common only
@@ -196,35 +132,23 @@ lnk pull --host work # Work-specific config
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
- Moving files safely (with atomic operations)
- Moving files safely
- Creating relative symlinks
- Handling conflicts and rollback
- Handling conflicts
- Tracking what's managed
- Processing multiple files efficiently
- Recursive directory traversal
- Preview mode for safety
## Examples
### First time setup
```bash
# Clone dotfiles and run bootstrap automatically
lnk init -r git@github.com:you/dotfiles.git
# → Downloads dependencies, installs packages, configures environment
# Add common config (shared across all machines) - multiple files at once
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig ~/.tmux.conf
# Add common config (shared across all machines)
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
# Add configuration directories individually
lnk add --recursive ~/.config/nvim ~/.config/zsh
# Preview before adding sensitive files
lnk add --dry-run ~/.ssh/id_rsa.pub
lnk add ~/.ssh/id_rsa.pub # Add after verification
# Add host-specific config (supports bulk operations)
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
# Add host-specific config
lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf
lnk push "initial setup"
```
@@ -232,18 +156,13 @@ lnk push "initial setup"
### On a new machine
```bash
# Bootstrap runs automatically
lnk init -r git@github.com:you/dotfiles.git
# → Sets up environment, installs dependencies
# Pull common config
lnk pull
# Pull host-specific config (if it exists)
lnk pull --host $(hostname)
# Or run bootstrap manually if needed
lnk bootstrap
```
### Daily edits
@@ -260,16 +179,15 @@ lnk push "new plugins" # commit & push
### Multi-machine workflow
```bash
# On your laptop - use bulk operations for efficiency
lnk add --host laptop ~/.ssh/config ~/.aws/credentials ~/.npmrc
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Common config (multiple files)
lnk push "laptop configuration"
# 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 ~/.ssh/config
lnk add --recursive ~/.config/work-tools # Work-specific tools
lnk push "work configuration"
lnk add --host work ~/.gitconfig
lnk push "work git config"
# Back on laptop
lnk pull # Get updates (work config won't affect laptop)
@@ -277,40 +195,19 @@ lnk pull # Get updates (work config won't affe
## Commands
- `lnk init [-r remote] [--no-bootstrap]` - Create repo (runs bootstrap automatically)
- `lnk add [--host HOST] [--recursive] [--dry-run] <files>...` - Move files to repo, create symlinks
- `lnk init [-r remote]` - Create repo
- `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 [--host HOST]` - Pull + restore missing symlinks
- `lnk bootstrap` - Run bootstrap script manually
### Command Options
- `--host HOST` - Manage files for specific host (default: common configuration)
- `--recursive, -r` - Add directory contents individually instead of the directory as a whole
- `--dry-run, -n` - Show what would be added without making changes
- `--all` - Show all configurations (common + all hosts) when listing
- `-r, --remote URL` - Clone from remote URL when initializing
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
### Add Command Examples
```bash
# Multiple files at once
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
# Recursive directory processing
lnk add --recursive ~/.config/nvim
# Preview changes first
lnk add --dry-run ~/.ssh/config
lnk add --dry-run --recursive ~/.config/kitty
# Host-specific bulk operations
lnk add --host work ~/.gitconfig ~/.ssh/config ~/.npmrc
```
## Technical bits
@@ -318,17 +215,13 @@ lnk add --host work ~/.gitconfig ~/.ssh/config ~/.npmrc
- **Relative symlinks** (portable)
- **XDG compliant** (`~/.config/lnk`)
- **Multihost support** (common + host-specific configs)
- **Bootstrap support** (automatic environment setup)
- **Bulk operations** (multiple files, atomic transactions)
- **Recursive processing** (directory contents individually)
- **Preview mode** (dry-run for safety)
- **Git-native** (standard Git repo, no special formats)
## Alternatives
| Tool | Complexity | Why choose it |
| ------- | ---------- | -------------------------------------------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run |
| ------- | ---------- | -------------------------------------------- |
| **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 |

View File

@@ -1,164 +1,52 @@
package cmd
import (
"context"
"path/filepath"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
"github.com/yarlson/lnk/internal/service"
)
func newAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "add <file>...",
Short: "✨ Add files to lnk management",
Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.
Examples:
lnk add ~/.bashrc ~/.vimrc # Add multiple files at once
lnk add --recursive ~/.config/nvim # Add directory contents individually
lnk add --dry-run ~/.gitconfig # Preview what would be added
lnk add --host work ~/.ssh/config # Add host-specific configuration
The --recursive flag processes directory contents individually instead of treating
the directory as a single unit. This is useful for configuration directories where
you want each file managed separately.
The --dry-run flag shows you exactly what files would be added without making any
changes to your system - perfect for verification before bulk operations.`,
Args: cobra.MinimumNArgs(1),
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,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host")
recursive, _ := cmd.Flags().GetBool("recursive")
dryRun, _ := cmd.Flags().GetBool("dry-run")
lnk := core.NewLnk(core.WithHost(host))
// Handle dry-run mode
if dryRun {
files, err := lnk.PreviewAdd(args, recursive)
if err != nil {
return err
}
// Display preview output
if recursive {
printf(cmd, "🔍 \033[1mWould add %d files recursively:\033[0m\n", len(files))
} else {
printf(cmd, "🔍 \033[1mWould add %d files:\033[0m\n", len(files))
}
// List files that would be added
for _, file := range files {
basename := filepath.Base(file)
printf(cmd, " 📄 \033[90m%s\033[0m\n", basename)
}
printf(cmd, "\n💡 \033[33mTo proceed:\033[0m run without --dry-run flag\n")
return nil
}
// Handle recursive mode
if recursive {
// Get preview to count files first for better output
previewFiles, err := lnk.PreviewAdd(args, recursive)
if err != nil {
return err
}
// Create progress callback for CLI display
progressCallback := func(current, total int, currentFile string) {
printf(cmd, "\r⏳ Processing %d/%d: %s", current, total, currentFile)
}
if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil {
return err
}
// Clear progress line and show completion
printf(cmd, "\r")
// Store processed file count for display
args = previewFiles // Replace args with actual files for display
} else {
// Use appropriate method based on number of files
if len(args) == 1 {
// Single file - use existing Add method for backward compatibility
if err := lnk.Add(args[0]); err != nil {
return err
}
} else {
// Multiple files - use AddMultiple for atomic operation
if err := lnk.AddMultiple(args); err != nil {
return err
}
}
}
// Display results
if recursive {
// Recursive mode - show enhanced message with count
if host != "" {
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk (host: %s)\033[0m\n", len(args), host)
} else {
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk\033[0m\n", len(args))
}
// Show some of the files that were added (limit to first few for readability)
filesToShow := len(args)
if filesToShow > 5 {
filesToShow = 5
}
for i := 0; i < filesToShow; i++ {
basename := filepath.Base(args[i])
if host != "" {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
} else {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
}
}
if len(args) > 5 {
printf(cmd, " \033[90m... and %d more files\033[0m\n", len(args)-5)
}
} else if len(args) == 1 {
// Single file - maintain existing output format for backward compatibility
filePath := args[0]
host, _ := cmd.Flags().GetString("host")
// Create service instance
lnkService, err := service.New()
if err != nil {
return wrapServiceError("initialize lnk service", err)
}
// 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", filePath, host, filePath)
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", filePath, filePath)
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", managedFile.OriginalPath, managedFile.RelativePath)
}
} else {
// Multiple files - show summary
if host != "" {
printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host)
} else {
printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args))
}
// List each added file
for _, filePath := range args {
basename := filepath.Base(filePath)
if host != "" {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
} else {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
}
}
}
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)")
cmd.Flags().BoolP("recursive", "r", false, "Add directory contents individually instead of the directory as a whole")
cmd.Flags().BoolP("dry-run", "n", false, "Show what would be added without making changes")
return cmd
}

View File

@@ -1,45 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newBootstrapCmd() *cobra.Command {
return &cobra.Command{
Use: "bootstrap",
Short: "🚀 Run the bootstrap script to set up your environment",
Long: "Executes the bootstrap script from your dotfiles repository to install dependencies and configure your system.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
scriptPath, err := lnk.FindBootstrapScript()
if err != nil {
return err
}
if scriptPath == "" {
printf(cmd, "💡 \033[33mNo bootstrap script found\033[0m\n")
printf(cmd, " 📝 Create a \033[1mbootstrap.sh\033[0m file in your dotfiles repository:\n")
printf(cmd, " \033[90m#!/bin/bash\033[0m\n")
printf(cmd, " \033[90mecho \"Setting up environment...\"\033[0m\n")
printf(cmd, " \033[90m# Your setup commands here\033[0m\n")
return nil
}
printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n")
printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath)
printf(cmd, "\n")
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
return err
}
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
printf(cmd, " 🎉 Your environment is ready to use\n")
return nil
},
}
}

View File

@@ -1,8 +1,11 @@
package cmd
import (
"context"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
"github.com/yarlson/lnk/internal/service"
)
func newInitCmd() *cobra.Command {
@@ -11,47 +14,26 @@ func newInitCmd() *cobra.Command {
Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote")
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil {
return err
// Create service instance
lnkService, err := service.New()
if err != nil {
return wrapServiceError("initialize lnk service", err)
}
// Initialize repository using service layer
ctx := context.Background()
if err := lnkService.InitializeRepository(ctx, remote); err != nil {
return formatError(err)
}
// 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")
// Try to run bootstrap script if not disabled
if !noBootstrap {
printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n")
scriptPath, err := lnk.FindBootstrapScript()
if err != nil {
return err
}
if scriptPath != "" {
printf(cmd, " ✅ Found bootstrap script: \033[36m%s\033[0m\n", scriptPath)
printf(cmd, "\n🚀 \033[1mRunning bootstrap script...\033[0m\n")
printf(cmd, "\n")
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
printf(cmd, "\n⚠ \033[33mBootstrap script failed, but repository was initialized successfully\033[0m\n")
printf(cmd, " 💡 You can run it manually with: \033[1mlnk bootstrap\033[0m\n")
printf(cmd, " 🔧 Error: %v\n", err)
} else {
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
}
} else {
printf(cmd, " 💡 No bootstrap script found\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")
@@ -68,6 +50,5 @@ func newInitCmd() *cobra.Command {
}
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning")
return cmd
}

View File

@@ -1,12 +1,14 @@
package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
"github.com/yarlson/lnk/internal/service"
)
func newListCmd() *cobra.Command {
@@ -15,7 +17,6 @@ func newListCmd() *cobra.Command {
Short: "📋 List files managed by lnk",
Long: "Display all files and directories currently managed by lnk.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host")
all, _ := cmd.Flags().GetBool("all")
@@ -41,26 +42,31 @@ func newListCmd() *cobra.Command {
}
func listCommonConfig(cmd *cobra.Command) error {
lnk := core.NewLnk()
managedItems, err := lnk.List()
ctx := context.Background()
lnkService, err := service.New()
if err != nil {
return err
return wrapServiceError("initialize lnk service", err)
}
if len(managedItems) == 0 {
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(managedItems))
if len(managedItems) > 1 {
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 _, item := range managedItems {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
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")
@@ -68,26 +74,31 @@ func listCommonConfig(cmd *cobra.Command) error {
}
func listHostConfig(cmd *cobra.Command, host string) error {
lnk := core.NewLnk(core.WithHost(host))
managedItems, err := lnk.List()
ctx := context.Background()
lnkService, err := service.New()
if err != nil {
return err
return wrapServiceError("initialize lnk service", err)
}
if len(managedItems) == 0 {
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(managedItems))
if len(managedItems) > 1 {
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 _, item := range managedItems {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
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")
@@ -95,56 +106,60 @@ func listHostConfig(cmd *cobra.Command, host string) error {
}
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")
lnk := core.NewLnk()
commonItems, err := lnk.List()
commonFiles, err := lnkService.ListManagedFiles(ctx, "")
if err != nil {
return err
return formatError(err)
}
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
if len(commonItems) > 1 {
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(commonItems) == 0 {
if len(commonFiles) == 0 {
printf(cmd, " \033[90m(no files)\033[0m\n")
} else {
for _, item := range commonItems {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
for _, file := range commonFiles {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
}
}
// Find all host-specific configurations
hosts, err := findHostConfigs()
hosts, err := findHostConfigs(lnkService)
if err != nil {
return err
return formatError(err)
}
for _, host := range hosts {
printf(cmd, "\n🖥 \033[1mHost: %s\033[0m", host)
hostLnk := core.NewLnk(core.WithHost(host))
hostItems, err := hostLnk.List()
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(hostItems))
if len(hostItems) > 1 {
printf(cmd, " (\033[36m%d item", len(hostFiles))
if len(hostFiles) > 1 {
printf(cmd, "s")
}
printf(cmd, "\033[0m):\n")
if len(hostItems) == 0 {
if len(hostFiles) == 0 {
printf(cmd, " \033[90m(no files)\033[0m\n")
} else {
for _, item := range hostItems {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
for _, file := range hostFiles {
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
}
}
}
@@ -153,8 +168,8 @@ func listAllConfigs(cmd *cobra.Command) error {
return nil
}
func findHostConfigs() ([]string, error) {
repoPath := getRepoPath()
func findHostConfigs(service *service.Service) ([]string, error) {
repoPath := service.GetRepoPath()
// Check if repo exists
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
@@ -163,7 +178,7 @@ func findHostConfigs() ([]string, error) {
entries, err := os.ReadDir(repoPath)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to read repository directory: %w", err)
}
var hosts []string
@@ -178,16 +193,3 @@ func findHostConfigs() ([]string, error) {
return hosts, nil
}
func getRepoPath() string {
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
if xdgConfig == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
xdgConfig = "."
} else {
xdgConfig = filepath.Join(homeDir, ".config")
}
}
return filepath.Join(xdgConfig, "lnk")
}

View File

@@ -1,8 +1,11 @@
package cmd
import (
"context"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
"github.com/yarlson/lnk/internal/service"
)
func newPullCmd() *cobra.Command {
@@ -11,15 +14,20 @@ func newPullCmd() *cobra.Command {
Short: "⬇️ Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
restored, err := lnk.Pull()
// Create service instance
lnkService, err := service.New()
if err != nil {
return err
return wrapServiceError("initialize lnk service", err)
}
// Pull changes using the service
ctx := context.Background()
restored, err := lnkService.PullChanges(ctx, host)
if err != nil {
return formatError(err)
}
if len(restored) > 0 {
@@ -34,7 +42,7 @@ func newPullCmd() *cobra.Command {
}
printf(cmd, "\033[0m:\n")
for _, file := range restored {
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
printf(cmd, " ✨ \033[36m%s\033[0m\n", file.RelativePath)
}
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
} else {

View File

@@ -1,8 +1,11 @@
package cmd
import (
"context"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
"github.com/yarlson/lnk/internal/service"
)
func newPushCmd() *cobra.Command {
@@ -12,16 +15,22 @@ func newPushCmd() *cobra.Command {
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
SilenceErrors: 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 err
// Create service instance
lnkService, err := service.New()
if err != nil {
return wrapServiceError("initialize lnk service", err)
}
// 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")

View File

@@ -1,10 +1,12 @@
package cmd
import (
"context"
"path/filepath"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
"github.com/yarlson/lnk/internal/service"
)
func newRemoveCmd() *cobra.Command {
@@ -14,15 +16,20 @@ func newRemoveCmd() *cobra.Command {
Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
// Create service instance
lnkService, err := service.New()
if err != nil {
return wrapServiceError("initialize lnk service", err)
}
if err := lnk.Remove(filePath); err != nil {
return err
// 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)

View File

@@ -20,23 +20,16 @@ func NewRootCommand() *cobra.Command {
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, host-specific setups, and bulk operations for multiple files.
Supports both common configurations and host-specific setups.
✨ Examples:
lnk init # Fresh start
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
lnk init -r <repo-url> # Clone existing dotfiles
lnk add ~/.vimrc ~/.bashrc # Start managing common files
lnk add --recursive ~/.config/nvim # Add directory contents individually
lnk add --dry-run ~/.gitconfig # Preview changes without applying
lnk add --host work ~/.ssh/config # Manage host-specific files
lnk list --all # Show all configurations
lnk pull --host work # Pull host-specific changes
lnk push "setup complete" # Sync to remote
lnk bootstrap # Run bootstrap script manually
🚀 Bootstrap Support:
Automatically runs bootstrap.sh when cloning a repository.
Use --no-bootstrap to disable.
🎯 Simple, fast, Git-native, and multi-host ready.`,
SilenceUsage: true,
@@ -52,7 +45,6 @@ Supports both common configurations, host-specific setups, and bulk operations f
rootCmd.AddCommand(newStatusCmd())
rootCmd.AddCommand(newPushCmd())
rootCmd.AddCommand(newPullCmd())
rootCmd.AddCommand(newBootstrapCmd())
return rootCmd
}
@@ -66,7 +58,9 @@ func SetVersion(v, bt string) {
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)
}
}

View File

@@ -2,7 +2,6 @@ package cmd
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -158,7 +157,7 @@ func (suite *CLITestSuite) TestStatusCommand() {
// Test status without remote - should fail
err = suite.runCommand("status")
suite.Error(err)
suite.Contains(err.Error(), "No remote repository is configured")
suite.Contains(err.Error(), "no remote configured")
}
func (suite *CLITestSuite) TestListCommand() {
@@ -248,7 +247,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
name: "add nonexistent file",
args: []string{"add", "/nonexistent/file"},
wantErr: true,
errContains: "File or directory not found",
errContains: "File does not exist",
},
{
name: "status without init",
@@ -278,7 +277,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
name: "add help",
args: []string{"add", "--help"},
wantErr: false,
outContains: "Moves files to the lnk repository",
outContains: "Moves a file to the lnk repository",
},
{
name: "list help",
@@ -417,7 +416,7 @@ func (suite *CLITestSuite) TestRemoveUnmanagedFile() {
// Try to remove it
err := suite.runCommand("rm", testFile)
suite.Error(err)
suite.Contains(err.Error(), "File is not managed by lnk")
suite.Contains(err.Error(), "not a symlink")
}
func (suite *CLITestSuite) TestAddDirectory() {
@@ -463,6 +462,54 @@ func (suite *CLITestSuite) TestAddDirectory() {
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")
@@ -738,7 +785,7 @@ func (suite *CLITestSuite) TestMultihostErrorHandling() {
err = suite.runCommand("rm", "--host", "nonexistent", testFile)
suite.Error(err)
suite.Contains(err.Error(), "File is not managed by lnk")
suite.Contains(err.Error(), "not a symlink")
// Try to list non-existent host config
err = suite.runCommand("list", "--host", "nonexistent")
@@ -747,597 +794,6 @@ func (suite *CLITestSuite) TestMultihostErrorHandling() {
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)")
}
func (suite *CLITestSuite) TestBootstrapCommand() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Test bootstrap command with no script
err = suite.runCommand("bootstrap")
suite.NoError(err)
output := suite.stdout.String()
suite.Contains(output, "No bootstrap script found")
suite.Contains(output, "bootstrap.sh")
suite.stdout.Reset()
// Create a bootstrap script
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
bootstrapScript := filepath.Join(lnkDir, "bootstrap.sh")
scriptContent := `#!/bin/bash
echo "Bootstrap script executed!"
echo "Working directory: $(pwd)"
touch bootstrap-ran.txt
`
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Test bootstrap command with script
err = suite.runCommand("bootstrap")
suite.NoError(err)
output = suite.stdout.String()
suite.Contains(output, "Running bootstrap script")
suite.Contains(output, "bootstrap.sh")
suite.Contains(output, "Bootstrap completed successfully")
// Verify script actually ran
markerFile := filepath.Join(lnkDir, "bootstrap-ran.txt")
suite.FileExists(markerFile)
}
func (suite *CLITestSuite) TestInitWithBootstrap() {
// Create a temporary remote repository with bootstrap script
remoteDir := filepath.Join(suite.tempDir, "remote")
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
// Initialize git repo in remote with main branch
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Create a working repo to populate the remote
workingDir := filepath.Join(suite.tempDir, "working")
err = os.MkdirAll(workingDir, 0755)
suite.Require().NoError(err)
cmd = exec.Command("git", "clone", remoteDir, workingDir)
err = cmd.Run()
suite.Require().NoError(err)
// Add a bootstrap script to the working repo
bootstrapScript := filepath.Join(workingDir, "bootstrap.sh")
scriptContent := `#!/bin/bash
echo "Remote bootstrap script executed!"
touch remote-bootstrap-ran.txt
`
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Add a dummy config file
configFile := filepath.Join(workingDir, ".bashrc")
err = os.WriteFile(configFile, []byte("echo 'Hello from remote!'"), 0644)
suite.Require().NoError(err)
// Add .lnk file to track the config
lnkFile := filepath.Join(workingDir, ".lnk")
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
// Commit and push to remote
cmd = exec.Command("git", "add", ".")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap and config")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
// Now test init with remote and automatic bootstrap
err = suite.runCommand("init", "-r", remoteDir)
suite.NoError(err)
output := suite.stdout.String()
suite.Contains(output, "Cloned from:")
suite.Contains(output, "Looking for bootstrap script")
suite.Contains(output, "Found bootstrap script:")
suite.Contains(output, "bootstrap.sh")
suite.Contains(output, "Running bootstrap script")
suite.Contains(output, "Bootstrap completed successfully")
// Verify bootstrap actually ran
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
markerFile := filepath.Join(lnkDir, "remote-bootstrap-ran.txt")
suite.FileExists(markerFile)
}
func (suite *CLITestSuite) TestInitWithBootstrapDisabled() {
// Create a temporary remote repository with bootstrap script
remoteDir := filepath.Join(suite.tempDir, "remote")
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
// Initialize git repo in remote with main branch
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Create a working repo to populate the remote
workingDir := filepath.Join(suite.tempDir, "working")
err = os.MkdirAll(workingDir, 0755)
suite.Require().NoError(err)
cmd = exec.Command("git", "clone", remoteDir, workingDir)
err = cmd.Run()
suite.Require().NoError(err)
// Add a bootstrap script
bootstrapScript := filepath.Join(workingDir, "bootstrap.sh")
scriptContent := `#!/bin/bash
echo "This should not run!"
touch should-not-exist.txt
`
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Commit and push
cmd = exec.Command("git", "add", ".")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
// Test init with --no-bootstrap flag
err = suite.runCommand("init", "-r", remoteDir, "--no-bootstrap")
suite.NoError(err)
output := suite.stdout.String()
suite.Contains(output, "Cloned from:")
suite.NotContains(output, "Looking for bootstrap script")
suite.NotContains(output, "Running bootstrap script")
// Verify bootstrap did NOT run
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
markerFile := filepath.Join(lnkDir, "should-not-exist.txt")
suite.NoFileExists(markerFile)
}
func (suite *CLITestSuite) TestAddCommandMultipleFiles() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Create multiple test files
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
err = os.WriteFile(testFile1, []byte("export PATH1"), 0644)
suite.Require().NoError(err)
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
err = os.WriteFile(testFile2, []byte("set number"), 0644)
suite.Require().NoError(err)
testFile3 := filepath.Join(suite.tempDir, ".gitconfig")
err = os.WriteFile(testFile3, []byte("[user]\n name = test"), 0644)
suite.Require().NoError(err)
// Test add command with multiple files - should succeed
err = suite.runCommand("add", testFile1, testFile2, testFile3)
suite.NoError(err, "Adding multiple files should succeed")
// Check output shows all files were added
output := suite.stdout.String()
suite.Contains(output, "Added 3 items to lnk")
suite.Contains(output, ".bashrc")
suite.Contains(output, ".vimrc")
suite.Contains(output, ".gitconfig")
// Verify all files are now symlinks
for _, file := range []string{testFile1, testFile2, testFile3} {
info, err := os.Lstat(file)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
}
// Verify all files exist in storage
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
suite.FileExists(filepath.Join(lnkDir, ".bashrc"))
suite.FileExists(filepath.Join(lnkDir, ".vimrc"))
suite.FileExists(filepath.Join(lnkDir, ".gitconfig"))
// Verify .lnk file contains all entries
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n.gitconfig\n.vimrc\n", string(lnkContent))
}
func (suite *CLITestSuite) TestAddCommandMixedTypes() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Create a file
testFile := filepath.Join(suite.tempDir, ".vimrc")
err = os.WriteFile(testFile, []byte("set number"), 0644)
suite.Require().NoError(err)
// Create a directory with content
testDir := filepath.Join(suite.tempDir, ".config", "git")
err = os.MkdirAll(testDir, 0755)
suite.Require().NoError(err)
configFile := filepath.Join(testDir, "config")
err = os.WriteFile(configFile, []byte("[user]"), 0644)
suite.Require().NoError(err)
// Test add command with mixed files and directories - should succeed
err = suite.runCommand("add", testFile, testDir)
suite.NoError(err, "Adding mixed files and directories should succeed")
// Check output shows both items were added
output := suite.stdout.String()
suite.Contains(output, "Added 2 items to lnk")
suite.Contains(output, ".vimrc")
suite.Contains(output, "git")
// Verify both are now symlinks
info1, err := os.Lstat(testFile)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink)
info2, err := os.Lstat(testDir)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink)
// Verify storage
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
suite.FileExists(filepath.Join(lnkDir, ".vimrc"))
suite.DirExists(filepath.Join(lnkDir, ".config", "git"))
suite.FileExists(filepath.Join(lnkDir, ".config", "git", "config"))
}
func (suite *CLITestSuite) TestAddCommandRecursiveFlag() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Create a directory with nested files
testDir := filepath.Join(suite.tempDir, ".config", "zed")
err = os.MkdirAll(testDir, 0755)
suite.Require().NoError(err)
// Create nested files
settingsFile := filepath.Join(testDir, "settings.json")
err = os.WriteFile(settingsFile, []byte(`{"theme": "dark"}`), 0644)
suite.Require().NoError(err)
keymapFile := filepath.Join(testDir, "keymap.json")
err = os.WriteFile(keymapFile, []byte(`{"ctrl+s": "save"}`), 0644)
suite.Require().NoError(err)
// Create a subdirectory with files
themesDir := filepath.Join(testDir, "themes")
err = os.MkdirAll(themesDir, 0755)
suite.Require().NoError(err)
themeFile := filepath.Join(themesDir, "custom.json")
err = os.WriteFile(themeFile, []byte(`{"colors": {}}`), 0644)
suite.Require().NoError(err)
// Test recursive flag - should process directory contents individually
err = suite.runCommand("add", "--recursive", testDir)
suite.NoError(err, "Adding directory recursively should succeed")
// Check output shows multiple files were processed
output := suite.stdout.String()
suite.Contains(output, "Added") // Should show some success message
// Verify individual files are now symlinks (not the directory itself)
info, err := os.Lstat(settingsFile)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "settings.json should be a symlink")
info, err = os.Lstat(keymapFile)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "keymap.json should be a symlink")
info, err = os.Lstat(themeFile)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "custom.json should be a symlink")
// The directory itself should NOT be a symlink
info, err = os.Lstat(testDir)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "Directory should not be a symlink")
// Verify files exist individually in storage
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "settings.json"))
suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "keymap.json"))
suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "themes", "custom.json"))
}
func (suite *CLITestSuite) TestAddCommandRecursiveMultipleDirs() {
// Initialize repository
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Create two directories with files
dir1 := filepath.Join(suite.tempDir, "dir1")
dir2 := filepath.Join(suite.tempDir, "dir2")
err = os.MkdirAll(dir1, 0755)
suite.Require().NoError(err)
err = os.MkdirAll(dir2, 0755)
suite.Require().NoError(err)
// Create files in each directory
file1 := filepath.Join(dir1, "file1.txt")
file2 := filepath.Join(dir2, "file2.txt")
err = os.WriteFile(file1, []byte("content1"), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file2, []byte("content2"), 0644)
suite.Require().NoError(err)
// Test recursive flag with multiple directories
err = suite.runCommand("add", "--recursive", dir1, dir2)
suite.NoError(err, "Adding multiple directories recursively should succeed")
// Verify both files are symlinks
info, err := os.Lstat(file1)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "file1.txt should be a symlink")
info, err = os.Lstat(file2)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "file2.txt should be a symlink")
// Verify directories are not symlinks
info, err = os.Lstat(dir1)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "dir1 should not be a symlink")
info, err = os.Lstat(dir2)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "dir2 should not be a symlink")
}
// Task 3.1: Dry-Run Mode Tests
func (suite *CLITestSuite) TestDryRunFlag() {
// Initialize repository
err := suite.runCommand("init")
suite.NoError(err)
initOutput := suite.stdout.String()
suite.Contains(initOutput, "Initialized")
suite.stdout.Reset()
// Create test files
testFile1 := filepath.Join(suite.tempDir, "test1.txt")
testFile2 := filepath.Join(suite.tempDir, "test2.txt")
suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644))
suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644))
// Run add with dry-run flag (should not exist yet)
err = suite.runCommand("add", "--dry-run", testFile1, testFile2)
suite.NoError(err, "Dry-run command should succeed")
output := suite.stdout.String()
// Basic check that some output was produced (flag exists but behavior TBD)
suite.NotEmpty(output, "Should produce some output")
// Verify files were NOT actually added (no symlinks created)
info, err := os.Lstat(testFile1)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be a symlink in dry-run")
info, err = os.Lstat(testFile2)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be a symlink in dry-run")
// Verify lnk list shows no managed files
suite.stdout.Reset()
err = suite.runCommand("list")
suite.NoError(err)
listOutput := suite.stdout.String()
suite.NotContains(listOutput, "test1.txt", "Files should not be managed after dry-run")
suite.NotContains(listOutput, "test2.txt", "Files should not be managed after dry-run")
}
func (suite *CLITestSuite) TestDryRunOutput() {
// Initialize repository
err := suite.runCommand("init")
suite.NoError(err)
initOutput := suite.stdout.String()
suite.Contains(initOutput, "Initialized")
suite.stdout.Reset()
// Create test files
testFile1 := filepath.Join(suite.tempDir, "test1.txt")
testFile2 := filepath.Join(suite.tempDir, "test2.txt")
suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644))
suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644))
// Run add with dry-run flag
err = suite.runCommand("add", "--dry-run", testFile1, testFile2)
suite.NoError(err, "Dry-run command should succeed")
output := suite.stdout.String()
// Verify dry-run shows preview of what would be added
suite.Contains(output, "Would add", "Should show dry-run preview")
suite.Contains(output, "test1.txt", "Should show first file")
suite.Contains(output, "test2.txt", "Should show second file")
suite.Contains(output, "2 files", "Should show file count")
// Should contain helpful instructions
suite.Contains(output, "run without --dry-run", "Should provide next steps")
}
func (suite *CLITestSuite) TestDryRunRecursive() {
// Initialize repository
err := suite.runCommand("init")
suite.NoError(err)
initOutput := suite.stdout.String()
suite.Contains(initOutput, "Initialized")
suite.stdout.Reset()
// Create directory structure with multiple files
configDir := filepath.Join(suite.tempDir, ".config", "test-app")
err = os.MkdirAll(configDir, 0755)
suite.Require().NoError(err)
// Create files in directory
for i := 1; i <= 15; i++ {
file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i))
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("config %d", i)), 0644))
}
// Run recursive add with dry-run
err = suite.runCommand("add", "--dry-run", "--recursive", configDir)
suite.NoError(err, "Dry-run recursive command should succeed")
output := suite.stdout.String()
// Verify dry-run shows all files that would be added
suite.Contains(output, "Would add", "Should show dry-run preview")
suite.Contains(output, "15 files", "Should show correct file count")
suite.Contains(output, "recursively", "Should indicate recursive mode")
// Should show some of the files
suite.Contains(output, "config1.json", "Should show first file")
suite.Contains(output, "config15.json", "Should show last file")
// Verify no actual changes were made
for i := 1; i <= 15; i++ {
file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i))
info, err := os.Lstat(file)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after dry-run")
}
}
// Task 3.2: Enhanced Output and Messaging Tests
func (suite *CLITestSuite) TestEnhancedSuccessOutput() {
// Initialize repository
err := suite.runCommand("init")
suite.NoError(err)
suite.stdout.Reset()
// Create multiple test files
testFiles := []string{
filepath.Join(suite.tempDir, "config1.txt"),
filepath.Join(suite.tempDir, "config2.txt"),
filepath.Join(suite.tempDir, "config3.txt"),
}
for i, file := range testFiles {
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i+1)), 0644))
}
// Add multiple files
args := append([]string{"add"}, testFiles...)
err = suite.runCommand(args...)
suite.NoError(err)
output := suite.stdout.String()
// Should have enhanced formatting with consistent indentation
suite.Contains(output, "🔗", "Should use link icons")
suite.Contains(output, "config1.txt", "Should show first file")
suite.Contains(output, "config2.txt", "Should show second file")
suite.Contains(output, "config3.txt", "Should show third file")
// Should show organized file list
suite.Contains(output, " ", "Should have consistent indentation")
// Should include summary information
suite.Contains(output, "3 items", "Should show total count")
}
func (suite *CLITestSuite) TestOperationSummary() {
// Initialize repository
err := suite.runCommand("init")
suite.NoError(err)
suite.stdout.Reset()
// Create directory with files for recursive operation
configDir := filepath.Join(suite.tempDir, ".config", "test-app")
err = os.MkdirAll(configDir, 0755)
suite.Require().NoError(err)
// Create files in directory
for i := 1; i <= 5; i++ {
file := filepath.Join(configDir, fmt.Sprintf("file%d.json", i))
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644))
}
// Add recursively
err = suite.runCommand("add", "--recursive", configDir)
suite.NoError(err)
output := suite.stdout.String()
// Should show operation summary
suite.Contains(output, "recursively", "Should indicate operation type")
suite.Contains(output, "5", "Should show correct file count")
// Should include contextual help message
suite.Contains(output, "lnk push", "Should suggest next steps")
suite.Contains(output, "sync to remote", "Should explain next step purpose")
// Should show operation completion confirmation
suite.Contains(output, "✨", "Should use success emoji")
suite.Contains(output, "Added", "Should confirm operation completed")
}
// Task 3.3: Documentation and Help Updates Tests
func (suite *CLITestSuite) TestUpdatedHelpText() {
// Test main help
err := suite.runCommand("help")
suite.NoError(err)
helpOutput := suite.stdout.String()
suite.stdout.Reset()
// Should mention bulk operations
suite.Contains(helpOutput, "multiple files", "Help should mention multiple file support")
// Test add command help
err = suite.runCommand("add", "--help")
suite.NoError(err)
addHelpOutput := suite.stdout.String()
// Should include new flags
suite.Contains(addHelpOutput, "--recursive", "Help should include recursive flag")
suite.Contains(addHelpOutput, "--dry-run", "Help should include dry-run flag")
// Should include examples
suite.Contains(addHelpOutput, "Examples:", "Help should include usage examples")
suite.Contains(addHelpOutput, "lnk add ~/.bashrc ~/.vimrc", "Help should show multiple file example")
suite.Contains(addHelpOutput, "lnk add --recursive ~/.config", "Help should show recursive example")
suite.Contains(addHelpOutput, "lnk add --dry-run", "Help should show dry-run example")
// Should describe what each flag does
suite.Contains(addHelpOutput, "directory contents individually", "Should explain recursive flag")
suite.Contains(addHelpOutput, "without making changes", "Should explain dry-run flag")
}
func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLITestSuite))
}

View File

@@ -1,8 +1,12 @@
package cmd
import (
"context"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
"github.com/yarlson/lnk/internal/models"
"github.com/yarlson/lnk/internal/service"
)
func newStatusCmd() *cobra.Command {
@@ -11,12 +15,16 @@ func newStatusCmd() *cobra.Command {
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,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
status, err := lnk.Status()
lnkService, err := service.New()
if err != nil {
return err
return wrapServiceError("initialize lnk service", err)
}
ctx := context.Background()
status, err := lnkService.GetStatus(ctx)
if err != nil {
return formatError(err)
}
if status.Dirty {
@@ -35,9 +43,9 @@ func newStatusCmd() *cobra.Command {
}
}
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
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", status.Remote)
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")
@@ -49,14 +57,14 @@ func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
}
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
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", status.Remote)
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", getRemoteDisplay(status))
}
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
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", status.Remote)
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status))
printf(cmd, "\n")
displayAheadBehindInfo(cmd, status, false)
@@ -68,7 +76,7 @@ func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
}
}
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
func displayAheadBehindInfo(cmd *cobra.Command, status *models.SyncStatus, isDirty bool) {
if status.Ahead > 0 {
commitText := getCommitText(status.Ahead)
if isDirty {
@@ -90,3 +98,13 @@ func getCommitText(count int) string {
}
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"
}

View File

@@ -1,12 +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

@@ -18,7 +18,7 @@ INSTALL_DIR="/usr/local/bin"
BINARY_NAME="lnk"
# Fallback version if redirect fails
FALLBACK_VERSION="v0.3.0"
FALLBACK_VERSION="v0.0.2"
# Detect OS and architecture
detect_platform() {

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

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

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,114 +0,0 @@
package fs
import (
"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 and get its info
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return &FileNotExistsError{Path: filePath, Err: err}
}
return &FileCheckError{Err: err}
}
// Allow both regular files and directories
if !info.Mode().IsRegular() && !info.IsDir() {
return &UnsupportedFileTypeError{Path: 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 and is a symlink
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
if err != nil {
if os.IsNotExist(err) {
return &FileNotExistsError{Path: filePath, Err: err}
}
return &FileCheckError{Err: err}
}
if info.Mode()&os.ModeSymlink == 0 {
return &NotManagedByLnkError{Path: filePath}
}
// Get symlink target and resolve to absolute path
target, err := os.Readlink(filePath)
if err != nil {
return &SymlinkReadError{Err: err}
}
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(filePath), target)
}
// Clean paths and check if target is inside the repository
target = filepath.Clean(target)
repoPath = filepath.Clean(repoPath)
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
return &NotManagedByLnkError{Path: filePath}
}
return nil
}
// Move moves a file or directory from source to destination based on the file info
func (fs *FileSystem) Move(src, dst string, info os.FileInfo) error {
if info.IsDir() {
return fs.MoveDirectory(src, dst)
}
return fs.MoveFile(src, dst)
}
// MoveFile moves a file from source to destination
func (fs *FileSystem) MoveFile(src, dst string) error {
// Ensure destination directory exists
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return &DirectoryCreationError{Operation: "destination directory", Err: err}
}
// Move the file
return os.Rename(src, dst)
}
// CreateSymlink creates a relative symlink from target to linkPath
func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
// Calculate relative path from linkPath to target
relTarget, err := filepath.Rel(filepath.Dir(linkPath), target)
if err != nil {
return &RelativePathCalculationError{Err: err}
}
// Create the symlink
return os.Symlink(relTarget, linkPath)
}
// MoveDirectory moves a directory from source to destination recursively
func (fs *FileSystem) MoveDirectory(src, dst string) error {
// Ensure destination parent directory exists
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return &DirectoryCreationError{Operation: "destination parent directory", Err: err}
}
// Move the directory
return os.Rename(src, dst)
}

View File

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

View File

@@ -1,508 +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 &GitInitError{Output: string(output), Err: err}
}
// 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 &BranchSetupError{Err: 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 &RemoteExistsError{Remote: name, ExistingURL: existingURL, NewURL: 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 &GitCommandError{Command: "remote add", Output: string(output), Err: err}
}
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 &GitCommandError{Command: "add", Output: string(output), Err: err}
}
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 &GitCommandError{Command: "rm", Output: string(output), Err: err}
}
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 &GitCommandError{Command: "commit", Output: string(output), Err: err}
}
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 &GitConfigError{Setting: "user.name", Err: 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 &GitConfigError{Setting: "user.email", Err: 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, &GitCommandError{Command: "log", Output: outputStr, Err: 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 "", &GitCommandError{Command: "remote", Output: string(output), Err: err}
}
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(remotes) == 0 || remotes[0] == "" {
return "", &NoRemoteError{}
}
// Use the first remote
url, err = g.getRemoteURL(remotes[0])
if err != nil {
return "", &RemoteNotFoundError{Remote: remotes[0], Err: err}
}
}
return url, nil
}
// StatusInfo contains repository status information
type StatusInfo struct {
Ahead int
Behind int
Remote string
Dirty bool
}
// 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
}
// Check for uncommitted changes
dirty, err := g.HasChanges()
if err != nil {
return nil, &UncommittedChangesError{Err: 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,
Dirty: dirty,
}, nil
}
remoteBranch := strings.TrimSpace(string(output))
return &StatusInfo{
Ahead: g.getAheadCount(remoteBranch),
Behind: g.getBehindCount(remoteBranch),
Remote: remoteBranch,
Dirty: dirty,
}, 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, &GitCommandError{Command: "status", Output: string(output), Err: 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 &GitCommandError{Command: "add", Output: string(output), Err: err}
}
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 &PushError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "push", "-u", "origin", "main")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return &PushError{Output: string(output), Err: err}
}
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 &PullError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "pull", "origin", "main")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return &PullError{Output: string(output), Err: err}
}
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 &DirectoryRemovalError{Path: g.repoPath, Err: err}
}
// Create parent directory
parentDir := filepath.Dir(g.repoPath)
if err := os.MkdirAll(parentDir, 0755); err != nil {
return &DirectoryCreationError{Path: parentDir, Err: err}
}
// Clone the repository
cmd := exec.Command("git", "clone", url, g.repoPath)
output, err := cmd.CombinedOutput()
if err != nil {
return &GitCommandError{Command: "clone", Output: string(output), Err: err}
}
// 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