mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-24 21:11:27 +02:00
Compare commits
28 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
430619b7e8 | ||
|
48535a68d3 | ||
|
0a3e522457 | ||
|
b94870b91a | ||
|
2aac515606 | ||
|
30ab78d506 | ||
|
7f10e1ce8a | ||
|
57839c795e | ||
|
dc524607fa | ||
|
9bf2e70d13 | ||
|
65db5fe738 | ||
|
43b68bc071 | ||
|
ab97fa86dc | ||
|
4cd8191805 | ||
|
6830c06eb4 | ||
|
8a29b7fe43 | ||
|
a6852e5ad5 | ||
|
36d76c881c | ||
|
6de387797e | ||
|
9cbad5e593 | ||
|
150e8adf8b | ||
|
4b11563bdf | ||
|
b476ce503b | ||
|
ae9cc175ce | ||
|
1e2c9704f3 | ||
|
3cba309c05 | ||
|
3e6b426a19 | ||
|
02f342b02b |
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
|
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@@ -9,20 +9,23 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.24'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
@@ -44,23 +47,23 @@ jobs:
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage.out
|
||||
files: ./coverage.out
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: latest
|
||||
|
||||
@@ -69,12 +72,12 @@ jobs:
|
||||
needs: [test, lint]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
@@ -84,4 +87,4 @@ jobs:
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: build --snapshot --clean
|
||||
args: build --snapshot --clean
|
||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
2
.github/workflows/validate.yml
vendored
2
.github/workflows/validate.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -43,4 +43,6 @@ desktop.ini
|
||||
*.log
|
||||
|
||||
# GoReleaser artifacts
|
||||
goreleaser/
|
||||
goreleaser/
|
||||
*.md
|
||||
!/README.md
|
||||
|
286
README.md
286
README.md
@@ -2,12 +2,18 @@
|
||||
|
||||
**Git-native dotfiles management that doesn't suck.**
|
||||
|
||||
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
|
||||
Lnk makes managing your dotfiles straightforward, no tedious setups, no complex configurations. Just tell Lnk what files you want tracked, and it'll automatically move them into a tidy Git repository under `~/.config/lnk`. It then creates clean, portable symlinks back to their original locations. Easy.
|
||||
|
||||
Why bother with Lnk instead of plain old Git or other dotfile managers? Unlike traditional methods, Lnk automates the boring parts: safely relocating files, handling host-specific setups, bulk operations for multiple files, recursive directory processing, and even running your custom bootstrap scripts automatically. This means fewer manual steps and less chance of accidentally overwriting something important.
|
||||
|
||||
With Lnk, your dotfiles setup stays organized and effortlessly portable, letting you spend more time doing real work, not wrestling with configuration files.
|
||||
|
||||
```bash
|
||||
lnk init
|
||||
lnk add ~/.vimrc ~/.bashrc # Common config
|
||||
lnk add --host work ~/.ssh/config # Host-specific config
|
||||
lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
|
||||
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Multiple files at once
|
||||
lnk add --recursive ~/.config/nvim # Process directory contents
|
||||
lnk add --dry-run ~/.tmux.conf # Preview changes first
|
||||
lnk add --host work ~/.ssh/config # Host-specific config
|
||||
lnk push "setup"
|
||||
```
|
||||
|
||||
@@ -20,7 +26,6 @@ curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
||||
|
||||
```bash
|
||||
# Homebrew (macOS/Linux)
|
||||
brew tap yarlson/lnk
|
||||
brew install lnk
|
||||
```
|
||||
|
||||
@@ -43,19 +48,35 @@ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv
|
||||
# Fresh start
|
||||
lnk init
|
||||
|
||||
# With existing repo
|
||||
# With existing repo (runs bootstrap automatically)
|
||||
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
|
||||
|
||||
```bash
|
||||
# Add files/directories (common config)
|
||||
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
||||
# Add multiple files at once (common config)
|
||||
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
|
||||
|
||||
# Add host-specific files
|
||||
lnk add --host laptop ~/.ssh/config
|
||||
lnk add --host work ~/.gitconfig
|
||||
# Add directory contents individually
|
||||
lnk add --recursive ~/.config/nvim ~/.config/zsh
|
||||
|
||||
# Preview changes before applying
|
||||
lnk add --dry-run ~/.config/git/config
|
||||
lnk add --dry-run --recursive ~/.config/kitty
|
||||
|
||||
# Add host-specific files (supports bulk operations)
|
||||
lnk add --host laptop ~/.ssh/config ~/.aws/credentials
|
||||
lnk add --host work ~/.gitconfig ~/.ssh/config
|
||||
|
||||
# List managed files
|
||||
lnk list # Common config only
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# Common config (shared everywhere)
|
||||
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig
|
||||
# Common config (shared everywhere) - supports multiple files
|
||||
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
|
||||
|
||||
# Host-specific config (unique per machine)
|
||||
lnk add --host $(hostname) ~/.ssh/config
|
||||
lnk add --host work ~/.gitconfig
|
||||
# Process directory contents individually
|
||||
lnk add --recursive ~/.config/nvim ~/.config/zsh
|
||||
|
||||
# Preview operations before making changes
|
||||
lnk add --dry-run ~/.config/alacritty/alacritty.yml
|
||||
lnk add --dry-run --recursive ~/.config/i3
|
||||
|
||||
# Host-specific config (unique per machine) - supports bulk operations
|
||||
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||
lnk add --host work ~/.gitconfig ~/.npmrc
|
||||
|
||||
# List configurations
|
||||
lnk list # Common only
|
||||
@@ -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:
|
||||
|
||||
- Moving files safely
|
||||
- Moving files safely (with atomic operations)
|
||||
- Creating relative symlinks
|
||||
- Handling conflicts
|
||||
- Handling conflicts and rollback
|
||||
- Tracking what's managed
|
||||
- Processing multiple files efficiently
|
||||
- Recursive directory traversal
|
||||
- Preview mode for safety
|
||||
|
||||
## Examples
|
||||
|
||||
### First time setup
|
||||
|
||||
```bash
|
||||
# Clone dotfiles and run bootstrap automatically
|
||||
lnk init -r git@github.com:you/dotfiles.git
|
||||
# → Downloads dependencies, installs packages, configures environment
|
||||
|
||||
# Add common config (shared across all machines)
|
||||
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
||||
# Add common config (shared across all machines) - multiple files at once
|
||||
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig ~/.tmux.conf
|
||||
|
||||
# Add host-specific config
|
||||
lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf
|
||||
# Add configuration directories individually
|
||||
lnk add --recursive ~/.config/nvim ~/.config/zsh
|
||||
|
||||
# Preview before adding sensitive files
|
||||
lnk add --dry-run ~/.ssh/id_rsa.pub
|
||||
lnk add ~/.ssh/id_rsa.pub # Add after verification
|
||||
|
||||
# Add host-specific config (supports bulk operations)
|
||||
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||
|
||||
lnk push "initial setup"
|
||||
```
|
||||
@@ -156,13 +261,18 @@ lnk push "initial setup"
|
||||
### On a new machine
|
||||
|
||||
```bash
|
||||
# Bootstrap runs automatically
|
||||
lnk init -r git@github.com:you/dotfiles.git
|
||||
# → Sets up environment, installs dependencies
|
||||
|
||||
# Pull common config
|
||||
lnk pull
|
||||
|
||||
# Pull host-specific config (if it exists)
|
||||
lnk pull --host $(hostname)
|
||||
|
||||
# Or run bootstrap manually if needed
|
||||
lnk bootstrap
|
||||
```
|
||||
|
||||
### Daily edits
|
||||
@@ -179,35 +289,126 @@ lnk push "new plugins" # commit & push
|
||||
### Multi-machine workflow
|
||||
|
||||
```bash
|
||||
# On your laptop
|
||||
lnk add --host laptop ~/.ssh/config
|
||||
lnk add ~/.vimrc # Common config
|
||||
lnk push "laptop ssh config"
|
||||
# On your laptop - use bulk operations for efficiency
|
||||
lnk add --host laptop ~/.ssh/config ~/.aws/credentials ~/.npmrc
|
||||
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Common config (multiple files)
|
||||
lnk push "laptop configuration"
|
||||
|
||||
# On your work machine
|
||||
lnk pull # Get common config
|
||||
lnk add --host work ~/.gitconfig
|
||||
lnk push "work git config"
|
||||
# On your work machine
|
||||
lnk pull # Get common config
|
||||
lnk add --host work ~/.gitconfig ~/.ssh/config
|
||||
lnk add --recursive ~/.config/work-tools # Work-specific tools
|
||||
lnk push "work configuration"
|
||||
|
||||
# Back on laptop
|
||||
lnk pull # Get updates (work config won't affect laptop)
|
||||
lnk pull # Get updates (work config won't affect laptop)
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `lnk init [-r remote]` - Create repo
|
||||
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
|
||||
- `lnk init [-r remote] [--no-bootstrap] [--force]` - Create repo (runs bootstrap automatically)
|
||||
- `lnk add [--host HOST] [--recursive] [--dry-run] <files>...` - Move files to repo, create symlinks
|
||||
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
||||
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
||||
- `lnk status` - Git status + sync info
|
||||
- `lnk push [msg]` - Stage all, commit, push
|
||||
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
|
||||
- `lnk bootstrap` - Run bootstrap script manually
|
||||
|
||||
### Command Options
|
||||
|
||||
- `--host HOST` - Manage files for specific host (default: common configuration)
|
||||
- `--recursive, -r` - Add directory contents individually instead of the directory as a whole
|
||||
- `--dry-run, -n` - Show what would be added without making changes
|
||||
- `--all` - Show all configurations (common + all hosts) when listing
|
||||
- `-r, --remote URL` - Clone from remote URL when initializing
|
||||
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
|
||||
- `--force` - Force initialization even if directory contains managed files (WARNING: overwrites existing content)
|
||||
|
||||
### Output Formatting
|
||||
|
||||
Lnk provides flexible output formatting options to suit different environments and preferences:
|
||||
|
||||
#### Color Output
|
||||
|
||||
Control when ANSI colors are used in output:
|
||||
|
||||
```bash
|
||||
# Default: auto-detect based on TTY
|
||||
lnk init
|
||||
|
||||
# Force colors regardless of environment
|
||||
lnk init --colors=always
|
||||
|
||||
# Disable colors completely
|
||||
lnk init --colors=never
|
||||
|
||||
# Environment variable support
|
||||
NO_COLOR=1 lnk init # Disables colors (acts like --colors=never)
|
||||
```
|
||||
|
||||
**Color modes:**
|
||||
|
||||
- `auto` (default): Use colors only when stdout is a TTY
|
||||
- `always`: Force color output regardless of TTY
|
||||
- `never`: Disable color output regardless of TTY
|
||||
|
||||
The `NO_COLOR` environment variable acts like `--colors=never` when set, but explicit `--colors` flags take precedence.
|
||||
|
||||
#### Emoji Output
|
||||
|
||||
Control emoji usage in output messages:
|
||||
|
||||
```bash
|
||||
# Default: emojis enabled
|
||||
lnk init
|
||||
|
||||
# Disable emojis
|
||||
lnk init --no-emoji
|
||||
|
||||
# Explicitly enable emojis
|
||||
lnk init --emoji
|
||||
```
|
||||
|
||||
**Emoji flags:**
|
||||
|
||||
- `--emoji` (default: true): Enable emoji in output
|
||||
- `--no-emoji`: Disable emoji in output
|
||||
|
||||
The `--emoji` and `--no-emoji` flags are mutually exclusive.
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Clean output for scripts/pipes
|
||||
lnk init --colors=never --no-emoji
|
||||
|
||||
# Force colorful output in non-TTY environments
|
||||
lnk init --colors=always
|
||||
|
||||
# Disable colors but keep emojis
|
||||
lnk init --colors=never
|
||||
|
||||
# Disable emojis but keep colors
|
||||
lnk init --no-emoji
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
@@ -215,17 +416,22 @@ lnk pull # Get updates (work config won't affect laptop)
|
||||
- **Relative symlinks** (portable)
|
||||
- **XDG compliant** (`~/.config/lnk`)
|
||||
- **Multihost support** (common + host-specific configs)
|
||||
- **Bootstrap support** (automatic environment setup)
|
||||
- **Bulk operations** (multiple files, atomic transactions)
|
||||
- **Recursive processing** (directory contents individually)
|
||||
- **Preview mode** (dry-run for safety)
|
||||
- **Data loss prevention** (safety checks with contextual warnings)
|
||||
- **Git-native** (standard Git repo, no special formats)
|
||||
|
||||
## Alternatives
|
||||
|
||||
| Tool | Complexity | Why choose it |
|
||||
| ------- | ---------- | -------------------------------------------- |
|
||||
| **lnk** | Minimal | Just works, no config, Git-native, multihost |
|
||||
| chezmoi | High | Templates, encryption, cross-platform |
|
||||
| yadm | Medium | Git power user, encryption |
|
||||
| dotbot | Low | YAML config, basic features |
|
||||
| stow | Low | Perl, symlink only |
|
||||
| Tool | Complexity | Why choose it |
|
||||
| ------- | ---------- | ----------------------------------------------------------------------------------------- |
|
||||
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks |
|
||||
| chezmoi | High | Templates, encryption, cross-platform |
|
||||
| yadm | Medium | Git power user, encryption |
|
||||
| dotbot | Low | YAML config, basic features |
|
||||
| stow | Low | Perl, symlink only |
|
||||
|
||||
## Contributing
|
||||
|
||||
|
190
RELEASE.md
190
RELEASE.md
@@ -1,190 +0,0 @@
|
||||
# Release Process
|
||||
|
||||
This document describes how to create releases for the lnk project using GoReleaser.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Push access to the main repository
|
||||
- Git tags pushed to GitHub trigger releases automatically
|
||||
- GoReleaser is configured in `.goreleaser.yml`
|
||||
- GitHub Actions will handle the release process
|
||||
- Access to the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap repository
|
||||
- **Personal Access Token** set up as `HOMEBREW_TAP_TOKEN` secret (see Setup section)
|
||||
|
||||
## Setup (One-time)
|
||||
|
||||
### GitHub Personal Access Token
|
||||
|
||||
For GoReleaser to update the Homebrew formula, you need a Personal Access Token:
|
||||
|
||||
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
|
||||
2. Click "Generate new token" → "Generate new token (classic)"
|
||||
3. Name: "GoReleaser Homebrew Access"
|
||||
4. Scopes: Select `repo` (Full control of private repositories)
|
||||
5. Generate and copy the token
|
||||
6. In your `yarlson/lnk` repository:
|
||||
- Go to Settings → Secrets and variables → Actions
|
||||
- Add new repository secret: `HOMEBREW_TAP_TOKEN`
|
||||
- Paste the token as the value
|
||||
|
||||
This allows GoReleaser to automatically update the Homebrew formula in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk).
|
||||
|
||||
## Creating a Release
|
||||
|
||||
### 1. Ensure everything is ready
|
||||
|
||||
```bash
|
||||
# Run all quality checks
|
||||
make check
|
||||
|
||||
# Test GoReleaser configuration
|
||||
make goreleaser-check
|
||||
|
||||
# Test build process
|
||||
make goreleaser-snapshot
|
||||
```
|
||||
|
||||
### 2. Create and push a version tag
|
||||
|
||||
```bash
|
||||
# Create a new tag (replace x.y.z with actual version)
|
||||
git tag -a v1.0.0 -m "Release v1.0.0"
|
||||
|
||||
# Push the tag to trigger the release
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
### 3. Monitor the release
|
||||
|
||||
- GitHub Actions will automatically build and release when the tag is pushed
|
||||
- Check the [Actions tab](https://github.com/yarlson/lnk/actions) for build status
|
||||
- The release will appear in [GitHub Releases](https://github.com/yarlson/lnk/releases)
|
||||
- The Homebrew formula will be automatically updated in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk)
|
||||
|
||||
## What GoReleaser Does
|
||||
|
||||
1. **Builds binaries** for multiple platforms:
|
||||
|
||||
- Linux (amd64, arm64)
|
||||
- macOS (amd64, arm64)
|
||||
- Windows (amd64)
|
||||
|
||||
2. **Creates archives** with consistent naming:
|
||||
|
||||
- `lnk_Linux_x86_64.tar.gz`
|
||||
- `lnk_Darwin_arm64.tar.gz`
|
||||
- etc.
|
||||
|
||||
3. **Generates checksums** for verification
|
||||
|
||||
4. **Creates GitHub release** with:
|
||||
|
||||
- Automatic changelog from conventional commits
|
||||
- Installation instructions
|
||||
- Download links for all platforms
|
||||
|
||||
5. **Updates Homebrew formula** automatically in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap
|
||||
|
||||
## Manual Release (if needed)
|
||||
|
||||
If you need to create a release manually:
|
||||
|
||||
```bash
|
||||
# Export GitHub token
|
||||
export GITHUB_TOKEN="your_token_here"
|
||||
|
||||
# Create release (requires a git tag)
|
||||
goreleaser release --clean
|
||||
```
|
||||
|
||||
## Testing Releases Locally
|
||||
|
||||
```bash
|
||||
# Test the build process without releasing
|
||||
make goreleaser-snapshot
|
||||
|
||||
# Built artifacts will be in dist/
|
||||
ls -la dist/
|
||||
|
||||
# Test a binary
|
||||
./dist/lnk_<platform>/lnk --version
|
||||
```
|
||||
|
||||
## Installation Methods
|
||||
|
||||
After a release is published, users can install lnk using multiple methods:
|
||||
|
||||
### 1. Shell Script (Recommended)
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
||||
```
|
||||
|
||||
### 2. Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew tap yarlson/lnk
|
||||
brew install lnk
|
||||
```
|
||||
|
||||
### 3. Manual Download
|
||||
|
||||
```bash
|
||||
# Download from GitHub releases
|
||||
wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz
|
||||
tar -xzf lnk_Linux_x86_64.tar.gz
|
||||
sudo mv lnk /usr/local/bin/
|
||||
```
|
||||
|
||||
## Version Numbering
|
||||
|
||||
We use [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- `v1.0.0` - Major release (breaking changes)
|
||||
- `v1.1.0` - Minor release (new features, backward compatible)
|
||||
- `v1.1.1` - Patch release (bug fixes)
|
||||
|
||||
## Changelog
|
||||
|
||||
GoReleaser automatically generates changelogs from git commits using conventional commit format:
|
||||
|
||||
- `feat:` - New features
|
||||
- `fix:` - Bug fixes
|
||||
- `docs:` - Documentation changes (excluded from changelog)
|
||||
- `test:` - Test changes (excluded from changelog)
|
||||
- `ci:` - CI changes (excluded from changelog)
|
||||
|
||||
## Homebrew Tap
|
||||
|
||||
The Homebrew formula is automatically maintained in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) repository. When a new release is created:
|
||||
|
||||
1. GoReleaser automatically creates/updates the formula
|
||||
2. The formula is committed to the tap repository
|
||||
3. Users can immediately install the new version via `brew install yarlson/lnk/lnk`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Release failed to create
|
||||
|
||||
1. Check that the tag follows the format `vX.Y.Z`
|
||||
2. Ensure GitHub Actions has proper permissions
|
||||
3. Check the Actions log for detailed error messages
|
||||
|
||||
### Missing binaries in release
|
||||
|
||||
1. Verify GoReleaser configuration: `make goreleaser-check`
|
||||
2. Test build locally: `make goreleaser-snapshot`
|
||||
3. Check the build matrix in `.goreleaser.yml`
|
||||
|
||||
### Changelog is empty
|
||||
|
||||
1. Ensure commits follow conventional commit format
|
||||
2. Check that there are commits since the last tag
|
||||
3. Verify changelog configuration in `.goreleaser.yml`
|
||||
|
||||
### Homebrew formula not updated
|
||||
|
||||
1. Check that the GITHUB_TOKEN has access to the homebrew-lnk repository
|
||||
2. Verify the repository name and owner in `.goreleaser.yml`
|
||||
3. Check the release workflow logs for Homebrew-related errors
|
||||
4. Ensure the homebrew-lnk repository exists and is accessible
|
187
cmd/add.go
187
cmd/add.go
@@ -5,44 +5,189 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newAddCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <file>",
|
||||
Short: "✨ Add a file to lnk management",
|
||||
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
Use: "add <file>...",
|
||||
Short: "✨ Add files to lnk management",
|
||||
Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.
|
||||
|
||||
Examples:
|
||||
lnk add ~/.bashrc ~/.vimrc # Add multiple files at once
|
||||
lnk add --recursive ~/.config/nvim # Add directory contents individually
|
||||
lnk add --dry-run ~/.gitconfig # Preview what would be added
|
||||
lnk add --host work ~/.ssh/config # Add host-specific configuration
|
||||
|
||||
The --recursive flag processes directory contents individually instead of treating
|
||||
the directory as a single unit. This is useful for configuration directories where
|
||||
you want each file managed separately.
|
||||
|
||||
The --dry-run flag shows you exactly what files would be added without making any
|
||||
changes to your system - perfect for verification before bulk operations.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath := args[0]
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
recursive, _ := cmd.Flags().GetBool("recursive")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
lnk := core.NewLnk(core.WithHost(host))
|
||||
w := GetWriter(cmd)
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
// Handle dry-run mode
|
||||
if dryRun {
|
||||
files, err := lnk.PreviewAdd(args, recursive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Display preview output
|
||||
if recursive {
|
||||
w.Writeln(Message{Text: fmt.Sprintf("Would add %d files recursively:", len(files)), Emoji: "🔍", Bold: true})
|
||||
} else {
|
||||
w.Writeln(Message{Text: fmt.Sprintf("Would add %d files:", len(files)), Emoji: "🔍", Bold: true})
|
||||
}
|
||||
|
||||
// List files that would be added
|
||||
for _, file := range files {
|
||||
basename := filepath.Base(file)
|
||||
w.WriteString(" ").
|
||||
Writeln(Message{Text: basename, Emoji: "📄"})
|
||||
}
|
||||
|
||||
w.WritelnString("").
|
||||
Writeln(Info("To proceed: run without --dry-run flag"))
|
||||
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
w.WriteString(fmt.Sprintf("\r⏳ Processing %d/%d: %s", current, total, currentFile))
|
||||
}
|
||||
|
||||
if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear progress line and show completion
|
||||
w.WriteString("\r")
|
||||
|
||||
// Store processed file count for display
|
||||
args = previewFiles // Replace args with actual files for display
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
// Use appropriate method based on number of files
|
||||
if len(args) == 1 {
|
||||
// Single file - use existing Add method for backward compatibility
|
||||
if err := lnk.Add(args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Multiple files - use AddMultiple for atomic operation
|
||||
if err := lnk.AddMultiple(args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := lnk.Add(filePath); err != nil {
|
||||
return fmt.Errorf("failed to add file: %w", err)
|
||||
}
|
||||
// Display results
|
||||
if recursive {
|
||||
// Recursive mode - show enhanced message with count
|
||||
if host != "" {
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk (host: %s)", len(args), host)))
|
||||
} else {
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk", len(args))))
|
||||
}
|
||||
|
||||
basename := filepath.Base(filePath)
|
||||
if host != "" {
|
||||
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath)
|
||||
// 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 != "" {
|
||||
w.WriteString(" ").
|
||||
Write(Link(basename)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan))
|
||||
} else {
|
||||
w.WriteString(" ").
|
||||
Write(Link(basename)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored("~/.config/lnk/...", ColorCyan))
|
||||
}
|
||||
}
|
||||
|
||||
if len(args) > 5 {
|
||||
w.WriteString(" ").
|
||||
Writeln(Colored(fmt.Sprintf("... and %d more files", len(args)-5), ColorGray))
|
||||
}
|
||||
} else if len(args) == 1 {
|
||||
// Single file - maintain existing output format for backward compatibility
|
||||
filePath := args[0]
|
||||
basename := filepath.Base(filePath)
|
||||
if host != "" {
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk (host: %s)", basename, host)))
|
||||
w.WriteString(" ").
|
||||
Write(Link(filePath)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, filePath), ColorCyan))
|
||||
} else {
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk", basename)))
|
||||
w.WriteString(" ").
|
||||
Write(Link(filePath)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s", filePath), ColorCyan))
|
||||
}
|
||||
} else {
|
||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
|
||||
// Multiple files - show summary
|
||||
if host != "" {
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk (host: %s)", len(args), host)))
|
||||
} else {
|
||||
w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk", len(args))))
|
||||
}
|
||||
|
||||
// List each added file
|
||||
for _, filePath := range args {
|
||||
basename := filepath.Base(filePath)
|
||||
if host != "" {
|
||||
w.WriteString(" ").
|
||||
Write(Link(basename)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan))
|
||||
} else {
|
||||
w.WriteString(" ").
|
||||
Write(Link(basename)).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored("~/.config/lnk/...", ColorCyan))
|
||||
}
|
||||
}
|
||||
}
|
||||
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||
return nil
|
||||
|
||||
w.WriteString(" ").
|
||||
Write(Message{Text: "Use ", Emoji: "📝"}).
|
||||
Write(Bold("lnk push")).
|
||||
WritelnString(" to sync to remote")
|
||||
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
62
cmd/bootstrap.go
Normal file
62
cmd/bootstrap.go
Normal file
@@ -0,0 +1,62 @@
|
||||
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()
|
||||
w := GetWriter(cmd)
|
||||
|
||||
scriptPath, err := lnk.FindBootstrapScript()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if scriptPath == "" {
|
||||
w.Writeln(Info("No bootstrap script found")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Create a ", Emoji: "📝"}).
|
||||
Write(Bold("bootstrap.sh")).
|
||||
WritelnString(" file in your dotfiles repository:").
|
||||
WriteString(" ").
|
||||
Writeln(Colored("#!/bin/bash", ColorGray)).
|
||||
WriteString(" ").
|
||||
Writeln(Colored("echo \"Setting up environment...\"", ColorGray)).
|
||||
WriteString(" ").
|
||||
Writeln(Colored("# Your setup commands here", ColorGray))
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
w.Writeln(Rocket("Running bootstrap script")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Script: ", Emoji: "📄"}).
|
||||
Writeln(Colored(scriptPath, ColorCyan)).
|
||||
WritelnString("")
|
||||
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.WritelnString("").
|
||||
Writeln(Success("Bootstrap completed successfully!")).
|
||||
WriteString(" ").
|
||||
Writeln(Message{Text: "Your environment is ready to use", Emoji: "🎉"})
|
||||
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
}
|
135
cmd/init.go
135
cmd/init.go
@@ -1,45 +1,136 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "🎯 Initialize a new lnk repository",
|
||||
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
||||
SilenceUsage: true,
|
||||
Use: "init",
|
||||
Short: "🎯 Initialize a new lnk repository",
|
||||
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
remote, _ := cmd.Flags().GetString("remote")
|
||||
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
lnk := core.NewLnk()
|
||||
if err := lnk.InitWithRemote(remote); err != nil {
|
||||
return fmt.Errorf("failed to initialize lnk: %w", err)
|
||||
w := GetWriter(cmd)
|
||||
|
||||
// Show warning when force is used and there are managed files to overwrite
|
||||
if force && remote != "" && lnk.HasUserContent() {
|
||||
w.Writeln(Warning("Using --force flag: This will overwrite existing managed files")).
|
||||
WriteString(" ").
|
||||
Writeln(Info("Only use this if you understand the risks")).
|
||||
WritelnString("")
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := lnk.InitWithRemoteForce(remote, force); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if remote != "" {
|
||||
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
||||
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
||||
} else {
|
||||
printf(cmd, "🎯 \033[1mInitialized empty lnk repository\033[0m\n")
|
||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||
printf(cmd, " • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
|
||||
printf(cmd, " • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
|
||||
}
|
||||
w.Writeln(Target("Initialized lnk repository")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Cloned from: ", Emoji: "📦"}).
|
||||
Writeln(Colored(remote, ColorCyan)).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Location: ", Emoji: "📁"}).
|
||||
Writeln(Colored("~/.config/lnk", ColorGray))
|
||||
|
||||
return nil
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try to run bootstrap script if not disabled
|
||||
if !noBootstrap {
|
||||
w.WritelnString("").
|
||||
Writeln(Message{Text: "Looking for bootstrap script...", Emoji: "🔍", Bold: true})
|
||||
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scriptPath, err := lnk.FindBootstrapScript()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if scriptPath != "" {
|
||||
w.WriteString(" ").
|
||||
Write(Success("Found bootstrap script: ")).
|
||||
Writeln(Colored(scriptPath, ColorCyan)).
|
||||
WritelnString("").
|
||||
Writeln(Rocket("Running bootstrap script...")).
|
||||
WritelnString("")
|
||||
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||
w.WritelnString("").
|
||||
Writeln(Warning("Bootstrap script failed, but repository was initialized successfully")).
|
||||
WriteString(" ").
|
||||
Write(Info("You can run it manually with: ")).
|
||||
Writeln(Bold("lnk bootstrap")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Error: ", Emoji: "🔧"}).
|
||||
Writeln(Plain(err.Error()))
|
||||
} else {
|
||||
w.WritelnString("").
|
||||
Writeln(Success("Bootstrap completed successfully!"))
|
||||
}
|
||||
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
w.WriteString(" ").
|
||||
Writeln(Info("No bootstrap script found"))
|
||||
if err := w.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WritelnString("").
|
||||
Writeln(Info("Next steps:")).
|
||||
WriteString(" • Run ").
|
||||
Write(Bold("lnk pull")).
|
||||
Writeln(Plain(" to restore symlinks")).
|
||||
WriteString(" • Use ").
|
||||
Write(Bold("lnk add <file>")).
|
||||
Writeln(Plain(" to manage new files"))
|
||||
|
||||
return w.Err()
|
||||
} else {
|
||||
w.Writeln(Target("Initialized empty lnk repository")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Location: ", Emoji: "📁"}).
|
||||
Writeln(Colored("~/.config/lnk", ColorGray)).
|
||||
WritelnString("").
|
||||
Writeln(Info("Next steps:")).
|
||||
WriteString(" • Run ").
|
||||
Write(Bold("lnk add <file>")).
|
||||
Writeln(Plain(" to start managing dotfiles")).
|
||||
WriteString(" • Add a remote with: ").
|
||||
Writeln(Bold("git remote add origin <url>"))
|
||||
|
||||
return w.Err()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
131
cmd/list.go
131
cmd/list.go
@@ -7,15 +7,17 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "📋 List files managed by lnk",
|
||||
Long: "Display all files and directories currently managed by lnk.",
|
||||
SilenceUsage: true,
|
||||
Use: "list",
|
||||
Short: "📋 List files managed by lnk",
|
||||
Long: "Display all files and directories currently managed by lnk.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
all, _ := cmd.Flags().GetBool("all")
|
||||
@@ -42,115 +44,156 @@ func newListCmd() *cobra.Command {
|
||||
|
||||
func listCommonConfig(cmd *cobra.Command) error {
|
||||
lnk := core.NewLnk()
|
||||
w := GetWriter(cmd)
|
||||
|
||||
managedItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list managed items: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(managedItems) == 0 {
|
||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n")
|
||||
printf(cmd, " 💡 Use \033[1mlnk add <file>\033[0m to start managing files\n")
|
||||
return nil
|
||||
w.Writeln(Message{Text: "No files currently managed by lnk (common)", Emoji: "📋", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold("lnk add <file>")).
|
||||
WritelnString(" to start managing files")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
|
||||
countText := fmt.Sprintf("Files managed by lnk (common) (%d item", len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
countText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m):\n\n")
|
||||
countText += "):"
|
||||
|
||||
w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}).
|
||||
WritelnString("")
|
||||
|
||||
for _, item := range managedItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
w.WriteString(" ").
|
||||
Writeln(Link(item))
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
return nil
|
||||
w.WritelnString("").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold("lnk status")).
|
||||
WritelnString(" to check sync status")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||
lnk := core.NewLnkWithHost(host)
|
||||
lnk := core.NewLnk(core.WithHost(host))
|
||||
w := GetWriter(cmd)
|
||||
|
||||
managedItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list managed items for host %s: %w", host, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(managedItems) == 0 {
|
||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host)
|
||||
printf(cmd, " 💡 Use \033[1mlnk add --host %s <file>\033[0m to start managing files\n", host)
|
||||
return nil
|
||||
w.Writeln(Message{Text: fmt.Sprintf("No files currently managed by lnk (host: %s)", host), Emoji: "📋", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold(fmt.Sprintf("lnk add --host %s <file>", host))).
|
||||
WritelnString(" to start managing files")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
|
||||
countText := fmt.Sprintf("Files managed by lnk (host: %s) (%d item", host, len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
countText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m):\n\n")
|
||||
countText += "):"
|
||||
|
||||
w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}).
|
||||
WritelnString("")
|
||||
|
||||
for _, item := range managedItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
w.WriteString(" ").
|
||||
Writeln(Link(item))
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
return nil
|
||||
w.WritelnString("").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold("lnk status")).
|
||||
WritelnString(" to check sync status")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
func listAllConfigs(cmd *cobra.Command) error {
|
||||
w := GetWriter(cmd)
|
||||
|
||||
// List common configuration
|
||||
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
||||
w.Writeln(Message{Text: "All configurations managed by lnk", Emoji: "📋", Bold: true}).
|
||||
WritelnString("")
|
||||
|
||||
lnk := core.NewLnk()
|
||||
commonItems, err := lnk.List()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list common managed items: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
|
||||
countText := fmt.Sprintf("Common configuration (%d item", len(commonItems))
|
||||
if len(commonItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
countText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
countText += "):"
|
||||
|
||||
w.Writeln(Message{Text: countText, Emoji: "🌐", Bold: true})
|
||||
|
||||
if len(commonItems) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
w.WriteString(" ").
|
||||
Writeln(Colored("(no files)", ColorGray))
|
||||
} else {
|
||||
for _, item := range commonItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
w.WriteString(" ").
|
||||
Writeln(Link(item))
|
||||
}
|
||||
}
|
||||
|
||||
// Find all host-specific configurations
|
||||
hosts, err := findHostConfigs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find host configurations: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||
w.WritelnString("").
|
||||
Write(Message{Text: fmt.Sprintf("Host: %s", host), Emoji: "🖥️", Bold: true})
|
||||
|
||||
hostLnk := core.NewLnkWithHost(host)
|
||||
hostLnk := core.NewLnk(core.WithHost(host))
|
||||
hostItems, err := hostLnk.List()
|
||||
if err != nil {
|
||||
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
||||
w.WriteString(" ").
|
||||
Writeln(Colored(fmt.Sprintf("(error: %v)", err), ColorRed))
|
||||
continue
|
||||
}
|
||||
|
||||
printf(cmd, " (\033[36m%d item", len(hostItems))
|
||||
countText := fmt.Sprintf(" (%d item", len(hostItems))
|
||||
if len(hostItems) > 1 {
|
||||
printf(cmd, "s")
|
||||
countText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
countText += "):"
|
||||
|
||||
w.WriteString(countText).
|
||||
WritelnString("")
|
||||
|
||||
if len(hostItems) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
w.WriteString(" ").
|
||||
Writeln(Colored("(no files)", ColorGray))
|
||||
} else {
|
||||
for _, item := range hostItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
w.WriteString(" ").
|
||||
Writeln(Link(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
|
||||
return nil
|
||||
w.WritelnString("").
|
||||
Write(Info("Use ")).
|
||||
Write(Bold("lnk list --host <hostname>")).
|
||||
WritelnString(" to see specific host configuration")
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
func findHostConfigs() ([]string, error) {
|
||||
@@ -163,7 +206,7 @@ func findHostConfigs() ([]string, error) {
|
||||
|
||||
entries, err := os.ReadDir(repoPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read repository directory: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var hosts []string
|
||||
|
230
cmd/output.go
Normal file
230
cmd/output.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// OutputConfig controls formatting behavior
|
||||
type OutputConfig struct {
|
||||
Colors bool
|
||||
Emoji bool
|
||||
}
|
||||
|
||||
// Writer provides formatted output with configurable styling
|
||||
type Writer struct {
|
||||
out io.Writer
|
||||
config OutputConfig
|
||||
err error // first error encountered
|
||||
}
|
||||
|
||||
// NewWriter creates a new Writer with the given configuration
|
||||
func NewWriter(out io.Writer, config OutputConfig) *Writer {
|
||||
return &Writer{
|
||||
out: out,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Message represents a structured message with optional formatting
|
||||
type Message struct {
|
||||
Text string
|
||||
Color string
|
||||
Emoji string
|
||||
Bold bool
|
||||
}
|
||||
|
||||
// Write outputs a message according to the writer's configuration
|
||||
func (w *Writer) Write(msg Message) *Writer {
|
||||
if w.err != nil {
|
||||
return w
|
||||
}
|
||||
|
||||
var output string
|
||||
|
||||
// Add emoji if enabled
|
||||
if w.config.Emoji && msg.Emoji != "" {
|
||||
output = msg.Emoji + " "
|
||||
}
|
||||
|
||||
// Add color/bold if enabled
|
||||
if w.config.Colors {
|
||||
if msg.Bold {
|
||||
output += "\033[1m"
|
||||
}
|
||||
if msg.Color != "" {
|
||||
output += msg.Color
|
||||
}
|
||||
}
|
||||
|
||||
output += msg.Text
|
||||
|
||||
// Close formatting if enabled
|
||||
if w.config.Colors && (msg.Bold || msg.Color != "") {
|
||||
output += "\033[0m"
|
||||
}
|
||||
|
||||
_, w.err = fmt.Fprint(w.out, output)
|
||||
return w
|
||||
}
|
||||
|
||||
// Printf is like Write but with format string
|
||||
func (w *Writer) Printf(msg Message, args ...any) *Writer {
|
||||
newMsg := msg
|
||||
newMsg.Text = fmt.Sprintf(msg.Text, args...)
|
||||
return w.Write(newMsg)
|
||||
}
|
||||
|
||||
// Writeln writes a message followed by a newline
|
||||
func (w *Writer) Writeln(msg Message) *Writer {
|
||||
return w.Write(msg).WriteString("\n")
|
||||
}
|
||||
|
||||
// WriteString outputs plain text (no formatting)
|
||||
func (w *Writer) WriteString(text string) *Writer {
|
||||
if w.err != nil {
|
||||
return w
|
||||
}
|
||||
_, w.err = fmt.Fprint(w.out, text)
|
||||
return w
|
||||
}
|
||||
|
||||
// WritelnString outputs plain text followed by a newline
|
||||
func (w *Writer) WritelnString(text string) *Writer {
|
||||
if w.err != nil {
|
||||
return w
|
||||
}
|
||||
|
||||
_, w.err = fmt.Fprintln(w.out, text)
|
||||
return w
|
||||
}
|
||||
|
||||
// ANSI color codes
|
||||
const (
|
||||
ColorRed = "\033[31m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorGray = "\033[90m"
|
||||
ColorBrightGreen = "\033[1;32m"
|
||||
ColorBrightYellow = "\033[1;33m"
|
||||
ColorBrightRed = "\033[1;31m"
|
||||
)
|
||||
|
||||
// Predefined message constructors for common patterns
|
||||
|
||||
func Success(text string) Message {
|
||||
return Message{Text: text, Color: ColorBrightGreen, Emoji: "✅", Bold: true}
|
||||
}
|
||||
|
||||
func Error(text string) Message {
|
||||
return Message{Text: text, Emoji: "❌"}
|
||||
}
|
||||
|
||||
func Warning(text string) Message {
|
||||
return Message{Text: text, Color: ColorBrightYellow, Emoji: "⚠️", Bold: true}
|
||||
}
|
||||
|
||||
func Info(text string) Message {
|
||||
return Message{Text: text, Color: ColorYellow, Emoji: "💡"}
|
||||
}
|
||||
|
||||
func Target(text string) Message {
|
||||
return Message{Text: text, Emoji: "🎯", Bold: true}
|
||||
}
|
||||
|
||||
func Rocket(text string) Message {
|
||||
return Message{Text: text, Emoji: "🚀", Bold: true}
|
||||
}
|
||||
|
||||
func Sparkles(text string) Message {
|
||||
return Message{Text: text, Emoji: "✨", Bold: true}
|
||||
}
|
||||
|
||||
func Link(text string) Message {
|
||||
return Message{Text: text, Color: ColorCyan, Emoji: "🔗"}
|
||||
}
|
||||
|
||||
func Plain(text string) Message {
|
||||
return Message{Text: text}
|
||||
}
|
||||
|
||||
func Bold(text string) Message {
|
||||
return Message{Text: text, Bold: true}
|
||||
}
|
||||
|
||||
func Colored(text, color string) Message {
|
||||
return Message{Text: text, Color: color}
|
||||
}
|
||||
|
||||
// Global output configuration
|
||||
var (
|
||||
globalConfig = OutputConfig{
|
||||
Colors: true, // auto-detect on first use
|
||||
Emoji: true,
|
||||
}
|
||||
autoDetected bool
|
||||
)
|
||||
|
||||
// SetGlobalConfig updates the global output configuration
|
||||
func SetGlobalConfig(colors string, emoji bool) error {
|
||||
switch colors {
|
||||
case "auto":
|
||||
globalConfig.Colors = isTerminal()
|
||||
case "always":
|
||||
globalConfig.Colors = true
|
||||
case "never":
|
||||
globalConfig.Colors = false
|
||||
default:
|
||||
return fmt.Errorf("invalid color mode: %s (valid: auto, always, never)", colors)
|
||||
}
|
||||
|
||||
// Check NO_COLOR environment variable (explicit flag takes precedence)
|
||||
if os.Getenv("NO_COLOR") != "" && colors == "auto" {
|
||||
globalConfig.Colors = false
|
||||
}
|
||||
|
||||
globalConfig.Emoji = emoji
|
||||
autoDetected = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTerminal checks if stdout is a terminal
|
||||
func isTerminal() bool {
|
||||
fileInfo, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fileInfo.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// autoDetectConfig performs one-time auto-detection if not explicitly configured
|
||||
func autoDetectConfig() {
|
||||
if !autoDetected {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
globalConfig.Colors = false
|
||||
} else {
|
||||
globalConfig.Colors = isTerminal()
|
||||
}
|
||||
autoDetected = true
|
||||
}
|
||||
}
|
||||
|
||||
// GetWriter returns a writer for the given cobra command
|
||||
func GetWriter(cmd *cobra.Command) *Writer {
|
||||
autoDetectConfig()
|
||||
return NewWriter(cmd.OutOrStdout(), globalConfig)
|
||||
}
|
||||
|
||||
// GetErrorWriter returns a writer for stderr
|
||||
func GetErrorWriter() *Writer {
|
||||
autoDetectConfig()
|
||||
return NewWriter(os.Stderr, globalConfig)
|
||||
}
|
||||
|
||||
// Err returns the first error encountered during writing
|
||||
func (w *Writer) Err() error {
|
||||
return w.err
|
||||
}
|
271
cmd/output_test.go
Normal file
271
cmd/output_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func TestOutputConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
colors string
|
||||
emoji bool
|
||||
expectError bool
|
||||
expectedColors bool
|
||||
expectedEmoji bool
|
||||
}{
|
||||
{
|
||||
name: "auto mode",
|
||||
colors: "auto",
|
||||
emoji: true,
|
||||
expectError: false,
|
||||
expectedColors: false, // TTY detection will return false in tests
|
||||
expectedEmoji: true,
|
||||
},
|
||||
{
|
||||
name: "always mode",
|
||||
colors: "always",
|
||||
emoji: false,
|
||||
expectError: false,
|
||||
expectedColors: true,
|
||||
expectedEmoji: false,
|
||||
},
|
||||
{
|
||||
name: "never mode",
|
||||
colors: "never",
|
||||
emoji: true,
|
||||
expectError: false,
|
||||
expectedColors: false,
|
||||
expectedEmoji: true,
|
||||
},
|
||||
{
|
||||
name: "invalid mode",
|
||||
colors: "invalid",
|
||||
emoji: true,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clear NO_COLOR for consistent testing
|
||||
_ = os.Unsetenv("NO_COLOR")
|
||||
|
||||
err := SetGlobalConfig(tt.colors, tt.emoji)
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
}
|
||||
if !tt.expectError && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !tt.expectError {
|
||||
if globalConfig.Colors != tt.expectedColors {
|
||||
t.Errorf("expected colors %v, got %v", tt.expectedColors, globalConfig.Colors)
|
||||
}
|
||||
if globalConfig.Emoji != tt.expectedEmoji {
|
||||
t.Errorf("expected emoji %v, got %v", tt.expectedEmoji, globalConfig.Emoji)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNOCOLOREnvironmentVariable(t *testing.T) {
|
||||
// Test NO_COLOR environment variable with auto mode
|
||||
_ = os.Setenv("NO_COLOR", "1")
|
||||
defer func() { _ = os.Unsetenv("NO_COLOR") }()
|
||||
|
||||
err := SetGlobalConfig("auto", true)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if globalConfig.Colors != false {
|
||||
t.Errorf("expected colors disabled when NO_COLOR is set, got %v", globalConfig.Colors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config OutputConfig
|
||||
message Message
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
name: "full formatting",
|
||||
config: OutputConfig{Colors: true, Emoji: true},
|
||||
message: Message{
|
||||
Text: "test message",
|
||||
Color: ColorRed,
|
||||
Emoji: "✅",
|
||||
Bold: true,
|
||||
},
|
||||
expectedOutput: "✅ \033[1m\033[31mtest message\033[0m",
|
||||
},
|
||||
{
|
||||
name: "colors only",
|
||||
config: OutputConfig{Colors: true, Emoji: false},
|
||||
message: Message{
|
||||
Text: "test message",
|
||||
Color: ColorRed,
|
||||
Emoji: "✅",
|
||||
Bold: true,
|
||||
},
|
||||
expectedOutput: "\033[1m\033[31mtest message\033[0m",
|
||||
},
|
||||
{
|
||||
name: "emoji only",
|
||||
config: OutputConfig{Colors: false, Emoji: true},
|
||||
message: Message{
|
||||
Text: "test message",
|
||||
Color: ColorRed,
|
||||
Emoji: "✅",
|
||||
Bold: true,
|
||||
},
|
||||
expectedOutput: "✅ test message",
|
||||
},
|
||||
{
|
||||
name: "no formatting",
|
||||
config: OutputConfig{Colors: false, Emoji: false},
|
||||
message: Message{
|
||||
Text: "test message",
|
||||
Color: ColorRed,
|
||||
Emoji: "✅",
|
||||
Bold: true,
|
||||
},
|
||||
expectedOutput: "test message",
|
||||
},
|
||||
{
|
||||
name: "plain message",
|
||||
config: OutputConfig{Colors: true, Emoji: true},
|
||||
message: Plain("plain text"),
|
||||
expectedOutput: "plain text",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&buf, tt.config)
|
||||
|
||||
writer.Write(tt.message)
|
||||
if err := writer.Err(); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if buf.String() != tt.expectedOutput {
|
||||
t.Errorf("expected %q, got %q", tt.expectedOutput, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredefinedMessages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
creator func(string) Message
|
||||
text string
|
||||
}{
|
||||
{"Success", Success, "operation succeeded"},
|
||||
{"Error", Error, "something failed"},
|
||||
{"Warning", Warning, "be careful"},
|
||||
{"Info", Info, "useful information"},
|
||||
{"Target", Target, "target reached"},
|
||||
{"Rocket", Rocket, "launching"},
|
||||
{"Sparkles", Sparkles, "amazing"},
|
||||
{"Link", Link, "connected"},
|
||||
{"Plain", Plain, "no formatting"},
|
||||
{"Bold", Bold, "emphasis"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := NewWriter(&buf, OutputConfig{Colors: true, Emoji: true})
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf.Reset()
|
||||
msg := tt.creator(tt.text)
|
||||
|
||||
writer.Write(msg)
|
||||
if err := writer.Err(); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, tt.text) {
|
||||
t.Errorf("output should contain text %q, got %q", tt.text, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *core.LnkError
|
||||
config OutputConfig
|
||||
contains []string
|
||||
notContains []string
|
||||
}{
|
||||
{
|
||||
name: "structured error with full formatting",
|
||||
err: &core.LnkError{
|
||||
Message: "Something went wrong",
|
||||
Suggestion: "Try this instead",
|
||||
Path: "/some/path",
|
||||
ErrorType: "test_error",
|
||||
},
|
||||
config: OutputConfig{Colors: true, Emoji: true},
|
||||
contains: []string{"❌", "Something went wrong", "/some/path", "💡", "Try this instead"},
|
||||
},
|
||||
{
|
||||
name: "structured error without emojis",
|
||||
err: &core.LnkError{
|
||||
Message: "Something went wrong",
|
||||
Suggestion: "Try this instead",
|
||||
Path: "/some/path",
|
||||
ErrorType: "test_error",
|
||||
},
|
||||
config: OutputConfig{Colors: true, Emoji: false},
|
||||
contains: []string{"Something went wrong", "/some/path", "Try this instead"},
|
||||
notContains: []string{"❌", "💡"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := NewWriter(&buf, tt.config)
|
||||
|
||||
// Test the component messages directly
|
||||
_ = w.Write(Error(tt.err.Message))
|
||||
if tt.err.Path != "" {
|
||||
_ = w.WriteString("\n ")
|
||||
_ = w.Write(Colored(tt.err.Path, ColorRed))
|
||||
}
|
||||
if tt.err.Suggestion != "" {
|
||||
_ = w.WriteString("\n ")
|
||||
_ = w.Write(Info(tt.err.Suggestion))
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
for _, expected := range tt.contains {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("output should contain %q, got %q", expected, output)
|
||||
}
|
||||
}
|
||||
for _, notExpected := range tt.notContains {
|
||||
if strings.Contains(output, notExpected) {
|
||||
t.Errorf("output should not contain %q, got %q", notExpected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
61
cmd/pull.go
61
cmd/pull.go
@@ -4,56 +4,69 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newPullCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "pull",
|
||||
Short: "⬇️ Pull changes from remote and restore symlinks",
|
||||
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
||||
SilenceUsage: true,
|
||||
Use: "pull",
|
||||
Short: "⬇️ Pull changes from remote and restore symlinks",
|
||||
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
}
|
||||
lnk := core.NewLnk(core.WithHost(host))
|
||||
w := GetWriter(cmd)
|
||||
|
||||
restored, err := lnk.Pull()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull changes: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(restored) > 0 {
|
||||
var successMsg string
|
||||
if host != "" {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||
successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host)
|
||||
} else {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
successMsg = "Successfully pulled changes"
|
||||
}
|
||||
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
||||
|
||||
symlinkText := fmt.Sprintf("Restored %d symlink", len(restored))
|
||||
if len(restored) > 1 {
|
||||
printf(cmd, "s")
|
||||
symlinkText += "s"
|
||||
}
|
||||
printf(cmd, "\033[0m:\n")
|
||||
symlinkText += ":"
|
||||
|
||||
w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}).
|
||||
WriteString(" ").
|
||||
Writeln(Link(symlinkText))
|
||||
|
||||
for _, file := range restored {
|
||||
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
|
||||
w.WriteString(" ").
|
||||
Writeln(Sparkles(file))
|
||||
}
|
||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Writeln(Message{Text: "Your dotfiles are synced and ready!", Emoji: "🎉"})
|
||||
} else {
|
||||
var successMsg string
|
||||
if host != "" {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||
successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host)
|
||||
} else {
|
||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||
successMsg = "Successfully pulled changes"
|
||||
}
|
||||
printf(cmd, " ✅ All symlinks already in place\n")
|
||||
printf(cmd, " 🎉 Everything is up to date!\n")
|
||||
|
||||
w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}).
|
||||
WriteString(" ").
|
||||
Writeln(Success("All symlinks already in place")).
|
||||
WriteString(" ").
|
||||
Writeln(Message{Text: "Everything is up to date!", Emoji: "🎉"})
|
||||
}
|
||||
|
||||
return nil
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
|
||||
|
33
cmd/push.go
33
cmd/push.go
@@ -1,19 +1,19 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newPushCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "push [message]",
|
||||
Short: "🚀 Push local changes to remote repository",
|
||||
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
SilenceUsage: true,
|
||||
Use: "push [message]",
|
||||
Short: "🚀 Push local changes to remote repository",
|
||||
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
message := "lnk: sync configuration files"
|
||||
if len(args) > 0 {
|
||||
@@ -21,15 +21,22 @@ func newPushCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
lnk := core.NewLnk()
|
||||
w := GetWriter(cmd)
|
||||
|
||||
if err := lnk.Push(message); err != nil {
|
||||
return fmt.Errorf("failed to push changes: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
||||
printf(cmd, " 💾 Commit: \033[90m%s\033[0m\n", message)
|
||||
printf(cmd, " 📡 Synced to remote\n")
|
||||
printf(cmd, " ✨ Your dotfiles are up to date!\n")
|
||||
return nil
|
||||
w.Writeln(Rocket("Successfully pushed changes")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Commit: ", Emoji: "💾"}).
|
||||
Writeln(Colored(message, ColorGray)).
|
||||
WriteString(" ").
|
||||
Writeln(Message{Text: "Synced to remote", Emoji: "📡"}).
|
||||
WriteString(" ").
|
||||
Writeln(Sparkles("Your dotfiles are up to date!"))
|
||||
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
44
cmd/rm.go
44
cmd/rm.go
@@ -5,41 +5,47 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newRemoveCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rm <file>",
|
||||
Short: "🗑️ Remove a file from lnk management",
|
||||
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
Use: "rm <file>",
|
||||
Short: "🗑️ Remove a file from lnk management",
|
||||
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath := args[0]
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
}
|
||||
lnk := core.NewLnk(core.WithHost(host))
|
||||
w := GetWriter(cmd)
|
||||
|
||||
if err := lnk.Remove(filePath); err != nil {
|
||||
return fmt.Errorf("failed to remove file: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
basename := filepath.Base(filePath)
|
||||
if host != "" {
|
||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host)
|
||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath)
|
||||
w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk (host: %s)", basename, host), Emoji: "🗑️", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, basename), Emoji: "↩️"}).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(filePath, ColorCyan))
|
||||
} else {
|
||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
|
||||
w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk", basename), Emoji: "🗑️", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s", basename), Emoji: "↩️"}).
|
||||
WriteString(" → ").
|
||||
Writeln(Colored(filePath, ColorCyan))
|
||||
}
|
||||
printf(cmd, " 📄 Original file restored\n")
|
||||
return nil
|
||||
|
||||
w.WriteString(" ").
|
||||
Writeln(Message{Text: "Original file restored", Emoji: "📄"})
|
||||
|
||||
return w.Err()
|
||||
},
|
||||
}
|
||||
|
||||
|
154
cmd/root.go
154
cmd/root.go
@@ -1,10 +1,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
"github.com/yarlson/lnk/internal/fs"
|
||||
"github.com/yarlson/lnk/internal/git"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -14,28 +19,63 @@ var (
|
||||
|
||||
// NewRootCommand creates a new root command (testable)
|
||||
func NewRootCommand() *cobra.Command {
|
||||
var (
|
||||
colors string
|
||||
emoji bool
|
||||
noEmoji bool
|
||||
)
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lnk",
|
||||
Short: "🔗 Dotfiles, linked. No fluff.",
|
||||
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
||||
|
||||
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
||||
Supports both common configurations and host-specific setups.
|
||||
Supports both common configurations, host-specific setups, and bulk operations for multiple files.
|
||||
|
||||
✨ Examples:
|
||||
lnk init # Fresh start
|
||||
lnk init -r <repo-url> # Clone existing dotfiles
|
||||
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
||||
lnk add --host work ~/.ssh/config # Manage host-specific files
|
||||
lnk list --all # Show all configurations
|
||||
lnk pull --host work # Pull host-specific changes
|
||||
lnk push "setup complete" # Sync to remote
|
||||
lnk init # Fresh start
|
||||
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
|
||||
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
||||
lnk add --recursive ~/.config/nvim # Add directory contents individually
|
||||
lnk add --dry-run ~/.gitconfig # Preview changes without applying
|
||||
lnk add --host work ~/.ssh/config # Manage host-specific files
|
||||
lnk list --all # Show all configurations
|
||||
lnk pull --host work # Pull host-specific changes
|
||||
lnk push "setup complete" # Sync to remote
|
||||
lnk bootstrap # Run bootstrap script manually
|
||||
|
||||
🚀 Bootstrap Support:
|
||||
Automatically runs bootstrap.sh when cloning a repository.
|
||||
Use --no-bootstrap to disable.
|
||||
|
||||
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||
SilenceUsage: true,
|
||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Handle emoji flag logic
|
||||
emojiEnabled := emoji
|
||||
if noEmoji {
|
||||
emojiEnabled = false
|
||||
}
|
||||
err := SetGlobalConfig(colors, emojiEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Add global flags for output formatting
|
||||
rootCmd.PersistentFlags().StringVar(&colors, "colors", "auto", "when to use colors (auto, always, never)")
|
||||
rootCmd.PersistentFlags().BoolVar(&emoji, "emoji", true, "enable emoji in output")
|
||||
rootCmd.PersistentFlags().BoolVar(&noEmoji, "no-emoji", false, "disable emoji in output")
|
||||
|
||||
// Mark emoji flags as mutually exclusive
|
||||
rootCmd.MarkFlagsMutuallyExclusive("emoji", "no-emoji")
|
||||
|
||||
// Add subcommands
|
||||
rootCmd.AddCommand(newInitCmd())
|
||||
rootCmd.AddCommand(newAddCmd())
|
||||
@@ -44,6 +84,7 @@ Supports both common configurations and host-specific setups.
|
||||
rootCmd.AddCommand(newStatusCmd())
|
||||
rootCmd.AddCommand(newPushCmd())
|
||||
rootCmd.AddCommand(newPullCmd())
|
||||
rootCmd.AddCommand(newBootstrapCmd())
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
@@ -57,7 +98,98 @@ func SetVersion(v, bt string) {
|
||||
func Execute() {
|
||||
rootCmd := NewRootCommand()
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
DisplayError(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// DisplayError formats and displays an error with appropriate styling
|
||||
func DisplayError(err error) {
|
||||
w := GetErrorWriter()
|
||||
|
||||
// Handle structured errors from core package
|
||||
var lnkErr *core.LnkError
|
||||
if errors.As(err, &lnkErr) {
|
||||
w.Write(Error(lnkErr.Message))
|
||||
if lnkErr.Path != "" {
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Colored(lnkErr.Path, ColorRed))
|
||||
}
|
||||
if lnkErr.Suggestion != "" {
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Info(lnkErr.Suggestion))
|
||||
}
|
||||
w.WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle structured errors from fs package
|
||||
var pathErr fs.ErrorWithPath
|
||||
if errors.As(err, &pathErr) {
|
||||
w.Write(Error(err.Error()))
|
||||
if pathErr.GetPath() != "" {
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Colored(pathErr.GetPath(), ColorRed))
|
||||
}
|
||||
var suggErr fs.ErrorWithSuggestion
|
||||
if errors.As(err, &suggErr) {
|
||||
w.WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Info(suggErr.GetSuggestion()))
|
||||
}
|
||||
w.WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle fs errors that only have suggestions
|
||||
var suggErr fs.ErrorWithSuggestion
|
||||
if errors.As(err, &suggErr) {
|
||||
w.Write(Error(err.Error())).
|
||||
WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Info(suggErr.GetSuggestion())).
|
||||
WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle git errors with paths
|
||||
var gitPathErr git.ErrorWithPath
|
||||
if errors.As(err, &gitPathErr) {
|
||||
w.Write(Error(err.Error())).
|
||||
WritelnString("").
|
||||
WriteString(" ").
|
||||
Write(Colored(gitPathErr.GetPath(), ColorRed)).
|
||||
WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle git errors with remotes
|
||||
var gitRemoteErr git.ErrorWithRemote
|
||||
if errors.As(err, &gitRemoteErr) {
|
||||
w.Write(Error(err.Error())).
|
||||
WritelnString("").
|
||||
WriteString(" Remote: ").
|
||||
Write(Colored(gitRemoteErr.GetRemote(), ColorCyan)).
|
||||
WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle git errors with reasons
|
||||
var gitReasonErr git.ErrorWithReason
|
||||
if errors.As(err, &gitReasonErr) {
|
||||
w.Write(Error(err.Error()))
|
||||
if gitReasonErr.GetReason() != "" {
|
||||
w.WritelnString("").
|
||||
WriteString(" Reason: ").
|
||||
Write(Colored(gitReasonErr.GetReason(), ColorYellow))
|
||||
}
|
||||
w.WritelnString("")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle generic errors
|
||||
w.Writeln(Error(err.Error()))
|
||||
}
|
||||
|
1239
cmd/root_test.go
1239
cmd/root_test.go
File diff suppressed because it is too large
Load Diff
@@ -4,20 +4,22 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
)
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "📊 Show repository sync status",
|
||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||
SilenceUsage: true,
|
||||
Use: "status",
|
||||
Short: "📊 Show repository sync status",
|
||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
lnk := core.NewLnk()
|
||||
status, err := lnk.Status()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get status: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if status.Dirty {
|
||||
@@ -37,51 +39,93 @@ func newStatusCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||
w := GetWriter(cmd)
|
||||
|
||||
w.Writeln(Warning("Repository has uncommitted changes")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Remote: ", Emoji: "📡"}).
|
||||
Writeln(Colored(status.Remote, ColorCyan))
|
||||
|
||||
if status.Ahead == 0 && status.Behind == 0 {
|
||||
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||
w.WritelnString("").
|
||||
Write(Info("Run ")).
|
||||
Write(Bold("git add && git commit")).
|
||||
WriteString(" in ").
|
||||
Write(Colored("~/.config/lnk", ColorCyan)).
|
||||
WriteString(" or ").
|
||||
Write(Bold("lnk push")).
|
||||
WritelnString(" to commit changes")
|
||||
return
|
||||
}
|
||||
|
||||
printf(cmd, "\n")
|
||||
w.WritelnString("")
|
||||
displayAheadBehindInfo(cmd, status, true)
|
||||
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||
w.WritelnString("").
|
||||
Write(Info("Run ")).
|
||||
Write(Bold("git add && git commit")).
|
||||
WriteString(" in ").
|
||||
Write(Colored("~/.config/lnk", ColorCyan)).
|
||||
WriteString(" or ").
|
||||
Write(Bold("lnk push")).
|
||||
WritelnString(" to commit changes")
|
||||
}
|
||||
|
||||
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
||||
w := GetWriter(cmd)
|
||||
|
||||
w.Writeln(Success("Repository is up to date")).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Synced with ", Emoji: "📡"}).
|
||||
Writeln(Colored(status.Remote, ColorCyan))
|
||||
}
|
||||
|
||||
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||
printf(cmd, "\n")
|
||||
w := GetWriter(cmd)
|
||||
|
||||
w.Writeln(Message{Text: "Repository Status", Emoji: "📊", Bold: true}).
|
||||
WriteString(" ").
|
||||
Write(Message{Text: "Remote: ", Emoji: "📡"}).
|
||||
Writeln(Colored(status.Remote, ColorCyan)).
|
||||
WritelnString("")
|
||||
|
||||
displayAheadBehindInfo(cmd, status, false)
|
||||
|
||||
if status.Ahead > 0 && status.Behind == 0 {
|
||||
printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes\n")
|
||||
w.WritelnString("").
|
||||
Write(Info("Run ")).
|
||||
Write(Bold("lnk push")).
|
||||
WritelnString(" to sync your changes")
|
||||
} else if status.Behind > 0 {
|
||||
printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes\n")
|
||||
w.WritelnString("").
|
||||
Write(Info("Run ")).
|
||||
Write(Bold("lnk pull")).
|
||||
WritelnString(" to get latest changes")
|
||||
}
|
||||
}
|
||||
|
||||
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
||||
w := GetWriter(cmd)
|
||||
|
||||
if status.Ahead > 0 {
|
||||
commitText := getCommitText(status.Ahead)
|
||||
if isDirty {
|
||||
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m (excluding uncommitted changes)\n", status.Ahead, commitText)
|
||||
w.WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}).
|
||||
WritelnString(" (excluding uncommitted changes)")
|
||||
} else {
|
||||
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
||||
w.WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}).
|
||||
WritelnString(" - ready to push")
|
||||
}
|
||||
}
|
||||
|
||||
if status.Behind > 0 {
|
||||
commitText := getCommitText(status.Behind)
|
||||
printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
|
||||
w.WriteString(" ").
|
||||
Write(Message{Text: fmt.Sprintf("%d %s behind", status.Behind, commitText), Emoji: "⬇️", Color: ColorBrightRed, Bold: true}).
|
||||
WriteString(" - run ").
|
||||
Write(Bold("lnk pull")).
|
||||
WritelnString("")
|
||||
}
|
||||
}
|
||||
|
||||
|
12
cmd/utils.go
12
cmd/utils.go
@@ -1,12 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// printf is a helper function to simplify output formatting in commands
|
||||
func printf(cmd *cobra.Command, format string, args ...interface{}) {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...)
|
||||
}
|
4
go.mod
4
go.mod
@@ -4,13 +4,13 @@ go 1.24
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/stretchr/testify v1.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
7
go.sum
7
go.sum
@@ -8,10 +8,11 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
|
||||
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
42
install.sh
42
install.sh
@@ -18,7 +18,7 @@ INSTALL_DIR="/usr/local/bin"
|
||||
BINARY_NAME="lnk"
|
||||
|
||||
# Fallback version if redirect fails
|
||||
FALLBACK_VERSION="v0.0.2"
|
||||
FALLBACK_VERSION="v0.3.0"
|
||||
|
||||
# Detect OS and architecture
|
||||
detect_platform() {
|
||||
@@ -51,28 +51,28 @@ detect_platform() {
|
||||
# Get latest version by following redirect
|
||||
get_latest_version() {
|
||||
echo -e "${BLUE}Getting latest release version...${NC}" >&2
|
||||
|
||||
|
||||
# Get redirect location from releases/latest
|
||||
local redirect_url
|
||||
redirect_url=$(curl -s -I "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/\r$//' | cut -d' ' -f2-)
|
||||
|
||||
|
||||
if [ -z "$redirect_url" ]; then
|
||||
echo -e "${YELLOW}⚠ Could not get redirect URL, using fallback version ${FALLBACK_VERSION}${NC}" >&2
|
||||
echo "$FALLBACK_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
# Extract version from redirect URL (format: https://github.com/user/repo/releases/tag/v1.2.3)
|
||||
local version
|
||||
version=$(echo "$redirect_url" | sed -E 's|.*/releases/tag/([^/]*)\s*$|\1|')
|
||||
|
||||
|
||||
if [ -z "$version" ] || [ "$version" = "$redirect_url" ]; then
|
||||
echo -e "${YELLOW}⚠ Could not parse version from redirect URL: $redirect_url${NC}" >&2
|
||||
echo -e "${YELLOW}Using fallback version ${FALLBACK_VERSION}${NC}" >&2
|
||||
echo "$FALLBACK_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
@@ -91,25 +91,25 @@ get_version() {
|
||||
# Download and install
|
||||
install_lnk() {
|
||||
local platform version
|
||||
|
||||
|
||||
echo -e "${BLUE}🔗 Installing lnk...${NC}"
|
||||
|
||||
|
||||
platform=$(detect_platform)
|
||||
version=$(get_version "$1")
|
||||
|
||||
|
||||
echo -e "${BLUE}Version: ${version}${NC}"
|
||||
echo -e "${BLUE}Platform: ${platform}${NC}"
|
||||
|
||||
|
||||
# Download URL
|
||||
local filename="lnk_${platform}.tar.gz"
|
||||
local url="https://github.com/${REPO}/releases/download/${version}/${filename}"
|
||||
|
||||
|
||||
echo -e "${BLUE}Downloading ${url}...${NC}"
|
||||
|
||||
|
||||
# Create temporary directory
|
||||
local tmp_dir=$(mktemp -d)
|
||||
cd "$tmp_dir"
|
||||
|
||||
|
||||
# Download the binary
|
||||
if ! curl -sL "$url" -o "$filename"; then
|
||||
echo -e "${RED}Error: Failed to download ${url}${NC}"
|
||||
@@ -117,7 +117,7 @@ install_lnk() {
|
||||
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Check if we got an HTML error page instead of the binary
|
||||
if file "$filename" 2>/dev/null | grep -q "HTML"; then
|
||||
echo -e "${RED}Error: Downloaded file appears to be an HTML page (404 error)${NC}"
|
||||
@@ -125,30 +125,30 @@ install_lnk() {
|
||||
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Extract the binary
|
||||
if ! tar -xzf "$filename"; then
|
||||
echo -e "${RED}Error: Failed to extract ${filename}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Make binary executable
|
||||
chmod +x "$BINARY_NAME"
|
||||
|
||||
|
||||
# Install to system directory
|
||||
echo -e "${YELLOW}Installing to ${INSTALL_DIR} (requires sudo)...${NC}"
|
||||
if ! sudo mv "$BINARY_NAME" "$INSTALL_DIR/"; then
|
||||
echo -e "${RED}Error: Failed to install binary${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Cleanup
|
||||
cd - > /dev/null
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
|
||||
echo -e "${GREEN}✅ lnk installed successfully!${NC}"
|
||||
echo -e "${GREEN}Run 'lnk --help' to get started.${NC}"
|
||||
|
||||
|
||||
# Test the installation
|
||||
if command -v lnk >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}Installed version: $(lnk --version)${NC}"
|
||||
@@ -177,4 +177,4 @@ if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||
fi
|
||||
|
||||
# Run the installer
|
||||
install_lnk "$1"
|
||||
install_lnk "$1"
|
||||
|
84
internal/core/errors.go
Normal file
84
internal/core/errors.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
|
||||
// LnkError represents a structured error with separate content and formatting hints
|
||||
type LnkError struct {
|
||||
Message string
|
||||
Suggestion string
|
||||
Path string
|
||||
ErrorType string
|
||||
}
|
||||
|
||||
func (e *LnkError) Error() string {
|
||||
if e.Suggestion != "" {
|
||||
return fmt.Sprintf("%s\n %s", e.Message, e.Suggestion)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// Error constructors that separate content from presentation
|
||||
|
||||
func ErrDirectoryContainsManagedFiles(path string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Directory %s already contains managed files", path),
|
||||
Suggestion: "Use 'lnk pull' to update from remote instead of 'lnk init -r'",
|
||||
Path: path,
|
||||
ErrorType: "managed_files_exist",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrDirectoryContainsGitRepo(path string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Directory %s contains an existing Git repository", path),
|
||||
Suggestion: "Please backup or move the existing repository before initializing lnk",
|
||||
Path: path,
|
||||
ErrorType: "git_repo_exists",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrFileAlreadyManaged(path string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("File is already managed by lnk: %s", path),
|
||||
Path: path,
|
||||
ErrorType: "already_managed",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrFileNotManaged(path string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("File is not managed by lnk: %s", path),
|
||||
Path: path,
|
||||
ErrorType: "not_managed",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrRepositoryNotInitialized() error {
|
||||
return &LnkError{
|
||||
Message: "Lnk repository not initialized",
|
||||
Suggestion: "Run 'lnk init' first",
|
||||
ErrorType: "not_initialized",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrBootstrapScriptNotFound(script string) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Bootstrap script not found: %s", script),
|
||||
Path: script,
|
||||
ErrorType: "script_not_found",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrBootstrapScriptFailed(err error) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Bootstrap script failed with error: %v", err),
|
||||
ErrorType: "script_failed",
|
||||
}
|
||||
}
|
||||
|
||||
func ErrBootstrapScriptNotExecutable(err error) error {
|
||||
return &LnkError{
|
||||
Message: fmt.Sprintf("Failed to make bootstrap script executable: %v", err),
|
||||
ErrorType: "script_permissions",
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -19,26 +20,58 @@ type Lnk struct {
|
||||
fs *fs.FileSystem
|
||||
}
|
||||
|
||||
// NewLnk creates a new Lnk instance for common configuration
|
||||
func NewLnk() *Lnk {
|
||||
repoPath := getRepoPath()
|
||||
return &Lnk{
|
||||
repoPath: repoPath,
|
||||
host: "", // Empty host means common configuration
|
||||
git: git.New(repoPath),
|
||||
fs: fs.New(),
|
||||
type Option func(*Lnk)
|
||||
|
||||
// WithHost sets the host for host-specific configuration
|
||||
func WithHost(host string) Option {
|
||||
return func(l *Lnk) {
|
||||
l.host = host
|
||||
}
|
||||
}
|
||||
|
||||
// NewLnkWithHost creates a new Lnk instance for host-specific configuration
|
||||
func NewLnkWithHost(host string) *Lnk {
|
||||
// NewLnk creates a new Lnk instance with optional configuration
|
||||
func NewLnk(opts ...Option) *Lnk {
|
||||
repoPath := getRepoPath()
|
||||
return &Lnk{
|
||||
lnk := &Lnk{
|
||||
repoPath: repoPath,
|
||||
host: host,
|
||||
host: "",
|
||||
git: git.New(repoPath),
|
||||
fs: fs.New(),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(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
|
||||
@@ -65,13 +98,6 @@ func getRepoPath() string {
|
||||
return filepath.Join(xdgConfig, "lnk")
|
||||
}
|
||||
|
||||
// generateRepoName creates a repository path from a relative path
|
||||
func generateRepoName(relativePath string, host string) string {
|
||||
// Always preserve the directory structure for consistency
|
||||
// Both common and host-specific files should maintain their path structure
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// getHostStoragePath returns the storage path for host-specific or common files
|
||||
func (l *Lnk) getHostStoragePath() string {
|
||||
if l.host == "" {
|
||||
@@ -121,7 +147,18 @@ func (l *Lnk) Init() error {
|
||||
|
||||
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
|
||||
func (l *Lnk) InitWithRemote(remoteURL string) error {
|
||||
return l.InitWithRemoteForce(remoteURL, false)
|
||||
}
|
||||
|
||||
// InitWithRemoteForce initializes the lnk repository with optional force override
|
||||
func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error {
|
||||
if remoteURL != "" {
|
||||
// Safety check: prevent data loss by checking for existing managed files
|
||||
if l.HasUserContent() {
|
||||
if !force {
|
||||
return ErrDirectoryContainsManagedFiles(l.repoPath)
|
||||
}
|
||||
}
|
||||
// Clone from remote
|
||||
return l.Clone(remoteURL)
|
||||
}
|
||||
@@ -139,32 +176,22 @@ func (l *Lnk) InitWithRemote(remoteURL string) error {
|
||||
return nil
|
||||
} else {
|
||||
// It's not a lnk repository, error to prevent data loss
|
||||
return fmt.Errorf("❌ Directory \033[31m%s\033[0m contains an existing Git repository\n 💡 Please backup or move the existing repository before initializing lnk", l.repoPath)
|
||||
return ErrDirectoryContainsGitRepo(l.repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
// No existing repository, initialize Git repository
|
||||
if err := l.git.Init(); err != nil {
|
||||
return fmt.Errorf("failed to initialize git repository: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return l.git.Init()
|
||||
}
|
||||
|
||||
// Clone clones a repository from the given URL
|
||||
func (l *Lnk) Clone(url string) error {
|
||||
if err := l.git.Clone(url); err != nil {
|
||||
return fmt.Errorf("failed to clone repository: %w", err)
|
||||
}
|
||||
return nil
|
||||
return l.git.Clone(url)
|
||||
}
|
||||
|
||||
// AddRemote adds a remote to the repository
|
||||
func (l *Lnk) AddRemote(name, url string) error {
|
||||
if err := l.git.AddRemote(name, url); err != nil {
|
||||
return fmt.Errorf("failed to add remote %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
return l.git.AddRemote(name, url)
|
||||
}
|
||||
|
||||
// Add moves a file or directory to the repository and creates a symlink
|
||||
@@ -187,9 +214,8 @@ func (l *Lnk) Add(filePath string) error {
|
||||
}
|
||||
|
||||
// Generate repository path from relative path
|
||||
repoName := generateRepoName(relativePath, l.host)
|
||||
storagePath := l.getHostStoragePath()
|
||||
destPath := filepath.Join(storagePath, repoName)
|
||||
destPath := filepath.Join(storagePath, relativePath)
|
||||
|
||||
// Ensure destination directory exists (including parent directories for host-specific files)
|
||||
destDir := filepath.Dir(destPath)
|
||||
@@ -204,7 +230,7 @@ func (l *Lnk) Add(filePath string) error {
|
||||
}
|
||||
for _, item := range managedItems {
|
||||
if item == relativePath {
|
||||
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||
return ErrFileAlreadyManaged(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,87 +241,208 @@ func (l *Lnk) Add(filePath string) error {
|
||||
}
|
||||
|
||||
// Move to repository (handles both files and directories)
|
||||
if info.IsDir() {
|
||||
if err := l.fs.MoveDirectory(absPath, destPath); err != nil {
|
||||
return fmt.Errorf("failed to move directory to repository: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := l.fs.MoveFile(absPath, destPath); err != nil {
|
||||
return fmt.Errorf("failed to move file to repository: %w", err)
|
||||
}
|
||||
if err := l.fs.Move(absPath, destPath, info); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
|
||||
// Try to restore the original if symlink creation fails
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
}
|
||||
return fmt.Errorf("failed to create symlink: %w", err)
|
||||
_ = l.fs.Move(destPath, absPath, info)
|
||||
return err
|
||||
}
|
||||
|
||||
// Add to .lnk tracking file using relative path
|
||||
if err := l.addManagedItem(relativePath); err != nil {
|
||||
// Try to restore the original state if tracking fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
}
|
||||
_ = os.Remove(absPath)
|
||||
_ = l.fs.Move(destPath, absPath, info)
|
||||
return fmt.Errorf("failed to update tracking file: %w", err)
|
||||
}
|
||||
|
||||
// Add both the item and .lnk file to git in a single commit
|
||||
// For host-specific files, we need to add the relative path from repo root
|
||||
gitPath := repoName
|
||||
gitPath := relativePath
|
||||
if l.host != "" {
|
||||
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||
gitPath = filepath.Join(l.host+".lnk", relativePath)
|
||||
}
|
||||
if err := l.git.Add(gitPath); err != nil {
|
||||
// Try to restore the original state if git add fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
}
|
||||
return fmt.Errorf("failed to add item to git: %w", err)
|
||||
_ = os.Remove(absPath)
|
||||
_ = l.removeManagedItem(relativePath)
|
||||
_ = l.fs.Move(destPath, absPath, info)
|
||||
return err
|
||||
}
|
||||
|
||||
// Add .lnk file to the same commit
|
||||
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||
// Try to restore the original state if git add fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
}
|
||||
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
||||
_ = os.Remove(absPath)
|
||||
_ = l.removeManagedItem(relativePath)
|
||||
_ = l.fs.Move(destPath, absPath, info)
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit both changes together
|
||||
basename := filepath.Base(relativePath)
|
||||
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
|
||||
// Try to restore the original state if commit fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
_ = os.Remove(absPath)
|
||||
_ = l.removeManagedItem(relativePath)
|
||||
_ = l.fs.Move(destPath, absPath, info)
|
||||
return err
|
||||
}
|
||||
|
||||
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 ErrFileAlreadyManaged(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Get file info
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat path %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
relativePaths = append(relativePaths, relativePath)
|
||||
absolutePaths = append(absolutePaths, absPath)
|
||||
infos = append(infos, info)
|
||||
}
|
||||
|
||||
// Phase 2: Process all files - move to repository and create symlinks
|
||||
var rollbackActions []func() error
|
||||
|
||||
for i, absPath := range absolutePaths {
|
||||
relativePath := relativePaths[i]
|
||||
info := infos[i]
|
||||
|
||||
// Generate repository path from relative path
|
||||
storagePath := l.getHostStoragePath()
|
||||
destPath := filepath.Join(storagePath, relativePath)
|
||||
|
||||
// Ensure destination directory exists
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
// Rollback previous operations
|
||||
l.rollbackOperations(rollbackActions)
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
// Move to repository
|
||||
if err := l.fs.Move(absPath, destPath, info); err != nil {
|
||||
// Rollback previous operations
|
||||
l.rollbackOperations(rollbackActions)
|
||||
return fmt.Errorf("failed to move %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
|
||||
// Try to restore the file we just moved, then rollback others
|
||||
_ = l.fs.Move(destPath, absPath, info)
|
||||
l.rollbackOperations(rollbackActions)
|
||||
return fmt.Errorf("failed to create symlink for %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
// Add to tracking
|
||||
if err := l.addManagedItem(relativePath); err != nil {
|
||||
// Restore this file and rollback others
|
||||
_ = os.Remove(absPath)
|
||||
_ = l.fs.Move(destPath, absPath, info)
|
||||
l.rollbackOperations(rollbackActions)
|
||||
return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err)
|
||||
}
|
||||
|
||||
// Add rollback action for this file
|
||||
rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info)
|
||||
rollbackActions = append(rollbackActions, rollbackAction)
|
||||
}
|
||||
|
||||
// Phase 3: Git operations - add all files and create single commit
|
||||
for i, relativePath := range relativePaths {
|
||||
// For host-specific files, we need to add the relative path from repo root
|
||||
gitPath := relativePath
|
||||
if l.host != "" {
|
||||
gitPath = filepath.Join(l.host+".lnk", relativePath)
|
||||
}
|
||||
if err := l.git.Add(gitPath); err != nil {
|
||||
// Rollback all operations
|
||||
l.rollbackOperations(rollbackActions)
|
||||
return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add .lnk file to the same commit
|
||||
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||
// Rollback all operations
|
||||
l.rollbackOperations(rollbackActions)
|
||||
return fmt.Errorf("failed to add tracking file to git: %w", err)
|
||||
}
|
||||
|
||||
// Commit all changes together
|
||||
commitMessage := fmt.Sprintf("lnk: added %d files", len(paths))
|
||||
if err := l.git.Commit(commitMessage); err != nil {
|
||||
// Rollback all operations
|
||||
l.rollbackOperations(rollbackActions)
|
||||
return fmt.Errorf("failed to commit changes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createRollbackAction creates a rollback function for a single file operation
|
||||
func (l *Lnk) createRollbackAction(absPath, destPath, relativePath string, info os.FileInfo) func() error {
|
||||
return func() error {
|
||||
_ = os.Remove(absPath)
|
||||
_ = l.removeManagedItem(relativePath)
|
||||
return l.fs.Move(destPath, absPath, info)
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackOperations executes rollback actions in reverse order
|
||||
func (l *Lnk) rollbackOperations(rollbackActions []func() error) {
|
||||
for i := len(rollbackActions) - 1; i >= 0; i-- {
|
||||
_ = rollbackActions[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove removes a symlink and restores the original file or directory
|
||||
func (l *Lnk) Remove(filePath string) error {
|
||||
// Get absolute path
|
||||
@@ -329,7 +476,7 @@ func (l *Lnk) Remove(filePath string) error {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||
return ErrFileNotManaged(relativePath)
|
||||
}
|
||||
|
||||
// Get the target path in the repository
|
||||
@@ -360,35 +507,28 @@ func (l *Lnk) Remove(filePath string) error {
|
||||
}
|
||||
|
||||
// Generate the correct git path for removal
|
||||
repoName := generateRepoName(relativePath, l.host)
|
||||
gitPath := repoName
|
||||
gitPath := relativePath
|
||||
if l.host != "" {
|
||||
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||
gitPath = filepath.Join(l.host+".lnk", relativePath)
|
||||
}
|
||||
if err := l.git.Remove(gitPath); err != nil {
|
||||
return fmt.Errorf("failed to remove from git: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Add .lnk file to the same commit
|
||||
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit both changes together
|
||||
basename := filepath.Base(relativePath)
|
||||
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
|
||||
return fmt.Errorf("failed to commit changes: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Move back from repository (handles both files and directories)
|
||||
if info.IsDir() {
|
||||
if err := l.fs.MoveDirectory(target, absPath); err != nil {
|
||||
return fmt.Errorf("failed to restore directory: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := l.fs.MoveFile(target, absPath); err != nil {
|
||||
return fmt.Errorf("failed to restore file: %w", err)
|
||||
}
|
||||
if err := l.fs.Move(target, absPath, info); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -411,12 +551,12 @@ type StatusInfo struct {
|
||||
func (l *Lnk) Status() (*StatusInfo, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
return nil, ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
gitStatus, err := l.git.GetStatus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repository status: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &StatusInfo{
|
||||
@@ -431,46 +571,42 @@ func (l *Lnk) Status() (*StatusInfo, error) {
|
||||
func (l *Lnk) Push(message 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")
|
||||
return ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
// Check if there are any changes
|
||||
hasChanges, err := l.git.HasChanges()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for changes: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
// Stage all changes
|
||||
if err := l.git.AddAll(); err != nil {
|
||||
return fmt.Errorf("failed to stage changes: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a sync commit
|
||||
if err := l.git.Commit(message); err != nil {
|
||||
return fmt.Errorf("failed to commit changes: %w", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Push to remote (this will be a no-op in tests since we don't have real remotes)
|
||||
// In real usage, this would push to the actual remote repository
|
||||
if err := l.git.Push(); err != nil {
|
||||
return fmt.Errorf("failed to push to remote: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return l.git.Push()
|
||||
}
|
||||
|
||||
// Pull fetches changes from remote and restores symlinks as needed
|
||||
func (l *Lnk) Pull() ([]string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
return nil, ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
|
||||
if err := l.git.Pull(); err != nil {
|
||||
return nil, fmt.Errorf("failed to pull from remote: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find all managed files in the repository and restore symlinks
|
||||
@@ -486,7 +622,7 @@ func (l *Lnk) Pull() ([]string, error) {
|
||||
func (l *Lnk) List() ([]string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
return nil, ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
// Get managed items from .lnk file
|
||||
@@ -515,9 +651,8 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||
|
||||
for _, relativePath := range managedItems {
|
||||
// Generate repository name from relative path
|
||||
repoName := generateRepoName(relativePath, l.host)
|
||||
storagePath := l.getHostStoragePath()
|
||||
repoItem := filepath.Join(storagePath, repoName)
|
||||
repoItem := filepath.Join(storagePath, relativePath)
|
||||
|
||||
// Check if item exists in repository
|
||||
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
|
||||
@@ -547,7 +682,7 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||
|
||||
// Create symlink
|
||||
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
restored = append(restored, relativePath)
|
||||
@@ -682,3 +817,373 @@ func (l *Lnk) writeManagedItems(items []string) error {
|
||||
|
||||
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 "", ErrRepositoryNotInitialized()
|
||||
}
|
||||
|
||||
// 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 ErrBootstrapScriptNotFound(scriptName)
|
||||
}
|
||||
|
||||
// Make sure it's executable
|
||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
||||
return ErrBootstrapScriptNotExecutable(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 ErrBootstrapScriptFailed(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 ErrFileAlreadyManaged(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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -30,6 +32,9 @@ func (suite *CoreTestSuite) SetupTest() {
|
||||
err = os.Chdir(tempDir)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Set HOME to temp directory for consistent relative path calculation
|
||||
suite.T().Setenv("HOME", tempDir)
|
||||
|
||||
// Set XDG_CONFIG_HOME to temp directory
|
||||
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
|
||||
@@ -85,8 +90,8 @@ func (suite *CoreTestSuite) TestCoreFileOperations() {
|
||||
// The repository file will preserve the directory structure
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// Find the .bashrc file in the repository (it should be at the relative path)
|
||||
repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc")
|
||||
// Find the .bashrc file in the repository (it should be at the relative path from HOME)
|
||||
repoFile := filepath.Join(lnkDir, ".bashrc")
|
||||
suite.FileExists(repoFile)
|
||||
|
||||
// Verify content is preserved
|
||||
@@ -136,8 +141,8 @@ func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
|
||||
// Check that the repository directory preserves the structure
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// The directory should be at the relative path
|
||||
repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir")
|
||||
// The directory should be at the relative path from HOME
|
||||
repoDir := filepath.Join(lnkDir, "testdir")
|
||||
suite.DirExists(repoDir)
|
||||
|
||||
// Remove the directory
|
||||
@@ -275,7 +280,7 @@ func (suite *CoreTestSuite) TestErrorConditions() {
|
||||
|
||||
err = suite.lnk.Add("/nonexistent/file")
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "File does not exist")
|
||||
suite.Contains(err.Error(), "File or directory not found")
|
||||
|
||||
// Test remove unmanaged file
|
||||
testFile := filepath.Join(suite.tempDir, ".regularfile")
|
||||
@@ -289,7 +294,7 @@ func (suite *CoreTestSuite) TestErrorConditions() {
|
||||
// Test status without remote
|
||||
_, err = suite.lnk.Status()
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "no remote configured")
|
||||
suite.Contains(err.Error(), "No remote repository is configured")
|
||||
}
|
||||
|
||||
// Test git operations
|
||||
@@ -592,7 +597,7 @@ func (suite *CoreTestSuite) TestMultihostFileOperations() {
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file to host-specific configuration
|
||||
hostLnk := NewLnkWithHost("workstation")
|
||||
hostLnk := NewLnk(WithHost("workstation"))
|
||||
err = hostLnk.Add(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
@@ -661,7 +666,7 @@ func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() {
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create files directly in host-specific storage (simulating a pull)
|
||||
hostLnk := NewLnkWithHost("testhost")
|
||||
hostLnk := NewLnk(WithHost("testhost"))
|
||||
|
||||
// Ensure host storage directory exists
|
||||
hostStoragePath := hostLnk.getHostStoragePath()
|
||||
@@ -729,7 +734,7 @@ func (suite *CoreTestSuite) TestMultihostIsolation() {
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add to host-specific
|
||||
hostLnk := NewLnkWithHost("work")
|
||||
hostLnk := NewLnk(WithHost("work"))
|
||||
err = hostLnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
@@ -749,6 +754,849 @@ func (suite *CoreTestSuite) TestMultihostIsolation() {
|
||||
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) {
|
||||
suite.Run(t, new(CoreTestSuite))
|
||||
}
|
||||
|
130
internal/fs/errors.go
Normal file
130
internal/fs/errors.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package fs
|
||||
|
||||
// Structured errors that separate content from presentation
|
||||
// These will be formatted by the cmd package based on user preferences
|
||||
|
||||
// FileNotExistsError represents an error when a file does not exist
|
||||
type FileNotExistsError struct {
|
||||
Path string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *FileNotExistsError) Error() string {
|
||||
return "File or directory not found: " + e.Path
|
||||
}
|
||||
|
||||
func (e *FileNotExistsError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// GetPath returns the path for formatting purposes
|
||||
func (e *FileNotExistsError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
// FileCheckError represents an error when failing to check a file
|
||||
type FileCheckError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *FileCheckError) Error() string {
|
||||
return "Unable to access file. Please check file permissions and try again."
|
||||
}
|
||||
|
||||
func (e *FileCheckError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// UnsupportedFileTypeError represents an error when a file type is not supported
|
||||
type UnsupportedFileTypeError struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e *UnsupportedFileTypeError) Error() string {
|
||||
return "Cannot manage this type of file: " + e.Path
|
||||
}
|
||||
|
||||
func (e *UnsupportedFileTypeError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func (e *UnsupportedFileTypeError) GetSuggestion() string {
|
||||
return "lnk can only manage regular files and directories"
|
||||
}
|
||||
|
||||
func (e *UnsupportedFileTypeError) Unwrap() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotManagedByLnkError represents an error when a file is not managed by lnk
|
||||
type NotManagedByLnkError struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e *NotManagedByLnkError) Error() string {
|
||||
return "File is not managed by lnk: " + e.Path
|
||||
}
|
||||
|
||||
func (e *NotManagedByLnkError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func (e *NotManagedByLnkError) GetSuggestion() string {
|
||||
return "Use 'lnk add' to manage this file first"
|
||||
}
|
||||
|
||||
func (e *NotManagedByLnkError) Unwrap() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SymlinkReadError represents an error when failing to read a symlink
|
||||
type SymlinkReadError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *SymlinkReadError) Error() string {
|
||||
return "Unable to read symlink. The file may be corrupted or have invalid permissions."
|
||||
}
|
||||
|
||||
func (e *SymlinkReadError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// DirectoryCreationError represents an error when failing to create a directory
|
||||
type DirectoryCreationError struct {
|
||||
Operation string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) Error() string {
|
||||
return "Failed to create directory. Please check permissions and available disk space."
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// RelativePathCalculationError represents an error when failing to calculate relative path
|
||||
type RelativePathCalculationError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RelativePathCalculationError) Error() string {
|
||||
return "Unable to create symlink due to path configuration issues. Please check file locations."
|
||||
}
|
||||
|
||||
func (e *RelativePathCalculationError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// ErrorWithPath is an interface for errors that have an associated file path
|
||||
type ErrorWithPath interface {
|
||||
error
|
||||
GetPath() string
|
||||
}
|
||||
|
||||
// ErrorWithSuggestion is an interface for errors that provide helpful suggestions
|
||||
type ErrorWithSuggestion interface {
|
||||
error
|
||||
GetSuggestion() string
|
||||
}
|
@@ -1,7 +1,6 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -17,18 +16,19 @@ func New() *FileSystem {
|
||||
|
||||
// ValidateFileForAdd validates that a file or directory can be added to lnk
|
||||
func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
|
||||
// Check if file exists
|
||||
// Check if file exists and get its info
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
|
||||
return &FileNotExistsError{Path: filePath, Err: err}
|
||||
}
|
||||
return fmt.Errorf("❌ Failed to check file: %w", err)
|
||||
|
||||
return &FileCheckError{Err: err}
|
||||
}
|
||||
|
||||
// Allow both regular files and directories
|
||||
if !info.Mode().IsRegular() && !info.IsDir() {
|
||||
return fmt.Errorf("❌ Only regular files and directories are supported: \033[31m%s\033[0m", filePath)
|
||||
return &UnsupportedFileTypeError{Path: filePath}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -36,98 +36,79 @@ func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
|
||||
|
||||
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk
|
||||
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
|
||||
// Check if file exists
|
||||
// Check if file exists and is a symlink
|
||||
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
|
||||
return &FileNotExistsError{Path: filePath, Err: err}
|
||||
}
|
||||
return fmt.Errorf("❌ Failed to check file: %w", err)
|
||||
|
||||
return &FileCheckError{Err: err}
|
||||
}
|
||||
|
||||
// Check if it's a symlink
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m\n 💡 Use \033[1mlnk add\033[0m to manage this file first", filePath)
|
||||
return &NotManagedByLnkError{Path: filePath}
|
||||
}
|
||||
|
||||
// Check if symlink points to the repository
|
||||
// Get symlink target and resolve to absolute path
|
||||
target, err := os.Readlink(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read symlink: %w", err)
|
||||
return &SymlinkReadError{Err: err}
|
||||
}
|
||||
|
||||
// Convert relative path to absolute if needed
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(filePath), target)
|
||||
}
|
||||
|
||||
// Clean the path to resolve any .. or . components
|
||||
// Clean paths and check if target is inside the repository
|
||||
target = filepath.Clean(target)
|
||||
repoPath = filepath.Clean(repoPath)
|
||||
|
||||
// Check if target is inside the repository
|
||||
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
|
||||
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", filePath)
|
||||
return &NotManagedByLnkError{Path: filePath}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move moves a file or directory from source to destination based on the file info
|
||||
func (fs *FileSystem) Move(src, dst string, info os.FileInfo) error {
|
||||
if info.IsDir() {
|
||||
return fs.MoveDirectory(src, dst)
|
||||
}
|
||||
return fs.MoveFile(src, dst)
|
||||
}
|
||||
|
||||
// MoveFile moves a file from source to destination
|
||||
func (fs *FileSystem) MoveFile(src, dst string) error {
|
||||
// Ensure destination directory exists
|
||||
dstDir := filepath.Dir(dst)
|
||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return &DirectoryCreationError{Operation: "destination directory", Err: err}
|
||||
}
|
||||
|
||||
// Move the file
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("failed to move file from %s to %s: %w", src, dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
|
||||
// CreateSymlink creates a relative symlink from target to linkPath
|
||||
func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
|
||||
// Calculate relative path from linkPath to target
|
||||
linkDir := filepath.Dir(linkPath)
|
||||
relTarget, err := filepath.Rel(linkDir, target)
|
||||
relTarget, err := filepath.Rel(filepath.Dir(linkPath), target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to calculate relative path: %w", err)
|
||||
return &RelativePathCalculationError{Err: err}
|
||||
}
|
||||
|
||||
// Create the symlink
|
||||
if err := os.Symlink(relTarget, linkPath); err != nil {
|
||||
return fmt.Errorf("failed to create symlink: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return os.Symlink(relTarget, linkPath)
|
||||
}
|
||||
|
||||
// MoveDirectory moves a directory from source to destination recursively
|
||||
func (fs *FileSystem) MoveDirectory(src, dst string) error {
|
||||
// Check if source is a directory
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat source: %w", err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("source is not a directory: %s", src)
|
||||
}
|
||||
|
||||
// Ensure destination parent directory exists
|
||||
dstParent := filepath.Dir(dst)
|
||||
if err := os.MkdirAll(dstParent, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination parent directory: %w", err)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return &DirectoryCreationError{Operation: "destination parent directory", Err: err}
|
||||
}
|
||||
|
||||
// Use os.Rename which works for directories
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("failed to move directory from %s to %s: %w", src, dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
// Move the directory
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
|
249
internal/git/errors.go
Normal file
249
internal/git/errors.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package git
|
||||
|
||||
// Structured errors that separate content from presentation
|
||||
// These will be formatted by the cmd package based on user preferences
|
||||
|
||||
// GitInitError represents an error during git initialization
|
||||
type GitInitError struct {
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *GitInitError) Error() string {
|
||||
return "Failed to initialize git repository. Please ensure git is installed and try again."
|
||||
}
|
||||
|
||||
func (e *GitInitError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// BranchSetupError represents an error setting up the default branch
|
||||
type BranchSetupError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *BranchSetupError) Error() string {
|
||||
return "Failed to set up the default branch. Please check your git installation."
|
||||
}
|
||||
|
||||
func (e *BranchSetupError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// RemoteExistsError represents an error when a remote already exists with different URL
|
||||
type RemoteExistsError struct {
|
||||
Remote string
|
||||
ExistingURL string
|
||||
NewURL string
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) Error() string {
|
||||
return "Remote " + e.Remote + " is already configured with a different repository (" + e.ExistingURL + "). Cannot add " + e.NewURL + "."
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) GetRemote() string {
|
||||
return e.Remote
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) GetExistingURL() string {
|
||||
return e.ExistingURL
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) GetNewURL() string {
|
||||
return e.NewURL
|
||||
}
|
||||
|
||||
func (e *RemoteExistsError) Unwrap() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitCommandError represents a generic git command execution error
|
||||
type GitCommandError struct {
|
||||
Command string
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *GitCommandError) Error() string {
|
||||
// Provide user-friendly messages based on common command types
|
||||
switch e.Command {
|
||||
case "add":
|
||||
return "Failed to stage files for commit. Please check file permissions and try again."
|
||||
case "commit":
|
||||
return "Failed to create commit. Please ensure you have staged changes and try again."
|
||||
case "remote add":
|
||||
return "Failed to add remote repository. Please check the repository URL and try again."
|
||||
case "rm":
|
||||
return "Failed to remove file from git tracking. Please check if the file exists and try again."
|
||||
case "log":
|
||||
return "Failed to retrieve commit history."
|
||||
case "remote":
|
||||
return "Failed to retrieve remote repository information."
|
||||
case "clone":
|
||||
return "Failed to clone repository. Please check the repository URL and your network connection."
|
||||
default:
|
||||
return "Git operation failed. Please check your repository state and try again."
|
||||
}
|
||||
}
|
||||
|
||||
func (e *GitCommandError) GetCommand() string {
|
||||
return e.Command
|
||||
}
|
||||
|
||||
func (e *GitCommandError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NoRemoteError represents an error when no remote is configured
|
||||
type NoRemoteError struct{}
|
||||
|
||||
func (e *NoRemoteError) Error() string {
|
||||
return "No remote repository is configured. Please add a remote repository first."
|
||||
}
|
||||
|
||||
func (e *NoRemoteError) Unwrap() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoteNotFoundError represents an error when a specific remote is not found
|
||||
type RemoteNotFoundError struct {
|
||||
Remote string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RemoteNotFoundError) Error() string {
|
||||
return "Remote repository " + e.Remote + " is not configured."
|
||||
}
|
||||
|
||||
func (e *RemoteNotFoundError) GetRemote() string {
|
||||
return e.Remote
|
||||
}
|
||||
|
||||
func (e *RemoteNotFoundError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// GitConfigError represents an error with git configuration
|
||||
type GitConfigError struct {
|
||||
Setting string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *GitConfigError) Error() string {
|
||||
return "Failed to configure git settings. Please check your git installation."
|
||||
}
|
||||
|
||||
func (e *GitConfigError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// UncommittedChangesError represents an error checking for uncommitted changes
|
||||
type UncommittedChangesError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *UncommittedChangesError) Error() string {
|
||||
return "Failed to check repository status. Please verify your git repository is valid."
|
||||
}
|
||||
|
||||
func (e *UncommittedChangesError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// DirectoryRemovalError represents an error removing a directory
|
||||
type DirectoryRemovalError struct {
|
||||
Path string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *DirectoryRemovalError) Error() string {
|
||||
return "Failed to prepare directory for operation. Please check directory permissions."
|
||||
}
|
||||
|
||||
func (e *DirectoryRemovalError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func (e *DirectoryRemovalError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// DirectoryCreationError represents an error creating a directory
|
||||
type DirectoryCreationError struct {
|
||||
Path string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) Error() string {
|
||||
return "Failed to create directory. Please check permissions and available disk space."
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) GetPath() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func (e *DirectoryCreationError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// PushError represents an error during git push operation
|
||||
type PushError struct {
|
||||
Reason string
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PushError) Error() string {
|
||||
if e.Reason != "" {
|
||||
return "Cannot push changes: " + e.Reason
|
||||
}
|
||||
return "Failed to push changes to remote repository. Please check your network connection and repository permissions."
|
||||
}
|
||||
|
||||
func (e *PushError) GetReason() string {
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
func (e *PushError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// PullError represents an error during git pull operation
|
||||
type PullError struct {
|
||||
Reason string
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PullError) Error() string {
|
||||
if e.Reason != "" {
|
||||
return "Cannot pull changes: " + e.Reason
|
||||
}
|
||||
return "Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts."
|
||||
}
|
||||
|
||||
func (e *PullError) GetReason() string {
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
func (e *PullError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// ErrorWithPath is an interface for git errors that have an associated file path
|
||||
type ErrorWithPath interface {
|
||||
error
|
||||
GetPath() string
|
||||
}
|
||||
|
||||
// ErrorWithRemote is an interface for git errors that involve a remote
|
||||
type ErrorWithRemote interface {
|
||||
error
|
||||
GetRemote() string
|
||||
}
|
||||
|
||||
// ErrorWithReason is an interface for git errors that have a specific reason
|
||||
type ErrorWithReason interface {
|
||||
error
|
||||
GetReason() string
|
||||
}
|
@@ -34,7 +34,7 @@ func (g *Git) Init() error {
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output))
|
||||
return &GitInitError{Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
// Set the default branch to main
|
||||
@@ -42,7 +42,7 @@ func (g *Git) Init() error {
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to set default branch to main: %w", err)
|
||||
return &BranchSetupError{Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (g *Git) AddRemote(name, url string) error {
|
||||
return nil
|
||||
}
|
||||
// Different URL, error
|
||||
return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url)
|
||||
return &RemoteExistsError{Remote: name, ExistingURL: existingURL, NewURL: url}
|
||||
}
|
||||
|
||||
// Remote doesn't exist, add it
|
||||
@@ -69,7 +69,7 @@ func (g *Git) AddRemote(name, url string) error {
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output))
|
||||
return &GitCommandError{Command: "remote add", Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -164,7 +164,7 @@ func (g *Git) Add(filename string) error {
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
|
||||
return &GitCommandError{Command: "add", Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -189,7 +189,7 @@ func (g *Git) Remove(filename string) error {
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output))
|
||||
return &GitCommandError{Command: "rm", Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -207,7 +207,7 @@ func (g *Git) Commit(message string) error {
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output))
|
||||
return &GitCommandError{Command: "commit", Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -223,7 +223,7 @@ func (g *Git) ensureGitConfig() error {
|
||||
cmd = exec.Command("git", "config", "user.name", "Lnk User")
|
||||
cmd.Dir = g.repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to set git user.name: %w", err)
|
||||
return &GitConfigError{Setting: "user.name", Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ func (g *Git) ensureGitConfig() error {
|
||||
cmd = exec.Command("git", "config", "user.email", "lnk@localhost")
|
||||
cmd.Dir = g.repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to set git user.email: %w", err)
|
||||
return &GitConfigError{Setting: "user.email", Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ func (g *Git) GetCommits() ([]string, error) {
|
||||
if strings.Contains(outputStr, "does not have any commits yet") {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("git log failed: %w", err)
|
||||
return nil, &GitCommandError{Command: "log", Output: outputStr, Err: err}
|
||||
}
|
||||
|
||||
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
@@ -282,18 +282,18 @@ func (g *Git) GetRemoteInfo() (string, error) {
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list remotes: %w", err)
|
||||
return "", &GitCommandError{Command: "remote", Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(remotes) == 0 || remotes[0] == "" {
|
||||
return "", fmt.Errorf("no remote configured")
|
||||
return "", &NoRemoteError{}
|
||||
}
|
||||
|
||||
// Use the first remote
|
||||
url, err = g.getRemoteURL(remotes[0])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get remote URL: %w", err)
|
||||
return "", &RemoteNotFoundError{Remote: remotes[0], Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
||||
// Check for uncommitted changes
|
||||
dirty, err := g.HasChanges()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check for uncommitted changes: %w", err)
|
||||
return nil, &UncommittedChangesError{Err: err}
|
||||
}
|
||||
|
||||
// Get the remote tracking branch
|
||||
@@ -410,7 +410,7 @@ func (g *Git) HasChanges() (bool, error) {
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status failed: %w", err)
|
||||
return false, &GitCommandError{Command: "status", Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||
@@ -423,7 +423,7 @@ func (g *Git) AddAll() error {
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
|
||||
return &GitCommandError{Command: "add", Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -434,15 +434,15 @@ func (g *Git) Push() error {
|
||||
// First ensure we have a remote configured
|
||||
_, err := g.GetRemoteInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot push: %w", err)
|
||||
return &PushError{Reason: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "push", "-u", "origin", "main")
|
||||
cmd := exec.Command("git", "push", "-u", "origin")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output))
|
||||
return &PushError{Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -453,15 +453,15 @@ func (g *Git) Pull() error {
|
||||
// First ensure we have a remote configured
|
||||
_, err := g.GetRemoteInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot pull: %w", err)
|
||||
return &PullError{Reason: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "pull", "origin", "main")
|
||||
cmd := exec.Command("git", "pull", "origin")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output))
|
||||
return &PullError{Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -471,20 +471,20 @@ func (g *Git) Pull() error {
|
||||
func (g *Git) Clone(url string) error {
|
||||
// Remove the directory if it exists to ensure clean clone
|
||||
if err := os.RemoveAll(g.repoPath); err != nil {
|
||||
return fmt.Errorf("failed to remove existing directory: %w", err)
|
||||
return &DirectoryRemovalError{Path: g.repoPath, Err: err}
|
||||
}
|
||||
|
||||
// Create parent directory
|
||||
parentDir := filepath.Dir(g.repoPath)
|
||||
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory: %w", err)
|
||||
return &DirectoryCreationError{Path: parentDir, Err: err}
|
||||
}
|
||||
|
||||
// Clone the repository
|
||||
cmd := exec.Command("git", "clone", url, g.repoPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output))
|
||||
return &GitCommandError{Command: "clone", Output: string(output), Err: err}
|
||||
}
|
||||
|
||||
// Set up upstream tracking for main branch
|
||||
|
Reference in New Issue
Block a user