15 Commits

Author SHA1 Message Date
Yar Kravtsov
dc524607fa fix: remove hardcoded branch names from push/pull operations
- Remove hardcoded "main" branch from git push and pull commands
- Let Git automatically detect and use current branch
- Add comprehensive tests for different branch names (main, master, develop)
- Fixes GitHub issue #14 where operations failed on repos using "master"
2025-08-01 06:45:56 +03:00
Yar Kravtsov
9bf2e70d13 docs: remove RELEASE.md in favor of automated process 2025-07-30 10:57:32 +03:00
Yar Kravtsov
65db5fe738 Merge pull request #13 from yarlson/force
fix(init): prevent data loss when reinitializing with existing content
2025-07-30 10:42:54 +03:00
Yar Kravtsov
43b68bc071 fix(init): prevent data loss when reinitializing with existing content 2025-07-30 10:41:03 +03:00
Yar Kravtsov
ab97fa86dc chore(brew): move lnk formula to core Homebrew repository 2025-07-29 12:29:09 +03:00
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
13 changed files with 2515 additions and 295 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:
contents: read
env:
GO_VERSION: '1.24'
jobs:
test:
runs-on: ubuntu-latest
@@ -19,10 +22,10 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: ${{ env.GO_VERSION }}
- name: Cache Go modules
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
@@ -44,9 +47,9 @@ jobs:
run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v5
with:
file: ./coverage.out
files: ./coverage.out
lint:
runs-on: ubuntu-latest
@@ -57,10 +60,10 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: ${{ env.GO_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v8
with:
version: latest
@@ -74,7 +77,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: ${{ env.GO_VERSION }}
- name: Build
run: go build -v ./...
@@ -84,4 +87,4 @@ jobs:
with:
distribution: goreleaser
version: latest
args: build --snapshot --clean
args: build --snapshot --clean

4
.gitignore vendored
View File

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

160
README.md
View File

@@ -2,12 +2,18 @@
**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. Automatically runs bootstrap scripts to set up your environment.
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
lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
lnk add ~/.vimrc ~/.bashrc # Common config
lnk add --host work ~/.ssh/config # Host-specific config
lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Multiple files at once
lnk add --recursive ~/.config/nvim # Process directory contents
lnk add --dry-run ~/.tmux.conf # Preview changes first
lnk add --host work ~/.ssh/config # Host-specific config
lnk push "setup"
```
@@ -20,7 +26,6 @@ curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```bash
# Homebrew (macOS/Linux)
brew tap yarlson/lnk
brew install lnk
```
@@ -49,6 +54,9 @@ lnk init -r git@github.com:user/dotfiles.git
# Skip automatic bootstrap
lnk init -r git@github.com:user/dotfiles.git --no-bootstrap
# Force initialization (WARNING: overwrites existing managed files)
lnk init -r git@github.com:user/dotfiles.git --force
# Run bootstrap script manually
lnk bootstrap
```
@@ -56,12 +64,19 @@ lnk bootstrap
### Daily workflow
```bash
# Add files/directories (common config)
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
# Add multiple files at once (common config)
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
# Add host-specific files
lnk add --host laptop ~/.ssh/config
lnk add --host work ~/.gitconfig
# Add directory contents individually
lnk add --recursive ~/.config/nvim ~/.config/zsh
# Preview changes before applying
lnk add --dry-run ~/.config/git/config
lnk add --dry-run --recursive ~/.config/kitty
# Add host-specific files (supports bulk operations)
lnk add --host laptop ~/.ssh/config ~/.aws/credentials
lnk add --host work ~/.gitconfig ~/.ssh/config
# List managed files
lnk list # Common config only
@@ -91,6 +106,33 @@ 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.
## Safety Features
Lnk includes built-in safety checks to prevent accidental data loss:
### Data Loss Prevention
```bash
# This will be blocked if you already have managed files
lnk init -r git@github.com:user/dotfiles.git
# ❌ Directory ~/.config/lnk already contains managed files
# 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r'
# Use pull instead to safely update
lnk pull
# Or force if you understand the risks (shows warning only when needed)
lnk init -r git@github.com:user/dotfiles.git --force
# ⚠️ Using --force flag: This will overwrite existing managed files
# 💡 Only use this if you understand the risks
```
### Smart Warnings
- **Contextual alerts**: Warnings only appear when there are actually managed files to overwrite
- **Clear guidance**: Error messages suggest the correct command to use
- **Force override**: Advanced users can bypass safety checks when needed
## 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.
@@ -155,12 +197,19 @@ Lnk supports both **common configurations** (shared across all machines) and **h
### Usage Patterns
```bash
# Common config (shared everywhere)
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig
# Common config (shared everywhere) - supports multiple files
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
# Host-specific config (unique per machine)
lnk add --host $(hostname) ~/.ssh/config
lnk add --host work ~/.gitconfig
# Process directory contents individually
lnk add --recursive ~/.config/nvim ~/.config/zsh
# Preview operations before making changes
lnk add --dry-run ~/.config/alacritty/alacritty.yml
lnk add --dry-run --recursive ~/.config/i3
# Host-specific config (unique per machine) - supports bulk operations
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
lnk add --host work ~/.gitconfig ~/.npmrc
# List configurations
lnk list # Common only
@@ -176,10 +225,13 @@ lnk pull --host work # Work-specific config
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
- Moving files safely
- Moving files safely (with atomic operations)
- Creating relative symlinks
- Handling conflicts
- Handling conflicts and rollback
- Tracking what's managed
- Processing multiple files efficiently
- Recursive directory traversal
- Preview mode for safety
## Examples
@@ -190,11 +242,18 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
lnk init -r git@github.com:you/dotfiles.git
# → Downloads dependencies, installs packages, configures environment
# Add common config (shared across all machines)
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
# Add common config (shared across all machines) - multiple files at once
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig ~/.tmux.conf
# Add host-specific config
lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf
# Add configuration directories individually
lnk add --recursive ~/.config/nvim ~/.config/zsh
# Preview before adding sensitive files
lnk add --dry-run ~/.ssh/id_rsa.pub
lnk add ~/.ssh/id_rsa.pub # Add after verification
# Add host-specific config (supports bulk operations)
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
lnk push "initial setup"
```
@@ -230,24 +289,25 @@ lnk push "new plugins" # commit & push
### Multi-machine workflow
```bash
# On your laptop
lnk add --host laptop ~/.ssh/config
lnk add ~/.vimrc # Common config
lnk push "laptop ssh config"
# On your laptop - use bulk operations for efficiency
lnk add --host laptop ~/.ssh/config ~/.aws/credentials ~/.npmrc
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Common config (multiple files)
lnk push "laptop configuration"
# On your work machine
lnk pull # Get common config
lnk add --host work ~/.gitconfig
lnk push "work git config"
lnk pull # Get common config
lnk add --host work ~/.gitconfig ~/.ssh/config
lnk add --recursive ~/.config/work-tools # Work-specific tools
lnk push "work configuration"
# Back on laptop
lnk pull # Get updates (work config won't affect laptop)
lnk pull # Get updates (work config won't affect laptop)
```
## Commands
- `lnk init [-r remote] [--no-bootstrap]` - Create repo (runs bootstrap automatically)
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
- `lnk init [-r remote] [--no-bootstrap] [--force]` - Create repo (runs bootstrap automatically)
- `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 list [--host HOST] [--all]` - List files managed by lnk
- `lnk status` - Git status + sync info
@@ -258,9 +318,29 @@ lnk pull # Get updates (work config won't affect laptop)
### Command Options
- `--host HOST` - Manage files for specific host (default: common configuration)
- `--recursive, -r` - Add directory contents individually instead of the directory as a whole
- `--dry-run, -n` - Show what would be added without making changes
- `--all` - Show all configurations (common + all hosts) when listing
- `-r, --remote URL` - Clone from remote URL when initializing
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
- `--force` - Force initialization even if directory contains managed files (WARNING: overwrites existing content)
### 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
@@ -269,17 +349,21 @@ lnk pull # Get updates (work config won't affect laptop)
- **XDG compliant** (`~/.config/lnk`)
- **Multihost support** (common + host-specific configs)
- **Bootstrap support** (automatic environment setup)
- **Bulk operations** (multiple files, atomic transactions)
- **Recursive processing** (directory contents individually)
- **Preview mode** (dry-run for safety)
- **Data loss prevention** (safety checks with contextual warnings)
- **Git-native** (standard Git repo, no special formats)
## Alternatives
| Tool | Complexity | Why choose it |
| ------- | ---------- | ------------------------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap |
| chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only |
| Tool | Complexity | Why choose it |
| ------- | ---------- | ----------------------------------------------------------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks |
| chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only |
## Contributing

View File

@@ -1,190 +0,0 @@
# Release Process
This document describes how to create releases for the lnk project using GoReleaser.
## Prerequisites
- Push access to the main repository
- Git tags pushed to GitHub trigger releases automatically
- GoReleaser is configured in `.goreleaser.yml`
- GitHub Actions will handle the release process
- Access to the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap repository
- **Personal Access Token** set up as `HOMEBREW_TAP_TOKEN` secret (see Setup section)
## Setup (One-time)
### GitHub Personal Access Token
For GoReleaser to update the Homebrew formula, you need a Personal Access Token:
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
2. Click "Generate new token" → "Generate new token (classic)"
3. Name: "GoReleaser Homebrew Access"
4. Scopes: Select `repo` (Full control of private repositories)
5. Generate and copy the token
6. In your `yarlson/lnk` repository:
- Go to Settings → Secrets and variables → Actions
- Add new repository secret: `HOMEBREW_TAP_TOKEN`
- Paste the token as the value
This allows GoReleaser to automatically update the Homebrew formula in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk).
## Creating a Release
### 1. Ensure everything is ready
```bash
# Run all quality checks
make check
# Test GoReleaser configuration
make goreleaser-check
# Test build process
make goreleaser-snapshot
```
### 2. Create and push a version tag
```bash
# Create a new tag (replace x.y.z with actual version)
git tag -a v1.0.0 -m "Release v1.0.0"
# Push the tag to trigger the release
git push origin v1.0.0
```
### 3. Monitor the release
- GitHub Actions will automatically build and release when the tag is pushed
- Check the [Actions tab](https://github.com/yarlson/lnk/actions) for build status
- The release will appear in [GitHub Releases](https://github.com/yarlson/lnk/releases)
- The Homebrew formula will be automatically updated in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk)
## What GoReleaser Does
1. **Builds binaries** for multiple platforms:
- Linux (amd64, arm64)
- macOS (amd64, arm64)
- Windows (amd64)
2. **Creates archives** with consistent naming:
- `lnk_Linux_x86_64.tar.gz`
- `lnk_Darwin_arm64.tar.gz`
- etc.
3. **Generates checksums** for verification
4. **Creates GitHub release** with:
- Automatic changelog from conventional commits
- Installation instructions
- Download links for all platforms
5. **Updates Homebrew formula** automatically in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap
## Manual Release (if needed)
If you need to create a release manually:
```bash
# Export GitHub token
export GITHUB_TOKEN="your_token_here"
# Create release (requires a git tag)
goreleaser release --clean
```
## Testing Releases Locally
```bash
# Test the build process without releasing
make goreleaser-snapshot
# Built artifacts will be in dist/
ls -la dist/
# Test a binary
./dist/lnk_<platform>/lnk --version
```
## Installation Methods
After a release is published, users can install lnk using multiple methods:
### 1. Shell Script (Recommended)
```bash
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```
### 2. Homebrew (macOS/Linux)
```bash
brew tap yarlson/lnk
brew install lnk
```
### 3. Manual Download
```bash
# Download from GitHub releases
wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz
tar -xzf lnk_Linux_x86_64.tar.gz
sudo mv lnk /usr/local/bin/
```
## Version Numbering
We use [Semantic Versioning](https://semver.org/):
- `v1.0.0` - Major release (breaking changes)
- `v1.1.0` - Minor release (new features, backward compatible)
- `v1.1.1` - Patch release (bug fixes)
## Changelog
GoReleaser automatically generates changelogs from git commits using conventional commit format:
- `feat:` - New features
- `fix:` - Bug fixes
- `docs:` - Documentation changes (excluded from changelog)
- `test:` - Test changes (excluded from changelog)
- `ci:` - CI changes (excluded from changelog)
## Homebrew Tap
The Homebrew formula is automatically maintained in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) repository. When a new release is created:
1. GoReleaser automatically creates/updates the formula
2. The formula is committed to the tap repository
3. Users can immediately install the new version via `brew install yarlson/lnk/lnk`
## Troubleshooting
### Release failed to create
1. Check that the tag follows the format `vX.Y.Z`
2. Ensure GitHub Actions has proper permissions
3. Check the Actions log for detailed error messages
### Missing binaries in release
1. Verify GoReleaser configuration: `make goreleaser-check`
2. Test build locally: `make goreleaser-snapshot`
3. Check the build matrix in `.goreleaser.yml`
### Changelog is empty
1. Ensure commits follow conventional commit format
2. Check that there are commits since the last tag
3. Verify changelog configuration in `.goreleaser.yml`
### Homebrew formula not updated
1. Check that the GITHUB_TOKEN has access to the homebrew-lnk repository
2. Verify the repository name and owner in `.goreleaser.yml`
3. Check the release workflow logs for Homebrew-related errors
4. Ensure the homebrew-lnk repository exists and is accessible

View File

@@ -9,35 +9,156 @@ import (
func newAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "add <file>",
Short: "✨ Add a file to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
Args: cobra.ExactArgs(1),
Use: "add <file>...",
Short: "✨ Add files to lnk management",
Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.
Examples:
lnk add ~/.bashrc ~/.vimrc # Add multiple files at once
lnk add --recursive ~/.config/nvim # Add directory contents individually
lnk add --dry-run ~/.gitconfig # Preview what would be added
lnk add --host work ~/.ssh/config # Add host-specific configuration
The --recursive flag processes directory contents individually instead of treating
the directory as a single unit. This is useful for configuration directories where
you want each file managed separately.
The --dry-run flag shows you exactly what files would be added without making any
changes to your system - perfect for verification before bulk operations.`,
Args: cobra.MinimumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
host, _ := cmd.Flags().GetString("host")
recursive, _ := cmd.Flags().GetBool("recursive")
dryRun, _ := cmd.Flags().GetBool("dry-run")
lnk := core.NewLnk(core.WithHost(host))
if err := lnk.Add(filePath); err != nil {
return err
// Handle dry-run mode
if dryRun {
files, err := lnk.PreviewAdd(args, recursive)
if err != nil {
return err
}
// Display preview output
if recursive {
printf(cmd, "🔍 \033[1mWould add %d files recursively:\033[0m\n", len(files))
} else {
printf(cmd, "🔍 \033[1mWould add %d files:\033[0m\n", len(files))
}
// List files that would be added
for _, file := range files {
basename := filepath.Base(file)
printf(cmd, " 📄 \033[90m%s\033[0m\n", basename)
}
printf(cmd, "\n💡 \033[33mTo proceed:\033[0m run without --dry-run flag\n")
return nil
}
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)
// 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 {
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)
// Use appropriate method based on number of files
if len(args) == 1 {
// Single file - use existing Add method for backward compatibility
if err := lnk.Add(args[0]); err != nil {
return err
}
} else {
// Multiple files - use AddMultiple for atomic operation
if err := lnk.AddMultiple(args); err != nil {
return err
}
}
}
// Display results
if recursive {
// Recursive mode - show enhanced message with count
if host != "" {
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk (host: %s)\033[0m\n", len(args), host)
} else {
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk\033[0m\n", len(args))
}
// Show some of the files that were added (limit to first few for readability)
filesToShow := len(args)
if filesToShow > 5 {
filesToShow = 5
}
for i := 0; i < filesToShow; i++ {
basename := filepath.Base(args[i])
if host != "" {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
} else {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
}
}
if len(args) > 5 {
printf(cmd, " \033[90m... and %d more files\033[0m\n", len(args)-5)
}
} else if len(args) == 1 {
// Single file - maintain existing output format for backward compatibility
filePath := args[0]
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 {
// Multiple files - show summary
if host != "" {
printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host)
} else {
printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args))
}
// List each added file
for _, filePath := range args {
basename := filepath.Base(filePath)
if host != "" {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
} else {
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
}
}
}
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
return nil
},
}
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
cmd.Flags().BoolP("recursive", "r", false, "Add directory contents individually instead of the directory as a whole")
cmd.Flags().BoolP("dry-run", "n", false, "Show what would be added without making changes")
return cmd
}

View File

@@ -15,9 +15,17 @@ func newInitCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote")
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
force, _ := cmd.Flags().GetBool("force")
lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil {
// Show warning when force is used and there are managed files to overwrite
if force && remote != "" && lnk.HasUserContent() {
printf(cmd, "⚠️ \033[33mUsing --force flag: This will overwrite existing managed files\033[0m\n")
printf(cmd, " 💡 Only use this if you understand the risks\n\n")
}
if err := lnk.InitWithRemoteForce(remote, force); err != nil {
return err
}
@@ -69,5 +77,6 @@ func newInitCmd() *cobra.Command {
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning")
cmd.Flags().Bool("force", false, "Force initialization even if directory contains managed files (WARNING: This will overwrite existing content)")
return cmd
}

View File

@@ -20,17 +20,19 @@ func NewRootCommand() *cobra.Command {
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
Supports both common configurations and host-specific setups.
Supports both common configurations, host-specific setups, and bulk operations for multiple files.
✨ Examples:
lnk init # Fresh start
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
lnk add ~/.vimrc ~/.bashrc # Start managing common files
lnk add --host work ~/.ssh/config # Manage host-specific files
lnk list --all # Show all configurations
lnk pull --host work # Pull host-specific changes
lnk push "setup complete" # Sync to remote
lnk bootstrap # Run bootstrap script manually
lnk init # Fresh start
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
lnk add ~/.vimrc ~/.bashrc # Start managing common files
lnk add --recursive ~/.config/nvim # Add directory contents individually
lnk add --dry-run ~/.gitconfig # Preview changes without applying
lnk add --host work ~/.ssh/config # Manage host-specific files
lnk list --all # Show all configurations
lnk pull --host work # Pull host-specific changes
lnk push "setup complete" # Sync to remote
lnk bootstrap # Run bootstrap script manually
🚀 Bootstrap Support:
Automatically runs bootstrap.sh when cloning a repository.

View File

@@ -2,10 +2,13 @@ package cmd
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/suite"
)
@@ -277,7 +280,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
name: "add help",
args: []string{"add", "--help"},
wantErr: false,
outContains: "Moves a file to the lnk repository",
outContains: "Moves files to the lnk repository",
},
{
name: "list help",
@@ -790,8 +793,8 @@ func (suite *CLITestSuite) TestInitWithBootstrap() {
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
// Initialize git repo in remote
cmd := exec.Command("git", "init", "--bare")
// 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)
@@ -835,7 +838,7 @@ touch remote-bootstrap-ran.txt
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "push", "origin", "master")
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
@@ -863,8 +866,8 @@ func (suite *CLITestSuite) TestInitWithBootstrapDisabled() {
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
// Initialize git repo in remote
cmd := exec.Command("git", "init", "--bare")
// 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)
@@ -898,7 +901,7 @@ touch should-not-exist.txt
err = cmd.Run()
suite.Require().NoError(err)
cmd = exec.Command("git", "push", "origin", "master")
cmd = exec.Command("git", "push", "origin", "main")
cmd.Dir = workingDir
err = cmd.Run()
suite.Require().NoError(err)
@@ -917,6 +920,891 @@ touch 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")
}
// Task 3.1: Tests for force flag functionality
func (suite *CLITestSuite) TestInitCmd_ForceFlag_BypassesSafetyCheck() {
// Setup: Create .lnk file to simulate existing content
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
err := os.MkdirAll(lnkDir, 0755)
suite.Require().NoError(err)
// Initialize git repo first
cmd := exec.Command("git", "init")
cmd.Dir = lnkDir
err = cmd.Run()
suite.Require().NoError(err)
lnkFile := filepath.Join(lnkDir, ".lnk")
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
// Create a dummy remote directory for testing
remoteDir := filepath.Join(suite.tempDir, "remote")
err = os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
cmd = exec.Command("git", "init", "--bare")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Execute init command with --force flag
err = suite.runCommand("init", "-r", remoteDir, "--force")
suite.NoError(err, "Force flag should bypass safety check")
// Verify output shows warning
output := suite.stdout.String()
suite.Contains(output, "force", "Should show force warning")
}
func (suite *CLITestSuite) TestInitCmd_NoForceFlag_RespectsSafetyCheck() {
// Setup: Create .lnk file to simulate existing content
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
err := os.MkdirAll(lnkDir, 0755)
suite.Require().NoError(err)
// Initialize git repo first
cmd := exec.Command("git", "init")
cmd.Dir = lnkDir
err = cmd.Run()
suite.Require().NoError(err)
lnkFile := filepath.Join(lnkDir, ".lnk")
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
// Create a dummy remote directory for testing
remoteDir := filepath.Join(suite.tempDir, "remote")
err = os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
cmd = exec.Command("git", "init", "--bare")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Execute init command without --force flag - should fail
err = suite.runCommand("init", "-r", remoteDir)
suite.Error(err, "Should respect safety check without force flag")
suite.Contains(err.Error(), "already contains managed files")
}
func (suite *CLITestSuite) TestInitCmd_ForceFlag_ShowsWarning() {
// Setup: Create .lnk file to simulate existing content
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
err := os.MkdirAll(lnkDir, 0755)
suite.Require().NoError(err)
// Initialize git repo first
cmd := exec.Command("git", "init")
cmd.Dir = lnkDir
err = cmd.Run()
suite.Require().NoError(err)
lnkFile := filepath.Join(lnkDir, ".lnk")
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
// Create a dummy remote directory for testing
remoteDir := filepath.Join(suite.tempDir, "remote")
err = os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
cmd = exec.Command("git", "init", "--bare")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Execute init command with --force flag
err = suite.runCommand("init", "-r", remoteDir, "--force")
suite.NoError(err, "Force flag should bypass safety check")
// Verify output shows appropriate warning
output := suite.stdout.String()
suite.Contains(output, "⚠️", "Should show warning emoji")
suite.Contains(output, "overwrite", "Should warn about overwriting")
}
// Task 4.1: Integration tests for end-to-end workflows
func (suite *CLITestSuite) TestE2E_InitAddInit_PreventDataLoss() {
// Run: lnk init
err := suite.runCommand("init")
suite.Require().NoError(err)
suite.stdout.Reset()
// Create and add test file
testFile := filepath.Join(suite.tempDir, ".testfile")
err = os.WriteFile(testFile, []byte("important content"), 0644)
suite.Require().NoError(err)
err = suite.runCommand("add", testFile)
suite.Require().NoError(err)
// Create dummy remote for testing
remoteDir := filepath.Join(suite.tempDir, "remote")
err = os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
cmd := exec.Command("git", "init", "--bare")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Run: lnk init -r <remote> → should FAIL
err = suite.runCommand("init", "-r", remoteDir)
suite.Error(err, "Should prevent data loss")
suite.Contains(err.Error(), "already contains managed files")
// Verify testfile still exists and is managed
suite.FileExists(testFile)
info, err := os.Lstat(testFile)
suite.NoError(err)
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "File should still be symlink")
}
func (suite *CLITestSuite) TestE2E_FreshInit_Success() {
// Create dummy remote for testing
remoteDir := filepath.Join(suite.tempDir, "remote")
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
cmd := exec.Command("git", "init", "--bare")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Fresh init with remote should succeed
err = suite.runCommand("init", "-r", remoteDir)
suite.NoError(err, "Fresh init should succeed")
// Verify repository was created
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
suite.DirExists(lnkDir)
gitDir := filepath.Join(lnkDir, ".git")
suite.DirExists(gitDir)
// Verify success message
output := suite.stdout.String()
suite.Contains(output, "Initialized lnk repository")
suite.Contains(output, "Cloned from:")
}
func (suite *CLITestSuite) TestE2E_ForceInit_OverwritesContent() {
// Setup: init and add content first
err := suite.runCommand("init")
suite.Require().NoError(err)
testFile := filepath.Join(suite.tempDir, ".testfile")
err = os.WriteFile(testFile, []byte("original content"), 0644)
suite.Require().NoError(err)
err = suite.runCommand("add", testFile)
suite.Require().NoError(err)
suite.stdout.Reset()
// Create dummy remote for testing
remoteDir := filepath.Join(suite.tempDir, "remote")
err = os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
cmd := exec.Command("git", "init", "--bare")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// Force init should succeed and show warning
err = suite.runCommand("init", "-r", remoteDir, "--force")
suite.NoError(err, "Force init should succeed")
// Verify warning was shown
output := suite.stdout.String()
suite.Contains(output, "⚠️", "Should show warning")
suite.Contains(output, "overwrite", "Should warn about overwriting")
suite.Contains(output, "Initialized lnk repository")
}
func (suite *CLITestSuite) TestE2E_ErrorMessage_SuggestsCorrectCommand() {
// Setup: init and add content first
err := suite.runCommand("init")
suite.Require().NoError(err)
testFile := filepath.Join(suite.tempDir, ".testfile")
err = os.WriteFile(testFile, []byte("important content"), 0644)
suite.Require().NoError(err)
err = suite.runCommand("add", testFile)
suite.Require().NoError(err)
// Try init with remote - should fail with helpful message
err = suite.runCommand("init", "-r", "https://github.com/test/dotfiles.git")
suite.Error(err, "Should fail with helpful error")
// Verify error message suggests correct alternative
suite.Contains(err.Error(), "already contains managed files", "Should explain the problem")
suite.Contains(err.Error(), "lnk pull", "Should suggest pull command")
suite.Contains(err.Error(), "instead of", "Should explain the alternative")
suite.Contains(err.Error(), "lnk init -r", "Should show the problematic command")
}
// Task 6.1: Regression tests to ensure existing functionality unchanged
func (suite *CLITestSuite) TestRegression_FreshInit_UnchangedBehavior() {
// Test that fresh init (no existing content) works exactly as before
err := suite.runCommand("init")
suite.NoError(err, "Fresh init should work unchanged")
// Verify same output format and behavior
output := suite.stdout.String()
suite.Contains(output, "Initialized empty lnk repository")
suite.Contains(output, "Location:")
// Verify repository structure is created correctly
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
suite.DirExists(lnkDir)
gitDir := filepath.Join(lnkDir, ".git")
suite.DirExists(gitDir)
}
func (suite *CLITestSuite) TestRegression_ExistingWorkflows_StillWork() {
// Test that all existing workflows continue to function
// 1. Normal init → add → list → remove workflow
err := suite.runCommand("init")
suite.NoError(err, "Init should work")
suite.stdout.Reset()
// Create and add a file
testFile := filepath.Join(suite.tempDir, ".bashrc")
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
suite.Require().NoError(err)
err = suite.runCommand("add", testFile)
suite.NoError(err, "Add should work")
suite.stdout.Reset()
// List files
err = suite.runCommand("list")
suite.NoError(err, "List should work")
output := suite.stdout.String()
suite.Contains(output, ".bashrc", "Should list added file")
suite.stdout.Reset()
// Remove file
err = suite.runCommand("rm", testFile)
suite.NoError(err, "Remove should work")
// Verify file is restored as regular file
info, err := os.Lstat(testFile)
suite.NoError(err)
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should be regular after remove")
}
func (suite *CLITestSuite) TestRegression_GitOperations_Unaffected() {
// Test that Git operations continue to work normally
err := suite.runCommand("init")
suite.NoError(err)
// Add a file to create commits
testFile := filepath.Join(suite.tempDir, ".vimrc")
err = os.WriteFile(testFile, []byte("set number"), 0644)
suite.Require().NoError(err)
err = suite.runCommand("add", testFile)
suite.NoError(err)
// Verify Git repository structure and commits are normal
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
// Check that commits are created normally
cmd := exec.Command("git", "log", "--oneline", "--format=%s")
cmd.Dir = lnkDir
output, err := cmd.Output()
suite.NoError(err, "Git log should work")
commits := string(output)
suite.Contains(commits, "lnk: added .vimrc", "Should have normal commit message")
// Check that git status works
cmd = exec.Command("git", "status", "--porcelain")
cmd.Dir = lnkDir
statusOutput, err := cmd.Output()
suite.NoError(err, "Git status should work")
suite.Empty(strings.TrimSpace(string(statusOutput)), "Working directory should be clean")
}
func (suite *CLITestSuite) TestRegression_PerformanceImpact_Minimal() {
// Test that the new safety checks don't significantly impact performance
// Simple performance check: ensure a single init completes quickly
start := time.Now()
err := suite.runCommand("init")
elapsed := time.Since(start)
suite.NoError(err, "Init should succeed")
suite.Less(elapsed, 2*time.Second, "Init should complete quickly")
// Test safety check performance on existing repository
suite.stdout.Reset()
start = time.Now()
err = suite.runCommand("init", "-r", "dummy-url")
elapsed = time.Since(start)
// Should fail quickly due to safety check (not hang)
suite.Error(err, "Should fail due to safety check")
suite.Less(elapsed, 1*time.Second, "Safety check should be fast")
}
// Task 7.1: Tests for help documentation
func (suite *CLITestSuite) TestInitCommand_HelpText_MentionsForceFlag() {
err := suite.runCommand("init", "--help")
suite.NoError(err)
output := suite.stdout.String()
suite.Contains(output, "--force", "Help should mention force flag")
suite.Contains(output, "overwrite", "Help should explain force behavior")
}
func (suite *CLITestSuite) TestInitCommand_HelpText_ExplainsDataProtection() {
err := suite.runCommand("init", "--help")
suite.NoError(err)
output := suite.stdout.String()
// Should explain what the command does
suite.Contains(output, "Creates", "Should explain what init does")
suite.Contains(output, "lnk directory", "Should mention lnk directory")
// Should warn about the force flag risks
suite.Contains(output, "WARNING", "Should warn about force flag risks")
suite.Contains(output, "overwrite existing content", "Should mention overwrite risk")
}
// TestPushPullWithDifferentBranches tests push/pull operations with different default branch names
func (suite *CLITestSuite) TestPushPullWithDifferentBranches() {
testCases := []struct {
name string
branchName string
setupRemote func(remoteDir string) error
}{
{
name: "master branch",
branchName: "master",
setupRemote: func(remoteDir string) error {
cmd := exec.Command("git", "init", "--bare", "--initial-branch=master")
cmd.Dir = remoteDir
return cmd.Run()
},
},
{
name: "main branch",
branchName: "main",
setupRemote: func(remoteDir string) error {
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
cmd.Dir = remoteDir
return cmd.Run()
},
},
{
name: "custom branch",
branchName: "develop",
setupRemote: func(remoteDir string) error {
cmd := exec.Command("git", "init", "--bare", "--initial-branch=develop")
cmd.Dir = remoteDir
return cmd.Run()
},
},
}
for _, tc := range testCases {
suite.Run(tc.name, func() {
// Create a separate temp directory for this test case
testDir, err := os.MkdirTemp("", "lnk-push-pull-test-*")
suite.Require().NoError(err)
defer func() { _ = os.RemoveAll(testDir) }()
// Save current dir and change to test dir
originalDir, err := os.Getwd()
suite.Require().NoError(err)
defer func() { _ = os.Chdir(originalDir) }()
err = os.Chdir(testDir)
suite.Require().NoError(err)
// Set HOME to test directory
suite.T().Setenv("HOME", testDir)
suite.T().Setenv("XDG_CONFIG_HOME", testDir)
// Create remote repository
remoteDir := filepath.Join(testDir, "remote.git")
err = os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
err = tc.setupRemote(remoteDir)
suite.Require().NoError(err)
// Initialize lnk with remote
err = suite.runCommand("init", "--remote", remoteDir)
suite.Require().NoError(err)
// Switch to the test branch if not main/master (since init creates main by default)
if tc.branchName != "main" {
lnkDir := filepath.Join(testDir, "lnk")
cmd := exec.Command("git", "checkout", "-b", tc.branchName)
cmd.Dir = lnkDir
_, err = cmd.CombinedOutput()
suite.Require().NoError(err)
}
// Add a test file
testFile := filepath.Join(testDir, ".testrc")
err = os.WriteFile(testFile, []byte("test config"), 0644)
suite.Require().NoError(err)
err = suite.runCommand("add", testFile)
suite.Require().NoError(err)
// Test push operation
err = suite.runCommand("push", "test push with "+tc.branchName)
suite.Require().NoError(err, "Push should work with %s branch", tc.branchName)
// Create another test directory to simulate pulling from another machine
pullTestDir, err := os.MkdirTemp("", "lnk-pull-test-*")
suite.Require().NoError(err)
defer func() { _ = os.RemoveAll(pullTestDir) }()
err = os.Chdir(pullTestDir)
suite.Require().NoError(err)
// Set HOME for pull test
suite.T().Setenv("HOME", pullTestDir)
suite.T().Setenv("XDG_CONFIG_HOME", pullTestDir)
// Clone and test pull
err = suite.runCommand("init", "--remote", remoteDir)
suite.Require().NoError(err)
err = suite.runCommand("pull")
suite.Require().NoError(err, "Pull should work with %s branch", tc.branchName)
// Verify the file was pulled correctly
lnkDir := filepath.Join(pullTestDir, "lnk")
pulledFile := filepath.Join(lnkDir, ".testrc")
suite.FileExists(pulledFile, "File should exist after pull with %s branch", tc.branchName)
content, err := os.ReadFile(pulledFile)
suite.Require().NoError(err)
suite.Equal("test config", string(content), "File content should match after pull with %s branch", tc.branchName)
})
}
}
func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLITestSuite))
}

View File

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

View File

@@ -46,6 +46,34 @@ func NewLnk(opts ...Option) *Lnk {
return lnk
}
// HasUserContent checks if the repository contains managed files
// by looking for .lnk tracker files (common or host-specific)
func (l *Lnk) HasUserContent() bool {
// Check for common tracker file
commonTracker := filepath.Join(l.repoPath, ".lnk")
if _, err := os.Stat(commonTracker); err == nil {
return true
}
// Check for host-specific tracker files if host is set
if l.host != "" {
hostTracker := filepath.Join(l.repoPath, fmt.Sprintf(".lnk.%s", l.host))
if _, err := os.Stat(hostTracker); err == nil {
return true
}
} else {
// If no specific host is set, check for any host-specific tracker files
// This handles cases where we want to detect any managed content
pattern := filepath.Join(l.repoPath, ".lnk.*")
matches, err := filepath.Glob(pattern)
if err == nil && len(matches) > 0 {
return true
}
}
return false
}
// GetCurrentHostname returns the current system hostname
func GetCurrentHostname() (string, error) {
hostname, err := os.Hostname()
@@ -119,7 +147,18 @@ func (l *Lnk) Init() error {
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
func (l *Lnk) InitWithRemote(remoteURL string) error {
return l.InitWithRemoteForce(remoteURL, false)
}
// InitWithRemoteForce initializes the lnk repository with optional force override
func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error {
if remoteURL != "" {
// Safety check: prevent data loss by checking for existing managed files
if l.HasUserContent() {
if !force {
return fmt.Errorf("❌ Directory \033[31m%s\033[0m already contains managed files\n 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r'", l.repoPath)
}
}
// Clone from remote
return l.Clone(remoteURL)
}
@@ -257,6 +296,153 @@ func (l *Lnk) Add(filePath string) error {
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 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
func (l *Lnk) Remove(filePath string) error {
// Get absolute path
@@ -680,3 +866,324 @@ func (l *Lnk) RunBootstrapScript(scriptName string) error {
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

@@ -3,6 +3,7 @@ package core
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -31,6 +32,9 @@ func (suite *CoreTestSuite) SetupTest() {
err = os.Chdir(tempDir)
suite.Require().NoError(err)
// Set HOME to temp directory for consistent relative path calculation
suite.T().Setenv("HOME", tempDir)
// Set XDG_CONFIG_HOME to temp directory
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
@@ -86,8 +90,8 @@ func (suite *CoreTestSuite) TestCoreFileOperations() {
// The repository file will preserve the directory structure
lnkDir := filepath.Join(suite.tempDir, "lnk")
// Find the .bashrc file in the repository (it should be at the relative path)
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc")
// Find the .bashrc file in the repository (it should be at the relative path from HOME)
repoFile := filepath.Join(lnkDir, ".bashrc")
suite.FileExists(repoFile)
// Verify content is preserved
@@ -137,8 +141,8 @@ func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
// Check that the repository directory preserves the structure
lnkDir := filepath.Join(suite.tempDir, "lnk")
// The directory should be at the relative path
repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir")
// The directory should be at the relative path from HOME
repoDir := filepath.Join(lnkDir, "testdir")
suite.DirExists(repoDir)
// Remove the directory
@@ -820,6 +824,779 @@ func (suite *CoreTestSuite) TestRunBootstrapScriptNotFound() {
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")
}
// Task 1.1: Tests for HasUserContent() method
func (suite *CoreTestSuite) TestHasUserContent_WithCommonTracker_ReturnsTrue() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create .lnk file to simulate existing content
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
// Call HasUserContent()
hasContent := suite.lnk.HasUserContent()
suite.True(hasContent, "Should detect common tracker file")
}
func (suite *CoreTestSuite) TestHasUserContent_WithHostTracker_ReturnsTrue() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create host-specific lnk instance
hostLnk := NewLnk(WithHost("testhost"))
// Create .lnk.hostname file to simulate host-specific content
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
err = os.WriteFile(lnkFile, []byte(".vimrc\n"), 0644)
suite.Require().NoError(err)
// Call HasUserContent()
hasContent := hostLnk.HasUserContent()
suite.True(hasContent, "Should detect host-specific tracker file")
}
func (suite *CoreTestSuite) TestHasUserContent_WithBothTrackers_ReturnsTrue() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create both common and host-specific tracker files
commonLnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
err = os.WriteFile(commonLnkFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
hostLnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
err = os.WriteFile(hostLnkFile, []byte(".vimrc\n"), 0644)
suite.Require().NoError(err)
// Test with common instance
hasContent := suite.lnk.HasUserContent()
suite.True(hasContent, "Should detect common tracker file")
// Test with host-specific instance
hostLnk := NewLnk(WithHost("testhost"))
hasContent = hostLnk.HasUserContent()
suite.True(hasContent, "Should detect host-specific tracker file")
}
func (suite *CoreTestSuite) TestHasUserContent_EmptyDirectory_ReturnsFalse() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Call HasUserContent() on empty repository
hasContent := suite.lnk.HasUserContent()
suite.False(hasContent, "Should return false for empty repository")
}
func (suite *CoreTestSuite) TestHasUserContent_NonTrackerFiles_ReturnsFalse() {
// Initialize lnk repository
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create non-tracker files
randomFile := filepath.Join(suite.tempDir, "lnk", "random.txt")
err = os.WriteFile(randomFile, []byte("some content"), 0644)
suite.Require().NoError(err)
configFile := filepath.Join(suite.tempDir, "lnk", ".gitignore")
err = os.WriteFile(configFile, []byte("*.log"), 0644)
suite.Require().NoError(err)
// Call HasUserContent()
hasContent := suite.lnk.HasUserContent()
suite.False(hasContent, "Should return false when only non-tracker files exist")
}
// Task 2.1: Tests for enhanced InitWithRemote() safety check
func (suite *CoreTestSuite) TestInitWithRemote_HasUserContent_ReturnsError() {
// Initialize and add content first
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create .lnk file to simulate existing content
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
// Try InitWithRemote - should fail
err = suite.lnk.InitWithRemote("https://github.com/test/dotfiles.git")
suite.Error(err, "Should fail when user content exists")
suite.Contains(err.Error(), "already contains managed files")
suite.Contains(err.Error(), "lnk pull")
// Verify .lnk file still exists (no deletion occurred)
suite.FileExists(lnkFile)
}
func (suite *CoreTestSuite) TestInitWithRemote_EmptyDirectory_Success() {
// Create a dummy remote directory for testing
remoteDir := filepath.Join(suite.tempDir, "remote")
err := os.MkdirAll(remoteDir, 0755)
suite.Require().NoError(err)
// Initialize a bare git repository as remote
cmd := exec.Command("git", "init", "--bare")
cmd.Dir = remoteDir
err = cmd.Run()
suite.Require().NoError(err)
// InitWithRemote should succeed on empty directory
err = suite.lnk.InitWithRemote(remoteDir)
suite.NoError(err, "Should succeed when no user content exists")
// Verify repository was cloned
lnkDir := filepath.Join(suite.tempDir, "lnk")
suite.DirExists(lnkDir)
gitDir := filepath.Join(lnkDir, ".git")
suite.DirExists(gitDir)
}
func (suite *CoreTestSuite) TestInitWithRemote_NoRemoteURL_BypassesSafetyCheck() {
// Initialize and add content first
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create .lnk file to simulate existing content
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
suite.Require().NoError(err)
// InitWithRemote with empty URL should bypass safety check (this is local init)
err = suite.lnk.InitWithRemote("")
suite.NoError(err, "Should bypass safety check when no remote URL provided")
}
func (suite *CoreTestSuite) TestInitWithRemote_ErrorMessage_ContainsSuggestedCommand() {
// Initialize and add content first
err := suite.lnk.Init()
suite.Require().NoError(err)
// Create host-specific content
hostLnk := NewLnk(WithHost("testhost"))
hostLnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
err = os.WriteFile(hostLnkFile, []byte(".vimrc\n"), 0644)
suite.Require().NoError(err)
// Try InitWithRemote - should fail with helpful message
err = hostLnk.InitWithRemote("https://github.com/test/dotfiles.git")
suite.Error(err, "Should fail when user content exists")
suite.Contains(err.Error(), "lnk pull", "Should suggest pull command")
suite.Contains(err.Error(), "instead of", "Should explain alternative")
}
func TestCoreSuite(t *testing.T) {
suite.Run(t, new(CoreTestSuite))
}

View File

@@ -437,7 +437,7 @@ func (g *Git) Push() error {
return &PushError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "push", "-u", "origin", "main")
cmd := exec.Command("git", "push", "-u", "origin")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
@@ -456,7 +456,7 @@ func (g *Git) Pull() error {
return &PullError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "pull", "origin", "main")
cmd := exec.Command("git", "pull", "origin")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()