15 Commits

Author SHA1 Message Date
Yar Kravtsov
4cd8191805 style: standardize code formatting and whitespace 2025-07-29 09:04:57 +03:00
Yar Kravtsov
6830c06eb4 docs(README): enhance usage examples and feature descriptions 2025-07-29 08:59:39 +03:00
Yar Kravtsov
8a29b7fe43 feat(add): implement dry-run mode and enhance output formatting 2025-07-29 08:56:33 +03:00
Yar Kravtsov
a6852e5ad5 feat(add): implement recursive file addition with progress tracking 2025-07-29 08:47:14 +03:00
Yar Kravtsov
36d76c881c feat(add): implement multiple file addition with atomic operation 2025-07-29 08:32:33 +03:00
Yar Kravtsov
6de387797e chore(gitignore): refine file exclusions for better repo management 2025-07-29 08:14:51 +03:00
Yar Kravtsov
9cbad5e593 Merge pull request #10 from reneleonhardt/chore/update-actions
ci: update github-actions
2025-07-28 14:20:31 +03:00
Rene Leonhardt
150e8adf8b ci: update github-actions 2025-07-28 09:10:51 +02:00
Yar Kravtsov
4b11563bdf Update README.md 2025-06-03 15:06:15 +03:00
Yar Kravtsov
b476ce503b docs(README): refine project description and highlight key benefits 2025-06-03 15:02:34 +03:00
Yar Kravtsov
ae9cc175ce feat(bootstrap): add automatic environment setup with bootstrap scripts
Implement bootstrap functionality for streamlined dotfiles setup:
- Add 'bootstrap' command to run setup scripts manually
- Auto-execute bootstrap on 'init' with remote (--no-bootstrap to skip)
- Update README with bootstrap usage and examples
- Extend tests to cover bootstrap scenarios
2025-06-03 08:33:59 +03:00
Yar Kravtsov
1e2c9704f3 refactor(errors): implement structured error handling for improved debugging 2025-06-03 07:58:21 +03:00
Yar Kravtsov
3cba309c05 refactor(core): simplify Lnk creation with functional options pattern 2025-06-03 06:50:52 +03:00
Yar Kravtsov
3e6b426a19 test(cmd): improve test coverage for file storage and .lnk tracking 2025-05-27 08:33:23 +03:00
Yar Kravtsov
02f342b02b refactor(core): simplify path handling and remove redundant generateRepoName function 2025-05-27 08:00:04 +03:00
21 changed files with 2877 additions and 373 deletions

17
.github/dependabot.yml vendored Normal file
View File

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

5
.gitignore vendored
View File

@@ -43,4 +43,7 @@ desktop.ini
*.log *.log
# GoReleaser artifacts # GoReleaser artifacts
goreleaser/ goreleaser/
*.md
!/README.md
!/RELEASE.md

185
README.md
View File

@@ -2,12 +2,18 @@
**Git-native dotfiles management that doesn't suck.** **Git-native dotfiles management that doesn't suck.**
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups. 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.
```bash ```bash
lnk init lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
lnk add ~/.vimrc ~/.bashrc # Common config lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Multiple files at once
lnk add --host work ~/.ssh/config # Host-specific config lnk add --recursive ~/.config/nvim # Process directory contents
lnk add --dry-run ~/.tmux.conf # Preview changes first
lnk add --host work ~/.ssh/config # Host-specific config
lnk push "setup" lnk push "setup"
``` ```
@@ -43,19 +49,32 @@ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv
# Fresh start # Fresh start
lnk init lnk init
# With existing repo # With existing repo (runs bootstrap automatically)
lnk init -r git@github.com:user/dotfiles.git 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 ### Daily workflow
```bash ```bash
# Add files/directories (common config) # Add multiple files at once (common config)
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
# Add host-specific files # Add directory contents individually
lnk add --host laptop ~/.ssh/config lnk add --recursive ~/.config/nvim ~/.config/zsh
lnk add --host work ~/.gitconfig
# 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
# List managed files # List managed files
lnk list # Common config only lnk list # Common config only
@@ -85,6 +104,44 @@ 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. 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 ## Multihost Support
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine). Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
@@ -111,12 +168,19 @@ Lnk supports both **common configurations** (shared across all machines) and **h
### Usage Patterns ### Usage Patterns
```bash ```bash
# Common config (shared everywhere) # Common config (shared everywhere) - supports multiple files
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
# Host-specific config (unique per machine) # Process directory contents individually
lnk add --host $(hostname) ~/.ssh/config lnk add --recursive ~/.config/nvim ~/.config/zsh
lnk add --host work ~/.gitconfig
# 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
# List configurations # List configurations
lnk list # Common only lnk list # Common only
@@ -132,23 +196,35 @@ lnk pull --host work # Work-specific config
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts: You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
- Moving files safely - Moving files safely (with atomic operations)
- Creating relative symlinks - Creating relative symlinks
- Handling conflicts - Handling conflicts and rollback
- Tracking what's managed - Tracking what's managed
- Processing multiple files efficiently
- Recursive directory traversal
- Preview mode for safety
## Examples ## Examples
### First time setup ### First time setup
```bash ```bash
# Clone dotfiles and run bootstrap automatically
lnk init -r git@github.com:you/dotfiles.git lnk init -r git@github.com:you/dotfiles.git
# → Downloads dependencies, installs packages, configures environment
# Add common config (shared across all machines) # Add common config (shared across all machines) - multiple files at once
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig lnk add ~/.bashrc ~/.vimrc ~/.gitconfig ~/.tmux.conf
# Add host-specific config # Add configuration directories individually
lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf 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
lnk push "initial setup" lnk push "initial setup"
``` ```
@@ -156,13 +232,18 @@ lnk push "initial setup"
### On a new machine ### On a new machine
```bash ```bash
# Bootstrap runs automatically
lnk init -r git@github.com:you/dotfiles.git lnk init -r git@github.com:you/dotfiles.git
# → Sets up environment, installs dependencies
# Pull common config # Pull common config
lnk pull lnk pull
# Pull host-specific config (if it exists) # Pull host-specific config (if it exists)
lnk pull --host $(hostname) lnk pull --host $(hostname)
# Or run bootstrap manually if needed
lnk bootstrap
``` ```
### Daily edits ### Daily edits
@@ -179,35 +260,57 @@ lnk push "new plugins" # commit & push
### Multi-machine workflow ### Multi-machine workflow
```bash ```bash
# On your laptop # On your laptop - use bulk operations for efficiency
lnk add --host laptop ~/.ssh/config lnk add --host laptop ~/.ssh/config ~/.aws/credentials ~/.npmrc
lnk add ~/.vimrc # Common config lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Common config (multiple files)
lnk push "laptop ssh config" lnk push "laptop configuration"
# On your work machine # On your work machine
lnk pull # Get common config lnk pull # Get common config
lnk add --host work ~/.gitconfig lnk add --host work ~/.gitconfig ~/.ssh/config
lnk push "work git config" lnk add --recursive ~/.config/work-tools # Work-specific tools
lnk push "work configuration"
# Back on laptop # Back on laptop
lnk pull # Get updates (work config won't affect laptop) lnk pull # Get updates (work config won't affect laptop)
``` ```
## Commands ## Commands
- `lnk init [-r remote]` - Create repo - `lnk init [-r remote] [--no-bootstrap]` - Create repo (runs bootstrap automatically)
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks - `lnk add [--host HOST] [--recursive] [--dry-run] <files>...` - Move files to repo, create symlinks
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks - `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
- `lnk list [--host HOST] [--all]` - List files managed by lnk - `lnk list [--host HOST] [--all]` - List files managed by lnk
- `lnk status` - Git status + sync info - `lnk status` - Git status + sync info
- `lnk push [msg]` - Stage all, commit, push - `lnk push [msg]` - Stage all, commit, push
- `lnk pull [--host HOST]` - Pull + restore missing symlinks - `lnk pull [--host HOST]` - Pull + restore missing symlinks
- `lnk bootstrap` - Run bootstrap script manually
### Command Options ### Command Options
- `--host HOST` - Manage files for specific host (default: common configuration) - `--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 - `--all` - Show all configurations (common + all hosts) when listing
- `-r, --remote URL` - Clone from remote URL when initializing - `-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 ## Technical bits
@@ -215,17 +318,21 @@ lnk pull # Get updates (work config won't affect laptop)
- **Relative symlinks** (portable) - **Relative symlinks** (portable)
- **XDG compliant** (`~/.config/lnk`) - **XDG compliant** (`~/.config/lnk`)
- **Multihost support** (common + host-specific configs) - **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) - **Git-native** (standard Git repo, no special formats)
## Alternatives ## Alternatives
| Tool | Complexity | Why choose it | | Tool | Complexity | Why choose it |
| ------- | ---------- | -------------------------------------------- | | ------- | ---------- | -------------------------------------------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native, multihost | | **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run |
| chezmoi | High | Templates, encryption, cross-platform | | chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption | | yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features | | dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only | | stow | Low | Perl, symlink only |
## Contributing ## Contributing

View File

@@ -1,7 +1,6 @@
package cmd package cmd
import ( import (
"fmt"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -10,39 +9,156 @@ import (
func newAddCmd() *cobra.Command { func newAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add <file>", Use: "add <file>...",
Short: "✨ Add a file to lnk management", Short: "✨ Add files to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.", Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.
Args: cobra.ExactArgs(1),
SilenceUsage: true, 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),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
host, _ := cmd.Flags().GetString("host") host, _ := cmd.Flags().GetString("host")
recursive, _ := cmd.Flags().GetBool("recursive")
dryRun, _ := cmd.Flags().GetBool("dry-run")
lnk := core.NewLnk(core.WithHost(host))
var lnk *core.Lnk // Handle dry-run mode
if host != "" { if dryRun {
lnk = core.NewLnkWithHost(host) 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 { } else {
lnk = core.NewLnk() // 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
}
}
} }
if err := lnk.Add(filePath); err != nil { // Display results
return fmt.Errorf("failed to add file: %w", err) 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))
}
basename := filepath.Base(filePath) // Show some of the files that were added (limit to first few for readability)
if host != "" { filesToShow := len(args)
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host) if filesToShow > 5 {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath) 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]
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)
} 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)
}
} else { } else {
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename) // Multiple files - show summary
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath) 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") printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
return nil return nil
}, },
} }
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)") 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 return cmd
} }

45
cmd/bootstrap.go Normal file
View File

@@ -0,0 +1,45 @@
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,30 +1,57 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
func newInitCmd() *cobra.Command { func newInitCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "init", Use: "init",
Short: "🎯 Initialize a new lnk repository", Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.", Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote") remote, _ := cmd.Flags().GetString("remote")
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
lnk := core.NewLnk() lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil { if err := lnk.InitWithRemote(remote); err != nil {
return fmt.Errorf("failed to initialize lnk: %w", err) return err
} }
if remote != "" { if remote != "" {
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n") printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote) printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n") 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, "\n💡 \033[33mNext steps:\033[0m\n")
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\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") printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
@@ -41,5 +68,6 @@ func newInitCmd() *cobra.Command {
} }
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository") 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 return cmd
} }

View File

@@ -1,7 +1,6 @@
package cmd package cmd
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -12,10 +11,11 @@ import (
func newListCmd() *cobra.Command { func newListCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "list", Use: "list",
Short: "📋 List files managed by lnk", Short: "📋 List files managed by lnk",
Long: "Display all files and directories currently managed by lnk.", Long: "Display all files and directories currently managed by lnk.",
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host") host, _ := cmd.Flags().GetString("host")
all, _ := cmd.Flags().GetBool("all") all, _ := cmd.Flags().GetBool("all")
@@ -44,7 +44,7 @@ func listCommonConfig(cmd *cobra.Command) error {
lnk := core.NewLnk() lnk := core.NewLnk()
managedItems, err := lnk.List() managedItems, err := lnk.List()
if err != nil { if err != nil {
return fmt.Errorf("failed to list managed items: %w", err) return err
} }
if len(managedItems) == 0 { if len(managedItems) == 0 {
@@ -68,10 +68,10 @@ func listCommonConfig(cmd *cobra.Command) error {
} }
func listHostConfig(cmd *cobra.Command, host string) error { func listHostConfig(cmd *cobra.Command, host string) error {
lnk := core.NewLnkWithHost(host) lnk := core.NewLnk(core.WithHost(host))
managedItems, err := lnk.List() managedItems, err := lnk.List()
if err != nil { if err != nil {
return fmt.Errorf("failed to list managed items for host %s: %w", host, err) return err
} }
if len(managedItems) == 0 { if len(managedItems) == 0 {
@@ -101,7 +101,7 @@ func listAllConfigs(cmd *cobra.Command) error {
lnk := core.NewLnk() lnk := core.NewLnk()
commonItems, err := lnk.List() commonItems, err := lnk.List()
if err != nil { if err != nil {
return fmt.Errorf("failed to list common managed items: %w", err) return err
} }
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems)) printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
@@ -121,13 +121,13 @@ func listAllConfigs(cmd *cobra.Command) error {
// Find all host-specific configurations // Find all host-specific configurations
hosts, err := findHostConfigs() hosts, err := findHostConfigs()
if err != nil { if err != nil {
return fmt.Errorf("failed to find host configurations: %w", err) return err
} }
for _, host := range hosts { for _, host := range hosts {
printf(cmd, "\n🖥 \033[1mHost: %s\033[0m", host) printf(cmd, "\n🖥 \033[1mHost: %s\033[0m", host)
hostLnk := core.NewLnkWithHost(host) hostLnk := core.NewLnk(core.WithHost(host))
hostItems, err := hostLnk.List() hostItems, err := hostLnk.List()
if err != nil { if err != nil {
printf(cmd, " \033[31m(error: %v)\033[0m\n", err) printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
@@ -163,7 +163,7 @@ func findHostConfigs() ([]string, error) {
entries, err := os.ReadDir(repoPath) entries, err := os.ReadDir(repoPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read repository directory: %w", err) return nil, err
} }
var hosts []string var hosts []string

View File

@@ -1,31 +1,25 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
func newPullCmd() *cobra.Command { func newPullCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "pull", Use: "pull",
Short: "⬇️ Pull changes from remote and restore symlinks", Short: "⬇️ Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.", Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host") host, _ := cmd.Flags().GetString("host")
var lnk *core.Lnk lnk := core.NewLnk(core.WithHost(host))
if host != "" {
lnk = core.NewLnkWithHost(host)
} else {
lnk = core.NewLnk()
}
restored, err := lnk.Pull() restored, err := lnk.Pull()
if err != nil { if err != nil {
return fmt.Errorf("failed to pull changes: %w", err) return err
} }
if len(restored) > 0 { if len(restored) > 0 {

View File

@@ -1,19 +1,18 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
func newPushCmd() *cobra.Command { func newPushCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "push [message]", Use: "push [message]",
Short: "🚀 Push local changes to remote repository", Short: "🚀 Push local changes to remote repository",
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.", Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
message := "lnk: sync configuration files" message := "lnk: sync configuration files"
if len(args) > 0 { if len(args) > 0 {
@@ -22,7 +21,7 @@ func newPushCmd() *cobra.Command {
lnk := core.NewLnk() lnk := core.NewLnk()
if err := lnk.Push(message); err != nil { if err := lnk.Push(message); err != nil {
return fmt.Errorf("failed to push changes: %w", err) return err
} }
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n") printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")

View File

@@ -1,7 +1,6 @@
package cmd package cmd
import ( import (
"fmt"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -10,24 +9,20 @@ import (
func newRemoveCmd() *cobra.Command { func newRemoveCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "rm <file>", Use: "rm <file>",
Short: "🗑️ Remove a file from lnk management", Short: "🗑️ Remove a file from lnk management",
Long: "Removes a symlink and restores the original file from the lnk repository.", Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0] filePath := args[0]
host, _ := cmd.Flags().GetString("host") host, _ := cmd.Flags().GetString("host")
var lnk *core.Lnk lnk := core.NewLnk(core.WithHost(host))
if host != "" {
lnk = core.NewLnkWithHost(host)
} else {
lnk = core.NewLnk()
}
if err := lnk.Remove(filePath); err != nil { if err := lnk.Remove(filePath); err != nil {
return fmt.Errorf("failed to remove file: %w", err) return err
} }
basename := filepath.Base(filePath) basename := filepath.Base(filePath)

View File

@@ -20,20 +20,28 @@ func NewRootCommand() *cobra.Command {
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck. Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal. Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
Supports both common configurations and host-specific setups. Supports both common configurations, host-specific setups, and bulk operations for multiple files.
✨ Examples: ✨ Examples:
lnk init # Fresh start lnk init # Fresh start
lnk init -r <repo-url> # Clone existing dotfiles lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
lnk add ~/.vimrc ~/.bashrc # Start managing common files lnk add ~/.vimrc ~/.bashrc # Start managing common files
lnk add --host work ~/.ssh/config # Manage host-specific files lnk add --recursive ~/.config/nvim # Add directory contents individually
lnk list --all # Show all configurations lnk add --dry-run ~/.gitconfig # Preview changes without applying
lnk pull --host work # Pull host-specific changes lnk add --host work ~/.ssh/config # Manage host-specific files
lnk push "setup complete" # Sync to remote 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.`, 🎯 Simple, fast, Git-native, and multi-host ready.`,
SilenceUsage: true, SilenceUsage: true,
Version: fmt.Sprintf("%s (built %s)", version, buildTime), SilenceErrors: true,
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
} }
// Add subcommands // Add subcommands
@@ -44,6 +52,7 @@ Supports both common configurations and host-specific setups.
rootCmd.AddCommand(newStatusCmd()) rootCmd.AddCommand(newStatusCmd())
rootCmd.AddCommand(newPushCmd()) rootCmd.AddCommand(newPushCmd())
rootCmd.AddCommand(newPullCmd()) rootCmd.AddCommand(newPullCmd())
rootCmd.AddCommand(newBootstrapCmd())
return rootCmd return rootCmd
} }
@@ -57,7 +66,7 @@ func SetVersion(v, bt string) {
func Execute() { func Execute() {
rootCmd := NewRootCommand() rootCmd := NewRootCommand()
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) _, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
} }

View File

@@ -2,6 +2,7 @@ package cmd
import ( import (
"bytes" "bytes"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -31,8 +32,11 @@ func (suite *CLITestSuite) SetupTest() {
err = os.Chdir(tempDir) err = os.Chdir(tempDir)
suite.Require().NoError(err) suite.Require().NoError(err)
// Set XDG_CONFIG_HOME to temp directory // Set HOME to temp directory for consistent relative path calculation
suite.T().Setenv("XDG_CONFIG_HOME", tempDir) suite.T().Setenv("HOME", tempDir)
// Set XDG_CONFIG_HOME to tempDir/.config for config files
suite.T().Setenv("XDG_CONFIG_HOME", filepath.Join(tempDir, ".config"))
// Capture output // Capture output
suite.stdout = &bytes.Buffer{} suite.stdout = &bytes.Buffer{}
@@ -66,20 +70,13 @@ func (suite *CLITestSuite) TestInitCommand() {
suite.Contains(output, "lnk add <file>") suite.Contains(output, "lnk add <file>")
// Verify actual effect // Verify actual effect
lnkDir := filepath.Join(suite.tempDir, "lnk") lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
suite.DirExists(lnkDir) suite.DirExists(lnkDir)
gitDir := filepath.Join(lnkDir, ".git") gitDir := filepath.Join(lnkDir, ".git")
suite.DirExists(gitDir) suite.DirExists(gitDir)
} }
func (suite *CLITestSuite) TestInitWithRemote() {
err := suite.runCommand("init", "-r", "https://github.com/user/dotfiles.git")
// This will fail because we don't have a real remote, but that's expected
suite.Error(err)
suite.Contains(err.Error(), "git clone failed")
}
func (suite *CLITestSuite) TestAddCommand() { func (suite *CLITestSuite) TestAddCommand() {
// Initialize first // Initialize first
err := suite.runCommand("init") err := suite.runCommand("init")
@@ -107,9 +104,20 @@ func (suite *CLITestSuite) TestAddCommand() {
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
// Verify the file exists in repo with preserved directory structure // Verify the file exists in repo with preserved directory structure
lnkDir := filepath.Join(suite.tempDir, "lnk") lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc") repoFile := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(repoFile) suite.FileExists(repoFile)
// Verify content is preserved in storage
storedContent, err := os.ReadFile(repoFile)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent))
// Verify .lnk file contains the correct entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(lnkContent))
} }
func (suite *CLITestSuite) TestRemoveCommand() { func (suite *CLITestSuite) TestRemoveCommand() {
@@ -150,7 +158,7 @@ func (suite *CLITestSuite) TestStatusCommand() {
// Test status without remote - should fail // Test status without remote - should fail
err = suite.runCommand("status") err = suite.runCommand("status")
suite.Error(err) suite.Error(err)
suite.Contains(err.Error(), "no remote configured") suite.Contains(err.Error(), "No remote repository is configured")
} }
func (suite *CLITestSuite) TestListCommand() { func (suite *CLITestSuite) TestListCommand() {
@@ -205,6 +213,27 @@ func (suite *CLITestSuite) TestListCommand() {
suite.Contains(output, "2 items") suite.Contains(output, "2 items")
suite.Contains(output, ".bashrc") suite.Contains(output, ".bashrc")
suite.Contains(output, ".vimrc") suite.Contains(output, ".vimrc")
// Verify both files exist in storage with correct content
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
bashrcStorage := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(bashrcStorage)
bashrcContent, err := os.ReadFile(bashrcStorage)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(bashrcContent))
vimrcStorage := filepath.Join(lnkDir, ".vimrc")
suite.FileExists(vimrcStorage)
vimrcContent, err := os.ReadFile(vimrcStorage)
suite.NoError(err)
suite.Equal("set number", string(vimrcContent))
// Verify .lnk file contains both entries (sorted)
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n.vimrc\n", string(lnkContent))
} }
func (suite *CLITestSuite) TestErrorHandling() { func (suite *CLITestSuite) TestErrorHandling() {
@@ -219,7 +248,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
name: "add nonexistent file", name: "add nonexistent file",
args: []string{"add", "/nonexistent/file"}, args: []string{"add", "/nonexistent/file"},
wantErr: true, wantErr: true,
errContains: "File does not exist", errContains: "File or directory not found",
}, },
{ {
name: "status without init", name: "status without init",
@@ -249,7 +278,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
name: "add help", name: "add help",
args: []string{"add", "--help"}, args: []string{"add", "--help"},
wantErr: false, wantErr: false,
outContains: "Moves a file to the lnk repository", outContains: "Moves files to the lnk repository",
}, },
{ {
name: "list help", name: "list help",
@@ -300,29 +329,57 @@ func (suite *CLITestSuite) TestCompleteWorkflow() {
}, },
{ {
name: "add config file", name: "add config file",
args: []string{"add", ".bashrc"}, args: []string{"add", filepath.Join(suite.tempDir, ".bashrc")},
setup: func() { setup: func() {
testFile := filepath.Join(suite.tempDir, ".bashrc") testFile := filepath.Join(suite.tempDir, ".bashrc")
_ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) _ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
}, },
verify: func(output string) { verify: func(output string) {
suite.Contains(output, "Added .bashrc to lnk") suite.Contains(output, "Added .bashrc to lnk")
// Verify storage and .lnk file
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
bashrcStorage := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(bashrcStorage)
storedContent, err := os.ReadFile(bashrcStorage)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent))
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(lnkContent))
}, },
}, },
{ {
name: "add another file", name: "add another file",
args: []string{"add", ".vimrc"}, args: []string{"add", filepath.Join(suite.tempDir, ".vimrc")},
setup: func() { setup: func() {
testFile := filepath.Join(suite.tempDir, ".vimrc") testFile := filepath.Join(suite.tempDir, ".vimrc")
_ = os.WriteFile(testFile, []byte("set number"), 0644) _ = os.WriteFile(testFile, []byte("set number"), 0644)
}, },
verify: func(output string) { verify: func(output string) {
suite.Contains(output, "Added .vimrc to lnk") suite.Contains(output, "Added .vimrc to lnk")
// Verify storage and .lnk file now contains both files
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
vimrcStorage := filepath.Join(lnkDir, ".vimrc")
suite.FileExists(vimrcStorage)
storedContent, err := os.ReadFile(vimrcStorage)
suite.NoError(err)
suite.Equal("set number", string(storedContent))
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n.vimrc\n", string(lnkContent))
}, },
}, },
{ {
name: "remove file", name: "remove file",
args: []string{"rm", ".vimrc"}, args: []string{"rm", filepath.Join(suite.tempDir, ".vimrc")},
verify: func(output string) { verify: func(output string) {
suite.Contains(output, "Removed .vimrc from lnk") suite.Contains(output, "Removed .vimrc from lnk")
}, },
@@ -369,10 +426,10 @@ func (suite *CLITestSuite) TestAddDirectory() {
suite.stdout.Reset() suite.stdout.Reset()
// Create a directory with files // Create a directory with files
testDir := filepath.Join(suite.tempDir, ".config") testDir := filepath.Join(suite.tempDir, ".ssh")
_ = os.MkdirAll(testDir, 0755) _ = os.MkdirAll(testDir, 0755)
configFile := filepath.Join(testDir, "app.conf") configFile := filepath.Join(testDir, "config")
_ = os.WriteFile(configFile, []byte("setting=value"), 0644) _ = os.WriteFile(configFile, []byte("Host example.com"), 0644)
// Add the directory // Add the directory
err := suite.runCommand("add", testDir) err := suite.runCommand("add", testDir)
@@ -380,7 +437,7 @@ func (suite *CLITestSuite) TestAddDirectory() {
// Check output // Check output
output := suite.stdout.String() output := suite.stdout.String()
suite.Contains(output, "Added .config to lnk") suite.Contains(output, "Added .ssh to lnk")
// Verify directory is now a symlink // Verify directory is now a symlink
info, err := os.Lstat(testDir) info, err := os.Lstat(testDir)
@@ -388,9 +445,22 @@ func (suite *CLITestSuite) TestAddDirectory() {
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
// Verify the directory exists in repo with preserved directory structure // Verify the directory exists in repo with preserved directory structure
lnkDir := filepath.Join(suite.tempDir, "lnk") lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
repoDir := filepath.Join(lnkDir, suite.tempDir, ".config") repoDir := filepath.Join(lnkDir, ".ssh")
suite.DirExists(repoDir) suite.DirExists(repoDir)
// Verify directory content is preserved
repoConfigFile := filepath.Join(repoDir, "config")
suite.FileExists(repoConfigFile)
storedContent, err := os.ReadFile(repoConfigFile)
suite.NoError(err)
suite.Equal("Host example.com", string(storedContent))
// Verify .lnk file contains the directory entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal(".ssh\n", string(lnkContent))
} }
func (suite *CLITestSuite) TestSameBasenameFilesBug() { func (suite *CLITestSuite) TestSameBasenameFilesBug() {
@@ -441,6 +511,27 @@ func (suite *CLITestSuite) TestSameBasenameFilesBug() {
suite.Equal(contentA, string(contentAfterAddA), "First file should keep its original content") suite.Equal(contentA, string(contentAfterAddA), "First file should keep its original content")
suite.Equal(contentB, string(contentAfterAddB), "Second file should keep its original content") suite.Equal(contentB, string(contentAfterAddB), "Second file should keep its original content")
// Verify both files exist in storage with correct paths and content
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
storageFileA := filepath.Join(lnkDir, "a", "config.json")
suite.FileExists(storageFileA)
storedContentA, err := os.ReadFile(storageFileA)
suite.NoError(err)
suite.Equal(contentA, string(storedContentA))
storageFileB := filepath.Join(lnkDir, "b", "config.json")
suite.FileExists(storageFileB)
storedContentB, err := os.ReadFile(storageFileB)
suite.NoError(err)
suite.Equal(contentB, string(storedContentB))
// Verify .lnk file contains both entries with correct relative paths
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal("a/config.json\nb/config.json\n", string(lnkContent))
// Both files should be removable independently // Both files should be removable independently
suite.stdout.Reset() suite.stdout.Reset()
err = suite.runCommand("rm", fileA) err = suite.runCommand("rm", fileA)
@@ -481,8 +572,21 @@ func (suite *CLITestSuite) TestStatusDirtyRepo() {
suite.Require().NoError(err) suite.Require().NoError(err)
suite.stdout.Reset() suite.stdout.Reset()
// Verify file is stored correctly
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
storageFile := filepath.Join(lnkDir, "a")
suite.FileExists(storageFile)
storedContent, err := os.ReadFile(storageFile)
suite.NoError(err)
suite.Equal("abc", string(storedContent))
// Verify .lnk file contains the entry
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal("a\n", string(lnkContent))
// Add a remote so status works // Add a remote so status works
lnkDir := filepath.Join(suite.tempDir, "lnk")
cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test/dotfiles.git") cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test/dotfiles.git")
cmd.Dir = lnkDir cmd.Dir = lnkDir
err = cmd.Run() err = cmd.Run()
@@ -540,6 +644,33 @@ func (suite *CLITestSuite) TestMultihostCommands() {
suite.Contains(output, "workstation.lnk") suite.Contains(output, "workstation.lnk")
suite.stdout.Reset() suite.stdout.Reset()
// Verify storage paths and .lnk files for both common and host-specific
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
// Verify common file storage and tracking
commonStorage := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(commonStorage)
commonContent, err := os.ReadFile(commonStorage)
suite.NoError(err)
suite.Equal("export PATH=/usr/local/bin:$PATH", string(commonContent))
commonLnkFile := filepath.Join(lnkDir, ".lnk")
commonLnkContent, err := os.ReadFile(commonLnkFile)
suite.NoError(err)
suite.Equal(".bashrc\n", string(commonLnkContent))
// Verify host-specific file storage and tracking
hostStorage := filepath.Join(lnkDir, "workstation.lnk", ".vimrc")
suite.FileExists(hostStorage)
hostContent, err := os.ReadFile(hostStorage)
suite.NoError(err)
suite.Equal("set number", string(hostContent))
hostLnkFile := filepath.Join(lnkDir, ".lnk.workstation")
hostLnkContent, err := os.ReadFile(hostLnkFile)
suite.NoError(err)
suite.Equal(".vimrc\n", string(hostLnkContent))
// Test list command - common only // Test list command - common only
err = suite.runCommand("list") err = suite.runCommand("list")
suite.NoError(err) suite.NoError(err)
@@ -616,6 +747,597 @@ func (suite *CLITestSuite) TestMultihostErrorHandling() {
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)") 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) { func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLITestSuite)) suite.Run(t, new(CLITestSuite))
} }

View File

@@ -1,23 +1,22 @@
package cmd package cmd
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core" "github.com/yarlson/lnk/internal/core"
) )
func newStatusCmd() *cobra.Command { func newStatusCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "status", Use: "status",
Short: "📊 Show repository sync status", Short: "📊 Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.", Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk() lnk := core.NewLnk()
status, err := lnk.Status() status, err := lnk.Status()
if err != nil { if err != nil {
return fmt.Errorf("failed to get status: %w", err) return err
} }
if status.Dirty { if status.Dirty {

View File

@@ -18,7 +18,7 @@ INSTALL_DIR="/usr/local/bin"
BINARY_NAME="lnk" BINARY_NAME="lnk"
# Fallback version if redirect fails # Fallback version if redirect fails
FALLBACK_VERSION="v0.0.2" FALLBACK_VERSION="v0.3.0"
# Detect OS and architecture # Detect OS and architecture
detect_platform() { detect_platform() {
@@ -51,28 +51,28 @@ detect_platform() {
# Get latest version by following redirect # Get latest version by following redirect
get_latest_version() { get_latest_version() {
echo -e "${BLUE}Getting latest release version...${NC}" >&2 echo -e "${BLUE}Getting latest release version...${NC}" >&2
# Get redirect location from releases/latest # Get redirect location from releases/latest
local redirect_url local redirect_url
redirect_url=$(curl -s -I "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/\r$//' | cut -d' ' -f2-) redirect_url=$(curl -s -I "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/\r$//' | cut -d' ' -f2-)
if [ -z "$redirect_url" ]; then if [ -z "$redirect_url" ]; then
echo -e "${YELLOW}⚠ Could not get redirect URL, using fallback version ${FALLBACK_VERSION}${NC}" >&2 echo -e "${YELLOW}⚠ Could not get redirect URL, using fallback version ${FALLBACK_VERSION}${NC}" >&2
echo "$FALLBACK_VERSION" echo "$FALLBACK_VERSION"
return 0 return 0
fi fi
# Extract version from redirect URL (format: https://github.com/user/repo/releases/tag/v1.2.3) # Extract version from redirect URL (format: https://github.com/user/repo/releases/tag/v1.2.3)
local version local version
version=$(echo "$redirect_url" | sed -E 's|.*/releases/tag/([^/]*)\s*$|\1|') version=$(echo "$redirect_url" | sed -E 's|.*/releases/tag/([^/]*)\s*$|\1|')
if [ -z "$version" ] || [ "$version" = "$redirect_url" ]; then if [ -z "$version" ] || [ "$version" = "$redirect_url" ]; then
echo -e "${YELLOW}⚠ Could not parse version from redirect URL: $redirect_url${NC}" >&2 echo -e "${YELLOW}⚠ Could not parse version from redirect URL: $redirect_url${NC}" >&2
echo -e "${YELLOW}Using fallback version ${FALLBACK_VERSION}${NC}" >&2 echo -e "${YELLOW}Using fallback version ${FALLBACK_VERSION}${NC}" >&2
echo "$FALLBACK_VERSION" echo "$FALLBACK_VERSION"
return 0 return 0
fi fi
echo "$version" echo "$version"
} }
@@ -91,25 +91,25 @@ get_version() {
# Download and install # Download and install
install_lnk() { install_lnk() {
local platform version local platform version
echo -e "${BLUE}🔗 Installing lnk...${NC}" echo -e "${BLUE}🔗 Installing lnk...${NC}"
platform=$(detect_platform) platform=$(detect_platform)
version=$(get_version "$1") version=$(get_version "$1")
echo -e "${BLUE}Version: ${version}${NC}" echo -e "${BLUE}Version: ${version}${NC}"
echo -e "${BLUE}Platform: ${platform}${NC}" echo -e "${BLUE}Platform: ${platform}${NC}"
# Download URL # Download URL
local filename="lnk_${platform}.tar.gz" local filename="lnk_${platform}.tar.gz"
local url="https://github.com/${REPO}/releases/download/${version}/${filename}" local url="https://github.com/${REPO}/releases/download/${version}/${filename}"
echo -e "${BLUE}Downloading ${url}...${NC}" echo -e "${BLUE}Downloading ${url}...${NC}"
# Create temporary directory # Create temporary directory
local tmp_dir=$(mktemp -d) local tmp_dir=$(mktemp -d)
cd "$tmp_dir" cd "$tmp_dir"
# Download the binary # Download the binary
if ! curl -sL "$url" -o "$filename"; then if ! curl -sL "$url" -o "$filename"; then
echo -e "${RED}Error: Failed to download ${url}${NC}" echo -e "${RED}Error: Failed to download ${url}${NC}"
@@ -117,7 +117,7 @@ install_lnk() {
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}" echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
exit 1 exit 1
fi fi
# Check if we got an HTML error page instead of the binary # Check if we got an HTML error page instead of the binary
if file "$filename" 2>/dev/null | grep -q "HTML"; then if file "$filename" 2>/dev/null | grep -q "HTML"; then
echo -e "${RED}Error: Downloaded file appears to be an HTML page (404 error)${NC}" echo -e "${RED}Error: Downloaded file appears to be an HTML page (404 error)${NC}"
@@ -125,30 +125,30 @@ install_lnk() {
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}" echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
exit 1 exit 1
fi fi
# Extract the binary # Extract the binary
if ! tar -xzf "$filename"; then if ! tar -xzf "$filename"; then
echo -e "${RED}Error: Failed to extract ${filename}${NC}" echo -e "${RED}Error: Failed to extract ${filename}${NC}"
exit 1 exit 1
fi fi
# Make binary executable # Make binary executable
chmod +x "$BINARY_NAME" chmod +x "$BINARY_NAME"
# Install to system directory # Install to system directory
echo -e "${YELLOW}Installing to ${INSTALL_DIR} (requires sudo)...${NC}" echo -e "${YELLOW}Installing to ${INSTALL_DIR} (requires sudo)...${NC}"
if ! sudo mv "$BINARY_NAME" "$INSTALL_DIR/"; then if ! sudo mv "$BINARY_NAME" "$INSTALL_DIR/"; then
echo -e "${RED}Error: Failed to install binary${NC}" echo -e "${RED}Error: Failed to install binary${NC}"
exit 1 exit 1
fi fi
# Cleanup # Cleanup
cd - > /dev/null cd - > /dev/null
rm -rf "$tmp_dir" rm -rf "$tmp_dir"
echo -e "${GREEN}✅ lnk installed successfully!${NC}" echo -e "${GREEN}✅ lnk installed successfully!${NC}"
echo -e "${GREEN}Run 'lnk --help' to get started.${NC}" echo -e "${GREEN}Run 'lnk --help' to get started.${NC}"
# Test the installation # Test the installation
if command -v lnk >/dev/null 2>&1; then if command -v lnk >/dev/null 2>&1; then
echo -e "${GREEN}Installed version: $(lnk --version)${NC}" echo -e "${GREEN}Installed version: $(lnk --version)${NC}"
@@ -177,4 +177,4 @@ if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
fi fi
# Run the installer # Run the installer
install_lnk "$1" install_lnk "$1"

View File

@@ -3,6 +3,7 @@ package core
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -19,26 +20,30 @@ type Lnk struct {
fs *fs.FileSystem fs *fs.FileSystem
} }
// NewLnk creates a new Lnk instance for common configuration type Option func(*Lnk)
func NewLnk() *Lnk {
repoPath := getRepoPath() // WithHost sets the host for host-specific configuration
return &Lnk{ func WithHost(host string) Option {
repoPath: repoPath, return func(l *Lnk) {
host: "", // Empty host means common configuration l.host = host
git: git.New(repoPath),
fs: fs.New(),
} }
} }
// NewLnkWithHost creates a new Lnk instance for host-specific configuration // NewLnk creates a new Lnk instance with optional configuration
func NewLnkWithHost(host string) *Lnk { func NewLnk(opts ...Option) *Lnk {
repoPath := getRepoPath() repoPath := getRepoPath()
return &Lnk{ lnk := &Lnk{
repoPath: repoPath, repoPath: repoPath,
host: host, host: "",
git: git.New(repoPath), git: git.New(repoPath),
fs: fs.New(), fs: fs.New(),
} }
for _, opt := range opts {
opt(lnk)
}
return lnk
} }
// GetCurrentHostname returns the current system hostname // GetCurrentHostname returns the current system hostname
@@ -65,13 +70,6 @@ func getRepoPath() string {
return filepath.Join(xdgConfig, "lnk") return filepath.Join(xdgConfig, "lnk")
} }
// generateRepoName creates a repository path from a relative path
func generateRepoName(relativePath string, host string) string {
// Always preserve the directory structure for consistency
// Both common and host-specific files should maintain their path structure
return relativePath
}
// getHostStoragePath returns the storage path for host-specific or common files // getHostStoragePath returns the storage path for host-specific or common files
func (l *Lnk) getHostStoragePath() string { func (l *Lnk) getHostStoragePath() string {
if l.host == "" { if l.host == "" {
@@ -144,27 +142,17 @@ func (l *Lnk) InitWithRemote(remoteURL string) error {
} }
// No existing repository, initialize Git repository // No existing repository, initialize Git repository
if err := l.git.Init(); err != nil { return l.git.Init()
return fmt.Errorf("failed to initialize git repository: %w", err)
}
return nil
} }
// Clone clones a repository from the given URL // Clone clones a repository from the given URL
func (l *Lnk) Clone(url string) error { func (l *Lnk) Clone(url string) error {
if err := l.git.Clone(url); err != nil { return l.git.Clone(url)
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
} }
// AddRemote adds a remote to the repository // AddRemote adds a remote to the repository
func (l *Lnk) AddRemote(name, url string) error { func (l *Lnk) AddRemote(name, url string) error {
if err := l.git.AddRemote(name, url); err != nil { return l.git.AddRemote(name, url)
return fmt.Errorf("failed to add remote %s: %w", name, err)
}
return nil
} }
// Add moves a file or directory to the repository and creates a symlink // Add moves a file or directory to the repository and creates a symlink
@@ -187,9 +175,8 @@ func (l *Lnk) Add(filePath string) error {
} }
// Generate repository path from relative path // Generate repository path from relative path
repoName := generateRepoName(relativePath, l.host)
storagePath := l.getHostStoragePath() storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, repoName) destPath := filepath.Join(storagePath, relativePath)
// Ensure destination directory exists (including parent directories for host-specific files) // Ensure destination directory exists (including parent directories for host-specific files)
destDir := filepath.Dir(destPath) destDir := filepath.Dir(destPath)
@@ -215,87 +202,208 @@ func (l *Lnk) Add(filePath string) error {
} }
// Move to repository (handles both files and directories) // Move to repository (handles both files and directories)
if info.IsDir() { if err := l.fs.Move(absPath, destPath, info); err != nil {
if err := l.fs.MoveDirectory(absPath, destPath); err != nil { return err
return fmt.Errorf("failed to move directory to repository: %w", err)
}
} else {
if err := l.fs.MoveFile(absPath, destPath); err != nil {
return fmt.Errorf("failed to move file to repository: %w", err)
}
} }
// Create symlink // Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil { if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the original if symlink creation fails // Try to restore the original if symlink creation fails
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to create symlink: %w", err)
} }
// Add to .lnk tracking file using relative path // Add to .lnk tracking file using relative path
if err := l.addManagedItem(relativePath); err != nil { if err := l.addManagedItem(relativePath); err != nil {
// Try to restore the original state if tracking fails // Try to restore the original state if tracking fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to update tracking file: %w", err) return fmt.Errorf("failed to update tracking file: %w", err)
} }
// Add both the item and .lnk file to git in a single commit // Add both the item and .lnk file to git in a single commit
// For host-specific files, we need to add the relative path from repo root // For host-specific files, we need to add the relative path from repo root
gitPath := repoName gitPath := relativePath
if l.host != "" { if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", repoName) gitPath = filepath.Join(l.host+".lnk", relativePath)
} }
if err := l.git.Add(gitPath); err != nil { if err := l.git.Add(gitPath); err != nil {
// Try to restore the original state if git add fails // Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup _ = l.removeManagedItem(relativePath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to add item to git: %w", err)
} }
// Add .lnk file to the same commit // Add .lnk file to the same commit
if err := l.git.Add(l.getLnkFileName()); err != nil { if err := l.git.Add(l.getLnkFileName()); err != nil {
// Try to restore the original state if git add fails // Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup _ = l.removeManagedItem(relativePath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to add .lnk file to git: %w", err)
} }
// Commit both changes together // Commit both changes together
basename := filepath.Base(relativePath) basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil { if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
// Try to restore the original state if commit fails // Try to restore the original state if commit fails
_ = os.Remove(absPath) // Ignore error in cleanup _ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup _ = l.removeManagedItem(relativePath)
if info.IsDir() { _ = l.fs.Move(destPath, absPath, info)
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup return err
} else { }
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
return nil
}
// AddMultiple adds multiple files or directories to the repository in a single transaction
func (l *Lnk) AddMultiple(paths []string) error {
if len(paths) == 0 {
return nil
}
// Phase 1: Validate all paths first
var relativePaths []string
var absolutePaths []string
var infos []os.FileInfo
for _, filePath := range paths {
// Validate the file or directory
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
return fmt.Errorf("validation failed for %s: %w", filePath, err)
} }
// Get absolute path
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err)
}
// Get relative path for tracking
relativePath, err := getRelativePath(absPath)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
}
// Check if this relative path is already managed
managedItems, err := l.getManagedItems()
if err != nil {
return fmt.Errorf("failed to get managed items: %w", err)
}
for _, item := range managedItems {
if item == relativePath {
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
}
}
// Get file info
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat path %s: %w", filePath, err)
}
relativePaths = append(relativePaths, relativePath)
absolutePaths = append(absolutePaths, absPath)
infos = append(infos, info)
}
// Phase 2: Process all files - move to repository and create symlinks
var rollbackActions []func() error
for i, absPath := range absolutePaths {
relativePath := relativePaths[i]
info := infos[i]
// Generate repository path from relative path
storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, relativePath)
// Ensure destination directory exists
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Move to repository
if err := l.fs.Move(absPath, destPath, info); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to move %s: %w", absPath, err)
}
// Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the file we just moved, then rollback others
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create symlink for %s: %w", absPath, err)
}
// Add to tracking
if err := l.addManagedItem(relativePath); err != nil {
// Restore this file and rollback others
_ = os.Remove(absPath)
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err)
}
// Add rollback action for this file
rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info)
rollbackActions = append(rollbackActions, rollbackAction)
}
// Phase 3: Git operations - add all files and create single commit
for i, relativePath := range relativePaths {
// For host-specific files, we need to add the relative path from repo root
gitPath := relativePath
if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
if err := l.git.Add(gitPath); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err)
}
}
// Add .lnk file to the same commit
if err := l.git.Add(l.getLnkFileName()); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add tracking file to git: %w", err)
}
// Commit all changes together
commitMessage := fmt.Sprintf("lnk: added %d files", len(paths))
if err := l.git.Commit(commitMessage); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to commit changes: %w", err) return fmt.Errorf("failed to commit changes: %w", err)
} }
return nil return nil
} }
// createRollbackAction creates a rollback function for a single file operation
func (l *Lnk) createRollbackAction(absPath, destPath, relativePath string, info os.FileInfo) func() error {
return func() error {
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
return l.fs.Move(destPath, absPath, info)
}
}
// rollbackOperations executes rollback actions in reverse order
func (l *Lnk) rollbackOperations(rollbackActions []func() error) {
for i := len(rollbackActions) - 1; i >= 0; i-- {
_ = rollbackActions[i]()
}
}
// Remove removes a symlink and restores the original file or directory // Remove removes a symlink and restores the original file or directory
func (l *Lnk) Remove(filePath string) error { func (l *Lnk) Remove(filePath string) error {
// Get absolute path // Get absolute path
@@ -360,35 +468,28 @@ func (l *Lnk) Remove(filePath string) error {
} }
// Generate the correct git path for removal // Generate the correct git path for removal
repoName := generateRepoName(relativePath, l.host) gitPath := relativePath
gitPath := repoName
if l.host != "" { if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", repoName) gitPath = filepath.Join(l.host+".lnk", relativePath)
} }
if err := l.git.Remove(gitPath); err != nil { if err := l.git.Remove(gitPath); err != nil {
return fmt.Errorf("failed to remove from git: %w", err) return err
} }
// Add .lnk file to the same commit // Add .lnk file to the same commit
if err := l.git.Add(l.getLnkFileName()); err != nil { if err := l.git.Add(l.getLnkFileName()); err != nil {
return fmt.Errorf("failed to add .lnk file to git: %w", err) return err
} }
// Commit both changes together // Commit both changes together
basename := filepath.Base(relativePath) basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil { if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
return fmt.Errorf("failed to commit changes: %w", err) return err
} }
// Move back from repository (handles both files and directories) // Move back from repository (handles both files and directories)
if info.IsDir() { if err := l.fs.Move(target, absPath, info); err != nil {
if err := l.fs.MoveDirectory(target, absPath); err != nil { return err
return fmt.Errorf("failed to restore directory: %w", err)
}
} else {
if err := l.fs.MoveFile(target, absPath); err != nil {
return fmt.Errorf("failed to restore file: %w", err)
}
} }
return nil return nil
@@ -416,7 +517,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
gitStatus, err := l.git.GetStatus() gitStatus, err := l.git.GetStatus()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get repository status: %w", err) return nil, err
} }
return &StatusInfo{ return &StatusInfo{
@@ -437,28 +538,24 @@ func (l *Lnk) Push(message string) error {
// Check if there are any changes // Check if there are any changes
hasChanges, err := l.git.HasChanges() hasChanges, err := l.git.HasChanges()
if err != nil { if err != nil {
return fmt.Errorf("failed to check for changes: %w", err) return err
} }
if hasChanges { if hasChanges {
// Stage all changes // Stage all changes
if err := l.git.AddAll(); err != nil { if err := l.git.AddAll(); err != nil {
return fmt.Errorf("failed to stage changes: %w", err) return err
} }
// Create a sync commit // Create a sync commit
if err := l.git.Commit(message); err != nil { if err := l.git.Commit(message); err != nil {
return fmt.Errorf("failed to commit changes: %w", err) return err
} }
} }
// Push to remote (this will be a no-op in tests since we don't have real remotes) // Push to remote (this will be a no-op in tests since we don't have real remotes)
// In real usage, this would push to the actual remote repository // In real usage, this would push to the actual remote repository
if err := l.git.Push(); err != nil { return l.git.Push()
return fmt.Errorf("failed to push to remote: %w", err)
}
return nil
} }
// Pull fetches changes from remote and restores symlinks as needed // Pull fetches changes from remote and restores symlinks as needed
@@ -470,7 +567,7 @@ func (l *Lnk) Pull() ([]string, error) {
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes) // Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
if err := l.git.Pull(); err != nil { if err := l.git.Pull(); err != nil {
return nil, fmt.Errorf("failed to pull from remote: %w", err) return nil, err
} }
// Find all managed files in the repository and restore symlinks // Find all managed files in the repository and restore symlinks
@@ -515,9 +612,8 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
for _, relativePath := range managedItems { for _, relativePath := range managedItems {
// Generate repository name from relative path // Generate repository name from relative path
repoName := generateRepoName(relativePath, l.host)
storagePath := l.getHostStoragePath() storagePath := l.getHostStoragePath()
repoItem := filepath.Join(storagePath, repoName) repoItem := filepath.Join(storagePath, relativePath)
// Check if item exists in repository // Check if item exists in repository
if _, err := os.Stat(repoItem); os.IsNotExist(err) { if _, err := os.Stat(repoItem); os.IsNotExist(err) {
@@ -547,7 +643,7 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
// Create symlink // Create symlink
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil { if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err) return nil, err
} }
restored = append(restored, relativePath) restored = append(restored, relativePath)
@@ -682,3 +778,373 @@ func (l *Lnk) writeManagedItems(items []string) error {
return nil return nil
} }
// FindBootstrapScript searches for a bootstrap script in the repository
func (l *Lnk) FindBootstrapScript() (string, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return "", fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
}
// Look for bootstrap.sh - simple, opinionated choice
scriptPath := filepath.Join(l.repoPath, "bootstrap.sh")
if _, err := os.Stat(scriptPath); err == nil {
return "bootstrap.sh", nil
}
return "", nil // No bootstrap script found
}
// RunBootstrapScript executes the bootstrap script
func (l *Lnk) RunBootstrapScript(scriptName string) error {
scriptPath := filepath.Join(l.repoPath, scriptName)
// Verify the script exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return fmt.Errorf("❌ Bootstrap script not found: \033[31m%s\033[0m", scriptName)
}
// Make sure it's executable
if err := os.Chmod(scriptPath, 0755); err != nil {
return fmt.Errorf("❌ Failed to make bootstrap script executable: %w", err)
}
// Run with bash (since we only support bootstrap.sh)
cmd := exec.Command("bash", scriptPath)
// Set working directory to the repository
cmd.Dir = l.repoPath
// Connect to stdout/stderr for user to see output
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
// Run the script
if err := cmd.Run(); err != nil {
return fmt.Errorf("❌ Bootstrap script failed with error: %w", err)
}
return nil
}
// walkDirectory walks through a directory and returns all regular files
func (l *Lnk) walkDirectory(dirPath string) ([]string, error) {
var files []string
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories - we only want files
if info.IsDir() {
return nil
}
// Handle symlinks: include them as files if they point to regular files
if info.Mode()&os.ModeSymlink != 0 {
// For symlinks, we'll include them but the AddMultiple logic
// will handle validation appropriately
files = append(files, path)
return nil
}
// Include regular files
if info.Mode().IsRegular() {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory %s: %w", dirPath, err)
}
return files, nil
}
// ProgressCallback defines the signature for progress reporting callbacks
type ProgressCallback func(current, total int, currentFile string)
// AddRecursiveWithProgress adds directory contents individually with progress reporting
func (l *Lnk) AddRecursiveWithProgress(paths []string, progress ProgressCallback) error {
var allFiles []string
for _, path := range paths {
// Get absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
// Check if it's a directory
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat %s: %w", path, err)
}
if info.IsDir() {
// Walk directory to get all files
files, err := l.walkDirectory(absPath)
if err != nil {
return fmt.Errorf("failed to walk directory %s: %w", path, err)
}
allFiles = append(allFiles, files...)
} else {
// It's a regular file, add it directly
allFiles = append(allFiles, absPath)
}
}
// Use AddMultiple for batch processing
if len(allFiles) == 0 {
return fmt.Errorf("no files found to add")
}
// Apply progress threshold: only show progress for >10 files
const progressThreshold = 10
if len(allFiles) > progressThreshold && progress != nil {
return l.addMultipleWithProgress(allFiles, progress)
}
// For small operations, use regular AddMultiple without progress
return l.AddMultiple(allFiles)
}
// addMultipleWithProgress adds multiple files with progress reporting
func (l *Lnk) addMultipleWithProgress(paths []string, progress ProgressCallback) error {
if len(paths) == 0 {
return nil
}
// Phase 1: Validate all paths first (same as AddMultiple)
var relativePaths []string
var absolutePaths []string
var infos []os.FileInfo
for _, filePath := range paths {
// Validate the file or directory
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
return fmt.Errorf("validation failed for %s: %w", filePath, err)
}
// Get absolute path
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err)
}
// Get relative path for tracking
relativePath, err := getRelativePath(absPath)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
}
// Check if this relative path is already managed
managedItems, err := l.getManagedItems()
if err != nil {
return fmt.Errorf("failed to get managed items: %w", err)
}
for _, item := range managedItems {
if item == relativePath {
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
}
}
// Get file info
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat path %s: %w", filePath, err)
}
relativePaths = append(relativePaths, relativePath)
absolutePaths = append(absolutePaths, absPath)
infos = append(infos, info)
}
// Phase 2: Process all files with progress reporting
var rollbackActions []func() error
total := len(absolutePaths)
for i, absPath := range absolutePaths {
// Report progress
if progress != nil {
progress(i+1, total, filepath.Base(absPath))
}
relativePath := relativePaths[i]
info := infos[i]
// Generate repository path from relative path
storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, relativePath)
// Ensure destination directory exists
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Move to repository
if err := l.fs.Move(absPath, destPath, info); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to move %s: %w", absPath, err)
}
// Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the file we just moved, then rollback others
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create symlink for %s: %w", absPath, err)
}
// Add to tracking
if err := l.addManagedItem(relativePath); err != nil {
// Restore this file and rollback others
_ = os.Remove(absPath)
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err)
}
// Add rollback action for this file
rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info)
rollbackActions = append(rollbackActions, rollbackAction)
}
// Phase 3: Git operations - add all files and create single commit
for i, relativePath := range relativePaths {
// For host-specific files, we need to add the relative path from repo root
gitPath := relativePath
if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
if err := l.git.Add(gitPath); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err)
}
}
// Add .lnk file to the same commit
if err := l.git.Add(l.getLnkFileName()); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add tracking file to git: %w", err)
}
// Commit all changes together
commitMessage := fmt.Sprintf("lnk: added %d files recursively", len(paths))
if err := l.git.Commit(commitMessage); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to commit changes: %w", err)
}
return nil
}
// AddRecursive adds directory contents individually instead of the directory as a whole
func (l *Lnk) AddRecursive(paths []string) error {
var allFiles []string
for _, path := range paths {
// Get absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
// Check if it's a directory
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat %s: %w", path, err)
}
if info.IsDir() {
// Walk directory to get all files
files, err := l.walkDirectory(absPath)
if err != nil {
return fmt.Errorf("failed to walk directory %s: %w", path, err)
}
allFiles = append(allFiles, files...)
} else {
// It's a regular file, add it directly
allFiles = append(allFiles, absPath)
}
}
// Use AddMultiple for batch processing
if len(allFiles) == 0 {
return fmt.Errorf("no files found to add")
}
return l.AddMultiple(allFiles)
}
// PreviewAdd simulates an add operation and returns files that would be affected
func (l *Lnk) PreviewAdd(paths []string, recursive bool) ([]string, error) {
var allFiles []string
for _, path := range paths {
// Get absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
// Check if it's a directory
info, err := os.Stat(absPath)
if err != nil {
return nil, fmt.Errorf("failed to stat %s: %w", path, err)
}
if info.IsDir() && recursive {
// Walk directory to get all files (same logic as AddRecursive)
files, err := l.walkDirectory(absPath)
if err != nil {
return nil, fmt.Errorf("failed to walk directory %s: %w", path, err)
}
allFiles = append(allFiles, files...)
} else {
// It's a regular file or non-recursive directory, add it directly
allFiles = append(allFiles, absPath)
}
}
// Validate files (same validation as AddMultiple but without making changes)
var validFiles []string
for _, filePath := range allFiles {
// Validate the file or directory
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
return nil, fmt.Errorf("validation failed for %s: %w", filePath, err)
}
// Get relative path for tracking
relativePath, err := getRelativePath(filePath)
if err != nil {
return nil, fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
}
// Check if this relative path is already managed
managedItems, err := l.getManagedItems()
if err != nil {
return nil, fmt.Errorf("failed to get managed items: %w", err)
}
for _, item := range managedItems {
if item == relativePath {
return nil, fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
}
}
validFiles = append(validFiles, filePath)
}
return validFiles, nil
}

View File

@@ -1,6 +1,7 @@
package core package core
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -30,6 +31,9 @@ func (suite *CoreTestSuite) SetupTest() {
err = os.Chdir(tempDir) err = os.Chdir(tempDir)
suite.Require().NoError(err) suite.Require().NoError(err)
// Set HOME to temp directory for consistent relative path calculation
suite.T().Setenv("HOME", tempDir)
// Set XDG_CONFIG_HOME to temp directory // Set XDG_CONFIG_HOME to temp directory
suite.T().Setenv("XDG_CONFIG_HOME", tempDir) suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
@@ -85,8 +89,8 @@ func (suite *CoreTestSuite) TestCoreFileOperations() {
// The repository file will preserve the directory structure // The repository file will preserve the directory structure
lnkDir := filepath.Join(suite.tempDir, "lnk") lnkDir := filepath.Join(suite.tempDir, "lnk")
// Find the .bashrc file in the repository (it should be at the relative path) // Find the .bashrc file in the repository (it should be at the relative path from HOME)
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc") repoFile := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(repoFile) suite.FileExists(repoFile)
// Verify content is preserved // Verify content is preserved
@@ -136,8 +140,8 @@ func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
// Check that the repository directory preserves the structure // Check that the repository directory preserves the structure
lnkDir := filepath.Join(suite.tempDir, "lnk") lnkDir := filepath.Join(suite.tempDir, "lnk")
// The directory should be at the relative path // The directory should be at the relative path from HOME
repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir") repoDir := filepath.Join(lnkDir, "testdir")
suite.DirExists(repoDir) suite.DirExists(repoDir)
// Remove the directory // Remove the directory
@@ -275,7 +279,7 @@ func (suite *CoreTestSuite) TestErrorConditions() {
err = suite.lnk.Add("/nonexistent/file") err = suite.lnk.Add("/nonexistent/file")
suite.Error(err) suite.Error(err)
suite.Contains(err.Error(), "File does not exist") suite.Contains(err.Error(), "File or directory not found")
// Test remove unmanaged file // Test remove unmanaged file
testFile := filepath.Join(suite.tempDir, ".regularfile") testFile := filepath.Join(suite.tempDir, ".regularfile")
@@ -289,7 +293,7 @@ func (suite *CoreTestSuite) TestErrorConditions() {
// Test status without remote // Test status without remote
_, err = suite.lnk.Status() _, err = suite.lnk.Status()
suite.Error(err) suite.Error(err)
suite.Contains(err.Error(), "no remote configured") suite.Contains(err.Error(), "No remote repository is configured")
} }
// Test git operations // Test git operations
@@ -592,7 +596,7 @@ func (suite *CoreTestSuite) TestMultihostFileOperations() {
suite.Require().NoError(err) suite.Require().NoError(err)
// Add file to host-specific configuration // Add file to host-specific configuration
hostLnk := NewLnkWithHost("workstation") hostLnk := NewLnk(WithHost("workstation"))
err = hostLnk.Add(testFile2) err = hostLnk.Add(testFile2)
suite.Require().NoError(err) suite.Require().NoError(err)
@@ -661,7 +665,7 @@ func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() {
suite.Require().NoError(err) suite.Require().NoError(err)
// Create files directly in host-specific storage (simulating a pull) // Create files directly in host-specific storage (simulating a pull)
hostLnk := NewLnkWithHost("testhost") hostLnk := NewLnk(WithHost("testhost"))
// Ensure host storage directory exists // Ensure host storage directory exists
hostStoragePath := hostLnk.getHostStoragePath() hostStoragePath := hostLnk.getHostStoragePath()
@@ -729,7 +733,7 @@ func (suite *CoreTestSuite) TestMultihostIsolation() {
suite.Require().NoError(err) suite.Require().NoError(err)
// Add to host-specific // Add to host-specific
hostLnk := NewLnkWithHost("work") hostLnk := NewLnk(WithHost("work"))
err = hostLnk.Add(testFile) err = hostLnk.Add(testFile)
suite.Require().NoError(err) suite.Require().NoError(err)
@@ -749,6 +753,685 @@ func (suite *CoreTestSuite) TestMultihostIsolation() {
suite.Equal(hostContent, string(symlinkContent)) suite.Equal(hostContent, string(symlinkContent))
} }
// Test bootstrap script detection
func (suite *CoreTestSuite) TestFindBootstrapScript() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Test with no bootstrap script
scriptPath, err := suite.lnk.FindBootstrapScript()
suite.NoError(err)
suite.Empty(scriptPath)
// Test with bootstrap.sh
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "bootstrap.sh")
err = os.WriteFile(bootstrapScript, []byte("#!/bin/bash\necho 'test'"), 0644)
suite.Require().NoError(err)
scriptPath, err = suite.lnk.FindBootstrapScript()
suite.NoError(err)
suite.Equal("bootstrap.sh", scriptPath)
}
// Test bootstrap script execution
func (suite *CoreTestSuite) TestRunBootstrapScript() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create a test script that creates a marker file
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "test.sh")
markerFile := filepath.Join(suite.tempDir, "lnk", "bootstrap-executed.txt")
scriptContent := fmt.Sprintf("#!/bin/bash\ntouch %s\necho 'Bootstrap executed'", markerFile)
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Run the bootstrap script
err = suite.lnk.RunBootstrapScript("test.sh")
suite.NoError(err)
// Verify the marker file was created
suite.FileExists(markerFile)
}
// Test bootstrap script execution with error
func (suite *CoreTestSuite) TestRunBootstrapScriptWithError() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create a script that will fail
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "failing.sh")
scriptContent := "#!/bin/bash\nexit 1"
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
suite.Require().NoError(err)
// Run the bootstrap script - should fail
err = suite.lnk.RunBootstrapScript("failing.sh")
suite.Error(err)
suite.Contains(err.Error(), "Bootstrap script failed")
}
// Test running bootstrap on non-existent script
func (suite *CoreTestSuite) TestRunBootstrapScriptNotFound() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Try to run non-existent script
err = suite.lnk.RunBootstrapScript("nonexistent.sh")
suite.Error(err)
suite.Contains(err.Error(), "Bootstrap script not found")
}
func (suite *CoreTestSuite) TestAddMultiple() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create multiple test files
file1 := filepath.Join(suite.tempDir, "file1.txt")
file2 := filepath.Join(suite.tempDir, "file2.txt")
file3 := filepath.Join(suite.tempDir, "file3.txt")
content1 := "content1"
content2 := "content2"
content3 := "content3"
err = os.WriteFile(file1, []byte(content1), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file2, []byte(content2), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file3, []byte(content3), 0644)
suite.Require().NoError(err)
// Test AddMultiple method - should succeed
paths := []string{file1, file2, file3}
err = suite.lnk.AddMultiple(paths)
suite.NoError(err, "AddMultiple should succeed")
// Verify all files are now symlinks
for _, file := range paths {
info, err := os.Lstat(file)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "File should be a symlink: %s", file)
}
// Verify all files exist in storage
lnkDir := filepath.Join(suite.tempDir, "lnk")
suite.FileExists(filepath.Join(lnkDir, "file1.txt"))
suite.FileExists(filepath.Join(lnkDir, "file2.txt"))
suite.FileExists(filepath.Join(lnkDir, "file3.txt"))
// Verify .lnk file contains all entries
lnkFile := filepath.Join(lnkDir, ".lnk")
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
suite.Equal("file1.txt\nfile2.txt\nfile3.txt\n", string(lnkContent))
// Verify Git commit was created
commits, err := suite.lnk.GetCommits()
suite.NoError(err)
suite.T().Logf("Commits: %v", commits)
// Should have at least 1 commit for the batch add
suite.GreaterOrEqual(len(commits), 1)
// The most recent commit should mention multiple files
suite.Contains(commits[0], "added 3 files")
}
func (suite *CoreTestSuite) TestAddMultipleWithConflicts() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create test files
file1 := filepath.Join(suite.tempDir, "file1.txt")
file2 := filepath.Join(suite.tempDir, "file2.txt")
file3 := filepath.Join(suite.tempDir, "file3.txt")
err = os.WriteFile(file1, []byte("content1"), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file2, []byte("content2"), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file3, []byte("content3"), 0644)
suite.Require().NoError(err)
// Add file2 individually first
err = suite.lnk.Add(file2)
suite.Require().NoError(err)
// Now try to add all three - should fail due to conflict with file2
paths := []string{file1, file2, file3}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "AddMultiple should fail due to conflict")
suite.Contains(err.Error(), "already managed")
// Verify no partial changes were made
// file1 and file3 should still be regular files, not symlinks
info1, err := os.Lstat(file1)
suite.NoError(err)
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink")
info3, err := os.Lstat(file3)
suite.NoError(err)
suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink")
// file2 should still be managed (was added before)
info2, err := os.Lstat(file2)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink")
}
func (suite *CoreTestSuite) TestAddMultipleRollback() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create test files - one will be invalid to force rollback
file1 := filepath.Join(suite.tempDir, "file1.txt")
file2 := filepath.Join(suite.tempDir, "nonexistent.txt") // This doesn't exist
file3 := filepath.Join(suite.tempDir, "file3.txt")
err = os.WriteFile(file1, []byte("content1"), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file3, []byte("content3"), 0644)
suite.Require().NoError(err)
// Note: file2 is intentionally not created
// Try to add all files - should fail and rollback
paths := []string{file1, file2, file3}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "AddMultiple should fail due to nonexistent file")
// Verify rollback - no files should be symlinks
info1, err := os.Lstat(file1)
suite.NoError(err)
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink after rollback")
info3, err := os.Lstat(file3)
suite.NoError(err)
suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink after rollback")
// Verify no files in storage
lnkDir := filepath.Join(suite.tempDir, "lnk")
suite.NoFileExists(filepath.Join(lnkDir, "file1.txt"))
suite.NoFileExists(filepath.Join(lnkDir, "file3.txt"))
// Verify .lnk file is empty or doesn't contain these files
lnkFile := filepath.Join(lnkDir, ".lnk")
if _, err := os.Stat(lnkFile); err == nil {
lnkContent, err := os.ReadFile(lnkFile)
suite.NoError(err)
content := string(lnkContent)
suite.NotContains(content, "file1.txt")
suite.NotContains(content, "file3.txt")
}
}
func (suite *CoreTestSuite) TestValidateMultiplePaths() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create a mix of valid and invalid paths
validFile := filepath.Join(suite.tempDir, "valid.txt")
err = os.WriteFile(validFile, []byte("content"), 0644)
suite.Require().NoError(err)
nonexistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
// Don't create this file
// Create a valid directory
validDir := filepath.Join(suite.tempDir, "validdir")
err = os.MkdirAll(validDir, 0755)
suite.Require().NoError(err)
// Test validation fails early with detailed error
paths := []string{validFile, nonexistentFile, validDir}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "Should fail due to nonexistent file")
suite.Contains(err.Error(), "validation failed")
suite.Contains(err.Error(), "nonexistent.txt")
// Verify no partial changes were made
info, err := os.Lstat(validFile)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid file should not be a symlink")
info, err = os.Lstat(validDir)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid directory should not be a symlink")
}
func (suite *CoreTestSuite) TestAtomicRollbackOnFailure() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create test files
file1 := filepath.Join(suite.tempDir, "file1.txt")
file2 := filepath.Join(suite.tempDir, "file2.txt")
file3 := filepath.Join(suite.tempDir, "file3.txt")
content1 := "original content 1"
content2 := "original content 2"
content3 := "original content 3"
err = os.WriteFile(file1, []byte(content1), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file2, []byte(content2), 0644)
suite.Require().NoError(err)
err = os.WriteFile(file3, []byte(content3), 0644)
suite.Require().NoError(err)
// Add file2 individually first to create a conflict
err = suite.lnk.Add(file2)
suite.Require().NoError(err)
// Store original states
info1Before, err := os.Lstat(file1)
suite.Require().NoError(err)
info3Before, err := os.Lstat(file3)
suite.Require().NoError(err)
// Try to add all files - should fail and rollback completely
paths := []string{file1, file2, file3}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "Should fail due to conflict with file2")
// Verify complete rollback
info1After, err := os.Lstat(file1)
suite.NoError(err)
suite.Equal(info1Before.Mode(), info1After.Mode(), "file1 mode should be unchanged")
info3After, err := os.Lstat(file3)
suite.NoError(err)
suite.Equal(info3Before.Mode(), info3After.Mode(), "file3 mode should be unchanged")
// Verify original contents are preserved
content1After, err := os.ReadFile(file1)
suite.NoError(err)
suite.Equal(content1, string(content1After), "file1 content should be preserved")
content3After, err := os.ReadFile(file3)
suite.NoError(err)
suite.Equal(content3, string(content3After), "file3 content should be preserved")
// file2 should still be managed (was added before)
info2, err := os.Lstat(file2)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink")
}
func (suite *CoreTestSuite) TestDetailedErrorMessages() {
err := suite.lnk.Init()
suite.Require().NoError(err)
// Test with multiple types of errors
validFile := filepath.Join(suite.tempDir, "valid.txt")
err = os.WriteFile(validFile, []byte("content"), 0644)
suite.Require().NoError(err)
nonexistentFile := filepath.Join(suite.tempDir, "does-not-exist.txt")
alreadyManagedFile := filepath.Join(suite.tempDir, "already-managed.txt")
err = os.WriteFile(alreadyManagedFile, []byte("managed"), 0644)
suite.Require().NoError(err)
// Add one file first to create conflict
err = suite.lnk.Add(alreadyManagedFile)
suite.Require().NoError(err)
// Test with nonexistent file
paths := []string{validFile, nonexistentFile}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "Should fail due to nonexistent file")
suite.Contains(err.Error(), "validation failed", "Error should mention validation failure")
suite.Contains(err.Error(), "does-not-exist.txt", "Error should include specific filename")
// Test with already managed file
paths = []string{validFile, alreadyManagedFile}
err = suite.lnk.AddMultiple(paths)
suite.Error(err, "Should fail due to already managed file")
suite.Contains(err.Error(), "already managed", "Error should mention already managed")
suite.Contains(err.Error(), "already-managed.txt", "Error should include specific filename")
}
// Task 2.2: Directory Walking Logic Tests
func (suite *CoreTestSuite) TestWalkDirectory() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create nested directory structure
configDir := filepath.Join(suite.tempDir, ".config", "myapp")
err = os.MkdirAll(configDir, 0755)
suite.Require().NoError(err)
themeDir := filepath.Join(configDir, "themes")
err = os.MkdirAll(themeDir, 0755)
suite.Require().NoError(err)
// Create files in different levels
file1 := filepath.Join(configDir, "config.json")
file2 := filepath.Join(configDir, "settings.json")
file3 := filepath.Join(themeDir, "dark.json")
file4 := filepath.Join(themeDir, "light.json")
suite.Require().NoError(os.WriteFile(file1, []byte("config"), 0644))
suite.Require().NoError(os.WriteFile(file2, []byte("settings"), 0644))
suite.Require().NoError(os.WriteFile(file3, []byte("dark theme"), 0644))
suite.Require().NoError(os.WriteFile(file4, []byte("light theme"), 0644))
// Call walkDirectory method (which doesn't exist yet)
files, err := suite.lnk.walkDirectory(configDir)
suite.Require().NoError(err, "walkDirectory should succeed")
// Should find all 4 files
suite.Len(files, 4, "Should find all files in nested structure")
// Check that all expected files are found (order may vary)
expectedFiles := []string{file1, file2, file3, file4}
for _, expectedFile := range expectedFiles {
suite.Contains(files, expectedFile, "Should include file %s", expectedFile)
}
}
func (suite *CoreTestSuite) TestWalkDirectoryIncludesHiddenFiles() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create directory with hidden files and directories
testDir := filepath.Join(suite.tempDir, "test-hidden")
err = os.MkdirAll(testDir, 0755)
suite.Require().NoError(err)
hiddenDir := filepath.Join(testDir, ".hidden")
err = os.MkdirAll(hiddenDir, 0755)
suite.Require().NoError(err)
// Create regular and hidden files
regularFile := filepath.Join(testDir, "regular.txt")
hiddenFile := filepath.Join(testDir, ".hidden-file")
hiddenDirFile := filepath.Join(hiddenDir, "file-in-hidden.txt")
suite.Require().NoError(os.WriteFile(regularFile, []byte("regular"), 0644))
suite.Require().NoError(os.WriteFile(hiddenFile, []byte("hidden"), 0644))
suite.Require().NoError(os.WriteFile(hiddenDirFile, []byte("in hidden dir"), 0644))
// Call walkDirectory method
files, err := suite.lnk.walkDirectory(testDir)
suite.Require().NoError(err, "walkDirectory should succeed with hidden files")
// Should find all files including hidden ones
suite.Len(files, 3, "Should find all files including hidden ones")
suite.Contains(files, regularFile, "Should include regular file")
suite.Contains(files, hiddenFile, "Should include hidden file")
suite.Contains(files, hiddenDirFile, "Should include file in hidden directory")
}
func (suite *CoreTestSuite) TestWalkDirectorySymlinkHandling() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create directory structure
testDir := filepath.Join(suite.tempDir, "test-symlinks")
err = os.MkdirAll(testDir, 0755)
suite.Require().NoError(err)
// Create a regular file
regularFile := filepath.Join(testDir, "regular.txt")
suite.Require().NoError(os.WriteFile(regularFile, []byte("regular"), 0644))
// Create a symlink to the regular file
symlinkFile := filepath.Join(testDir, "link-to-regular.txt")
err = os.Symlink(regularFile, symlinkFile)
suite.Require().NoError(err)
// Call walkDirectory method
files, err := suite.lnk.walkDirectory(testDir)
suite.Require().NoError(err, "walkDirectory should handle symlinks")
// Should include both regular file and properly handle symlink
// (exact behavior depends on implementation - could include symlink as file)
suite.GreaterOrEqual(len(files), 1, "Should find at least the regular file")
suite.Contains(files, regularFile, "Should include regular file")
// The symlink handling behavior will be defined in implementation
// For now, we just ensure no errors occur
}
func (suite *CoreTestSuite) TestWalkDirectoryEmptyDirs() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create directory structure with empty directories
testDir := filepath.Join(suite.tempDir, "test-empty")
err = os.MkdirAll(testDir, 0755)
suite.Require().NoError(err)
// Create empty subdirectories
emptyDir1 := filepath.Join(testDir, "empty1")
emptyDir2 := filepath.Join(testDir, "empty2")
err = os.MkdirAll(emptyDir1, 0755)
suite.Require().NoError(err)
err = os.MkdirAll(emptyDir2, 0755)
suite.Require().NoError(err)
// Create one file in a non-empty directory
nonEmptyDir := filepath.Join(testDir, "non-empty")
err = os.MkdirAll(nonEmptyDir, 0755)
suite.Require().NoError(err)
testFile := filepath.Join(nonEmptyDir, "test.txt")
suite.Require().NoError(os.WriteFile(testFile, []byte("content"), 0644))
// Call walkDirectory method
files, err := suite.lnk.walkDirectory(testDir)
suite.Require().NoError(err, "walkDirectory should skip empty directories")
// Should only find the one file, not empty directories
suite.Len(files, 1, "Should only find files, not empty directories")
suite.Contains(files, testFile, "Should include the actual file")
}
// Task 2.3: Progress Indication System Tests
func (suite *CoreTestSuite) TestProgressReporting() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create directory with multiple files to test progress reporting
testDir := filepath.Join(suite.tempDir, "progress-test")
err = os.MkdirAll(testDir, 0755)
suite.Require().NoError(err)
// Create 15 files to exceed threshold
expectedFiles := 15
for i := 0; i < expectedFiles; i++ {
file := filepath.Join(testDir, fmt.Sprintf("file%d.txt", i))
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644))
}
// Track progress calls
var progressCalls []struct {
Current int
Total int
CurrentFile string
}
progressCallback := func(current, total int, currentFile string) {
progressCalls = append(progressCalls, struct {
Current int
Total int
CurrentFile string
}{
Current: current,
Total: total,
CurrentFile: currentFile,
})
}
// Call AddRecursiveWithProgress method (which doesn't exist yet)
err = suite.lnk.AddRecursiveWithProgress([]string{testDir}, progressCallback)
suite.Require().NoError(err, "AddRecursiveWithProgress should succeed")
// Verify progress was reported
suite.Greater(len(progressCalls), 0, "Progress callback should be called")
suite.Equal(expectedFiles, len(progressCalls), "Should have progress calls for each file")
// Verify progress order and totals
for i, call := range progressCalls {
suite.Equal(i+1, call.Current, "Current count should increment")
suite.Equal(expectedFiles, call.Total, "Total should be consistent")
suite.NotEmpty(call.CurrentFile, "CurrentFile should be provided")
}
}
func (suite *CoreTestSuite) TestProgressThreshold() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Test with few files (under threshold)
smallDir := filepath.Join(suite.tempDir, "small-test")
err = os.MkdirAll(smallDir, 0755)
suite.Require().NoError(err)
// Create only 5 files (under 10 threshold)
for i := 0; i < 5; i++ {
file := filepath.Join(smallDir, fmt.Sprintf("small%d.txt", i))
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644))
}
// Track progress calls for small operation
smallProgressCalls := 0
smallCallback := func(current, total int, currentFile string) {
smallProgressCalls++
}
err = suite.lnk.AddRecursiveWithProgress([]string{smallDir}, smallCallback)
suite.Require().NoError(err, "AddRecursiveWithProgress should succeed for small operation")
// Should NOT call progress for small operations
suite.Equal(0, smallProgressCalls, "Progress should not be called for operations under threshold")
// Test with many files (over threshold)
largeDir := filepath.Join(suite.tempDir, "large-test")
err = os.MkdirAll(largeDir, 0755)
suite.Require().NoError(err)
// Create 15 files (over 10 threshold)
for i := 0; i < 15; i++ {
file := filepath.Join(largeDir, fmt.Sprintf("large%d.txt", i))
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644))
}
// Track progress calls for large operation
largeProgressCalls := 0
largeCallback := func(current, total int, currentFile string) {
largeProgressCalls++
}
err = suite.lnk.AddRecursiveWithProgress([]string{largeDir}, largeCallback)
suite.Require().NoError(err, "AddRecursiveWithProgress should succeed for large operation")
// Should call progress for large operations
suite.Equal(15, largeProgressCalls, "Progress should be called for operations over threshold")
}
// Task 3.1: Dry-Run Mode Core Tests
func (suite *CoreTestSuite) TestPreviewAdd() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// 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))
// Test PreviewAdd for multiple files
files, err := suite.lnk.PreviewAdd([]string{testFile1, testFile2}, false)
suite.Require().NoError(err, "PreviewAdd should succeed")
// Should return both files
suite.Len(files, 2, "Should preview both files")
suite.Contains(files, testFile1, "Should include first file")
suite.Contains(files, testFile2, "Should include second file")
// Verify no actual changes were made (files should still be regular files)
info, err := os.Lstat(testFile1)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after preview")
info, err = os.Lstat(testFile2)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after preview")
}
func (suite *CoreTestSuite) TestPreviewAddRecursive() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create directory structure
configDir := filepath.Join(suite.tempDir, ".config", "test-app")
err = os.MkdirAll(configDir, 0755)
suite.Require().NoError(err)
// Create files in directory
expectedFiles := 5
var createdFiles []string
for i := 1; i <= expectedFiles; i++ {
file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i))
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("config %d", i)), 0644))
createdFiles = append(createdFiles, file)
}
// Test PreviewAdd with recursive
files, err := suite.lnk.PreviewAdd([]string{configDir}, true)
suite.Require().NoError(err, "PreviewAdd recursive should succeed")
// Should return all files in directory
suite.Len(files, expectedFiles, "Should preview all files in directory")
// Check that all created files are included
for _, createdFile := range createdFiles {
suite.Contains(files, createdFile, "Should include file %s", createdFile)
}
// Verify no actual changes were made
for _, createdFile := range createdFiles {
info, err := os.Lstat(createdFile)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after preview")
}
}
func (suite *CoreTestSuite) TestPreviewAddValidation() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Test with nonexistent file
nonexistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
_, err = suite.lnk.PreviewAdd([]string{nonexistentFile}, false)
suite.Error(err, "PreviewAdd should fail for nonexistent file")
suite.Contains(err.Error(), "failed to stat", "Error should mention stat failure")
// Create and add a file first
testFile := filepath.Join(suite.tempDir, "test.txt")
suite.Require().NoError(os.WriteFile(testFile, []byte("content"), 0644))
err = suite.lnk.Add(testFile)
suite.Require().NoError(err)
// Test preview with already managed file
_, err = suite.lnk.PreviewAdd([]string{testFile}, false)
suite.Error(err, "PreviewAdd should fail for already managed file")
suite.Contains(err.Error(), "already managed", "Error should mention already managed")
}
func TestCoreSuite(t *testing.T) { func TestCoreSuite(t *testing.T) {
suite.Run(t, new(CoreTestSuite)) suite.Run(t, new(CoreTestSuite))
} }

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

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

View File

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

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

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

View File

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