mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-25 21:18:57 +02:00
Compare commits
39 Commits
v0.0.2
...
fix/dynami
Author | SHA1 | Date | |
---|---|---|---|
|
dc524607fa | ||
|
9bf2e70d13 | ||
|
65db5fe738 | ||
|
43b68bc071 | ||
|
ab97fa86dc | ||
|
4cd8191805 | ||
|
6830c06eb4 | ||
|
8a29b7fe43 | ||
|
a6852e5ad5 | ||
|
36d76c881c | ||
|
6de387797e | ||
|
9cbad5e593 | ||
|
150e8adf8b | ||
|
4b11563bdf | ||
|
b476ce503b | ||
|
ae9cc175ce | ||
|
1e2c9704f3 | ||
|
3cba309c05 | ||
|
3e6b426a19 | ||
|
02f342b02b | ||
|
92f2575090 | ||
|
0f74723a03 | ||
|
093cc8ebe7 | ||
|
ff3cddc065 | ||
|
4a275ce4ca | ||
|
69c1038f3e | ||
|
c670ac1fd8 | ||
|
27196e3341 | ||
|
84c507828d | ||
|
d02f112200 | ||
|
f96bfb6ce0 | ||
|
7007ec64f2 | ||
|
ec6ad6b0d0 | ||
|
e7f316ea6e | ||
|
09d67f181e | ||
|
3a34e4fb37 | ||
|
fc0b567e9f | ||
|
61a9cc8c88 | ||
|
1e2728fe33 |
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
|
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -6,6 +6,12 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: '1.24'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -16,10 +22,10 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -38,12 +44,12 @@ jobs:
|
|||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test -v -race -coverprofile=coverage.out ./test
|
run: go test -v -race -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
file: ./coverage.out
|
files: ./coverage.out
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -54,10 +60,10 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v8
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
|
|
||||||
@@ -71,7 +77,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
go-version: '1.24'
|
go-version: '1.24'
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test ./test
|
run: go test ./...
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ desktop.ini
|
|||||||
|
|
||||||
# GoReleaser artifacts
|
# GoReleaser artifacts
|
||||||
goreleaser/
|
goreleaser/
|
||||||
|
*.md
|
||||||
|
!/README.md
|
||||||
|
8
Makefile
8
Makefile
@@ -60,19 +60,19 @@ build:
|
|||||||
## test: Run tests
|
## test: Run tests
|
||||||
test:
|
test:
|
||||||
@echo "$(BLUE)Running tests...$(NC)"
|
@echo "$(BLUE)Running tests...$(NC)"
|
||||||
@go test ./test
|
@go test ./...
|
||||||
@echo "$(GREEN)✓ Tests passed$(NC)"
|
@echo "$(GREEN)✓ Tests passed$(NC)"
|
||||||
|
|
||||||
## test-v: Run tests with verbose output
|
## test-v: Run tests with verbose output
|
||||||
test-v:
|
test-v:
|
||||||
@echo "$(BLUE)Running tests (verbose)...$(NC)"
|
@echo "$(BLUE)Running tests (verbose)...$(NC)"
|
||||||
@go test -v ./test
|
@go test -v ./...
|
||||||
|
|
||||||
## test-cover: Run tests with coverage
|
## test-cover: Run tests with coverage
|
||||||
test-cover:
|
test-cover:
|
||||||
@echo "$(BLUE)Running tests with coverage...$(NC)"
|
@echo "$(BLUE)Running tests with coverage...$(NC)"
|
||||||
@go test -v -cover ./test
|
@go test -v -cover ./...
|
||||||
@go test -coverprofile=coverage.out ./test
|
@go test -coverprofile=coverage.out ./
|
||||||
@go tool cover -html=coverage.out -o coverage.html
|
@go tool cover -html=coverage.out -o coverage.html
|
||||||
@echo "$(GREEN)✓ Coverage report generated: coverage.html$(NC)"
|
@echo "$(GREEN)✓ Coverage report generated: coverage.html$(NC)"
|
||||||
|
|
||||||
|
313
README.md
313
README.md
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
**Git-native dotfiles management that doesn't suck.**
|
**Git-native dotfiles management that doesn't suck.**
|
||||||
|
|
||||||
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. That's it.
|
Lnk makes managing your dotfiles straightforward, no tedious setups, no complex configurations. Just tell Lnk what files you want tracked, and it'll automatically move them into a tidy Git repository under `~/.config/lnk`. It then creates clean, portable symlinks back to their original locations. Easy.
|
||||||
|
|
||||||
|
Why bother with Lnk instead of plain old Git or other dotfile managers? Unlike traditional methods, Lnk automates the boring parts: safely relocating files, handling host-specific setups, bulk operations for multiple files, recursive directory processing, and even running your custom bootstrap scripts automatically. This means fewer manual steps and less chance of accidentally overwriting something important.
|
||||||
|
|
||||||
|
With Lnk, your dotfiles setup stays organized and effortlessly portable, letting you spend more time doing real work, not wrestling with configuration files.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lnk init
|
lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
|
||||||
lnk add ~/.vimrc ~/.bashrc
|
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"
|
lnk push "setup"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -15,15 +22,20 @@ lnk push "setup"
|
|||||||
```bash
|
```bash
|
||||||
# Quick install (recommended)
|
# Quick install (recommended)
|
||||||
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
# Homebrew (macOS/Linux)
|
# Homebrew (macOS/Linux)
|
||||||
brew tap yarlson/lnk
|
|
||||||
brew install lnk
|
brew install lnk
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
# Manual download
|
# Manual download
|
||||||
wget https://github.com/yarlson/lnk/releases/latest/download/lnk-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64
|
wget https://github.com/yarlson/lnk/releases/latest/download/lnk-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64
|
||||||
chmod +x lnk-* && sudo mv lnk-* /usr/local/bin/lnk
|
chmod +x lnk-* && sudo mv lnk-* /usr/local/bin/lnk
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
# From source
|
# From source
|
||||||
git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/
|
git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/
|
||||||
```
|
```
|
||||||
@@ -36,107 +48,322 @@ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv
|
|||||||
# Fresh start
|
# Fresh start
|
||||||
lnk init
|
lnk init
|
||||||
|
|
||||||
# With existing repo
|
# With existing repo (runs bootstrap automatically)
|
||||||
lnk init -r git@github.com:user/dotfiles.git
|
lnk init -r git@github.com:user/dotfiles.git
|
||||||
|
|
||||||
|
# Skip automatic bootstrap
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git --no-bootstrap
|
||||||
|
|
||||||
|
# Force initialization (WARNING: overwrites existing managed files)
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git --force
|
||||||
|
|
||||||
|
# Run bootstrap script manually
|
||||||
|
lnk bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
### Daily workflow
|
### Daily workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add files/directories
|
# Add multiple files at once (common config)
|
||||||
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
|
||||||
|
|
||||||
|
# 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
|
||||||
|
lnk list --host laptop # Laptop-specific config
|
||||||
|
lnk list --all # All configurations
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
lnk status
|
lnk status
|
||||||
|
|
||||||
# Sync changes
|
# Sync changes
|
||||||
lnk push "updated vim config"
|
lnk push "updated vim config"
|
||||||
lnk pull
|
lnk pull # Pull common config
|
||||||
|
lnk pull --host laptop # Pull laptop-specific config
|
||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
|
Common files:
|
||||||
Before: ~/.vimrc (file)
|
Before: ~/.vimrc (file)
|
||||||
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
|
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
|
||||||
|
|
||||||
|
Host-specific files:
|
||||||
|
Before: ~/.ssh/config (file)
|
||||||
|
After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
|
||||||
```
|
```
|
||||||
|
|
||||||
Your files live in `~/.config/lnk` (a Git repo). Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||||
|
|
||||||
|
## Safety Features
|
||||||
|
|
||||||
|
Lnk includes built-in safety checks to prevent accidental data loss:
|
||||||
|
|
||||||
|
### Data Loss Prevention
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This will be blocked if you already have managed files
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git
|
||||||
|
# ❌ Directory ~/.config/lnk already contains managed files
|
||||||
|
# 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r'
|
||||||
|
|
||||||
|
# Use pull instead to safely update
|
||||||
|
lnk pull
|
||||||
|
|
||||||
|
# Or force if you understand the risks (shows warning only when needed)
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git --force
|
||||||
|
# ⚠️ Using --force flag: This will overwrite existing managed files
|
||||||
|
# 💡 Only use this if you understand the risks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smart Warnings
|
||||||
|
|
||||||
|
- **Contextual alerts**: Warnings only appear when there are actually managed files to overwrite
|
||||||
|
- **Clear guidance**: Error messages suggest the correct command to use
|
||||||
|
- **Force override**: Advanced users can bypass safety checks when needed
|
||||||
|
|
||||||
|
## Bootstrap Support
|
||||||
|
|
||||||
|
Lnk automatically runs bootstrap scripts when cloning dotfiles repositories, making it easy to set up your development environment. Just add a `bootstrap.sh` file to your dotfiles repo.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Simple bootstrap script:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# bootstrap.sh
|
||||||
|
echo "Setting up development environment..."
|
||||||
|
|
||||||
|
# Install Homebrew (macOS)
|
||||||
|
if ! command -v brew &> /dev/null; then
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install packages
|
||||||
|
brew install git vim tmux
|
||||||
|
|
||||||
|
echo "✅ Setup complete!"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Automatic bootstrap on clone
|
||||||
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
|
# → Clones repo and runs bootstrap script automatically
|
||||||
|
|
||||||
|
# Skip bootstrap if needed
|
||||||
|
lnk init -r git@github.com:you/dotfiles.git --no-bootstrap
|
||||||
|
|
||||||
|
# Run bootstrap manually later
|
||||||
|
lnk bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multihost Support
|
||||||
|
|
||||||
|
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
|
||||||
|
|
||||||
|
### File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/lnk/
|
||||||
|
├── .lnk # Tracks common files
|
||||||
|
├── .lnk.laptop # Tracks laptop-specific files
|
||||||
|
├── .lnk.work # Tracks work-specific files
|
||||||
|
├── .vimrc # Common file
|
||||||
|
├── .gitconfig # Common file
|
||||||
|
├── laptop.lnk/ # Laptop-specific storage
|
||||||
|
│ ├── .ssh/
|
||||||
|
│ │ └── config
|
||||||
|
│ └── .tmux.conf
|
||||||
|
└── work.lnk/ # Work-specific storage
|
||||||
|
├── .ssh/
|
||||||
|
│ └── config
|
||||||
|
└── .gitconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Patterns
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Common config (shared everywhere) - supports multiple files
|
||||||
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
|
||||||
|
|
||||||
|
# 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
|
||||||
|
lnk list --host work # Work host only
|
||||||
|
lnk list --all # Everything
|
||||||
|
|
||||||
|
# Pull configurations
|
||||||
|
lnk pull # Common config
|
||||||
|
lnk pull --host work # Work-specific config
|
||||||
|
```
|
||||||
|
|
||||||
## Why not just Git?
|
## Why not just Git?
|
||||||
|
|
||||||
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
|
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
|
||||||
|
|
||||||
- Moving files safely
|
- Moving files safely (with atomic operations)
|
||||||
- Creating relative symlinks
|
- Creating relative symlinks
|
||||||
- Handling conflicts
|
- Handling conflicts and rollback
|
||||||
- Tracking what's managed
|
- Tracking what's managed
|
||||||
|
- Processing multiple files efficiently
|
||||||
|
- Recursive directory traversal
|
||||||
|
- Preview mode for safety
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### First time setup
|
### First time setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Clone dotfiles and run bootstrap automatically
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
# → Downloads dependencies, installs packages, configures environment
|
||||||
|
|
||||||
|
# Add common config (shared across all machines) - multiple files at once
|
||||||
|
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig ~/.tmux.conf
|
||||||
|
|
||||||
|
# Add 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"
|
lnk push "initial setup"
|
||||||
```
|
```
|
||||||
|
|
||||||
### On a new machine
|
### On a new machine
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Bootstrap runs automatically
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
lnk pull # auto-creates symlinks
|
# → 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
|
### Daily edits
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vim ~/.vimrc # edit normally
|
vim ~/.vimrc # edit normally
|
||||||
lnk status # check what changed
|
lnk list # see common config
|
||||||
lnk push "new plugins" # commit & push
|
lnk list --host $(hostname) # see host-specific config
|
||||||
|
lnk list --all # see everything
|
||||||
|
lnk status # check what changed
|
||||||
|
lnk push "new plugins" # commit & push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-machine workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On your laptop - use bulk operations for efficiency
|
||||||
|
lnk add --host laptop ~/.ssh/config ~/.aws/credentials ~/.npmrc
|
||||||
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Common config (multiple files)
|
||||||
|
lnk push "laptop configuration"
|
||||||
|
|
||||||
|
# On your 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `lnk init [-r remote]` - Create repo
|
- `lnk init [-r remote] [--no-bootstrap] [--force]` - Create repo (runs bootstrap automatically)
|
||||||
- `lnk add <files>` - Move files to repo, create symlinks
|
- `lnk add [--host HOST] [--recursive] [--dry-run] <files>...` - Move files to repo, create symlinks
|
||||||
- `lnk rm <files>` - Move files back, remove symlinks
|
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
||||||
|
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
||||||
- `lnk status` - Git status + sync info
|
- `lnk status` - Git status + sync info
|
||||||
- `lnk push [msg]` - Stage all, commit, push
|
- `lnk push [msg]` - Stage all, commit, push
|
||||||
- `lnk pull` - Pull + restore missing symlinks
|
- `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)
|
||||||
|
|
||||||
|
### Add Command Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Multiple files at once
|
||||||
|
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
||||||
|
|
||||||
|
# Recursive directory processing
|
||||||
|
lnk add --recursive ~/.config/nvim
|
||||||
|
|
||||||
|
# Preview changes first
|
||||||
|
lnk add --dry-run ~/.ssh/config
|
||||||
|
lnk add --dry-run --recursive ~/.config/kitty
|
||||||
|
|
||||||
|
# Host-specific bulk operations
|
||||||
|
lnk add --host work ~/.gitconfig ~/.ssh/config ~/.npmrc
|
||||||
|
```
|
||||||
|
|
||||||
## Technical bits
|
## Technical bits
|
||||||
|
|
||||||
- **Single binary** (~8MB, no deps)
|
- **Single binary** (~8MB, no deps)
|
||||||
- **Atomic operations** (rollback on failure)
|
|
||||||
- **Relative symlinks** (portable)
|
- **Relative symlinks** (portable)
|
||||||
- **XDG compliant** (`~/.config/lnk`)
|
- **XDG compliant** (`~/.config/lnk`)
|
||||||
- **20 integration tests**
|
- **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
|
## Alternatives
|
||||||
|
|
||||||
| Tool | Complexity | Why choose it |
|
| Tool | Complexity | Why choose it |
|
||||||
| ------- | ---------- | ------------------------------------- |
|
| ------- | ---------- | ----------------------------------------------------------------------------------------- |
|
||||||
| **lnk** | Minimal | Just works, no config, Git-native |
|
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks |
|
||||||
| chezmoi | High | Templates, encryption, cross-platform |
|
| chezmoi | High | Templates, encryption, cross-platform |
|
||||||
| yadm | Medium | Git power user, encryption |
|
| yadm | Medium | Git power user, encryption |
|
||||||
| dotbot | Low | YAML config, basic features |
|
| dotbot | Low | YAML config, basic features |
|
||||||
| stow | Low | Perl, symlink only |
|
| stow | Low | Perl, symlink only |
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: What if I already have dotfiles in Git?**
|
|
||||||
A: `git clone your-repo ~/.config/lnk && lnk add ~/.vimrc` (adopts existing files)
|
|
||||||
|
|
||||||
**Q: How do I handle machine-specific configs?**
|
|
||||||
A: Git branches, or just don't manage machine-specific files with lnk
|
|
||||||
|
|
||||||
**Q: Windows support?**
|
|
||||||
A: Symlinks work on Windows 10+, but untested
|
|
||||||
|
|
||||||
**Q: Production ready?**
|
|
||||||
A: I use it daily. It won't break your files. API might change (pre-1.0).
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
184
RELEASE.md
184
RELEASE.md
@@ -1,184 +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
|
|
172
cmd/add.go
172
cmd/add.go
@@ -1,32 +1,164 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var addCmd = &cobra.Command{
|
func newAddCmd() *cobra.Command {
|
||||||
Use: "add <file>",
|
cmd := &cobra.Command{
|
||||||
Short: "Add a file to lnk management",
|
Use: "add <file>...",
|
||||||
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
Short: "✨ Add files to lnk management",
|
||||||
Args: cobra.ExactArgs(1),
|
Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
filePath := args[0]
|
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
Examples:
|
||||||
if err := lnk.Add(filePath); err != nil {
|
lnk add ~/.bashrc ~/.vimrc # Add multiple files at once
|
||||||
return fmt.Errorf("failed to add file: %w", err)
|
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
|
||||||
|
|
||||||
basename := filepath.Base(filePath)
|
The --recursive flag processes directory contents individually instead of treating
|
||||||
fmt.Printf("Added %s to lnk\n", basename)
|
the directory as a single unit. This is useful for configuration directories where
|
||||||
return nil
|
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.`,
|
||||||
func init() {
|
Args: cobra.MinimumNArgs(1),
|
||||||
rootCmd.AddCommand(addCmd)
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
recursive, _ := cmd.Flags().GetBool("recursive")
|
||||||
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
|
|
||||||
|
// Handle dry-run mode
|
||||||
|
if dryRun {
|
||||||
|
files, err := lnk.PreviewAdd(args, recursive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display preview output
|
||||||
|
if recursive {
|
||||||
|
printf(cmd, "🔍 \033[1mWould add %d files recursively:\033[0m\n", len(files))
|
||||||
|
} else {
|
||||||
|
printf(cmd, "🔍 \033[1mWould add %d files:\033[0m\n", len(files))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List files that would be added
|
||||||
|
for _, file := range files {
|
||||||
|
basename := filepath.Base(file)
|
||||||
|
printf(cmd, " 📄 \033[90m%s\033[0m\n", basename)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 \033[33mTo proceed:\033[0m run without --dry-run flag\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle recursive mode
|
||||||
|
if recursive {
|
||||||
|
// Get preview to count files first for better output
|
||||||
|
previewFiles, err := lnk.PreviewAdd(args, recursive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create progress callback for CLI display
|
||||||
|
progressCallback := func(current, total int, currentFile string) {
|
||||||
|
printf(cmd, "\r⏳ Processing %d/%d: %s", current, total, currentFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear progress line and show completion
|
||||||
|
printf(cmd, "\r")
|
||||||
|
|
||||||
|
// Store processed file count for display
|
||||||
|
args = previewFiles // Replace args with actual files for display
|
||||||
|
} else {
|
||||||
|
// Use appropriate method based on number of files
|
||||||
|
if len(args) == 1 {
|
||||||
|
// Single file - use existing Add method for backward compatibility
|
||||||
|
if err := lnk.Add(args[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple files - use AddMultiple for atomic operation
|
||||||
|
if err := lnk.AddMultiple(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
if recursive {
|
||||||
|
// Recursive mode - show enhanced message with count
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk (host: %s)\033[0m\n", len(args), host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk\033[0m\n", len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show some of the files that were added (limit to first few for readability)
|
||||||
|
filesToShow := len(args)
|
||||||
|
if filesToShow > 5 {
|
||||||
|
filesToShow = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < filesToShow; i++ {
|
||||||
|
basename := filepath.Base(args[i])
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) > 5 {
|
||||||
|
printf(cmd, " \033[90m... and %d more files\033[0m\n", len(args)-5)
|
||||||
|
}
|
||||||
|
} else if len(args) == 1 {
|
||||||
|
// Single file - maintain existing output format for backward compatibility
|
||||||
|
filePath := args[0]
|
||||||
|
basename := filepath.Base(filePath)
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple files - show summary
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List each added file
|
||||||
|
for _, filePath := range args {
|
||||||
|
basename := filepath.Base(filePath)
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
|
||||||
|
cmd.Flags().BoolP("recursive", "r", false, "Add directory contents individually instead of the directory as a whole")
|
||||||
|
cmd.Flags().BoolP("dry-run", "n", false, "Show what would be added without making changes")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
45
cmd/bootstrap.go
Normal file
45
cmd/bootstrap.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBootstrapCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "bootstrap",
|
||||||
|
Short: "🚀 Run the bootstrap script to set up your environment",
|
||||||
|
Long: "Executes the bootstrap script from your dotfiles repository to install dependencies and configure your system.",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
lnk := core.NewLnk()
|
||||||
|
|
||||||
|
scriptPath, err := lnk.FindBootstrapScript()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if scriptPath == "" {
|
||||||
|
printf(cmd, "💡 \033[33mNo bootstrap script found\033[0m\n")
|
||||||
|
printf(cmd, " 📝 Create a \033[1mbootstrap.sh\033[0m file in your dotfiles repository:\n")
|
||||||
|
printf(cmd, " \033[90m#!/bin/bash\033[0m\n")
|
||||||
|
printf(cmd, " \033[90mecho \"Setting up environment...\"\033[0m\n")
|
||||||
|
printf(cmd, " \033[90m# Your setup commands here\033[0m\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n")
|
||||||
|
printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath)
|
||||||
|
printf(cmd, "\n")
|
||||||
|
|
||||||
|
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||||
|
printf(cmd, " 🎉 Your environment is ready to use\n")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
95
cmd/init.go
95
cmd/init.go
@@ -1,35 +1,82 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var initCmd = &cobra.Command{
|
func newInitCmd() *cobra.Command {
|
||||||
Use: "init",
|
cmd := &cobra.Command{
|
||||||
Short: "Initialize a new lnk repository",
|
Use: "init",
|
||||||
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
Short: "🎯 Initialize a new lnk repository",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
||||||
remote, _ := cmd.Flags().GetString("remote")
|
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()
|
lnk := core.NewLnk()
|
||||||
if err := lnk.InitWithRemote(remote); err != nil {
|
|
||||||
return fmt.Errorf("failed to initialize lnk: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if remote != "" {
|
// Show warning when force is used and there are managed files to overwrite
|
||||||
fmt.Printf("Initialized lnk repository by cloning: %s\n", remote)
|
if force && remote != "" && lnk.HasUserContent() {
|
||||||
} else {
|
printf(cmd, "⚠️ \033[33mUsing --force flag: This will overwrite existing managed files\033[0m\n")
|
||||||
fmt.Println("Initialized lnk repository")
|
printf(cmd, " 💡 Only use this if you understand the risks\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
if err := lnk.InitWithRemoteForce(remote, force); err != nil {
|
||||||
},
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
if remote != "" {
|
||||||
initCmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||||
rootCmd.AddCommand(initCmd)
|
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||||
|
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||||
|
|
||||||
|
// Try to run bootstrap script if not disabled
|
||||||
|
if !noBootstrap {
|
||||||
|
printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n")
|
||||||
|
|
||||||
|
scriptPath, err := lnk.FindBootstrapScript()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if scriptPath != "" {
|
||||||
|
printf(cmd, " ✅ Found bootstrap script: \033[36m%s\033[0m\n", scriptPath)
|
||||||
|
printf(cmd, "\n🚀 \033[1mRunning bootstrap script...\033[0m\n")
|
||||||
|
printf(cmd, "\n")
|
||||||
|
|
||||||
|
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||||
|
printf(cmd, "\n⚠️ \033[33mBootstrap script failed, but repository was initialized successfully\033[0m\n")
|
||||||
|
printf(cmd, " 💡 You can run it manually with: \033[1mlnk bootstrap\033[0m\n")
|
||||||
|
printf(cmd, " 🔧 Error: %v\n", err)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
printf(cmd, " 💡 No bootstrap script found\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||||
|
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
||||||
|
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
||||||
|
} 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
193
cmd/list.go
Normal file
193
cmd/list.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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,
|
||||||
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
all, _ := cmd.Flags().GetBool("all")
|
||||||
|
|
||||||
|
if host != "" {
|
||||||
|
// Show specific host configuration
|
||||||
|
return listHostConfig(cmd, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if all {
|
||||||
|
// Show all configurations (common + all hosts)
|
||||||
|
return listAllConfigs(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: show common configuration
|
||||||
|
return listCommonConfig(cmd)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "List files for specific host")
|
||||||
|
cmd.Flags().BoolP("all", "a", false, "List files for all hosts and common configuration")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func listCommonConfig(cmd *cobra.Command) error {
|
||||||
|
lnk := core.NewLnk()
|
||||||
|
managedItems, err := lnk.List()
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
|
||||||
|
if len(managedItems) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n\n")
|
||||||
|
|
||||||
|
for _, item := range managedItems {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||||
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
|
managedItems, err := lnk.List()
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
|
||||||
|
if len(managedItems) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n\n")
|
||||||
|
|
||||||
|
for _, item := range managedItems {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAllConfigs(cmd *cobra.Command) error {
|
||||||
|
// List common configuration
|
||||||
|
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
||||||
|
|
||||||
|
lnk := core.NewLnk()
|
||||||
|
commonItems, err := lnk.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
|
||||||
|
if len(commonItems) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n")
|
||||||
|
|
||||||
|
if len(commonItems) == 0 {
|
||||||
|
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||||
|
} else {
|
||||||
|
for _, item := range commonItems {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all host-specific configurations
|
||||||
|
hosts, err := findHostConfigs()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||||
|
|
||||||
|
hostLnk := core.NewLnk(core.WithHost(host))
|
||||||
|
hostItems, err := hostLnk.List()
|
||||||
|
if err != nil {
|
||||||
|
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, " (\033[36m%d item", len(hostItems))
|
||||||
|
if len(hostItems) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n")
|
||||||
|
|
||||||
|
if len(hostItems) == 0 {
|
||||||
|
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||||
|
} else {
|
||||||
|
for _, item := range hostItems {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findHostConfigs() ([]string, error) {
|
||||||
|
repoPath := getRepoPath()
|
||||||
|
|
||||||
|
// Check if repo exists
|
||||||
|
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var hosts []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
// Look for .lnk.<hostname> files
|
||||||
|
if strings.HasPrefix(name, ".lnk.") && name != ".lnk" {
|
||||||
|
host := strings.TrimPrefix(name, ".lnk.")
|
||||||
|
hosts = append(hosts, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRepoPath() string {
|
||||||
|
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if xdgConfig == "" {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
xdgConfig = "."
|
||||||
|
} else {
|
||||||
|
xdgConfig = filepath.Join(homeDir, ".config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(xdgConfig, "lnk")
|
||||||
|
}
|
68
cmd/pull.go
68
cmd/pull.go
@@ -1,36 +1,56 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pullCmd = &cobra.Command{
|
func newPullCmd() *cobra.Command {
|
||||||
Use: "pull",
|
cmd := &cobra.Command{
|
||||||
Short: "Pull changes from remote and restore symlinks",
|
Use: "pull",
|
||||||
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
Short: "⬇️ Pull changes from remote and restore symlinks",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
||||||
lnk := core.NewLnk()
|
SilenceUsage: true,
|
||||||
restored, err := lnk.Pull()
|
SilenceErrors: true,
|
||||||
if err != nil {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("failed to pull changes: %w", err)
|
host, _ := cmd.Flags().GetString("host")
|
||||||
}
|
|
||||||
|
|
||||||
if len(restored) > 0 {
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
fmt.Printf("Successfully pulled changes and restored %d symlink(s):\n", len(restored))
|
|
||||||
for _, file := range restored {
|
restored, err := lnk.Pull()
|
||||||
fmt.Printf(" - %s\n", file)
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
fmt.Println("Successfully pulled changes (no symlinks needed restoration)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
if len(restored) > 0 {
|
||||||
},
|
if host != "" {
|
||||||
}
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||||
|
}
|
||||||
|
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
||||||
|
if len(restored) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m:\n")
|
||||||
|
for _, file := range restored {
|
||||||
|
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
|
||||||
|
}
|
||||||
|
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||||
|
} else {
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||||
|
}
|
||||||
|
printf(cmd, " ✅ All symlinks already in place\n")
|
||||||
|
printf(cmd, " 🎉 Everything is up to date!\n")
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
return nil
|
||||||
rootCmd.AddCommand(pullCmd)
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
47
cmd/push.go
47
cmd/push.go
@@ -1,33 +1,34 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pushCmd = &cobra.Command{
|
func newPushCmd() *cobra.Command {
|
||||||
Use: "push [message]",
|
return &cobra.Command{
|
||||||
Short: "Push local changes to remote repository",
|
Use: "push [message]",
|
||||||
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
Short: "🚀 Push local changes to remote repository",
|
||||||
Args: cobra.MaximumNArgs(1),
|
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Args: cobra.MaximumNArgs(1),
|
||||||
message := "lnk: sync configuration files"
|
SilenceUsage: true,
|
||||||
if len(args) > 0 {
|
SilenceErrors: true,
|
||||||
message = args[0]
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
}
|
message := "lnk: sync configuration files"
|
||||||
|
if len(args) > 0 {
|
||||||
|
message = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
if err := lnk.Push(message); err != nil {
|
if err := lnk.Push(message); err != nil {
|
||||||
return fmt.Errorf("failed to push changes: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Successfully pushed changes to remote")
|
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
||||||
return nil
|
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
|
||||||
func init() {
|
},
|
||||||
rootCmd.AddCommand(pushCmd)
|
}
|
||||||
}
|
}
|
||||||
|
51
cmd/rm.go
51
cmd/rm.go
@@ -1,32 +1,43 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rmCmd = &cobra.Command{
|
func newRemoveCmd() *cobra.Command {
|
||||||
Use: "rm <file>",
|
cmd := &cobra.Command{
|
||||||
Short: "Remove a file from lnk management",
|
Use: "rm <file>",
|
||||||
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
Short: "🗑️ Remove a file from lnk management",
|
||||||
Args: cobra.ExactArgs(1),
|
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Args: cobra.ExactArgs(1),
|
||||||
filePath := args[0]
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
filePath := args[0]
|
||||||
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
if err := lnk.Remove(filePath); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
basename := filepath.Base(filePath)
|
if err := lnk.Remove(filePath); err != nil {
|
||||||
fmt.Printf("Removed %s from lnk\n", basename)
|
return err
|
||||||
return nil
|
}
|
||||||
},
|
|
||||||
}
|
basename := filepath.Base(filePath)
|
||||||
|
if host != "" {
|
||||||
func init() {
|
printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host)
|
||||||
rootCmd.AddCommand(rmCmd)
|
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
printf(cmd, " 📄 Original file restored\n")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
51
cmd/root.go
51
cmd/root.go
@@ -12,22 +12,61 @@ var (
|
|||||||
buildTime = "unknown"
|
buildTime = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
// NewRootCommand creates a new root command (testable)
|
||||||
Use: "lnk",
|
func NewRootCommand() *cobra.Command {
|
||||||
Short: "Dotfiles, linked. No fluff.",
|
rootCmd := &cobra.Command{
|
||||||
Long: "Lnk is a minimalist CLI tool for managing dotfiles using symlinks and Git.",
|
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, host-specific setups, and bulk operations for multiple files.
|
||||||
|
|
||||||
|
✨ Examples:
|
||||||
|
lnk init # Fresh start
|
||||||
|
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
|
||||||
|
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
||||||
|
lnk add --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,
|
||||||
|
SilenceErrors: true,
|
||||||
|
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subcommands
|
||||||
|
rootCmd.AddCommand(newInitCmd())
|
||||||
|
rootCmd.AddCommand(newAddCmd())
|
||||||
|
rootCmd.AddCommand(newRemoveCmd())
|
||||||
|
rootCmd.AddCommand(newListCmd())
|
||||||
|
rootCmd.AddCommand(newStatusCmd())
|
||||||
|
rootCmd.AddCommand(newPushCmd())
|
||||||
|
rootCmd.AddCommand(newPullCmd())
|
||||||
|
rootCmd.AddCommand(newBootstrapCmd())
|
||||||
|
|
||||||
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetVersion sets the version information for the CLI
|
// SetVersion sets the version information for the CLI
|
||||||
func SetVersion(v, bt string) {
|
func SetVersion(v, bt string) {
|
||||||
version = v
|
version = v
|
||||||
buildTime = bt
|
buildTime = bt
|
||||||
rootCmd.Version = fmt.Sprintf("%s (built %s)", version, buildTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
|
rootCmd := NewRootCommand()
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1810
cmd/root_test.go
Normal file
1810
cmd/root_test.go
Normal file
File diff suppressed because it is too large
Load Diff
102
cmd/status.go
102
cmd/status.go
@@ -1,38 +1,92 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var statusCmd = &cobra.Command{
|
func newStatusCmd() *cobra.Command {
|
||||||
Use: "status",
|
return &cobra.Command{
|
||||||
Short: "Show repository sync status",
|
Use: "status",
|
||||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
|
Short: "📊 Show repository sync status",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||||
lnk := core.NewLnk()
|
SilenceUsage: true,
|
||||||
status, err := lnk.Status()
|
SilenceErrors: true,
|
||||||
if err != nil {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("failed to get status: %w", err)
|
lnk := core.NewLnk()
|
||||||
}
|
status, err := lnk.Status()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if status.Ahead == 0 && status.Behind == 0 {
|
if status.Dirty {
|
||||||
fmt.Println("Repository is up to date with remote")
|
displayDirtyStatus(cmd, status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Ahead == 0 && status.Behind == 0 {
|
||||||
|
displayUpToDateStatus(cmd, status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
displaySyncStatus(cmd, status)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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")
|
||||||
|
} else if status.Behind > 0 {
|
||||||
|
printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
||||||
|
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)
|
||||||
} else {
|
} else {
|
||||||
if status.Ahead > 0 {
|
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
||||||
fmt.Printf("Your branch is ahead of '%s' by %d commit(s)\n", status.Remote, status.Ahead)
|
|
||||||
}
|
|
||||||
if status.Behind > 0 {
|
|
||||||
fmt.Printf("Your branch is behind '%s' by %d commit(s)\n", status.Remote, status.Behind)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func getCommitText(count int) string {
|
||||||
rootCmd.AddCommand(statusCmd)
|
if count == 1 {
|
||||||
|
return "commit"
|
||||||
|
}
|
||||||
|
return "commits"
|
||||||
}
|
}
|
||||||
|
12
cmd/utils.go
Normal file
12
cmd/utils.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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...)
|
||||||
|
}
|
84
install.sh
84
install.sh
@@ -17,6 +17,9 @@ REPO="yarlson/lnk"
|
|||||||
INSTALL_DIR="/usr/local/bin"
|
INSTALL_DIR="/usr/local/bin"
|
||||||
BINARY_NAME="lnk"
|
BINARY_NAME="lnk"
|
||||||
|
|
||||||
|
# Fallback version if redirect fails
|
||||||
|
FALLBACK_VERSION="v0.3.0"
|
||||||
|
|
||||||
# Detect OS and architecture
|
# Detect OS and architecture
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
local os arch
|
local os arch
|
||||||
@@ -45,11 +48,44 @@ detect_platform() {
|
|||||||
echo "${os}_${arch}"
|
echo "${os}_${arch}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the latest release version
|
# Get latest version by following redirect
|
||||||
get_latest_version() {
|
get_latest_version() {
|
||||||
curl -s "https://api.github.com/repos/${REPO}/releases/latest" | \
|
echo -e "${BLUE}Getting latest release version...${NC}" >&2
|
||||||
grep '"tag_name":' | \
|
|
||||||
sed -E 's/.*"([^"]+)".*/\1/'
|
# 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get version to install
|
||||||
|
get_version() {
|
||||||
|
# Allow override via environment variable
|
||||||
|
if [ -n "$LNK_VERSION" ]; then
|
||||||
|
echo "$LNK_VERSION"
|
||||||
|
elif [ -n "$1" ]; then
|
||||||
|
echo "$1"
|
||||||
|
else
|
||||||
|
get_latest_version
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Download and install
|
# Download and install
|
||||||
@@ -59,14 +95,9 @@ install_lnk() {
|
|||||||
echo -e "${BLUE}🔗 Installing lnk...${NC}"
|
echo -e "${BLUE}🔗 Installing lnk...${NC}"
|
||||||
|
|
||||||
platform=$(detect_platform)
|
platform=$(detect_platform)
|
||||||
version=$(get_latest_version)
|
version=$(get_version "$1")
|
||||||
|
|
||||||
if [ -z "$version" ]; then
|
echo -e "${BLUE}Version: ${version}${NC}"
|
||||||
echo -e "${RED}Error: Failed to get latest version${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${BLUE}Latest version: ${version}${NC}"
|
|
||||||
echo -e "${BLUE}Platform: ${platform}${NC}"
|
echo -e "${BLUE}Platform: ${platform}${NC}"
|
||||||
|
|
||||||
# Download URL
|
# Download URL
|
||||||
@@ -82,6 +113,16 @@ install_lnk() {
|
|||||||
# Download the binary
|
# Download the binary
|
||||||
if ! curl -sL "$url" -o "$filename"; then
|
if ! curl -sL "$url" -o "$filename"; then
|
||||||
echo -e "${RED}Error: Failed to download ${url}${NC}"
|
echo -e "${RED}Error: Failed to download ${url}${NC}"
|
||||||
|
echo -e "${YELLOW}Please check if the release exists at: https://github.com/${REPO}/releases/tag/${version}${NC}"
|
||||||
|
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}"
|
||||||
|
echo -e "${YELLOW}The release ${version} might not exist.${NC}"
|
||||||
|
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -107,20 +148,33 @@ install_lnk() {
|
|||||||
|
|
||||||
echo -e "${GREEN}✅ lnk installed successfully!${NC}"
|
echo -e "${GREEN}✅ lnk installed successfully!${NC}"
|
||||||
echo -e "${GREEN}Run 'lnk --help' to get started.${NC}"
|
echo -e "${GREEN}Run 'lnk --help' to get started.${NC}"
|
||||||
|
|
||||||
|
# Test the installation
|
||||||
|
if command -v lnk >/dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}Installed version: $(lnk --version)${NC}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if running with --help
|
# Check if running with --help
|
||||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||||
echo "Lnk installer script"
|
echo "Lnk installer script"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Usage: curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
|
echo "Usage:"
|
||||||
|
echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
|
||||||
|
echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash -s v0.0.1"
|
||||||
|
echo " LNK_VERSION=v0.0.1 curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
|
||||||
echo ""
|
echo ""
|
||||||
echo "This script will:"
|
echo "This script will:"
|
||||||
echo " 1. Detect your OS and architecture"
|
echo " 1. Detect your OS and architecture"
|
||||||
echo " 2. Download the latest lnk release"
|
echo " 2. Auto-detect the latest release by following GitHub redirects"
|
||||||
echo " 3. Install it to /usr/local/bin (requires sudo)"
|
echo " 3. Download and install to /usr/local/bin (requires sudo)"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables:"
|
||||||
|
echo " LNK_VERSION - Specify version to install (e.g., v0.0.1)"
|
||||||
|
echo ""
|
||||||
|
echo "Manual installation: https://github.com/yarlson/lnk/releases"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run the installer
|
# Run the installer
|
||||||
install_lnk
|
install_lnk "$1"
|
||||||
|
File diff suppressed because it is too large
Load Diff
1602
internal/core/lnk_test.go
Normal file
1602
internal/core/lnk_test.go
Normal file
File diff suppressed because it is too large
Load Diff
119
internal/fs/errors.go
Normal file
119
internal/fs/errors.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package fs
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ANSI color codes for consistent formatting
|
||||||
|
const (
|
||||||
|
colorReset = "\033[0m"
|
||||||
|
colorRed = "\033[31m"
|
||||||
|
colorBold = "\033[1m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatError creates a consistently formatted error message with ❌ prefix
|
||||||
|
func formatError(message string, args ...interface{}) string {
|
||||||
|
return fmt.Sprintf("❌ "+message, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatPath formats a file path with red color
|
||||||
|
func formatPath(path string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", colorRed, path, colorReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCommand formats a command with bold styling
|
||||||
|
func formatCommand(command string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", colorBold, command, colorReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileNotExistsError represents an error when a file does not exist
|
||||||
|
type FileNotExistsError struct {
|
||||||
|
Path string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FileNotExistsError) Error() string {
|
||||||
|
return formatError("File or directory not found: %s", formatPath(e.Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FileNotExistsError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileCheckError represents an error when failing to check a file
|
||||||
|
type FileCheckError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FileCheckError) Error() string {
|
||||||
|
return formatError("Unable to access file. Please check file permissions and try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *FileCheckError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsupportedFileTypeError represents an error when a file type is not supported
|
||||||
|
type UnsupportedFileTypeError struct {
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UnsupportedFileTypeError) Error() string {
|
||||||
|
return formatError("Cannot manage this type of file: %s\n 💡 lnk can only manage regular files and directories", formatPath(e.Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UnsupportedFileTypeError) Unwrap() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotManagedByLnkError represents an error when a file is not managed by lnk
|
||||||
|
type NotManagedByLnkError struct {
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NotManagedByLnkError) Error() string {
|
||||||
|
return formatError("File is not managed by lnk: %s\n 💡 Use %s to manage this file first",
|
||||||
|
formatPath(e.Path), formatCommand("lnk add"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NotManagedByLnkError) Unwrap() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SymlinkReadError represents an error when failing to read a symlink
|
||||||
|
type SymlinkReadError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SymlinkReadError) Error() string {
|
||||||
|
return formatError("Unable to read symlink. The file may be corrupted or have invalid permissions.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SymlinkReadError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectoryCreationError represents an error when failing to create a directory
|
||||||
|
type DirectoryCreationError struct {
|
||||||
|
Operation string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DirectoryCreationError) Error() string {
|
||||||
|
return formatError("Failed to create directory. Please check permissions and available disk space.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DirectoryCreationError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelativePathCalculationError represents an error when failing to calculate relative path
|
||||||
|
type RelativePathCalculationError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RelativePathCalculationError) Error() string {
|
||||||
|
return formatError("Unable to create symlink due to path configuration issues. Please check file locations.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RelativePathCalculationError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
@@ -1,7 +1,6 @@
|
|||||||
package fs
|
package fs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,18 +16,19 @@ func New() *FileSystem {
|
|||||||
|
|
||||||
// ValidateFileForAdd validates that a file or directory can be added to lnk
|
// ValidateFileForAdd validates that a file or directory can be added to lnk
|
||||||
func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
|
func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
|
||||||
// Check if file exists
|
// Check if file exists and get its info
|
||||||
info, err := os.Stat(filePath)
|
info, err := os.Stat(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("file does not exist: %s", filePath)
|
return &FileNotExistsError{Path: filePath, Err: err}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to stat file: %w", err)
|
|
||||||
|
return &FileCheckError{Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow both regular files and directories
|
// Allow both regular files and directories
|
||||||
if !info.Mode().IsRegular() && !info.IsDir() {
|
if !info.Mode().IsRegular() && !info.IsDir() {
|
||||||
return fmt.Errorf("only regular files and directories are supported: %s", filePath)
|
return &UnsupportedFileTypeError{Path: filePath}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -36,98 +36,79 @@ func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
|
|||||||
|
|
||||||
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk
|
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk
|
||||||
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
|
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
|
||||||
// Check if file exists
|
// Check if file exists and is a symlink
|
||||||
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
|
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("file does not exist: %s", filePath)
|
return &FileNotExistsError{Path: filePath, Err: err}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to stat file: %w", err)
|
|
||||||
|
return &FileCheckError{Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a symlink
|
|
||||||
if info.Mode()&os.ModeSymlink == 0 {
|
if info.Mode()&os.ModeSymlink == 0 {
|
||||||
return fmt.Errorf("file is not managed by lnk: %s", filePath)
|
return &NotManagedByLnkError{Path: filePath}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if symlink points to the repository
|
// Get symlink target and resolve to absolute path
|
||||||
target, err := os.Readlink(filePath)
|
target, err := os.Readlink(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read symlink: %w", err)
|
return &SymlinkReadError{Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert relative path to absolute if needed
|
|
||||||
if !filepath.IsAbs(target) {
|
if !filepath.IsAbs(target) {
|
||||||
target = filepath.Join(filepath.Dir(filePath), target)
|
target = filepath.Join(filepath.Dir(filePath), target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean the path to resolve any .. or . components
|
// Clean paths and check if target is inside the repository
|
||||||
target = filepath.Clean(target)
|
target = filepath.Clean(target)
|
||||||
repoPath = filepath.Clean(repoPath)
|
repoPath = filepath.Clean(repoPath)
|
||||||
|
|
||||||
// Check if target is inside the repository
|
|
||||||
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
|
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
|
||||||
return fmt.Errorf("file is not managed by lnk: %s", filePath)
|
return &NotManagedByLnkError{Path: filePath}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move moves a file or directory from source to destination based on the file info
|
||||||
|
func (fs *FileSystem) Move(src, dst string, info os.FileInfo) error {
|
||||||
|
if info.IsDir() {
|
||||||
|
return fs.MoveDirectory(src, dst)
|
||||||
|
}
|
||||||
|
return fs.MoveFile(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
// MoveFile moves a file from source to destination
|
// MoveFile moves a file from source to destination
|
||||||
func (fs *FileSystem) MoveFile(src, dst string) error {
|
func (fs *FileSystem) MoveFile(src, dst string) error {
|
||||||
// Ensure destination directory exists
|
// Ensure destination directory exists
|
||||||
dstDir := filepath.Dir(dst)
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
return &DirectoryCreationError{Operation: "destination directory", Err: err}
|
||||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move the file
|
// Move the file
|
||||||
if err := os.Rename(src, dst); err != nil {
|
return os.Rename(src, dst)
|
||||||
return fmt.Errorf("failed to move file from %s to %s: %w", src, dst, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSymlink creates a relative symlink from target to linkPath
|
// CreateSymlink creates a relative symlink from target to linkPath
|
||||||
func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
|
func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
|
||||||
// Calculate relative path from linkPath to target
|
// Calculate relative path from linkPath to target
|
||||||
linkDir := filepath.Dir(linkPath)
|
relTarget, err := filepath.Rel(filepath.Dir(linkPath), target)
|
||||||
relTarget, err := filepath.Rel(linkDir, target)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to calculate relative path: %w", err)
|
return &RelativePathCalculationError{Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the symlink
|
// Create the symlink
|
||||||
if err := os.Symlink(relTarget, linkPath); err != nil {
|
return os.Symlink(relTarget, linkPath)
|
||||||
return fmt.Errorf("failed to create symlink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveDirectory moves a directory from source to destination recursively
|
// MoveDirectory moves a directory from source to destination recursively
|
||||||
func (fs *FileSystem) MoveDirectory(src, dst string) error {
|
func (fs *FileSystem) MoveDirectory(src, dst string) error {
|
||||||
// Check if source is a directory
|
|
||||||
info, err := os.Stat(src)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to stat source: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !info.IsDir() {
|
|
||||||
return fmt.Errorf("source is not a directory: %s", src)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure destination parent directory exists
|
// Ensure destination parent directory exists
|
||||||
dstParent := filepath.Dir(dst)
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
if err := os.MkdirAll(dstParent, 0755); err != nil {
|
return &DirectoryCreationError{Operation: "destination parent directory", Err: err}
|
||||||
return fmt.Errorf("failed to create destination parent directory: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use os.Rename which works for directories
|
// Move the directory
|
||||||
if err := os.Rename(src, dst); err != nil {
|
return os.Rename(src, dst)
|
||||||
return fmt.Errorf("failed to move directory from %s to %s: %w", src, dst, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
218
internal/git/errors.go
Normal file
218
internal/git/errors.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ANSI color codes for consistent formatting
|
||||||
|
const (
|
||||||
|
colorReset = "\033[0m"
|
||||||
|
colorBold = "\033[1m"
|
||||||
|
colorGreen = "\033[32m"
|
||||||
|
colorYellow = "\033[33m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatError creates a consistently formatted error message with ❌ prefix
|
||||||
|
func formatError(message string, args ...interface{}) string {
|
||||||
|
return fmt.Sprintf("❌ "+message, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatURL formats a URL with styling
|
||||||
|
func formatURL(url string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", colorBold, url, colorReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRemote formats a remote name with styling
|
||||||
|
func formatRemote(remote string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", colorGreen, remote, colorReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitInitError represents an error during git initialization
|
||||||
|
type GitInitError struct {
|
||||||
|
Output string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GitInitError) Error() string {
|
||||||
|
return formatError("Failed to initialize git repository. Please ensure git is installed and try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GitInitError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchSetupError represents an error setting up the default branch
|
||||||
|
type BranchSetupError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BranchSetupError) Error() string {
|
||||||
|
return formatError("Failed to set up the default branch. Please check your git installation.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BranchSetupError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteExistsError represents an error when a remote already exists with different URL
|
||||||
|
type RemoteExistsError struct {
|
||||||
|
Remote string
|
||||||
|
ExistingURL string
|
||||||
|
NewURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RemoteExistsError) Error() string {
|
||||||
|
return formatError("Remote %s is already configured with a different repository (%s). Cannot add %s.",
|
||||||
|
formatRemote(e.Remote), formatURL(e.ExistingURL), formatURL(e.NewURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RemoteExistsError) Unwrap() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitCommandError represents a generic git command execution error
|
||||||
|
type GitCommandError struct {
|
||||||
|
Command string
|
||||||
|
Output string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GitCommandError) Error() string {
|
||||||
|
// Provide user-friendly messages based on common command types
|
||||||
|
switch e.Command {
|
||||||
|
case "add":
|
||||||
|
return formatError("Failed to stage files for commit. Please check file permissions and try again.")
|
||||||
|
case "commit":
|
||||||
|
return formatError("Failed to create commit. Please ensure you have staged changes and try again.")
|
||||||
|
case "remote add":
|
||||||
|
return formatError("Failed to add remote repository. Please check the repository URL and try again.")
|
||||||
|
case "rm":
|
||||||
|
return formatError("Failed to remove file from git tracking. Please check if the file exists and try again.")
|
||||||
|
case "log":
|
||||||
|
return formatError("Failed to retrieve commit history.")
|
||||||
|
case "remote":
|
||||||
|
return formatError("Failed to retrieve remote repository information.")
|
||||||
|
case "clone":
|
||||||
|
return formatError("Failed to clone repository. Please check the repository URL and your network connection.")
|
||||||
|
default:
|
||||||
|
return formatError("Git operation failed. Please check your repository state and try again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GitCommandError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoRemoteError represents an error when no remote is configured
|
||||||
|
type NoRemoteError struct{}
|
||||||
|
|
||||||
|
func (e *NoRemoteError) Error() string {
|
||||||
|
return formatError("No remote repository is configured. Please add a remote repository first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NoRemoteError) Unwrap() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteNotFoundError represents an error when a specific remote is not found
|
||||||
|
type RemoteNotFoundError struct {
|
||||||
|
Remote string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RemoteNotFoundError) Error() string {
|
||||||
|
return formatError("Remote repository %s is not configured.", formatRemote(e.Remote))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RemoteNotFoundError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitConfigError represents an error with git configuration
|
||||||
|
type GitConfigError struct {
|
||||||
|
Setting string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GitConfigError) Error() string {
|
||||||
|
return formatError("Failed to configure git settings. Please check your git installation.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GitConfigError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UncommittedChangesError represents an error checking for uncommitted changes
|
||||||
|
type UncommittedChangesError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UncommittedChangesError) Error() string {
|
||||||
|
return formatError("Failed to check repository status. Please verify your git repository is valid.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UncommittedChangesError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectoryRemovalError represents an error removing a directory
|
||||||
|
type DirectoryRemovalError struct {
|
||||||
|
Path string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DirectoryRemovalError) Error() string {
|
||||||
|
return formatError("Failed to prepare directory for operation. Please check directory permissions.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DirectoryRemovalError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectoryCreationError represents an error creating a directory
|
||||||
|
type DirectoryCreationError struct {
|
||||||
|
Path string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DirectoryCreationError) Error() string {
|
||||||
|
return formatError("Failed to create directory. Please check permissions and available disk space.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DirectoryCreationError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushError represents an error during git push operation
|
||||||
|
type PushError struct {
|
||||||
|
Reason string
|
||||||
|
Output string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PushError) Error() string {
|
||||||
|
if e.Reason != "" {
|
||||||
|
return formatError("Cannot push changes: %s", e.Reason)
|
||||||
|
}
|
||||||
|
return formatError("Failed to push changes to remote repository. Please check your network connection and repository permissions.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PushError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullError represents an error during git pull operation
|
||||||
|
type PullError struct {
|
||||||
|
Reason string
|
||||||
|
Output string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PullError) Error() string {
|
||||||
|
if e.Reason != "" {
|
||||||
|
return formatError("Cannot pull changes: %s", e.Reason)
|
||||||
|
}
|
||||||
|
return formatError("Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PullError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
@@ -34,7 +34,7 @@ func (g *Git) Init() error {
|
|||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output))
|
return &GitInitError{Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the default branch to main
|
// Set the default branch to main
|
||||||
@@ -42,7 +42,7 @@ func (g *Git) Init() error {
|
|||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to set default branch to main: %w", err)
|
return &BranchSetupError{Err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ func (g *Git) AddRemote(name, url string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Different URL, error
|
// Different URL, error
|
||||||
return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url)
|
return &RemoteExistsError{Remote: name, ExistingURL: existingURL, NewURL: url}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote doesn't exist, add it
|
// Remote doesn't exist, add it
|
||||||
@@ -69,7 +69,7 @@ func (g *Git) AddRemote(name, url string) error {
|
|||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output))
|
return &GitCommandError{Command: "remote add", Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -164,7 +164,7 @@ func (g *Git) Add(filename string) error {
|
|||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
|
return &GitCommandError{Command: "add", Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -189,7 +189,7 @@ func (g *Git) Remove(filename string) error {
|
|||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output))
|
return &GitCommandError{Command: "rm", Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -207,7 +207,7 @@ func (g *Git) Commit(message string) error {
|
|||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output))
|
return &GitCommandError{Command: "commit", Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -223,7 +223,7 @@ func (g *Git) ensureGitConfig() error {
|
|||||||
cmd = exec.Command("git", "config", "user.name", "Lnk User")
|
cmd = exec.Command("git", "config", "user.name", "Lnk User")
|
||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to set git user.name: %w", err)
|
return &GitConfigError{Setting: "user.name", Err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ func (g *Git) ensureGitConfig() error {
|
|||||||
cmd = exec.Command("git", "config", "user.email", "lnk@localhost")
|
cmd = exec.Command("git", "config", "user.email", "lnk@localhost")
|
||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to set git user.email: %w", err)
|
return &GitConfigError{Setting: "user.email", Err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ func (g *Git) GetCommits() ([]string, error) {
|
|||||||
if strings.Contains(outputStr, "does not have any commits yet") {
|
if strings.Contains(outputStr, "does not have any commits yet") {
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("git log failed: %w", err)
|
return nil, &GitCommandError{Command: "log", Output: outputStr, Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
|
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
@@ -282,18 +282,18 @@ func (g *Git) GetRemoteInfo() (string, error) {
|
|||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to list remotes: %w", err)
|
return "", &GitCommandError{Command: "remote", Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
if len(remotes) == 0 || remotes[0] == "" {
|
if len(remotes) == 0 || remotes[0] == "" {
|
||||||
return "", fmt.Errorf("no remote configured")
|
return "", &NoRemoteError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the first remote
|
// Use the first remote
|
||||||
url, err = g.getRemoteURL(remotes[0])
|
url, err = g.getRemoteURL(remotes[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get remote URL: %w", err)
|
return "", &RemoteNotFoundError{Remote: remotes[0], Err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +305,7 @@ type StatusInfo struct {
|
|||||||
Ahead int
|
Ahead int
|
||||||
Behind int
|
Behind int
|
||||||
Remote string
|
Remote string
|
||||||
|
Dirty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns the repository status relative to remote
|
// GetStatus returns the repository status relative to remote
|
||||||
@@ -315,6 +316,12 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for uncommitted changes
|
||||||
|
dirty, err := g.HasChanges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &UncommittedChangesError{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
// Get the remote tracking branch
|
// Get the remote tracking branch
|
||||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
@@ -327,6 +334,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
|||||||
Ahead: g.getAheadCount(remoteBranch),
|
Ahead: g.getAheadCount(remoteBranch),
|
||||||
Behind: 0, // Can't be behind if no upstream
|
Behind: 0, // Can't be behind if no upstream
|
||||||
Remote: remoteBranch,
|
Remote: remoteBranch,
|
||||||
|
Dirty: dirty,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +344,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
|||||||
Ahead: g.getAheadCount(remoteBranch),
|
Ahead: g.getAheadCount(remoteBranch),
|
||||||
Behind: g.getBehindCount(remoteBranch),
|
Behind: g.getBehindCount(remoteBranch),
|
||||||
Remote: remoteBranch,
|
Remote: remoteBranch,
|
||||||
|
Dirty: dirty,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +410,7 @@ func (g *Git) HasChanges() (bool, error) {
|
|||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("git status failed: %w", err)
|
return false, &GitCommandError{Command: "status", Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||||
@@ -414,7 +423,7 @@ func (g *Git) AddAll() error {
|
|||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
|
return &GitCommandError{Command: "add", Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -425,15 +434,15 @@ func (g *Git) Push() error {
|
|||||||
// First ensure we have a remote configured
|
// First ensure we have a remote configured
|
||||||
_, err := g.GetRemoteInfo()
|
_, err := g.GetRemoteInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot push: %w", err)
|
return &PushError{Reason: err.Error(), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("git", "push", "-u", "origin", "main")
|
cmd := exec.Command("git", "push", "-u", "origin")
|
||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output))
|
return &PushError{Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -444,15 +453,15 @@ func (g *Git) Pull() error {
|
|||||||
// First ensure we have a remote configured
|
// First ensure we have a remote configured
|
||||||
_, err := g.GetRemoteInfo()
|
_, err := g.GetRemoteInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot pull: %w", err)
|
return &PullError{Reason: err.Error(), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("git", "pull", "origin", "main")
|
cmd := exec.Command("git", "pull", "origin")
|
||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output))
|
return &PullError{Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -462,20 +471,20 @@ func (g *Git) Pull() error {
|
|||||||
func (g *Git) Clone(url string) error {
|
func (g *Git) Clone(url string) error {
|
||||||
// Remove the directory if it exists to ensure clean clone
|
// Remove the directory if it exists to ensure clean clone
|
||||||
if err := os.RemoveAll(g.repoPath); err != nil {
|
if err := os.RemoveAll(g.repoPath); err != nil {
|
||||||
return fmt.Errorf("failed to remove existing directory: %w", err)
|
return &DirectoryRemovalError{Path: g.repoPath, Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create parent directory
|
// Create parent directory
|
||||||
parentDir := filepath.Dir(g.repoPath)
|
parentDir := filepath.Dir(g.repoPath)
|
||||||
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create parent directory: %w", err)
|
return &DirectoryCreationError{Path: parentDir, Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the repository
|
// Clone the repository
|
||||||
cmd := exec.Command("git", "clone", url, g.repoPath)
|
cmd := exec.Command("git", "clone", url, g.repoPath)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output))
|
return &GitCommandError{Command: "clone", Output: string(output), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up upstream tracking for main branch
|
// Set up upstream tracking for main branch
|
||||||
|
@@ -1,718 +0,0 @@
|
|||||||
package test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LnkIntegrationTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
tempDir string
|
|
||||||
originalDir string
|
|
||||||
lnk *core.Lnk
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) SetupTest() {
|
|
||||||
// Create temporary directory for each test
|
|
||||||
tempDir, err := os.MkdirTemp("", "lnk-test-*")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.tempDir = tempDir
|
|
||||||
|
|
||||||
// Change to temp directory
|
|
||||||
originalDir, err := os.Getwd()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.originalDir = originalDir
|
|
||||||
|
|
||||||
err = os.Chdir(tempDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Set XDG_CONFIG_HOME to temp directory
|
|
||||||
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
|
||||||
|
|
||||||
// Initialize Lnk instance
|
|
||||||
suite.lnk = core.NewLnk()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TearDownTest() {
|
|
||||||
// Return to original directory
|
|
||||||
err := os.Chdir(suite.originalDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Clean up temp directory
|
|
||||||
err = os.RemoveAll(suite.tempDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInit() {
|
|
||||||
// Test that init creates the directory and Git repo
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the lnk directory was created
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
suite.DirExists(lnkDir)
|
|
||||||
|
|
||||||
// Check that Git repo was initialized
|
|
||||||
gitDir := filepath.Join(lnkDir, ".git")
|
|
||||||
suite.DirExists(gitDir)
|
|
||||||
|
|
||||||
// Verify it's a non-bare repo
|
|
||||||
configPath := filepath.Join(gitDir, "config")
|
|
||||||
suite.FileExists(configPath)
|
|
||||||
|
|
||||||
// Verify the default branch is set to 'main'
|
|
||||||
cmd := exec.Command("git", "symbolic-ref", "HEAD")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
output, err := cmd.Output()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal("refs/heads/main", strings.TrimSpace(string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestAddFile() {
|
|
||||||
// Initialize first
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
|
||||||
content := "export PATH=$PATH:/usr/local/bin"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add the file
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the original file is now a symlink
|
|
||||||
info, err := os.Lstat(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
|
||||||
|
|
||||||
// Check that the file exists in the repo
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
|
||||||
suite.FileExists(repoFile)
|
|
||||||
|
|
||||||
// Check that the content is preserved
|
|
||||||
repoContent, err := os.ReadFile(repoFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(repoContent))
|
|
||||||
|
|
||||||
// Check that symlink points to the correct location
|
|
||||||
linkTarget, err := os.Readlink(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
expectedTarget, err := filepath.Rel(filepath.Dir(testFile), repoFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(expectedTarget, linkTarget)
|
|
||||||
|
|
||||||
// Check that Git commit was made
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Len(commits, 1)
|
|
||||||
suite.Contains(commits[0], "lnk: added .bashrc")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestRemoveFile() {
|
|
||||||
// Initialize and add a file first
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
|
||||||
content := "set number"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Now remove the file
|
|
||||||
err = suite.lnk.Remove(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the symlink is gone and regular file is restored
|
|
||||||
info, err := os.Lstat(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
|
||||||
|
|
||||||
// Check that content is preserved
|
|
||||||
restoredContent, err := os.ReadFile(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(restoredContent))
|
|
||||||
|
|
||||||
// Check that file is removed from repo
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".vimrc")
|
|
||||||
suite.NoFileExists(repoFile)
|
|
||||||
|
|
||||||
// Check that Git commit was made
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Len(commits, 2) // add + remove
|
|
||||||
suite.Contains(commits[0], "lnk: removed .vimrc")
|
|
||||||
suite.Contains(commits[1], "lnk: added .vimrc")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestAddNonexistentFile() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add("/nonexistent/file")
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "file does not exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestAddDirectory() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a directory with files
|
|
||||||
testDir := filepath.Join(suite.tempDir, "testdir")
|
|
||||||
err = os.MkdirAll(testDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add files to the directory
|
|
||||||
testFile1 := filepath.Join(testDir, "file1.txt")
|
|
||||||
err = os.WriteFile(testFile1, []byte("content1"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testFile2 := filepath.Join(testDir, "file2.txt")
|
|
||||||
err = os.WriteFile(testFile2, []byte("content2"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add the directory - should now succeed
|
|
||||||
err = suite.lnk.Add(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the directory is now a symlink
|
|
||||||
info, err := os.Lstat(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
|
||||||
|
|
||||||
// Check that the directory exists in the repo
|
|
||||||
repoDir := filepath.Join(suite.tempDir, "lnk", "testdir")
|
|
||||||
suite.DirExists(repoDir)
|
|
||||||
|
|
||||||
// Check that files are preserved
|
|
||||||
repoFile1 := filepath.Join(repoDir, "file1.txt")
|
|
||||||
repoFile2 := filepath.Join(repoDir, "file2.txt")
|
|
||||||
suite.FileExists(repoFile1)
|
|
||||||
suite.FileExists(repoFile2)
|
|
||||||
|
|
||||||
content1, err := os.ReadFile(repoFile1)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal("content1", string(content1))
|
|
||||||
|
|
||||||
content2, err := os.ReadFile(repoFile2)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal("content2", string(content2))
|
|
||||||
|
|
||||||
// Check that .lnk file was created and contains the directory
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
suite.FileExists(lnkFile)
|
|
||||||
|
|
||||||
lnkContent, err := os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Contains(string(lnkContent), "testdir")
|
|
||||||
|
|
||||||
// Check that Git commit was made
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Len(commits, 1)
|
|
||||||
suite.Contains(commits[0], "lnk: added testdir")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestRemoveDirectory() {
|
|
||||||
// Initialize and add a directory first
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testDir := filepath.Join(suite.tempDir, "testdir")
|
|
||||||
err = os.MkdirAll(testDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testFile := filepath.Join(testDir, "config.txt")
|
|
||||||
content := "test config"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Now remove the directory
|
|
||||||
err = suite.lnk.Remove(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the symlink is gone and regular directory is restored
|
|
||||||
info, err := os.Lstat(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
|
||||||
suite.True(info.IsDir()) // Is a directory
|
|
||||||
|
|
||||||
// Check that content is preserved
|
|
||||||
restoredContent, err := os.ReadFile(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(restoredContent))
|
|
||||||
|
|
||||||
// Check that directory is removed from repo
|
|
||||||
repoDir := filepath.Join(suite.tempDir, "lnk", "testdir")
|
|
||||||
suite.NoDirExists(repoDir)
|
|
||||||
|
|
||||||
// Check that .lnk file no longer contains the directory
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
if suite.FileExists(lnkFile) {
|
|
||||||
lnkContent, err := os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.NotContains(string(lnkContent), "testdir")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that Git commit was made
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Len(commits, 2) // add + remove
|
|
||||||
suite.Contains(commits[0], "lnk: removed testdir")
|
|
||||||
suite.Contains(commits[1], "lnk: added testdir")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestLnkFileTracking() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add a file
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
|
||||||
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add a directory
|
|
||||||
testDir := filepath.Join(suite.tempDir, ".ssh")
|
|
||||||
err = os.MkdirAll(testDir, 0700)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
configFile := filepath.Join(testDir, "config")
|
|
||||||
err = os.WriteFile(configFile, []byte("Host example.com"), 0600)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check .lnk file contains both entries
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
suite.FileExists(lnkFile)
|
|
||||||
|
|
||||||
lnkContent, err := os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(lnkContent)), "\n")
|
|
||||||
suite.Len(lines, 2)
|
|
||||||
suite.Contains(lines, ".bashrc")
|
|
||||||
suite.Contains(lines, ".ssh")
|
|
||||||
|
|
||||||
// Remove a file and check .lnk is updated
|
|
||||||
err = suite.lnk.Remove(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lnkContent, err = os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lines = strings.Split(strings.TrimSpace(string(lnkContent)), "\n")
|
|
||||||
suite.Len(lines, 1)
|
|
||||||
suite.Contains(lines, ".ssh")
|
|
||||||
suite.NotContains(lines, ".bashrc")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestPullWithDirectories() {
|
|
||||||
// Initialize repo
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add remote for pull to work
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a directory and .lnk file in the repo directly to simulate a pull
|
|
||||||
repoDir := filepath.Join(suite.tempDir, "lnk", ".config")
|
|
||||||
err = os.MkdirAll(repoDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
configFile := filepath.Join(repoDir, "app.conf")
|
|
||||||
content := "setting=value"
|
|
||||||
err = os.WriteFile(configFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create .lnk file
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
err = os.WriteFile(lnkFile, []byte(".config\n"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Get home directory for the test
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
targetDir := filepath.Join(homeDir, ".config")
|
|
||||||
|
|
||||||
// Clean up the test directory after the test
|
|
||||||
defer func() {
|
|
||||||
_ = os.RemoveAll(targetDir)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Create a regular directory in home to simulate conflict scenario
|
|
||||||
err = os.MkdirAll(targetDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = os.WriteFile(filepath.Join(targetDir, "different.conf"), []byte("different"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Pull should restore symlinks and handle conflicts
|
|
||||||
restored, err := suite.lnk.Pull()
|
|
||||||
// In tests, pull will fail because we don't have real remotes, but that's expected
|
|
||||||
// We can still test the symlink restoration part
|
|
||||||
if err != nil {
|
|
||||||
suite.Contains(err.Error(), "git pull failed")
|
|
||||||
// Test symlink restoration directly
|
|
||||||
restored, err = suite.lnk.RestoreSymlinks()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have restored the symlink
|
|
||||||
suite.GreaterOrEqual(len(restored), 1)
|
|
||||||
if len(restored) > 0 {
|
|
||||||
suite.Equal(".config", restored[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that directory is back to being a symlink
|
|
||||||
info, err := os.Lstat(targetDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
|
||||||
|
|
||||||
// Check content is preserved from repo
|
|
||||||
repoContent, err := os.ReadFile(configFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(repoContent))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestRemoveNonSymlink() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a regular file (not managed by lnk)
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".regularfile")
|
|
||||||
err = os.WriteFile(testFile, []byte("content"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Remove(testFile)
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "file is not managed by lnk")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestXDGConfigHomeFallback() {
|
|
||||||
// Test fallback to ~/.config/lnk when XDG_CONFIG_HOME is not set
|
|
||||||
suite.T().Setenv("XDG_CONFIG_HOME", "")
|
|
||||||
|
|
||||||
homeDir := filepath.Join(suite.tempDir, "home")
|
|
||||||
err := os.MkdirAll(homeDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.T().Setenv("HOME", homeDir)
|
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
|
||||||
err = lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the lnk directory was created under ~/.config/lnk
|
|
||||||
expectedDir := filepath.Join(homeDir, ".config", "lnk")
|
|
||||||
suite.DirExists(expectedDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitWithRemote() {
|
|
||||||
// Test that init with remote adds the origin remote
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
remoteURL := "https://github.com/user/dotfiles.git"
|
|
||||||
err = suite.lnk.AddRemote("origin", remoteURL)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify the remote was added by checking git config
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(remoteURL, strings.TrimSpace(string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitIdempotent() {
|
|
||||||
// Test that running init multiple times is safe
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
|
|
||||||
// Add a file to the repo to ensure it's not lost
|
|
||||||
testFile := filepath.Join(lnkDir, "test.txt")
|
|
||||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Run init again - should be idempotent
|
|
||||||
err = suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// File should still exist
|
|
||||||
suite.FileExists(testFile)
|
|
||||||
content, err := os.ReadFile(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal("test content", string(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitWithExistingRemote() {
|
|
||||||
// Test init with remote when remote already exists (same URL)
|
|
||||||
remoteURL := "https://github.com/user/dotfiles.git"
|
|
||||||
|
|
||||||
// First init with remote
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = suite.lnk.AddRemote("origin", remoteURL)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Init again with same remote should be idempotent
|
|
||||||
err = suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = suite.lnk.AddRemote("origin", remoteURL)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify remote is still correct
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
output, err := cmd.Output()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(remoteURL, strings.TrimSpace(string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitWithDifferentRemote() {
|
|
||||||
// Test init with different remote when remote already exists
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add first remote
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/user/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Try to add different remote - should error
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/user/other-repo.git")
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "already exists with different URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitWithNonLnkRepo() {
|
|
||||||
// Test init when directory contains a non-lnk Git repository
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
err := os.MkdirAll(lnkDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a non-lnk git repo in the lnk directory
|
|
||||||
cmd := exec.Command("git", "init")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add some content to make it look like a real repo
|
|
||||||
testFile := filepath.Join(lnkDir, "important-file.txt")
|
|
||||||
err = os.WriteFile(testFile, []byte("important data"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Configure git and commit
|
|
||||||
cmd = exec.Command("git", "config", "user.name", "Test User")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
cmd = exec.Command("git", "config", "user.email", "test@example.com")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
cmd = exec.Command("git", "add", "important-file.txt")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
cmd = exec.Command("git", "commit", "-m", "important commit")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Now try to init lnk - should error to protect existing repo
|
|
||||||
err = suite.lnk.Init()
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "appears to contain an existing Git repository")
|
|
||||||
|
|
||||||
// Verify the original file is still there
|
|
||||||
suite.FileExists(testFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncStatus tests the status command functionality
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncStatus() {
|
|
||||||
// Initialize repo with remote
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add a file to create some local changes
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
|
||||||
content := "export PATH=$PATH:/usr/local/bin"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Get status - should show 1 commit ahead
|
|
||||||
status, err := suite.lnk.Status()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(1, status.Ahead)
|
|
||||||
suite.Equal(0, status.Behind)
|
|
||||||
suite.Equal("origin/main", status.Remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncPush tests the push command functionality
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncPush() {
|
|
||||||
// Initialize repo
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add remote for push to work
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add a file
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
|
||||||
content := "set number"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add another file for a second commit
|
|
||||||
testFile2 := filepath.Join(suite.tempDir, ".gitconfig")
|
|
||||||
content2 := "[user]\n name = Test User"
|
|
||||||
err = os.WriteFile(testFile2, []byte(content2), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile2)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Modify one of the files to create uncommitted changes
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".vimrc")
|
|
||||||
modifiedContent := "set number\nset relativenumber"
|
|
||||||
err = os.WriteFile(repoFile, []byte(modifiedContent), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Push should stage all changes and create a sync commit
|
|
||||||
message := "Updated configuration files"
|
|
||||||
err = suite.lnk.Push(message)
|
|
||||||
// In tests, push will fail because we don't have real remotes, but that's expected
|
|
||||||
// The important part is that it stages and commits changes
|
|
||||||
if err != nil {
|
|
||||||
suite.Contains(err.Error(), "git push failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that a sync commit was made (even if push failed)
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.GreaterOrEqual(len(commits), 3) // at least 2 add commits + 1 sync commit
|
|
||||||
suite.Contains(commits[0], message) // Latest commit should contain our message
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncPull tests the pull command functionality
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncPull() {
|
|
||||||
// Initialize repo
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add remote for pull to work
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Pull should attempt to pull from remote (will fail in tests but that's expected)
|
|
||||||
_, err = suite.lnk.Pull()
|
|
||||||
// In tests, pull will fail because we don't have real remotes, but that's expected
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "git pull failed")
|
|
||||||
|
|
||||||
// Test RestoreSymlinks functionality separately
|
|
||||||
// Create a file in the repo directly
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
|
||||||
content := "export PATH=$PATH:/usr/local/bin"
|
|
||||||
err = os.WriteFile(repoFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test that RestoreSymlinks can be called (even if it doesn't restore anything in this test setup)
|
|
||||||
restored, err := suite.lnk.RestoreSymlinks()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
// In this test setup, it might not restore anything, and that's okay for Phase 1
|
|
||||||
suite.GreaterOrEqual(len(restored), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncStatusNoRemote tests status when no remote is configured
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncStatusNoRemote() {
|
|
||||||
// Initialize repo without remote
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Status should indicate no remote
|
|
||||||
_, err = suite.lnk.Status()
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "no remote configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncPushWithModifiedFiles tests push when files are modified
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncPushWithModifiedFiles() {
|
|
||||||
// Initialize repo and add a file
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add remote for push to work
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
|
||||||
content := "export PATH=$PATH:/usr/local/bin"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Modify the file in the repo (simulate editing managed file)
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
|
||||||
modifiedContent := "export PATH=$PATH:/usr/local/bin\nexport EDITOR=vim"
|
|
||||||
err = os.WriteFile(repoFile, []byte(modifiedContent), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Push should detect and commit the changes
|
|
||||||
message := "Updated bashrc with editor setting"
|
|
||||||
err = suite.lnk.Push(message)
|
|
||||||
// In tests, push will fail because we don't have real remotes, but that's expected
|
|
||||||
if err != nil {
|
|
||||||
suite.Contains(err.Error(), "git push failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that changes were committed (even if push failed)
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.GreaterOrEqual(len(commits), 2) // add commit + sync commit
|
|
||||||
suite.Contains(commits[0], message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLnkIntegrationSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(LnkIntegrationTestSuite))
|
|
||||||
}
|
|
Reference in New Issue
Block a user