mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-24 21:11:27 +02:00
Compare commits
14 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
65db5fe738 | ||
|
43b68bc071 | ||
|
ab97fa86dc | ||
|
4cd8191805 | ||
|
6830c06eb4 | ||
|
8a29b7fe43 | ||
|
a6852e5ad5 | ||
|
36d76c881c | ||
|
6de387797e | ||
|
9cbad5e593 | ||
|
150e8adf8b | ||
|
4b11563bdf | ||
|
b476ce503b | ||
|
ae9cc175ce |
17
.github/dependabot.yml
vendored
Normal file
17
.github/dependabot.yml
vendored
Normal 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
|
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -9,6 +9,9 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: '1.24'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -19,10 +22,10 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -44,9 +47,9 @@ jobs:
|
|||||||
run: go test -v -race -coverprofile=coverage.out ./...
|
run: go test -v -race -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
file: ./coverage.out
|
files: ./coverage.out
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -57,10 +60,10 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v8
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
|
|
||||||
@@ -74,7 +77,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,3 +44,6 @@ desktop.ini
|
|||||||
|
|
||||||
# GoReleaser artifacts
|
# GoReleaser artifacts
|
||||||
goreleaser/
|
goreleaser/
|
||||||
|
*.md
|
||||||
|
!/README.md
|
||||||
|
!/RELEASE.md
|
||||||
|
200
README.md
200
README.md
@@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
**Git-native dotfiles management that doesn't suck.**
|
**Git-native dotfiles management that doesn't suck.**
|
||||||
|
|
||||||
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
|
Lnk makes managing your dotfiles straightforward, no tedious setups, no complex configurations. Just tell Lnk what files you want tracked, and it'll automatically move them into a tidy Git repository under `~/.config/lnk`. It then creates clean, portable symlinks back to their original locations. Easy.
|
||||||
|
|
||||||
|
Why bother with Lnk instead of plain old Git or other dotfile managers? Unlike traditional methods, Lnk automates the boring parts: safely relocating files, handling host-specific setups, bulk operations for multiple files, recursive directory processing, and even running your custom bootstrap scripts automatically. This means fewer manual steps and less chance of accidentally overwriting something important.
|
||||||
|
|
||||||
|
With Lnk, your dotfiles setup stays organized and effortlessly portable, letting you spend more time doing real work, not wrestling with configuration files.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lnk init
|
lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
|
||||||
lnk add ~/.vimrc ~/.bashrc # Common config
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Multiple files at once
|
||||||
|
lnk add --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 add --host work ~/.ssh/config # Host-specific config
|
||||||
lnk push "setup"
|
lnk push "setup"
|
||||||
```
|
```
|
||||||
@@ -20,7 +26,6 @@ curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Homebrew (macOS/Linux)
|
# Homebrew (macOS/Linux)
|
||||||
brew tap yarlson/lnk
|
|
||||||
brew install lnk
|
brew install lnk
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -43,19 +48,35 @@ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv
|
|||||||
# Fresh start
|
# Fresh start
|
||||||
lnk init
|
lnk init
|
||||||
|
|
||||||
# With existing repo
|
# With existing repo (runs bootstrap automatically)
|
||||||
lnk init -r git@github.com:user/dotfiles.git
|
lnk init -r git@github.com:user/dotfiles.git
|
||||||
|
|
||||||
|
# Skip automatic bootstrap
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git --no-bootstrap
|
||||||
|
|
||||||
|
# Force initialization (WARNING: overwrites existing managed files)
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git --force
|
||||||
|
|
||||||
|
# Run bootstrap script manually
|
||||||
|
lnk bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
### Daily workflow
|
### Daily workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add files/directories (common config)
|
# Add multiple files at once (common config)
|
||||||
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
|
||||||
|
|
||||||
# Add host-specific files
|
# Add directory contents individually
|
||||||
lnk add --host laptop ~/.ssh/config
|
lnk add --recursive ~/.config/nvim ~/.config/zsh
|
||||||
lnk add --host work ~/.gitconfig
|
|
||||||
|
# Preview changes before applying
|
||||||
|
lnk add --dry-run ~/.config/git/config
|
||||||
|
lnk add --dry-run --recursive ~/.config/kitty
|
||||||
|
|
||||||
|
# Add host-specific files (supports bulk operations)
|
||||||
|
lnk add --host laptop ~/.ssh/config ~/.aws/credentials
|
||||||
|
lnk add --host work ~/.gitconfig ~/.ssh/config
|
||||||
|
|
||||||
# List managed files
|
# List managed files
|
||||||
lnk list # Common config only
|
lnk list # Common config only
|
||||||
@@ -85,6 +106,71 @@ After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
|
|||||||
|
|
||||||
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Simple bootstrap script:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# bootstrap.sh
|
||||||
|
echo "Setting up development environment..."
|
||||||
|
|
||||||
|
# Install Homebrew (macOS)
|
||||||
|
if ! command -v brew &> /dev/null; then
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install packages
|
||||||
|
brew install git vim tmux
|
||||||
|
|
||||||
|
echo "✅ Setup complete!"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Automatic bootstrap on clone
|
||||||
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
|
# → Clones repo and runs bootstrap script automatically
|
||||||
|
|
||||||
|
# Skip bootstrap if needed
|
||||||
|
lnk init -r git@github.com:you/dotfiles.git --no-bootstrap
|
||||||
|
|
||||||
|
# Run bootstrap manually later
|
||||||
|
lnk bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
## Multihost Support
|
## Multihost Support
|
||||||
|
|
||||||
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
|
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
|
||||||
@@ -111,12 +197,19 @@ Lnk supports both **common configurations** (shared across all machines) and **h
|
|||||||
### Usage Patterns
|
### Usage Patterns
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Common config (shared everywhere)
|
# Common config (shared everywhere) - supports multiple files
|
||||||
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
|
||||||
|
|
||||||
# Host-specific config (unique per machine)
|
# Process directory contents individually
|
||||||
lnk add --host $(hostname) ~/.ssh/config
|
lnk add --recursive ~/.config/nvim ~/.config/zsh
|
||||||
lnk add --host work ~/.gitconfig
|
|
||||||
|
# Preview operations before making changes
|
||||||
|
lnk add --dry-run ~/.config/alacritty/alacritty.yml
|
||||||
|
lnk add --dry-run --recursive ~/.config/i3
|
||||||
|
|
||||||
|
# Host-specific config (unique per machine) - supports bulk operations
|
||||||
|
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||||
|
lnk add --host work ~/.gitconfig ~/.npmrc
|
||||||
|
|
||||||
# List configurations
|
# List configurations
|
||||||
lnk list # Common only
|
lnk list # Common only
|
||||||
@@ -132,23 +225,35 @@ lnk pull --host work # Work-specific config
|
|||||||
|
|
||||||
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
|
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
|
||||||
|
|
||||||
- Moving files safely
|
- Moving files safely (with atomic operations)
|
||||||
- Creating relative symlinks
|
- Creating relative symlinks
|
||||||
- Handling conflicts
|
- Handling conflicts and rollback
|
||||||
- Tracking what's managed
|
- Tracking what's managed
|
||||||
|
- Processing multiple files efficiently
|
||||||
|
- Recursive directory traversal
|
||||||
|
- Preview mode for safety
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### First time setup
|
### First time setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Clone dotfiles and run bootstrap automatically
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
|
# → Downloads dependencies, installs packages, configures environment
|
||||||
|
|
||||||
# Add common config (shared across all machines)
|
# Add common config (shared across all machines) - multiple files at once
|
||||||
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig ~/.tmux.conf
|
||||||
|
|
||||||
# Add host-specific config
|
# Add configuration directories individually
|
||||||
lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf
|
lnk add --recursive ~/.config/nvim ~/.config/zsh
|
||||||
|
|
||||||
|
# Preview before adding sensitive files
|
||||||
|
lnk add --dry-run ~/.ssh/id_rsa.pub
|
||||||
|
lnk add ~/.ssh/id_rsa.pub # Add after verification
|
||||||
|
|
||||||
|
# Add host-specific config (supports bulk operations)
|
||||||
|
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||||
|
|
||||||
lnk push "initial setup"
|
lnk push "initial setup"
|
||||||
```
|
```
|
||||||
@@ -156,13 +261,18 @@ lnk push "initial setup"
|
|||||||
### On a new machine
|
### On a new machine
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Bootstrap runs automatically
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
|
# → Sets up environment, installs dependencies
|
||||||
|
|
||||||
# Pull common config
|
# Pull common config
|
||||||
lnk pull
|
lnk pull
|
||||||
|
|
||||||
# Pull host-specific config (if it exists)
|
# Pull host-specific config (if it exists)
|
||||||
lnk pull --host $(hostname)
|
lnk pull --host $(hostname)
|
||||||
|
|
||||||
|
# Or run bootstrap manually if needed
|
||||||
|
lnk bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
### Daily edits
|
### Daily edits
|
||||||
@@ -179,15 +289,16 @@ lnk push "new plugins" # commit & push
|
|||||||
### Multi-machine workflow
|
### Multi-machine workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On your laptop
|
# On your laptop - use bulk operations for efficiency
|
||||||
lnk add --host laptop ~/.ssh/config
|
lnk add --host laptop ~/.ssh/config ~/.aws/credentials ~/.npmrc
|
||||||
lnk add ~/.vimrc # Common config
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Common config (multiple files)
|
||||||
lnk push "laptop ssh config"
|
lnk push "laptop configuration"
|
||||||
|
|
||||||
# On your work machine
|
# On your work machine
|
||||||
lnk pull # Get common config
|
lnk pull # Get common config
|
||||||
lnk add --host work ~/.gitconfig
|
lnk add --host work ~/.gitconfig ~/.ssh/config
|
||||||
lnk push "work git config"
|
lnk add --recursive ~/.config/work-tools # Work-specific tools
|
||||||
|
lnk push "work configuration"
|
||||||
|
|
||||||
# Back on laptop
|
# Back on laptop
|
||||||
lnk pull # Get updates (work config won't affect laptop)
|
lnk pull # Get updates (work config won't affect laptop)
|
||||||
@@ -195,19 +306,41 @@ lnk pull # Get updates (work config won't affect laptop)
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `lnk init [-r remote]` - Create repo
|
- `lnk init [-r remote] [--no-bootstrap] [--force]` - Create repo (runs bootstrap automatically)
|
||||||
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
|
- `lnk add [--host HOST] [--recursive] [--dry-run] <files>...` - Move files to repo, create symlinks
|
||||||
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
||||||
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
||||||
- `lnk status` - Git status + sync info
|
- `lnk status` - Git status + sync info
|
||||||
- `lnk push [msg]` - Stage all, commit, push
|
- `lnk push [msg]` - Stage all, commit, push
|
||||||
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
|
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
|
||||||
|
- `lnk bootstrap` - Run bootstrap script manually
|
||||||
|
|
||||||
### Command Options
|
### Command Options
|
||||||
|
|
||||||
- `--host HOST` - Manage files for specific host (default: common configuration)
|
- `--host HOST` - Manage files for specific host (default: common configuration)
|
||||||
|
- `--recursive, -r` - Add directory contents individually instead of the directory as a whole
|
||||||
|
- `--dry-run, -n` - Show what would be added without making changes
|
||||||
- `--all` - Show all configurations (common + all hosts) when listing
|
- `--all` - Show all configurations (common + all hosts) when listing
|
||||||
- `-r, --remote URL` - Clone from remote URL when initializing
|
- `-r, --remote URL` - Clone from remote URL when initializing
|
||||||
|
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
|
||||||
|
- `--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
|
## Technical bits
|
||||||
|
|
||||||
@@ -215,13 +348,18 @@ lnk pull # Get updates (work config won't affect laptop)
|
|||||||
- **Relative symlinks** (portable)
|
- **Relative symlinks** (portable)
|
||||||
- **XDG compliant** (`~/.config/lnk`)
|
- **XDG compliant** (`~/.config/lnk`)
|
||||||
- **Multihost support** (common + host-specific configs)
|
- **Multihost support** (common + host-specific configs)
|
||||||
|
- **Bootstrap support** (automatic environment setup)
|
||||||
|
- **Bulk operations** (multiple files, atomic transactions)
|
||||||
|
- **Recursive processing** (directory contents individually)
|
||||||
|
- **Preview mode** (dry-run for safety)
|
||||||
|
- **Data loss prevention** (safety checks with contextual warnings)
|
||||||
- **Git-native** (standard Git repo, no special formats)
|
- **Git-native** (standard Git repo, no special formats)
|
||||||
|
|
||||||
## Alternatives
|
## Alternatives
|
||||||
|
|
||||||
| Tool | Complexity | Why choose it |
|
| Tool | Complexity | Why choose it |
|
||||||
| ------- | ---------- | -------------------------------------------- |
|
| ------- | ---------- | ----------------------------------------------------------------------------------------- |
|
||||||
| **lnk** | Minimal | Just works, no config, Git-native, multihost |
|
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks |
|
||||||
| chezmoi | High | Templates, encryption, cross-platform |
|
| chezmoi | High | Templates, encryption, cross-platform |
|
||||||
| yadm | Medium | Git power user, encryption |
|
| yadm | Medium | Git power user, encryption |
|
||||||
| dotbot | Low | YAML config, basic features |
|
| dotbot | Low | YAML config, basic features |
|
||||||
|
135
cmd/add.go
135
cmd/add.go
@@ -9,22 +9,122 @@ import (
|
|||||||
|
|
||||||
func newAddCmd() *cobra.Command {
|
func newAddCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <file>",
|
Use: "add <file>...",
|
||||||
Short: "✨ Add a file to lnk management",
|
Short: "✨ Add files to lnk management",
|
||||||
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
|
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,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
filePath := args[0]
|
|
||||||
host, _ := cmd.Flags().GetString("host")
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
recursive, _ := cmd.Flags().GetBool("recursive")
|
||||||
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
lnk := core.NewLnk(core.WithHost(host))
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
|
|
||||||
if err := lnk.Add(filePath); err != nil {
|
// Handle dry-run mode
|
||||||
|
if dryRun {
|
||||||
|
files, err := lnk.PreviewAdd(args, recursive)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display preview output
|
||||||
|
if recursive {
|
||||||
|
printf(cmd, "🔍 \033[1mWould add %d files recursively:\033[0m\n", len(files))
|
||||||
|
} else {
|
||||||
|
printf(cmd, "🔍 \033[1mWould add %d files:\033[0m\n", len(files))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List files that would be added
|
||||||
|
for _, file := range files {
|
||||||
|
basename := filepath.Base(file)
|
||||||
|
printf(cmd, " 📄 \033[90m%s\033[0m\n", basename)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 \033[33mTo proceed:\033[0m run without --dry-run flag\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle recursive mode
|
||||||
|
if recursive {
|
||||||
|
// Get preview to count files first for better output
|
||||||
|
previewFiles, err := lnk.PreviewAdd(args, recursive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create progress callback for CLI display
|
||||||
|
progressCallback := func(current, total int, currentFile string) {
|
||||||
|
printf(cmd, "\r⏳ Processing %d/%d: %s", current, total, currentFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear progress line and show completion
|
||||||
|
printf(cmd, "\r")
|
||||||
|
|
||||||
|
// Store processed file count for display
|
||||||
|
args = previewFiles // Replace args with actual files for display
|
||||||
|
} else {
|
||||||
|
// Use appropriate method based on number of files
|
||||||
|
if len(args) == 1 {
|
||||||
|
// Single file - use existing Add method for backward compatibility
|
||||||
|
if err := lnk.Add(args[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple files - use AddMultiple for atomic operation
|
||||||
|
if err := lnk.AddMultiple(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
if recursive {
|
||||||
|
// Recursive mode - show enhanced message with count
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk (host: %s)\033[0m\n", len(args), host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk\033[0m\n", len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show some of the files that were added (limit to first few for readability)
|
||||||
|
filesToShow := len(args)
|
||||||
|
if filesToShow > 5 {
|
||||||
|
filesToShow = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < filesToShow; i++ {
|
||||||
|
basename := filepath.Base(args[i])
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) > 5 {
|
||||||
|
printf(cmd, " \033[90m... and %d more files\033[0m\n", len(args)-5)
|
||||||
|
}
|
||||||
|
} else if len(args) == 1 {
|
||||||
|
// Single file - maintain existing output format for backward compatibility
|
||||||
|
filePath := args[0]
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
||||||
@@ -33,11 +133,32 @@ func newAddCmd() *cobra.Command {
|
|||||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", 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")
|
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
|
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
|
||||||
|
cmd.Flags().BoolP("recursive", "r", false, "Add directory contents individually instead of the directory as a whole")
|
||||||
|
cmd.Flags().BoolP("dry-run", "n", false, "Show what would be added without making changes")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
45
cmd/bootstrap.go
Normal file
45
cmd/bootstrap.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBootstrapCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "bootstrap",
|
||||||
|
Short: "🚀 Run the bootstrap script to set up your environment",
|
||||||
|
Long: "Executes the bootstrap script from your dotfiles repository to install dependencies and configure your system.",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
lnk := core.NewLnk()
|
||||||
|
|
||||||
|
scriptPath, err := lnk.FindBootstrapScript()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if scriptPath == "" {
|
||||||
|
printf(cmd, "💡 \033[33mNo bootstrap script found\033[0m\n")
|
||||||
|
printf(cmd, " 📝 Create a \033[1mbootstrap.sh\033[0m file in your dotfiles repository:\n")
|
||||||
|
printf(cmd, " \033[90m#!/bin/bash\033[0m\n")
|
||||||
|
printf(cmd, " \033[90mecho \"Setting up environment...\"\033[0m\n")
|
||||||
|
printf(cmd, " \033[90m# Your setup commands here\033[0m\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n")
|
||||||
|
printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath)
|
||||||
|
printf(cmd, "\n")
|
||||||
|
|
||||||
|
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||||
|
printf(cmd, " 🎉 Your environment is ready to use\n")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
40
cmd/init.go
40
cmd/init.go
@@ -14,9 +14,18 @@ func newInitCmd() *cobra.Command {
|
|||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
remote, _ := cmd.Flags().GetString("remote")
|
remote, _ := cmd.Flags().GetString("remote")
|
||||||
|
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +33,33 @@ func newInitCmd() *cobra.Command {
|
|||||||
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||||
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||||
|
|
||||||
|
// Try to run bootstrap script if not disabled
|
||||||
|
if !noBootstrap {
|
||||||
|
printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n")
|
||||||
|
|
||||||
|
scriptPath, err := lnk.FindBootstrapScript()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if scriptPath != "" {
|
||||||
|
printf(cmd, " ✅ Found bootstrap script: \033[36m%s\033[0m\n", scriptPath)
|
||||||
|
printf(cmd, "\n🚀 \033[1mRunning bootstrap script...\033[0m\n")
|
||||||
|
printf(cmd, "\n")
|
||||||
|
|
||||||
|
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||||
|
printf(cmd, "\n⚠️ \033[33mBootstrap script failed, but repository was initialized successfully\033[0m\n")
|
||||||
|
printf(cmd, " 💡 You can run it manually with: \033[1mlnk bootstrap\033[0m\n")
|
||||||
|
printf(cmd, " 🔧 Error: %v\n", err)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
printf(cmd, " 💡 No bootstrap script found\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||||
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
||||||
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
||||||
@@ -40,5 +76,7 @@ func newInitCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
||||||
|
cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning")
|
||||||
|
cmd.Flags().Bool("force", false, "Force initialization even if directory contains managed files (WARNING: This will overwrite existing content)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
12
cmd/root.go
12
cmd/root.go
@@ -20,16 +20,23 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
||||||
|
|
||||||
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
||||||
Supports both common configurations and host-specific setups.
|
Supports both common configurations, host-specific setups, and bulk operations for multiple files.
|
||||||
|
|
||||||
✨ Examples:
|
✨ Examples:
|
||||||
lnk init # Fresh start
|
lnk init # Fresh start
|
||||||
lnk init -r <repo-url> # Clone existing dotfiles
|
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
|
||||||
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
||||||
|
lnk add --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 add --host work ~/.ssh/config # Manage host-specific files
|
||||||
lnk list --all # Show all configurations
|
lnk list --all # Show all configurations
|
||||||
lnk pull --host work # Pull host-specific changes
|
lnk pull --host work # Pull host-specific changes
|
||||||
lnk push "setup complete" # Sync to remote
|
lnk push "setup complete" # Sync to remote
|
||||||
|
lnk bootstrap # Run bootstrap script manually
|
||||||
|
|
||||||
|
🚀 Bootstrap Support:
|
||||||
|
Automatically runs bootstrap.sh when cloning a repository.
|
||||||
|
Use --no-bootstrap to disable.
|
||||||
|
|
||||||
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
@@ -45,6 +52,7 @@ Supports both common configurations and host-specific setups.
|
|||||||
rootCmd.AddCommand(newStatusCmd())
|
rootCmd.AddCommand(newStatusCmd())
|
||||||
rootCmd.AddCommand(newPushCmd())
|
rootCmd.AddCommand(newPushCmd())
|
||||||
rootCmd.AddCommand(newPullCmd())
|
rootCmd.AddCommand(newPullCmd())
|
||||||
|
rootCmd.AddCommand(newBootstrapCmd())
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
942
cmd/root_test.go
942
cmd/root_test.go
@@ -2,10 +2,13 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
)
|
)
|
||||||
@@ -277,7 +280,7 @@ func (suite *CLITestSuite) TestErrorHandling() {
|
|||||||
name: "add help",
|
name: "add help",
|
||||||
args: []string{"add", "--help"},
|
args: []string{"add", "--help"},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
outContains: "Moves a file to the lnk repository",
|
outContains: "Moves files to the lnk repository",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "list help",
|
name: "list help",
|
||||||
@@ -746,6 +749,943 @@ func (suite *CLITestSuite) TestMultihostErrorHandling() {
|
|||||||
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)")
|
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestBootstrapCommand() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test bootstrap command with no script
|
||||||
|
err = suite.runCommand("bootstrap")
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "No bootstrap script found")
|
||||||
|
suite.Contains(output, "bootstrap.sh")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create a bootstrap script
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
bootstrapScript := filepath.Join(lnkDir, "bootstrap.sh")
|
||||||
|
scriptContent := `#!/bin/bash
|
||||||
|
echo "Bootstrap script executed!"
|
||||||
|
echo "Working directory: $(pwd)"
|
||||||
|
touch bootstrap-ran.txt
|
||||||
|
`
|
||||||
|
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test bootstrap command with script
|
||||||
|
err = suite.runCommand("bootstrap")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Running bootstrap script")
|
||||||
|
suite.Contains(output, "bootstrap.sh")
|
||||||
|
suite.Contains(output, "Bootstrap completed successfully")
|
||||||
|
|
||||||
|
// Verify script actually ran
|
||||||
|
markerFile := filepath.Join(lnkDir, "bootstrap-ran.txt")
|
||||||
|
suite.FileExists(markerFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestInitWithBootstrap() {
|
||||||
|
// Create a temporary remote repository with bootstrap script
|
||||||
|
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||||
|
err := os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Initialize git repo in remote with main branch
|
||||||
|
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a working repo to populate the remote
|
||||||
|
workingDir := filepath.Join(suite.tempDir, "working")
|
||||||
|
err = os.MkdirAll(workingDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "clone", remoteDir, workingDir)
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add a bootstrap script to the working repo
|
||||||
|
bootstrapScript := filepath.Join(workingDir, "bootstrap.sh")
|
||||||
|
scriptContent := `#!/bin/bash
|
||||||
|
echo "Remote bootstrap script executed!"
|
||||||
|
touch remote-bootstrap-ran.txt
|
||||||
|
`
|
||||||
|
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add a dummy config file
|
||||||
|
configFile := filepath.Join(workingDir, ".bashrc")
|
||||||
|
err = os.WriteFile(configFile, []byte("echo 'Hello from remote!'"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add .lnk file to track the config
|
||||||
|
lnkFile := filepath.Join(workingDir, ".lnk")
|
||||||
|
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Commit and push to remote
|
||||||
|
cmd = exec.Command("git", "add", ".")
|
||||||
|
cmd.Dir = workingDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap and config")
|
||||||
|
cmd.Dir = workingDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "push", "origin", "main")
|
||||||
|
cmd.Dir = workingDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Now test init with remote and automatic bootstrap
|
||||||
|
err = suite.runCommand("init", "-r", remoteDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Cloned from:")
|
||||||
|
suite.Contains(output, "Looking for bootstrap script")
|
||||||
|
suite.Contains(output, "Found bootstrap script:")
|
||||||
|
suite.Contains(output, "bootstrap.sh")
|
||||||
|
suite.Contains(output, "Running bootstrap script")
|
||||||
|
suite.Contains(output, "Bootstrap completed successfully")
|
||||||
|
|
||||||
|
// Verify bootstrap actually ran
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
markerFile := filepath.Join(lnkDir, "remote-bootstrap-ran.txt")
|
||||||
|
suite.FileExists(markerFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestInitWithBootstrapDisabled() {
|
||||||
|
// Create a temporary remote repository with bootstrap script
|
||||||
|
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||||
|
err := os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Initialize git repo in remote with main branch
|
||||||
|
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a working repo to populate the remote
|
||||||
|
workingDir := filepath.Join(suite.tempDir, "working")
|
||||||
|
err = os.MkdirAll(workingDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "clone", remoteDir, workingDir)
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add a bootstrap script
|
||||||
|
bootstrapScript := filepath.Join(workingDir, "bootstrap.sh")
|
||||||
|
scriptContent := `#!/bin/bash
|
||||||
|
echo "This should not run!"
|
||||||
|
touch should-not-exist.txt
|
||||||
|
`
|
||||||
|
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Commit and push
|
||||||
|
cmd = exec.Command("git", "add", ".")
|
||||||
|
cmd.Dir = workingDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap")
|
||||||
|
cmd.Dir = workingDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "push", "origin", "main")
|
||||||
|
cmd.Dir = workingDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test init with --no-bootstrap flag
|
||||||
|
err = suite.runCommand("init", "-r", remoteDir, "--no-bootstrap")
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Cloned from:")
|
||||||
|
suite.NotContains(output, "Looking for bootstrap script")
|
||||||
|
suite.NotContains(output, "Running bootstrap script")
|
||||||
|
|
||||||
|
// Verify bootstrap did NOT run
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
markerFile := filepath.Join(lnkDir, "should-not-exist.txt")
|
||||||
|
suite.NoFileExists(markerFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestAddCommandMultipleFiles() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create multiple test files
|
||||||
|
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
|
err = os.WriteFile(testFile1, []byte("export PATH1"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||||
|
err = os.WriteFile(testFile2, []byte("set number"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
testFile3 := filepath.Join(suite.tempDir, ".gitconfig")
|
||||||
|
err = os.WriteFile(testFile3, []byte("[user]\n name = test"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test add command with multiple files - should succeed
|
||||||
|
err = suite.runCommand("add", testFile1, testFile2, testFile3)
|
||||||
|
suite.NoError(err, "Adding multiple files should succeed")
|
||||||
|
|
||||||
|
// Check output shows all files were added
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Added 3 items to lnk")
|
||||||
|
suite.Contains(output, ".bashrc")
|
||||||
|
suite.Contains(output, ".vimrc")
|
||||||
|
suite.Contains(output, ".gitconfig")
|
||||||
|
|
||||||
|
// Verify all files are now symlinks
|
||||||
|
for _, file := range []string{testFile1, testFile2, testFile3} {
|
||||||
|
info, err := os.Lstat(file)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all files exist in storage
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, ".bashrc"))
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, ".vimrc"))
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, ".gitconfig"))
|
||||||
|
|
||||||
|
// Verify .lnk file contains all entries
|
||||||
|
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||||
|
lnkContent, err := os.ReadFile(lnkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(".bashrc\n.gitconfig\n.vimrc\n", string(lnkContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestAddCommandMixedTypes() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create a file
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
||||||
|
err = os.WriteFile(testFile, []byte("set number"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a directory with content
|
||||||
|
testDir := filepath.Join(suite.tempDir, ".config", "git")
|
||||||
|
err = os.MkdirAll(testDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
configFile := filepath.Join(testDir, "config")
|
||||||
|
err = os.WriteFile(configFile, []byte("[user]"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test add command with mixed files and directories - should succeed
|
||||||
|
err = suite.runCommand("add", testFile, testDir)
|
||||||
|
suite.NoError(err, "Adding mixed files and directories should succeed")
|
||||||
|
|
||||||
|
// Check output shows both items were added
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Added 2 items to lnk")
|
||||||
|
suite.Contains(output, ".vimrc")
|
||||||
|
suite.Contains(output, "git")
|
||||||
|
|
||||||
|
// Verify both are now symlinks
|
||||||
|
info1, err := os.Lstat(testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
info2, err := os.Lstat(testDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
// Verify storage
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, ".vimrc"))
|
||||||
|
suite.DirExists(filepath.Join(lnkDir, ".config", "git"))
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, ".config", "git", "config"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestAddCommandRecursiveFlag() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create a directory with nested files
|
||||||
|
testDir := filepath.Join(suite.tempDir, ".config", "zed")
|
||||||
|
err = os.MkdirAll(testDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create nested files
|
||||||
|
settingsFile := filepath.Join(testDir, "settings.json")
|
||||||
|
err = os.WriteFile(settingsFile, []byte(`{"theme": "dark"}`), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
keymapFile := filepath.Join(testDir, "keymap.json")
|
||||||
|
err = os.WriteFile(keymapFile, []byte(`{"ctrl+s": "save"}`), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a subdirectory with files
|
||||||
|
themesDir := filepath.Join(testDir, "themes")
|
||||||
|
err = os.MkdirAll(themesDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
themeFile := filepath.Join(themesDir, "custom.json")
|
||||||
|
err = os.WriteFile(themeFile, []byte(`{"colors": {}}`), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test recursive flag - should process directory contents individually
|
||||||
|
err = suite.runCommand("add", "--recursive", testDir)
|
||||||
|
suite.NoError(err, "Adding directory recursively should succeed")
|
||||||
|
|
||||||
|
// Check output shows multiple files were processed
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Added") // Should show some success message
|
||||||
|
|
||||||
|
// Verify individual files are now symlinks (not the directory itself)
|
||||||
|
info, err := os.Lstat(settingsFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "settings.json should be a symlink")
|
||||||
|
|
||||||
|
info, err = os.Lstat(keymapFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "keymap.json should be a symlink")
|
||||||
|
|
||||||
|
info, err = os.Lstat(themeFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "custom.json should be a symlink")
|
||||||
|
|
||||||
|
// The directory itself should NOT be a symlink
|
||||||
|
info, err = os.Lstat(testDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "Directory should not be a symlink")
|
||||||
|
|
||||||
|
// Verify files exist individually in storage
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "settings.json"))
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "keymap.json"))
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "themes", "custom.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestAddCommandRecursiveMultipleDirs() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create two directories with files
|
||||||
|
dir1 := filepath.Join(suite.tempDir, "dir1")
|
||||||
|
dir2 := filepath.Join(suite.tempDir, "dir2")
|
||||||
|
err = os.MkdirAll(dir1, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.MkdirAll(dir2, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create files in each directory
|
||||||
|
file1 := filepath.Join(dir1, "file1.txt")
|
||||||
|
file2 := filepath.Join(dir2, "file2.txt")
|
||||||
|
err = os.WriteFile(file1, []byte("content1"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(file2, []byte("content2"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test recursive flag with multiple directories
|
||||||
|
err = suite.runCommand("add", "--recursive", dir1, dir2)
|
||||||
|
suite.NoError(err, "Adding multiple directories recursively should succeed")
|
||||||
|
|
||||||
|
// Verify both files are symlinks
|
||||||
|
info, err := os.Lstat(file1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "file1.txt should be a symlink")
|
||||||
|
|
||||||
|
info, err = os.Lstat(file2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "file2.txt should be a symlink")
|
||||||
|
|
||||||
|
// Verify directories are not symlinks
|
||||||
|
info, err = os.Lstat(dir1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "dir1 should not be a symlink")
|
||||||
|
|
||||||
|
info, err = os.Lstat(dir2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "dir2 should not be a symlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 3.1: Dry-Run Mode Tests
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestDryRunFlag() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.NoError(err)
|
||||||
|
initOutput := suite.stdout.String()
|
||||||
|
suite.Contains(initOutput, "Initialized")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testFile1 := filepath.Join(suite.tempDir, "test1.txt")
|
||||||
|
testFile2 := filepath.Join(suite.tempDir, "test2.txt")
|
||||||
|
suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644))
|
||||||
|
suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644))
|
||||||
|
|
||||||
|
// Run add with dry-run flag (should not exist yet)
|
||||||
|
err = suite.runCommand("add", "--dry-run", testFile1, testFile2)
|
||||||
|
suite.NoError(err, "Dry-run command should succeed")
|
||||||
|
output := suite.stdout.String()
|
||||||
|
|
||||||
|
// Basic check that some output was produced (flag exists but behavior TBD)
|
||||||
|
suite.NotEmpty(output, "Should produce some output")
|
||||||
|
|
||||||
|
// Verify files were NOT actually added (no symlinks created)
|
||||||
|
info, err := os.Lstat(testFile1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be a symlink in dry-run")
|
||||||
|
|
||||||
|
info, err = os.Lstat(testFile2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be a symlink in dry-run")
|
||||||
|
|
||||||
|
// Verify lnk list shows no managed files
|
||||||
|
suite.stdout.Reset()
|
||||||
|
err = suite.runCommand("list")
|
||||||
|
suite.NoError(err)
|
||||||
|
listOutput := suite.stdout.String()
|
||||||
|
suite.NotContains(listOutput, "test1.txt", "Files should not be managed after dry-run")
|
||||||
|
suite.NotContains(listOutput, "test2.txt", "Files should not be managed after dry-run")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestDryRunOutput() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.NoError(err)
|
||||||
|
initOutput := suite.stdout.String()
|
||||||
|
suite.Contains(initOutput, "Initialized")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testFile1 := filepath.Join(suite.tempDir, "test1.txt")
|
||||||
|
testFile2 := filepath.Join(suite.tempDir, "test2.txt")
|
||||||
|
suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644))
|
||||||
|
suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644))
|
||||||
|
|
||||||
|
// Run add with dry-run flag
|
||||||
|
err = suite.runCommand("add", "--dry-run", testFile1, testFile2)
|
||||||
|
suite.NoError(err, "Dry-run command should succeed")
|
||||||
|
output := suite.stdout.String()
|
||||||
|
|
||||||
|
// Verify dry-run shows preview of what would be added
|
||||||
|
suite.Contains(output, "Would add", "Should show dry-run preview")
|
||||||
|
suite.Contains(output, "test1.txt", "Should show first file")
|
||||||
|
suite.Contains(output, "test2.txt", "Should show second file")
|
||||||
|
suite.Contains(output, "2 files", "Should show file count")
|
||||||
|
|
||||||
|
// Should contain helpful instructions
|
||||||
|
suite.Contains(output, "run without --dry-run", "Should provide next steps")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestDryRunRecursive() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.NoError(err)
|
||||||
|
initOutput := suite.stdout.String()
|
||||||
|
suite.Contains(initOutput, "Initialized")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create directory structure with multiple files
|
||||||
|
configDir := filepath.Join(suite.tempDir, ".config", "test-app")
|
||||||
|
err = os.MkdirAll(configDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create files in directory
|
||||||
|
for i := 1; i <= 15; i++ {
|
||||||
|
file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i))
|
||||||
|
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("config %d", i)), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run recursive add with dry-run
|
||||||
|
err = suite.runCommand("add", "--dry-run", "--recursive", configDir)
|
||||||
|
suite.NoError(err, "Dry-run recursive command should succeed")
|
||||||
|
output := suite.stdout.String()
|
||||||
|
|
||||||
|
// Verify dry-run shows all files that would be added
|
||||||
|
suite.Contains(output, "Would add", "Should show dry-run preview")
|
||||||
|
suite.Contains(output, "15 files", "Should show correct file count")
|
||||||
|
suite.Contains(output, "recursively", "Should indicate recursive mode")
|
||||||
|
|
||||||
|
// Should show some of the files
|
||||||
|
suite.Contains(output, "config1.json", "Should show first file")
|
||||||
|
suite.Contains(output, "config15.json", "Should show last file")
|
||||||
|
|
||||||
|
// Verify no actual changes were made
|
||||||
|
for i := 1; i <= 15; i++ {
|
||||||
|
file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i))
|
||||||
|
info, err := os.Lstat(file)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after dry-run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 3.2: Enhanced Output and Messaging Tests
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestEnhancedSuccessOutput() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create multiple test files
|
||||||
|
testFiles := []string{
|
||||||
|
filepath.Join(suite.tempDir, "config1.txt"),
|
||||||
|
filepath.Join(suite.tempDir, "config2.txt"),
|
||||||
|
filepath.Join(suite.tempDir, "config3.txt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, file := range testFiles {
|
||||||
|
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i+1)), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add multiple files
|
||||||
|
args := append([]string{"add"}, testFiles...)
|
||||||
|
err = suite.runCommand(args...)
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
|
||||||
|
// Should have enhanced formatting with consistent indentation
|
||||||
|
suite.Contains(output, "🔗", "Should use link icons")
|
||||||
|
suite.Contains(output, "config1.txt", "Should show first file")
|
||||||
|
suite.Contains(output, "config2.txt", "Should show second file")
|
||||||
|
suite.Contains(output, "config3.txt", "Should show third file")
|
||||||
|
|
||||||
|
// Should show organized file list
|
||||||
|
suite.Contains(output, " ", "Should have consistent indentation")
|
||||||
|
|
||||||
|
// Should include summary information
|
||||||
|
suite.Contains(output, "3 items", "Should show total count")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestOperationSummary() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create directory with files for recursive operation
|
||||||
|
configDir := filepath.Join(suite.tempDir, ".config", "test-app")
|
||||||
|
err = os.MkdirAll(configDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create files in directory
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
file := filepath.Join(configDir, fmt.Sprintf("file%d.json", i))
|
||||||
|
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add recursively
|
||||||
|
err = suite.runCommand("add", "--recursive", configDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
|
||||||
|
// Should show operation summary
|
||||||
|
suite.Contains(output, "recursively", "Should indicate operation type")
|
||||||
|
suite.Contains(output, "5", "Should show correct file count")
|
||||||
|
|
||||||
|
// Should include contextual help message
|
||||||
|
suite.Contains(output, "lnk push", "Should suggest next steps")
|
||||||
|
suite.Contains(output, "sync to remote", "Should explain next step purpose")
|
||||||
|
|
||||||
|
// Should show operation completion confirmation
|
||||||
|
suite.Contains(output, "✨", "Should use success emoji")
|
||||||
|
suite.Contains(output, "Added", "Should confirm operation completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 3.3: Documentation and Help Updates Tests
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestUpdatedHelpText() {
|
||||||
|
// Test main help
|
||||||
|
err := suite.runCommand("help")
|
||||||
|
suite.NoError(err)
|
||||||
|
helpOutput := suite.stdout.String()
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Should mention bulk operations
|
||||||
|
suite.Contains(helpOutput, "multiple files", "Help should mention multiple file support")
|
||||||
|
|
||||||
|
// Test add command help
|
||||||
|
err = suite.runCommand("add", "--help")
|
||||||
|
suite.NoError(err)
|
||||||
|
addHelpOutput := suite.stdout.String()
|
||||||
|
|
||||||
|
// Should include new flags
|
||||||
|
suite.Contains(addHelpOutput, "--recursive", "Help should include recursive flag")
|
||||||
|
suite.Contains(addHelpOutput, "--dry-run", "Help should include dry-run flag")
|
||||||
|
|
||||||
|
// Should include examples
|
||||||
|
suite.Contains(addHelpOutput, "Examples:", "Help should include usage examples")
|
||||||
|
suite.Contains(addHelpOutput, "lnk add ~/.bashrc ~/.vimrc", "Help should show multiple file example")
|
||||||
|
suite.Contains(addHelpOutput, "lnk add --recursive ~/.config", "Help should show recursive example")
|
||||||
|
suite.Contains(addHelpOutput, "lnk add --dry-run", "Help should show dry-run example")
|
||||||
|
|
||||||
|
// Should describe what each flag does
|
||||||
|
suite.Contains(addHelpOutput, "directory contents individually", "Should explain recursive flag")
|
||||||
|
suite.Contains(addHelpOutput, "without making changes", "Should explain dry-run flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCLISuite(t *testing.T) {
|
func TestCLISuite(t *testing.T) {
|
||||||
suite.Run(t, new(CLITestSuite))
|
suite.Run(t, new(CLITestSuite))
|
||||||
}
|
}
|
||||||
|
@@ -18,7 +18,7 @@ INSTALL_DIR="/usr/local/bin"
|
|||||||
BINARY_NAME="lnk"
|
BINARY_NAME="lnk"
|
||||||
|
|
||||||
# Fallback version if redirect fails
|
# Fallback version if redirect fails
|
||||||
FALLBACK_VERSION="v0.0.2"
|
FALLBACK_VERSION="v0.3.0"
|
||||||
|
|
||||||
# Detect OS and architecture
|
# Detect OS and architecture
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
|
@@ -3,6 +3,7 @@ package core
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -45,6 +46,34 @@ func NewLnk(opts ...Option) *Lnk {
|
|||||||
return 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
|
// GetCurrentHostname returns the current system hostname
|
||||||
func GetCurrentHostname() (string, error) {
|
func GetCurrentHostname() (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
@@ -118,7 +147,18 @@ func (l *Lnk) Init() error {
|
|||||||
|
|
||||||
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
|
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
|
||||||
func (l *Lnk) InitWithRemote(remoteURL string) error {
|
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 != "" {
|
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
|
// Clone from remote
|
||||||
return l.Clone(remoteURL)
|
return l.Clone(remoteURL)
|
||||||
}
|
}
|
||||||
@@ -256,6 +296,153 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
return nil
|
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
|
// Remove removes a symlink and restores the original file or directory
|
||||||
func (l *Lnk) Remove(filePath string) error {
|
func (l *Lnk) Remove(filePath string) error {
|
||||||
// Get absolute path
|
// Get absolute path
|
||||||
@@ -630,3 +817,373 @@ func (l *Lnk) writeManagedItems(items []string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindBootstrapScript searches for a bootstrap script in the repository
|
||||||
|
func (l *Lnk) FindBootstrapScript() (string, error) {
|
||||||
|
// Check if repository is initialized
|
||||||
|
if !l.git.IsGitRepository() {
|
||||||
|
return "", fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for bootstrap.sh - simple, opinionated choice
|
||||||
|
scriptPath := filepath.Join(l.repoPath, "bootstrap.sh")
|
||||||
|
if _, err := os.Stat(scriptPath); err == nil {
|
||||||
|
return "bootstrap.sh", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil // No bootstrap script found
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunBootstrapScript executes the bootstrap script
|
||||||
|
func (l *Lnk) RunBootstrapScript(scriptName string) error {
|
||||||
|
scriptPath := filepath.Join(l.repoPath, scriptName)
|
||||||
|
|
||||||
|
// Verify the script exists
|
||||||
|
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("❌ Bootstrap script not found: \033[31m%s\033[0m", scriptName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure it's executable
|
||||||
|
if err := os.Chmod(scriptPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("❌ Failed to make bootstrap script executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run with bash (since we only support bootstrap.sh)
|
||||||
|
cmd := exec.Command("bash", scriptPath)
|
||||||
|
|
||||||
|
// Set working directory to the repository
|
||||||
|
cmd.Dir = l.repoPath
|
||||||
|
|
||||||
|
// Connect to stdout/stderr for user to see output
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("❌ Bootstrap script failed with error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkDirectory walks through a directory and returns all regular files
|
||||||
|
func (l *Lnk) walkDirectory(dirPath string) ([]string, error) {
|
||||||
|
var files []string
|
||||||
|
|
||||||
|
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories - we only want files
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle symlinks: include them as files if they point to regular files
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
// For symlinks, we'll include them but the AddMultiple logic
|
||||||
|
// will handle validation appropriately
|
||||||
|
files = append(files, path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include regular files
|
||||||
|
if info.Mode().IsRegular() {
|
||||||
|
files = append(files, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to walk directory %s: %w", dirPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressCallback defines the signature for progress reporting callbacks
|
||||||
|
type ProgressCallback func(current, total int, currentFile string)
|
||||||
|
|
||||||
|
// AddRecursiveWithProgress adds directory contents individually with progress reporting
|
||||||
|
func (l *Lnk) AddRecursiveWithProgress(paths []string, progress ProgressCallback) error {
|
||||||
|
var allFiles []string
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
// Get absolute path
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get absolute path for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a directory
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
// Walk directory to get all files
|
||||||
|
files, err := l.walkDirectory(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to walk directory %s: %w", path, err)
|
||||||
|
}
|
||||||
|
allFiles = append(allFiles, files...)
|
||||||
|
} else {
|
||||||
|
// It's a regular file, add it directly
|
||||||
|
allFiles = append(allFiles, absPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use AddMultiple for batch processing
|
||||||
|
if len(allFiles) == 0 {
|
||||||
|
return fmt.Errorf("no files found to add")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply progress threshold: only show progress for >10 files
|
||||||
|
const progressThreshold = 10
|
||||||
|
if len(allFiles) > progressThreshold && progress != nil {
|
||||||
|
return l.addMultipleWithProgress(allFiles, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For small operations, use regular AddMultiple without progress
|
||||||
|
return l.AddMultiple(allFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMultipleWithProgress adds multiple files with progress reporting
|
||||||
|
func (l *Lnk) addMultipleWithProgress(paths []string, progress ProgressCallback) error {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Validate all paths first (same as AddMultiple)
|
||||||
|
var relativePaths []string
|
||||||
|
var absolutePaths []string
|
||||||
|
var infos []os.FileInfo
|
||||||
|
|
||||||
|
for _, filePath := range paths {
|
||||||
|
// Validate the file or directory
|
||||||
|
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
|
||||||
|
return fmt.Errorf("validation failed for %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get absolute path
|
||||||
|
absPath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path for tracking
|
||||||
|
relativePath, err := getRelativePath(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this relative path is already managed
|
||||||
|
managedItems, err := l.getManagedItems()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get managed items: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range managedItems {
|
||||||
|
if item == relativePath {
|
||||||
|
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat path %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
relativePaths = append(relativePaths, relativePath)
|
||||||
|
absolutePaths = append(absolutePaths, absPath)
|
||||||
|
infos = append(infos, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Process all files with progress reporting
|
||||||
|
var rollbackActions []func() error
|
||||||
|
total := len(absolutePaths)
|
||||||
|
|
||||||
|
for i, absPath := range absolutePaths {
|
||||||
|
// Report progress
|
||||||
|
if progress != nil {
|
||||||
|
progress(i+1, total, filepath.Base(absPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
relativePath := relativePaths[i]
|
||||||
|
info := infos[i]
|
||||||
|
|
||||||
|
// Generate repository path from relative path
|
||||||
|
storagePath := l.getHostStoragePath()
|
||||||
|
destPath := filepath.Join(storagePath, relativePath)
|
||||||
|
|
||||||
|
// Ensure destination directory exists
|
||||||
|
destDir := filepath.Dir(destPath)
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
// Rollback previous operations
|
||||||
|
l.rollbackOperations(rollbackActions)
|
||||||
|
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to repository
|
||||||
|
if err := l.fs.Move(absPath, destPath, info); err != nil {
|
||||||
|
// Rollback previous operations
|
||||||
|
l.rollbackOperations(rollbackActions)
|
||||||
|
return fmt.Errorf("failed to move %s: %w", absPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create symlink
|
||||||
|
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
|
||||||
|
// Try to restore the file we just moved, then rollback others
|
||||||
|
_ = l.fs.Move(destPath, absPath, info)
|
||||||
|
l.rollbackOperations(rollbackActions)
|
||||||
|
return fmt.Errorf("failed to create symlink for %s: %w", absPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to tracking
|
||||||
|
if err := l.addManagedItem(relativePath); err != nil {
|
||||||
|
// Restore this file and rollback others
|
||||||
|
_ = os.Remove(absPath)
|
||||||
|
_ = l.fs.Move(destPath, absPath, info)
|
||||||
|
l.rollbackOperations(rollbackActions)
|
||||||
|
return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rollback action for this file
|
||||||
|
rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info)
|
||||||
|
rollbackActions = append(rollbackActions, rollbackAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Git operations - add all files and create single commit
|
||||||
|
for i, relativePath := range relativePaths {
|
||||||
|
// For host-specific files, we need to add the relative path from repo root
|
||||||
|
gitPath := relativePath
|
||||||
|
if l.host != "" {
|
||||||
|
gitPath = filepath.Join(l.host+".lnk", relativePath)
|
||||||
|
}
|
||||||
|
if err := l.git.Add(gitPath); err != nil {
|
||||||
|
// Rollback all operations
|
||||||
|
l.rollbackOperations(rollbackActions)
|
||||||
|
return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add .lnk file to the same commit
|
||||||
|
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||||
|
// Rollback all operations
|
||||||
|
l.rollbackOperations(rollbackActions)
|
||||||
|
return fmt.Errorf("failed to add tracking file to git: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit all changes together
|
||||||
|
commitMessage := fmt.Sprintf("lnk: added %d files recursively", len(paths))
|
||||||
|
if err := l.git.Commit(commitMessage); err != nil {
|
||||||
|
// Rollback all operations
|
||||||
|
l.rollbackOperations(rollbackActions)
|
||||||
|
return fmt.Errorf("failed to commit changes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRecursive adds directory contents individually instead of the directory as a whole
|
||||||
|
func (l *Lnk) AddRecursive(paths []string) error {
|
||||||
|
var allFiles []string
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
// Get absolute path
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get absolute path for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a directory
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
// Walk directory to get all files
|
||||||
|
files, err := l.walkDirectory(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to walk directory %s: %w", path, err)
|
||||||
|
}
|
||||||
|
allFiles = append(allFiles, files...)
|
||||||
|
} else {
|
||||||
|
// It's a regular file, add it directly
|
||||||
|
allFiles = append(allFiles, absPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use AddMultiple for batch processing
|
||||||
|
if len(allFiles) == 0 {
|
||||||
|
return fmt.Errorf("no files found to add")
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.AddMultiple(allFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewAdd simulates an add operation and returns files that would be affected
|
||||||
|
func (l *Lnk) PreviewAdd(paths []string, recursive bool) ([]string, error) {
|
||||||
|
var allFiles []string
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
// Get absolute path
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get absolute path for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a directory
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to stat %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() && recursive {
|
||||||
|
// Walk directory to get all files (same logic as AddRecursive)
|
||||||
|
files, err := l.walkDirectory(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to walk directory %s: %w", path, err)
|
||||||
|
}
|
||||||
|
allFiles = append(allFiles, files...)
|
||||||
|
} else {
|
||||||
|
// It's a regular file or non-recursive directory, add it directly
|
||||||
|
allFiles = append(allFiles, absPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate files (same validation as AddMultiple but without making changes)
|
||||||
|
var validFiles []string
|
||||||
|
for _, filePath := range allFiles {
|
||||||
|
// Validate the file or directory
|
||||||
|
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
|
||||||
|
return nil, fmt.Errorf("validation failed for %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path for tracking
|
||||||
|
relativePath, err := getRelativePath(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this relative path is already managed
|
||||||
|
managedItems, err := l.getManagedItems()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get managed items: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range managedItems {
|
||||||
|
if item == relativePath {
|
||||||
|
return nil, fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles = append(validFiles, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return validFiles, nil
|
||||||
|
}
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -30,6 +32,9 @@ func (suite *CoreTestSuite) SetupTest() {
|
|||||||
err = os.Chdir(tempDir)
|
err = os.Chdir(tempDir)
|
||||||
suite.Require().NoError(err)
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Set HOME to temp directory for consistent relative path calculation
|
||||||
|
suite.T().Setenv("HOME", tempDir)
|
||||||
|
|
||||||
// Set XDG_CONFIG_HOME to temp directory
|
// Set XDG_CONFIG_HOME to temp directory
|
||||||
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
||||||
|
|
||||||
@@ -85,8 +90,8 @@ func (suite *CoreTestSuite) TestCoreFileOperations() {
|
|||||||
// The repository file will preserve the directory structure
|
// The repository file will preserve the directory structure
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
|
||||||
// Find the .bashrc file in the repository (it should be at the relative path)
|
// Find the .bashrc file in the repository (it should be at the relative path from HOME)
|
||||||
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc")
|
repoFile := filepath.Join(lnkDir, ".bashrc")
|
||||||
suite.FileExists(repoFile)
|
suite.FileExists(repoFile)
|
||||||
|
|
||||||
// Verify content is preserved
|
// Verify content is preserved
|
||||||
@@ -136,8 +141,8 @@ func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
|
|||||||
// Check that the repository directory preserves the structure
|
// Check that the repository directory preserves the structure
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
|
||||||
// The directory should be at the relative path
|
// The directory should be at the relative path from HOME
|
||||||
repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir")
|
repoDir := filepath.Join(lnkDir, "testdir")
|
||||||
suite.DirExists(repoDir)
|
suite.DirExists(repoDir)
|
||||||
|
|
||||||
// Remove the directory
|
// Remove the directory
|
||||||
@@ -749,6 +754,849 @@ func (suite *CoreTestSuite) TestMultihostIsolation() {
|
|||||||
suite.Equal(hostContent, string(symlinkContent))
|
suite.Equal(hostContent, string(symlinkContent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test bootstrap script detection
|
||||||
|
func (suite *CoreTestSuite) TestFindBootstrapScript() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test with no bootstrap script
|
||||||
|
scriptPath, err := suite.lnk.FindBootstrapScript()
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Empty(scriptPath)
|
||||||
|
|
||||||
|
// Test with bootstrap.sh
|
||||||
|
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "bootstrap.sh")
|
||||||
|
err = os.WriteFile(bootstrapScript, []byte("#!/bin/bash\necho 'test'"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
scriptPath, err = suite.lnk.FindBootstrapScript()
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal("bootstrap.sh", scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test bootstrap script execution
|
||||||
|
func (suite *CoreTestSuite) TestRunBootstrapScript() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a test script that creates a marker file
|
||||||
|
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "test.sh")
|
||||||
|
markerFile := filepath.Join(suite.tempDir, "lnk", "bootstrap-executed.txt")
|
||||||
|
scriptContent := fmt.Sprintf("#!/bin/bash\ntouch %s\necho 'Bootstrap executed'", markerFile)
|
||||||
|
|
||||||
|
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Run the bootstrap script
|
||||||
|
err = suite.lnk.RunBootstrapScript("test.sh")
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Verify the marker file was created
|
||||||
|
suite.FileExists(markerFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test bootstrap script execution with error
|
||||||
|
func (suite *CoreTestSuite) TestRunBootstrapScriptWithError() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a script that will fail
|
||||||
|
bootstrapScript := filepath.Join(suite.tempDir, "lnk", "failing.sh")
|
||||||
|
scriptContent := "#!/bin/bash\nexit 1"
|
||||||
|
|
||||||
|
err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Run the bootstrap script - should fail
|
||||||
|
err = suite.lnk.RunBootstrapScript("failing.sh")
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "Bootstrap script failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test running bootstrap on non-existent script
|
||||||
|
func (suite *CoreTestSuite) TestRunBootstrapScriptNotFound() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Try to run non-existent script
|
||||||
|
err = suite.lnk.RunBootstrapScript("nonexistent.sh")
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "Bootstrap script not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestAddMultiple() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create multiple test files
|
||||||
|
file1 := filepath.Join(suite.tempDir, "file1.txt")
|
||||||
|
file2 := filepath.Join(suite.tempDir, "file2.txt")
|
||||||
|
file3 := filepath.Join(suite.tempDir, "file3.txt")
|
||||||
|
|
||||||
|
content1 := "content1"
|
||||||
|
content2 := "content2"
|
||||||
|
content3 := "content3"
|
||||||
|
|
||||||
|
err = os.WriteFile(file1, []byte(content1), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(file2, []byte(content2), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(file3, []byte(content3), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test AddMultiple method - should succeed
|
||||||
|
paths := []string{file1, file2, file3}
|
||||||
|
err = suite.lnk.AddMultiple(paths)
|
||||||
|
suite.NoError(err, "AddMultiple should succeed")
|
||||||
|
|
||||||
|
// Verify all files are now symlinks
|
||||||
|
for _, file := range paths {
|
||||||
|
info, err := os.Lstat(file)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "File should be a symlink: %s", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all files exist in storage
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, "file1.txt"))
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, "file2.txt"))
|
||||||
|
suite.FileExists(filepath.Join(lnkDir, "file3.txt"))
|
||||||
|
|
||||||
|
// Verify .lnk file contains all entries
|
||||||
|
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||||
|
lnkContent, err := os.ReadFile(lnkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal("file1.txt\nfile2.txt\nfile3.txt\n", string(lnkContent))
|
||||||
|
|
||||||
|
// Verify Git commit was created
|
||||||
|
commits, err := suite.lnk.GetCommits()
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.T().Logf("Commits: %v", commits)
|
||||||
|
// Should have at least 1 commit for the batch add
|
||||||
|
suite.GreaterOrEqual(len(commits), 1)
|
||||||
|
// The most recent commit should mention multiple files
|
||||||
|
suite.Contains(commits[0], "added 3 files")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestAddMultipleWithConflicts() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
file1 := filepath.Join(suite.tempDir, "file1.txt")
|
||||||
|
file2 := filepath.Join(suite.tempDir, "file2.txt")
|
||||||
|
file3 := filepath.Join(suite.tempDir, "file3.txt")
|
||||||
|
|
||||||
|
err = os.WriteFile(file1, []byte("content1"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(file2, []byte("content2"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(file3, []byte("content3"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add file2 individually first
|
||||||
|
err = suite.lnk.Add(file2)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Now try to add all three - should fail due to conflict with file2
|
||||||
|
paths := []string{file1, file2, file3}
|
||||||
|
err = suite.lnk.AddMultiple(paths)
|
||||||
|
suite.Error(err, "AddMultiple should fail due to conflict")
|
||||||
|
suite.Contains(err.Error(), "already managed")
|
||||||
|
|
||||||
|
// Verify no partial changes were made
|
||||||
|
// file1 and file3 should still be regular files, not symlinks
|
||||||
|
info1, err := os.Lstat(file1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink")
|
||||||
|
|
||||||
|
info3, err := os.Lstat(file3)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink")
|
||||||
|
|
||||||
|
// file2 should still be managed (was added before)
|
||||||
|
info2, err := os.Lstat(file2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestAddMultipleRollback() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create test files - one will be invalid to force rollback
|
||||||
|
file1 := filepath.Join(suite.tempDir, "file1.txt")
|
||||||
|
file2 := filepath.Join(suite.tempDir, "nonexistent.txt") // This doesn't exist
|
||||||
|
file3 := filepath.Join(suite.tempDir, "file3.txt")
|
||||||
|
|
||||||
|
err = os.WriteFile(file1, []byte("content1"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(file3, []byte("content3"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
// Note: file2 is intentionally not created
|
||||||
|
|
||||||
|
// Try to add all files - should fail and rollback
|
||||||
|
paths := []string{file1, file2, file3}
|
||||||
|
err = suite.lnk.AddMultiple(paths)
|
||||||
|
suite.Error(err, "AddMultiple should fail due to nonexistent file")
|
||||||
|
|
||||||
|
// Verify rollback - no files should be symlinks
|
||||||
|
info1, err := os.Lstat(file1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink after rollback")
|
||||||
|
|
||||||
|
info3, err := os.Lstat(file3)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink after rollback")
|
||||||
|
|
||||||
|
// Verify no files in storage
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
suite.NoFileExists(filepath.Join(lnkDir, "file1.txt"))
|
||||||
|
suite.NoFileExists(filepath.Join(lnkDir, "file3.txt"))
|
||||||
|
|
||||||
|
// Verify .lnk file is empty or doesn't contain these files
|
||||||
|
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||||
|
if _, err := os.Stat(lnkFile); err == nil {
|
||||||
|
lnkContent, err := os.ReadFile(lnkFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
content := string(lnkContent)
|
||||||
|
suite.NotContains(content, "file1.txt")
|
||||||
|
suite.NotContains(content, "file3.txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestValidateMultiplePaths() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a mix of valid and invalid paths
|
||||||
|
validFile := filepath.Join(suite.tempDir, "valid.txt")
|
||||||
|
err = os.WriteFile(validFile, []byte("content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
nonexistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||||
|
// Don't create this file
|
||||||
|
|
||||||
|
// Create a valid directory
|
||||||
|
validDir := filepath.Join(suite.tempDir, "validdir")
|
||||||
|
err = os.MkdirAll(validDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test validation fails early with detailed error
|
||||||
|
paths := []string{validFile, nonexistentFile, validDir}
|
||||||
|
err = suite.lnk.AddMultiple(paths)
|
||||||
|
suite.Error(err, "Should fail due to nonexistent file")
|
||||||
|
suite.Contains(err.Error(), "validation failed")
|
||||||
|
suite.Contains(err.Error(), "nonexistent.txt")
|
||||||
|
|
||||||
|
// Verify no partial changes were made
|
||||||
|
info, err := os.Lstat(validFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid file should not be a symlink")
|
||||||
|
|
||||||
|
info, err = os.Lstat(validDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid directory should not be a symlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestAtomicRollbackOnFailure() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
file1 := filepath.Join(suite.tempDir, "file1.txt")
|
||||||
|
file2 := filepath.Join(suite.tempDir, "file2.txt")
|
||||||
|
file3 := filepath.Join(suite.tempDir, "file3.txt")
|
||||||
|
|
||||||
|
content1 := "original content 1"
|
||||||
|
content2 := "original content 2"
|
||||||
|
content3 := "original content 3"
|
||||||
|
|
||||||
|
err = os.WriteFile(file1, []byte(content1), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(file2, []byte(content2), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(file3, []byte(content3), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add file2 individually first to create a conflict
|
||||||
|
err = suite.lnk.Add(file2)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Store original states
|
||||||
|
info1Before, err := os.Lstat(file1)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
info3Before, err := os.Lstat(file3)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Try to add all files - should fail and rollback completely
|
||||||
|
paths := []string{file1, file2, file3}
|
||||||
|
err = suite.lnk.AddMultiple(paths)
|
||||||
|
suite.Error(err, "Should fail due to conflict with file2")
|
||||||
|
|
||||||
|
// Verify complete rollback
|
||||||
|
info1After, err := os.Lstat(file1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(info1Before.Mode(), info1After.Mode(), "file1 mode should be unchanged")
|
||||||
|
|
||||||
|
info3After, err := os.Lstat(file3)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(info3Before.Mode(), info3After.Mode(), "file3 mode should be unchanged")
|
||||||
|
|
||||||
|
// Verify original contents are preserved
|
||||||
|
content1After, err := os.ReadFile(file1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(content1, string(content1After), "file1 content should be preserved")
|
||||||
|
|
||||||
|
content3After, err := os.ReadFile(file3)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(content3, string(content3After), "file3 content should be preserved")
|
||||||
|
|
||||||
|
// file2 should still be managed (was added before)
|
||||||
|
info2, err := os.Lstat(file2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestDetailedErrorMessages() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test with multiple types of errors
|
||||||
|
validFile := filepath.Join(suite.tempDir, "valid.txt")
|
||||||
|
err = os.WriteFile(validFile, []byte("content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
nonexistentFile := filepath.Join(suite.tempDir, "does-not-exist.txt")
|
||||||
|
alreadyManagedFile := filepath.Join(suite.tempDir, "already-managed.txt")
|
||||||
|
err = os.WriteFile(alreadyManagedFile, []byte("managed"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add one file first to create conflict
|
||||||
|
err = suite.lnk.Add(alreadyManagedFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test with nonexistent file
|
||||||
|
paths := []string{validFile, nonexistentFile}
|
||||||
|
err = suite.lnk.AddMultiple(paths)
|
||||||
|
suite.Error(err, "Should fail due to nonexistent file")
|
||||||
|
suite.Contains(err.Error(), "validation failed", "Error should mention validation failure")
|
||||||
|
suite.Contains(err.Error(), "does-not-exist.txt", "Error should include specific filename")
|
||||||
|
|
||||||
|
// Test with already managed file
|
||||||
|
paths = []string{validFile, alreadyManagedFile}
|
||||||
|
err = suite.lnk.AddMultiple(paths)
|
||||||
|
suite.Error(err, "Should fail due to already managed file")
|
||||||
|
suite.Contains(err.Error(), "already managed", "Error should mention already managed")
|
||||||
|
suite.Contains(err.Error(), "already-managed.txt", "Error should include specific filename")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 2.2: Directory Walking Logic Tests
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestWalkDirectory() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create nested directory structure
|
||||||
|
configDir := filepath.Join(suite.tempDir, ".config", "myapp")
|
||||||
|
err = os.MkdirAll(configDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
themeDir := filepath.Join(configDir, "themes")
|
||||||
|
err = os.MkdirAll(themeDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create files in different levels
|
||||||
|
file1 := filepath.Join(configDir, "config.json")
|
||||||
|
file2 := filepath.Join(configDir, "settings.json")
|
||||||
|
file3 := filepath.Join(themeDir, "dark.json")
|
||||||
|
file4 := filepath.Join(themeDir, "light.json")
|
||||||
|
|
||||||
|
suite.Require().NoError(os.WriteFile(file1, []byte("config"), 0644))
|
||||||
|
suite.Require().NoError(os.WriteFile(file2, []byte("settings"), 0644))
|
||||||
|
suite.Require().NoError(os.WriteFile(file3, []byte("dark theme"), 0644))
|
||||||
|
suite.Require().NoError(os.WriteFile(file4, []byte("light theme"), 0644))
|
||||||
|
|
||||||
|
// Call walkDirectory method (which doesn't exist yet)
|
||||||
|
files, err := suite.lnk.walkDirectory(configDir)
|
||||||
|
suite.Require().NoError(err, "walkDirectory should succeed")
|
||||||
|
|
||||||
|
// Should find all 4 files
|
||||||
|
suite.Len(files, 4, "Should find all files in nested structure")
|
||||||
|
|
||||||
|
// Check that all expected files are found (order may vary)
|
||||||
|
expectedFiles := []string{file1, file2, file3, file4}
|
||||||
|
for _, expectedFile := range expectedFiles {
|
||||||
|
suite.Contains(files, expectedFile, "Should include file %s", expectedFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestWalkDirectoryIncludesHiddenFiles() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create directory with hidden files and directories
|
||||||
|
testDir := filepath.Join(suite.tempDir, "test-hidden")
|
||||||
|
err = os.MkdirAll(testDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
hiddenDir := filepath.Join(testDir, ".hidden")
|
||||||
|
err = os.MkdirAll(hiddenDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create regular and hidden files
|
||||||
|
regularFile := filepath.Join(testDir, "regular.txt")
|
||||||
|
hiddenFile := filepath.Join(testDir, ".hidden-file")
|
||||||
|
hiddenDirFile := filepath.Join(hiddenDir, "file-in-hidden.txt")
|
||||||
|
|
||||||
|
suite.Require().NoError(os.WriteFile(regularFile, []byte("regular"), 0644))
|
||||||
|
suite.Require().NoError(os.WriteFile(hiddenFile, []byte("hidden"), 0644))
|
||||||
|
suite.Require().NoError(os.WriteFile(hiddenDirFile, []byte("in hidden dir"), 0644))
|
||||||
|
|
||||||
|
// Call walkDirectory method
|
||||||
|
files, err := suite.lnk.walkDirectory(testDir)
|
||||||
|
suite.Require().NoError(err, "walkDirectory should succeed with hidden files")
|
||||||
|
|
||||||
|
// Should find all files including hidden ones
|
||||||
|
suite.Len(files, 3, "Should find all files including hidden ones")
|
||||||
|
suite.Contains(files, regularFile, "Should include regular file")
|
||||||
|
suite.Contains(files, hiddenFile, "Should include hidden file")
|
||||||
|
suite.Contains(files, hiddenDirFile, "Should include file in hidden directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestWalkDirectorySymlinkHandling() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create directory structure
|
||||||
|
testDir := filepath.Join(suite.tempDir, "test-symlinks")
|
||||||
|
err = os.MkdirAll(testDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a regular file
|
||||||
|
regularFile := filepath.Join(testDir, "regular.txt")
|
||||||
|
suite.Require().NoError(os.WriteFile(regularFile, []byte("regular"), 0644))
|
||||||
|
|
||||||
|
// Create a symlink to the regular file
|
||||||
|
symlinkFile := filepath.Join(testDir, "link-to-regular.txt")
|
||||||
|
err = os.Symlink(regularFile, symlinkFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Call walkDirectory method
|
||||||
|
files, err := suite.lnk.walkDirectory(testDir)
|
||||||
|
suite.Require().NoError(err, "walkDirectory should handle symlinks")
|
||||||
|
|
||||||
|
// Should include both regular file and properly handle symlink
|
||||||
|
// (exact behavior depends on implementation - could include symlink as file)
|
||||||
|
suite.GreaterOrEqual(len(files), 1, "Should find at least the regular file")
|
||||||
|
suite.Contains(files, regularFile, "Should include regular file")
|
||||||
|
|
||||||
|
// The symlink handling behavior will be defined in implementation
|
||||||
|
// For now, we just ensure no errors occur
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestWalkDirectoryEmptyDirs() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create directory structure with empty directories
|
||||||
|
testDir := filepath.Join(suite.tempDir, "test-empty")
|
||||||
|
err = os.MkdirAll(testDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create empty subdirectories
|
||||||
|
emptyDir1 := filepath.Join(testDir, "empty1")
|
||||||
|
emptyDir2 := filepath.Join(testDir, "empty2")
|
||||||
|
err = os.MkdirAll(emptyDir1, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.MkdirAll(emptyDir2, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create one file in a non-empty directory
|
||||||
|
nonEmptyDir := filepath.Join(testDir, "non-empty")
|
||||||
|
err = os.MkdirAll(nonEmptyDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
testFile := filepath.Join(nonEmptyDir, "test.txt")
|
||||||
|
suite.Require().NoError(os.WriteFile(testFile, []byte("content"), 0644))
|
||||||
|
|
||||||
|
// Call walkDirectory method
|
||||||
|
files, err := suite.lnk.walkDirectory(testDir)
|
||||||
|
suite.Require().NoError(err, "walkDirectory should skip empty directories")
|
||||||
|
|
||||||
|
// Should only find the one file, not empty directories
|
||||||
|
suite.Len(files, 1, "Should only find files, not empty directories")
|
||||||
|
suite.Contains(files, testFile, "Should include the actual file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 2.3: Progress Indication System Tests
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestProgressReporting() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create directory with multiple files to test progress reporting
|
||||||
|
testDir := filepath.Join(suite.tempDir, "progress-test")
|
||||||
|
err = os.MkdirAll(testDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create 15 files to exceed threshold
|
||||||
|
expectedFiles := 15
|
||||||
|
for i := 0; i < expectedFiles; i++ {
|
||||||
|
file := filepath.Join(testDir, fmt.Sprintf("file%d.txt", i))
|
||||||
|
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track progress calls
|
||||||
|
var progressCalls []struct {
|
||||||
|
Current int
|
||||||
|
Total int
|
||||||
|
CurrentFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback := func(current, total int, currentFile string) {
|
||||||
|
progressCalls = append(progressCalls, struct {
|
||||||
|
Current int
|
||||||
|
Total int
|
||||||
|
CurrentFile string
|
||||||
|
}{
|
||||||
|
Current: current,
|
||||||
|
Total: total,
|
||||||
|
CurrentFile: currentFile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call AddRecursiveWithProgress method (which doesn't exist yet)
|
||||||
|
err = suite.lnk.AddRecursiveWithProgress([]string{testDir}, progressCallback)
|
||||||
|
suite.Require().NoError(err, "AddRecursiveWithProgress should succeed")
|
||||||
|
|
||||||
|
// Verify progress was reported
|
||||||
|
suite.Greater(len(progressCalls), 0, "Progress callback should be called")
|
||||||
|
suite.Equal(expectedFiles, len(progressCalls), "Should have progress calls for each file")
|
||||||
|
|
||||||
|
// Verify progress order and totals
|
||||||
|
for i, call := range progressCalls {
|
||||||
|
suite.Equal(i+1, call.Current, "Current count should increment")
|
||||||
|
suite.Equal(expectedFiles, call.Total, "Total should be consistent")
|
||||||
|
suite.NotEmpty(call.CurrentFile, "CurrentFile should be provided")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestProgressThreshold() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test with few files (under threshold)
|
||||||
|
smallDir := filepath.Join(suite.tempDir, "small-test")
|
||||||
|
err = os.MkdirAll(smallDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create only 5 files (under 10 threshold)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
file := filepath.Join(smallDir, fmt.Sprintf("small%d.txt", i))
|
||||||
|
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track progress calls for small operation
|
||||||
|
smallProgressCalls := 0
|
||||||
|
smallCallback := func(current, total int, currentFile string) {
|
||||||
|
smallProgressCalls++
|
||||||
|
}
|
||||||
|
|
||||||
|
err = suite.lnk.AddRecursiveWithProgress([]string{smallDir}, smallCallback)
|
||||||
|
suite.Require().NoError(err, "AddRecursiveWithProgress should succeed for small operation")
|
||||||
|
|
||||||
|
// Should NOT call progress for small operations
|
||||||
|
suite.Equal(0, smallProgressCalls, "Progress should not be called for operations under threshold")
|
||||||
|
|
||||||
|
// Test with many files (over threshold)
|
||||||
|
largeDir := filepath.Join(suite.tempDir, "large-test")
|
||||||
|
err = os.MkdirAll(largeDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create 15 files (over 10 threshold)
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
file := filepath.Join(largeDir, fmt.Sprintf("large%d.txt", i))
|
||||||
|
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track progress calls for large operation
|
||||||
|
largeProgressCalls := 0
|
||||||
|
largeCallback := func(current, total int, currentFile string) {
|
||||||
|
largeProgressCalls++
|
||||||
|
}
|
||||||
|
|
||||||
|
err = suite.lnk.AddRecursiveWithProgress([]string{largeDir}, largeCallback)
|
||||||
|
suite.Require().NoError(err, "AddRecursiveWithProgress should succeed for large operation")
|
||||||
|
|
||||||
|
// Should call progress for large operations
|
||||||
|
suite.Equal(15, largeProgressCalls, "Progress should be called for operations over threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 3.1: Dry-Run Mode Core Tests
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestPreviewAdd() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testFile1 := filepath.Join(suite.tempDir, "test1.txt")
|
||||||
|
testFile2 := filepath.Join(suite.tempDir, "test2.txt")
|
||||||
|
suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644))
|
||||||
|
suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644))
|
||||||
|
|
||||||
|
// Test PreviewAdd for multiple files
|
||||||
|
files, err := suite.lnk.PreviewAdd([]string{testFile1, testFile2}, false)
|
||||||
|
suite.Require().NoError(err, "PreviewAdd should succeed")
|
||||||
|
|
||||||
|
// Should return both files
|
||||||
|
suite.Len(files, 2, "Should preview both files")
|
||||||
|
suite.Contains(files, testFile1, "Should include first file")
|
||||||
|
suite.Contains(files, testFile2, "Should include second file")
|
||||||
|
|
||||||
|
// Verify no actual changes were made (files should still be regular files)
|
||||||
|
info, err := os.Lstat(testFile1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after preview")
|
||||||
|
|
||||||
|
info, err = os.Lstat(testFile2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after preview")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestPreviewAddRecursive() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create directory structure
|
||||||
|
configDir := filepath.Join(suite.tempDir, ".config", "test-app")
|
||||||
|
err = os.MkdirAll(configDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create files in directory
|
||||||
|
expectedFiles := 5
|
||||||
|
var createdFiles []string
|
||||||
|
for i := 1; i <= expectedFiles; i++ {
|
||||||
|
file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i))
|
||||||
|
suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("config %d", i)), 0644))
|
||||||
|
createdFiles = append(createdFiles, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test PreviewAdd with recursive
|
||||||
|
files, err := suite.lnk.PreviewAdd([]string{configDir}, true)
|
||||||
|
suite.Require().NoError(err, "PreviewAdd recursive should succeed")
|
||||||
|
|
||||||
|
// Should return all files in directory
|
||||||
|
suite.Len(files, expectedFiles, "Should preview all files in directory")
|
||||||
|
|
||||||
|
// Check that all created files are included
|
||||||
|
for _, createdFile := range createdFiles {
|
||||||
|
suite.Contains(files, createdFile, "Should include file %s", createdFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no actual changes were made
|
||||||
|
for _, createdFile := range createdFiles {
|
||||||
|
info, err := os.Lstat(createdFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after preview")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestPreviewAddValidation() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test with nonexistent file
|
||||||
|
nonexistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||||
|
_, err = suite.lnk.PreviewAdd([]string{nonexistentFile}, false)
|
||||||
|
suite.Error(err, "PreviewAdd should fail for nonexistent file")
|
||||||
|
suite.Contains(err.Error(), "failed to stat", "Error should mention stat failure")
|
||||||
|
|
||||||
|
// Create and add a file first
|
||||||
|
testFile := filepath.Join(suite.tempDir, "test.txt")
|
||||||
|
suite.Require().NoError(os.WriteFile(testFile, []byte("content"), 0644))
|
||||||
|
err = suite.lnk.Add(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test preview with already managed file
|
||||||
|
_, err = suite.lnk.PreviewAdd([]string{testFile}, false)
|
||||||
|
suite.Error(err, "PreviewAdd should fail for already managed file")
|
||||||
|
suite.Contains(err.Error(), "already managed", "Error should mention already managed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestCoreSuite(t *testing.T) {
|
||||||
suite.Run(t, new(CoreTestSuite))
|
suite.Run(t, new(CoreTestSuite))
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user