mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-25 21:18:57 +02:00
Compare commits
15 Commits
refactorin
...
force
Author | SHA1 | Date | |
---|---|---|---|
|
43b68bc071 | ||
|
ab97fa86dc | ||
|
4cd8191805 | ||
|
6830c06eb4 | ||
|
8a29b7fe43 | ||
|
a6852e5ad5 | ||
|
36d76c881c | ||
|
6de387797e | ||
|
9cbad5e593 | ||
|
150e8adf8b | ||
|
4b11563bdf | ||
|
b476ce503b | ||
|
ae9cc175ce | ||
|
1e2c9704f3 | ||
|
3cba309c05 |
17
.github/dependabot.yml
vendored
Normal file
17
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/optimizing-pr-creation-version-updates#setting-up-a-cooldown-period-for-dependency-updates
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#groups--
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
# Combine all images of last week
|
||||||
|
patterns: ["*"]
|
||||||
|
- package-ecosystem: gomod
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -9,6 +9,9 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: '1.24'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -19,10 +22,10 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: Cache Go modules
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
path: ~/go/pkg/mod
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
@@ -44,9 +47,9 @@ jobs:
|
|||||||
run: go test -v -race -coverprofile=coverage.out ./...
|
run: go test -v -race -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
file: ./coverage.out
|
files: ./coverage.out
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -57,10 +60,10 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v8
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
|
|
||||||
@@ -74,7 +77,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,3 +44,6 @@ desktop.ini
|
|||||||
|
|
||||||
# GoReleaser artifacts
|
# GoReleaser artifacts
|
||||||
goreleaser/
|
goreleaser/
|
||||||
|
*.md
|
||||||
|
!/README.md
|
||||||
|
!/RELEASE.md
|
||||||
|
200
README.md
200
README.md
@@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
**Git-native dotfiles management that doesn't suck.**
|
**Git-native dotfiles management that doesn't suck.**
|
||||||
|
|
||||||
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
|
Lnk makes managing your dotfiles straightforward, no tedious setups, no complex configurations. Just tell Lnk what files you want tracked, and it'll automatically move them into a tidy Git repository under `~/.config/lnk`. It then creates clean, portable symlinks back to their original locations. Easy.
|
||||||
|
|
||||||
|
Why bother with Lnk instead of plain old Git or other dotfile managers? Unlike traditional methods, Lnk automates the boring parts: safely relocating files, handling host-specific setups, bulk operations for multiple files, recursive directory processing, and even running your custom bootstrap scripts automatically. This means fewer manual steps and less chance of accidentally overwriting something important.
|
||||||
|
|
||||||
|
With Lnk, your dotfiles setup stays organized and effortlessly portable, letting you spend more time doing real work, not wrestling with configuration files.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lnk init
|
lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
|
||||||
lnk add ~/.vimrc ~/.bashrc # Common config
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Multiple files at once
|
||||||
|
lnk add --recursive ~/.config/nvim # Process directory contents
|
||||||
|
lnk add --dry-run ~/.tmux.conf # Preview changes first
|
||||||
lnk add --host work ~/.ssh/config # Host-specific config
|
lnk add --host work ~/.ssh/config # Host-specific config
|
||||||
lnk push "setup"
|
lnk push "setup"
|
||||||
```
|
```
|
||||||
@@ -20,7 +26,6 @@ curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Homebrew (macOS/Linux)
|
# Homebrew (macOS/Linux)
|
||||||
brew tap yarlson/lnk
|
|
||||||
brew install lnk
|
brew install lnk
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -43,19 +48,35 @@ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv
|
|||||||
# Fresh start
|
# Fresh start
|
||||||
lnk init
|
lnk init
|
||||||
|
|
||||||
# With existing repo
|
# With existing repo (runs bootstrap automatically)
|
||||||
lnk init -r git@github.com:user/dotfiles.git
|
lnk init -r git@github.com:user/dotfiles.git
|
||||||
|
|
||||||
|
# Skip automatic bootstrap
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git --no-bootstrap
|
||||||
|
|
||||||
|
# Force initialization (WARNING: overwrites existing managed files)
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git --force
|
||||||
|
|
||||||
|
# Run bootstrap script manually
|
||||||
|
lnk bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
### Daily workflow
|
### Daily workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add files/directories (common config)
|
# Add multiple files at once (common config)
|
||||||
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
|
||||||
|
|
||||||
# Add host-specific files
|
# Add directory contents individually
|
||||||
lnk add --host laptop ~/.ssh/config
|
lnk add --recursive ~/.config/nvim ~/.config/zsh
|
||||||
lnk add --host work ~/.gitconfig
|
|
||||||
|
# Preview changes before applying
|
||||||
|
lnk add --dry-run ~/.config/git/config
|
||||||
|
lnk add --dry-run --recursive ~/.config/kitty
|
||||||
|
|
||||||
|
# Add host-specific files (supports bulk operations)
|
||||||
|
lnk add --host laptop ~/.ssh/config ~/.aws/credentials
|
||||||
|
lnk add --host work ~/.gitconfig ~/.ssh/config
|
||||||
|
|
||||||
# List managed files
|
# List managed files
|
||||||
lnk list # Common config only
|
lnk list # Common config only
|
||||||
@@ -85,6 +106,71 @@ After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
|
|||||||
|
|
||||||
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||||
|
|
||||||
|
## Safety Features
|
||||||
|
|
||||||
|
Lnk includes built-in safety checks to prevent accidental data loss:
|
||||||
|
|
||||||
|
### Data Loss Prevention
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This will be blocked if you already have managed files
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git
|
||||||
|
# ❌ Directory ~/.config/lnk already contains managed files
|
||||||
|
# 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r'
|
||||||
|
|
||||||
|
# Use pull instead to safely update
|
||||||
|
lnk pull
|
||||||
|
|
||||||
|
# Or force if you understand the risks (shows warning only when needed)
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git --force
|
||||||
|
# ⚠️ Using --force flag: This will overwrite existing managed files
|
||||||
|
# 💡 Only use this if you understand the risks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smart Warnings
|
||||||
|
|
||||||
|
- **Contextual alerts**: Warnings only appear when there are actually managed files to overwrite
|
||||||
|
- **Clear guidance**: Error messages suggest the correct command to use
|
||||||
|
- **Force override**: Advanced users can bypass safety checks when needed
|
||||||
|
|
||||||
|
## Bootstrap Support
|
||||||
|
|
||||||
|
Lnk automatically runs bootstrap scripts when cloning dotfiles repositories, making it easy to set up your development environment. Just add a `bootstrap.sh` file to your dotfiles repo.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Simple bootstrap script:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# bootstrap.sh
|
||||||
|
echo "Setting up development environment..."
|
||||||
|
|
||||||
|
# Install Homebrew (macOS)
|
||||||
|
if ! command -v brew &> /dev/null; then
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install packages
|
||||||
|
brew install git vim tmux
|
||||||
|
|
||||||
|
echo "✅ Setup complete!"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Automatic bootstrap on clone
|
||||||
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
|
# → Clones repo and runs bootstrap script automatically
|
||||||
|
|
||||||
|
# Skip bootstrap if needed
|
||||||
|
lnk init -r git@github.com:you/dotfiles.git --no-bootstrap
|
||||||
|
|
||||||
|
# Run bootstrap manually later
|
||||||
|
lnk bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
## Multihost Support
|
## Multihost Support
|
||||||
|
|
||||||
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
|
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
|
||||||
@@ -111,12 +197,19 @@ Lnk supports both **common configurations** (shared across all machines) and **h
|
|||||||
### Usage Patterns
|
### Usage Patterns
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Common config (shared everywhere)
|
# Common config (shared everywhere) - supports multiple files
|
||||||
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
|
||||||
|
|
||||||
# Host-specific config (unique per machine)
|
# Process directory contents individually
|
||||||
lnk add --host $(hostname) ~/.ssh/config
|
lnk add --recursive ~/.config/nvim ~/.config/zsh
|
||||||
lnk add --host work ~/.gitconfig
|
|
||||||
|
# Preview operations before making changes
|
||||||
|
lnk add --dry-run ~/.config/alacritty/alacritty.yml
|
||||||
|
lnk add --dry-run --recursive ~/.config/i3
|
||||||
|
|
||||||
|
# Host-specific config (unique per machine) - supports bulk operations
|
||||||
|
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||||
|
lnk add --host work ~/.gitconfig ~/.npmrc
|
||||||
|
|
||||||
# List configurations
|
# List configurations
|
||||||
lnk list # Common only
|
lnk list # Common only
|
||||||
@@ -132,23 +225,35 @@ lnk pull --host work # Work-specific config
|
|||||||
|
|
||||||
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
|
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
|
||||||
|
|
||||||
- Moving files safely
|
- Moving files safely (with atomic operations)
|
||||||
- Creating relative symlinks
|
- Creating relative symlinks
|
||||||
- Handling conflicts
|
- Handling conflicts and rollback
|
||||||
- Tracking what's managed
|
- Tracking what's managed
|
||||||
|
- Processing multiple files efficiently
|
||||||
|
- Recursive directory traversal
|
||||||
|
- Preview mode for safety
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### First time setup
|
### First time setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Clone dotfiles and run bootstrap automatically
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
|
# → Downloads dependencies, installs packages, configures environment
|
||||||
|
|
||||||
# Add common config (shared across all machines)
|
# Add common config (shared across all machines) - multiple files at once
|
||||||
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig ~/.tmux.conf
|
||||||
|
|
||||||
# Add host-specific config
|
# Add configuration directories individually
|
||||||
lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf
|
lnk add --recursive ~/.config/nvim ~/.config/zsh
|
||||||
|
|
||||||
|
# Preview before adding sensitive files
|
||||||
|
lnk add --dry-run ~/.ssh/id_rsa.pub
|
||||||
|
lnk add ~/.ssh/id_rsa.pub # Add after verification
|
||||||
|
|
||||||
|
# Add host-specific config (supports bulk operations)
|
||||||
|
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||||
|
|
||||||
lnk push "initial setup"
|
lnk push "initial setup"
|
||||||
```
|
```
|
||||||
@@ -156,13 +261,18 @@ lnk push "initial setup"
|
|||||||
### On a new machine
|
### On a new machine
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Bootstrap runs automatically
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
|
# → Sets up environment, installs dependencies
|
||||||
|
|
||||||
# Pull common config
|
# Pull common config
|
||||||
lnk pull
|
lnk pull
|
||||||
|
|
||||||
# Pull host-specific config (if it exists)
|
# Pull host-specific config (if it exists)
|
||||||
lnk pull --host $(hostname)
|
lnk pull --host $(hostname)
|
||||||
|
|
||||||
|
# Or run bootstrap manually if needed
|
||||||
|
lnk bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
### Daily edits
|
### Daily edits
|
||||||
@@ -179,15 +289,16 @@ lnk push "new plugins" # commit & push
|
|||||||
### Multi-machine workflow
|
### Multi-machine workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On your laptop
|
# On your laptop - use bulk operations for efficiency
|
||||||
lnk add --host laptop ~/.ssh/config
|
lnk add --host laptop ~/.ssh/config ~/.aws/credentials ~/.npmrc
|
||||||
lnk add ~/.vimrc # Common config
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Common config (multiple files)
|
||||||
lnk push "laptop ssh config"
|
lnk push "laptop configuration"
|
||||||
|
|
||||||
# On your work machine
|
# On your work machine
|
||||||
lnk pull # Get common config
|
lnk pull # Get common config
|
||||||
lnk add --host work ~/.gitconfig
|
lnk add --host work ~/.gitconfig ~/.ssh/config
|
||||||
lnk push "work git config"
|
lnk add --recursive ~/.config/work-tools # Work-specific tools
|
||||||
|
lnk push "work configuration"
|
||||||
|
|
||||||
# Back on laptop
|
# Back on laptop
|
||||||
lnk pull # Get updates (work config won't affect laptop)
|
lnk pull # Get updates (work config won't affect laptop)
|
||||||
@@ -195,19 +306,41 @@ lnk pull # Get updates (work config won't affect laptop)
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `lnk init [-r remote]` - Create repo
|
- `lnk init [-r remote] [--no-bootstrap] [--force]` - Create repo (runs bootstrap automatically)
|
||||||
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
|
- `lnk add [--host HOST] [--recursive] [--dry-run] <files>...` - Move files to repo, create symlinks
|
||||||
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
||||||
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
||||||
- `lnk status` - Git status + sync info
|
- `lnk status` - Git status + sync info
|
||||||
- `lnk push [msg]` - Stage all, commit, push
|
- `lnk push [msg]` - Stage all, commit, push
|
||||||
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
|
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
|
||||||
|
- `lnk bootstrap` - Run bootstrap script manually
|
||||||
|
|
||||||
### Command Options
|
### Command Options
|
||||||
|
|
||||||
- `--host HOST` - Manage files for specific host (default: common configuration)
|
- `--host HOST` - Manage files for specific host (default: common configuration)
|
||||||
|
- `--recursive, -r` - Add directory contents individually instead of the directory as a whole
|
||||||
|
- `--dry-run, -n` - Show what would be added without making changes
|
||||||
- `--all` - Show all configurations (common + all hosts) when listing
|
- `--all` - Show all configurations (common + all hosts) when listing
|
||||||
- `-r, --remote URL` - Clone from remote URL when initializing
|
- `-r, --remote URL` - Clone from remote URL when initializing
|
||||||
|
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
|
||||||
|
- `--force` - Force initialization even if directory contains managed files (WARNING: overwrites existing content)
|
||||||
|
|
||||||
|
### Add Command Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Multiple files at once
|
||||||
|
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
||||||
|
|
||||||
|
# Recursive directory processing
|
||||||
|
lnk add --recursive ~/.config/nvim
|
||||||
|
|
||||||
|
# Preview changes first
|
||||||
|
lnk add --dry-run ~/.ssh/config
|
||||||
|
lnk add --dry-run --recursive ~/.config/kitty
|
||||||
|
|
||||||
|
# Host-specific bulk operations
|
||||||
|
lnk add --host work ~/.gitconfig ~/.ssh/config ~/.npmrc
|
||||||
|
```
|
||||||
|
|
||||||
## Technical bits
|
## Technical bits
|
||||||
|
|
||||||
@@ -215,13 +348,18 @@ lnk pull # Get updates (work config won't affect laptop)
|
|||||||
- **Relative symlinks** (portable)
|
- **Relative symlinks** (portable)
|
||||||
- **XDG compliant** (`~/.config/lnk`)
|
- **XDG compliant** (`~/.config/lnk`)
|
||||||
- **Multihost support** (common + host-specific configs)
|
- **Multihost support** (common + host-specific configs)
|
||||||
|
- **Bootstrap support** (automatic environment setup)
|
||||||
|
- **Bulk operations** (multiple files, atomic transactions)
|
||||||
|
- **Recursive processing** (directory contents individually)
|
||||||
|
- **Preview mode** (dry-run for safety)
|
||||||
|
- **Data loss prevention** (safety checks with contextual warnings)
|
||||||
- **Git-native** (standard Git repo, no special formats)
|
- **Git-native** (standard Git repo, no special formats)
|
||||||
|
|
||||||
## Alternatives
|
## Alternatives
|
||||||
|
|
||||||
| Tool | Complexity | Why choose it |
|
| Tool | Complexity | Why choose it |
|
||||||
| ------- | ---------- | -------------------------------------------- |
|
| ------- | ---------- | ----------------------------------------------------------------------------------------- |
|
||||||
| **lnk** | Minimal | Just works, no config, Git-native, multihost |
|
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks |
|
||||||
| chezmoi | High | Templates, encryption, cross-platform |
|
| chezmoi | High | Templates, encryption, cross-platform |
|
||||||
| yadm | Medium | Git power user, encryption |
|
| yadm | Medium | Git power user, encryption |
|
||||||
| dotbot | Low | YAML config, basic features |
|
| dotbot | Low | YAML config, basic features |
|
||||||
|
150
cmd/add.go
150
cmd/add.go
@@ -1,52 +1,164 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
"github.com/yarlson/lnk/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newAddCmd() *cobra.Command {
|
func newAddCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <file>",
|
Use: "add <file>...",
|
||||||
Short: "✨ Add a file to lnk management",
|
Short: "✨ Add files to lnk management",
|
||||||
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
|
Examples:
|
||||||
|
lnk add ~/.bashrc ~/.vimrc # Add multiple files at once
|
||||||
|
lnk add --recursive ~/.config/nvim # Add directory contents individually
|
||||||
|
lnk add --dry-run ~/.gitconfig # Preview what would be added
|
||||||
|
lnk add --host work ~/.ssh/config # Add host-specific configuration
|
||||||
|
|
||||||
|
The --recursive flag processes directory contents individually instead of treating
|
||||||
|
the directory as a single unit. This is useful for configuration directories where
|
||||||
|
you want each file managed separately.
|
||||||
|
|
||||||
|
The --dry-run flag shows you exactly what files would be added without making any
|
||||||
|
changes to your system - perfect for verification before bulk operations.`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
filePath := args[0]
|
|
||||||
host, _ := cmd.Flags().GetString("host")
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
recursive, _ := cmd.Flags().GetBool("recursive")
|
||||||
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
|
|
||||||
// Create service instance
|
// Handle dry-run mode
|
||||||
lnkService, err := service.New()
|
if dryRun {
|
||||||
|
files, err := lnk.PreviewAdd(args, recursive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wrapServiceError("initialize lnk service", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add file using service layer
|
// Display preview output
|
||||||
ctx := context.Background()
|
if recursive {
|
||||||
managedFile, err := lnkService.AddFile(ctx, filePath, host)
|
printf(cmd, "🔍 \033[1mWould add %d files recursively:\033[0m\n", len(files))
|
||||||
if err != nil {
|
} else {
|
||||||
return formatError(err)
|
printf(cmd, "🔍 \033[1mWould add %d files:\033[0m\n", len(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display success message
|
// List files that would be added
|
||||||
|
for _, file := range files {
|
||||||
|
basename := filepath.Base(file)
|
||||||
|
printf(cmd, " 📄 \033[90m%s\033[0m\n", basename)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 \033[33mTo proceed:\033[0m run without --dry-run flag\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle recursive mode
|
||||||
|
if recursive {
|
||||||
|
// Get preview to count files first for better output
|
||||||
|
previewFiles, err := lnk.PreviewAdd(args, recursive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create progress callback for CLI display
|
||||||
|
progressCallback := func(current, total int, currentFile string) {
|
||||||
|
printf(cmd, "\r⏳ Processing %d/%d: %s", current, total, currentFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear progress line and show completion
|
||||||
|
printf(cmd, "\r")
|
||||||
|
|
||||||
|
// Store processed file count for display
|
||||||
|
args = previewFiles // Replace args with actual files for display
|
||||||
|
} else {
|
||||||
|
// Use appropriate method based on number of files
|
||||||
|
if len(args) == 1 {
|
||||||
|
// Single file - use existing Add method for backward compatibility
|
||||||
|
if err := lnk.Add(args[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple files - use AddMultiple for atomic operation
|
||||||
|
if err := lnk.AddMultiple(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display results
|
||||||
|
if recursive {
|
||||||
|
// Recursive mode - show enhanced message with count
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk (host: %s)\033[0m\n", len(args), host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk\033[0m\n", len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show some of the files that were added (limit to first few for readability)
|
||||||
|
filesToShow := len(args)
|
||||||
|
if filesToShow > 5 {
|
||||||
|
filesToShow = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < filesToShow; i++ {
|
||||||
|
basename := filepath.Base(args[i])
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) > 5 {
|
||||||
|
printf(cmd, " \033[90m... and %d more files\033[0m\n", len(args)-5)
|
||||||
|
}
|
||||||
|
} else if len(args) == 1 {
|
||||||
|
// Single file - maintain existing output format for backward compatibility
|
||||||
|
filePath := args[0]
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", managedFile.OriginalPath, host, managedFile.RelativePath)
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath)
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", managedFile.OriginalPath, managedFile.RelativePath)
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple files - show summary
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List each added file
|
||||||
|
for _, filePath := range args {
|
||||||
|
basename := filepath.Base(filePath)
|
||||||
|
if host != "" {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
|
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
|
||||||
|
cmd.Flags().BoolP("recursive", "r", false, "Add directory contents individually instead of the directory as a whole")
|
||||||
|
cmd.Flags().BoolP("dry-run", "n", false, "Show what would be added without making changes")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
45
cmd/bootstrap.go
Normal file
45
cmd/bootstrap.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBootstrapCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "bootstrap",
|
||||||
|
Short: "🚀 Run the bootstrap script to set up your environment",
|
||||||
|
Long: "Executes the bootstrap script from your dotfiles repository to install dependencies and configure your system.",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
lnk := core.NewLnk()
|
||||||
|
|
||||||
|
scriptPath, err := lnk.FindBootstrapScript()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if scriptPath == "" {
|
||||||
|
printf(cmd, "💡 \033[33mNo bootstrap script found\033[0m\n")
|
||||||
|
printf(cmd, " 📝 Create a \033[1mbootstrap.sh\033[0m file in your dotfiles repository:\n")
|
||||||
|
printf(cmd, " \033[90m#!/bin/bash\033[0m\n")
|
||||||
|
printf(cmd, " \033[90mecho \"Setting up environment...\"\033[0m\n")
|
||||||
|
printf(cmd, " \033[90m# Your setup commands here\033[0m\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n")
|
||||||
|
printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath)
|
||||||
|
printf(cmd, "\n")
|
||||||
|
|
||||||
|
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||||
|
printf(cmd, " 🎉 Your environment is ready to use\n")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
54
cmd/init.go
54
cmd/init.go
@@ -1,11 +1,8 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
"github.com/yarlson/lnk/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newInitCmd() *cobra.Command {
|
func newInitCmd() *cobra.Command {
|
||||||
@@ -14,26 +11,55 @@ func newInitCmd() *cobra.Command {
|
|||||||
Short: "🎯 Initialize a new lnk repository",
|
Short: "🎯 Initialize a new lnk repository",
|
||||||
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
remote, _ := cmd.Flags().GetString("remote")
|
remote, _ := cmd.Flags().GetString("remote")
|
||||||
|
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
// Create service instance
|
lnk := core.NewLnk()
|
||||||
lnkService, err := service.New()
|
|
||||||
if err != nil {
|
// Show warning when force is used and there are managed files to overwrite
|
||||||
return wrapServiceError("initialize lnk service", err)
|
if force && remote != "" && lnk.HasUserContent() {
|
||||||
|
printf(cmd, "⚠️ \033[33mUsing --force flag: This will overwrite existing managed files\033[0m\n")
|
||||||
|
printf(cmd, " 💡 Only use this if you understand the risks\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize repository using service layer
|
if err := lnk.InitWithRemoteForce(remote, force); err != nil {
|
||||||
ctx := context.Background()
|
return err
|
||||||
if err := lnkService.InitializeRepository(ctx, remote); err != nil {
|
|
||||||
return formatError(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display success message
|
|
||||||
if remote != "" {
|
if remote != "" {
|
||||||
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||||
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||||
|
|
||||||
|
// Try to run bootstrap script if not disabled
|
||||||
|
if !noBootstrap {
|
||||||
|
printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n")
|
||||||
|
|
||||||
|
scriptPath, err := lnk.FindBootstrapScript()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if scriptPath != "" {
|
||||||
|
printf(cmd, " ✅ Found bootstrap script: \033[36m%s\033[0m\n", scriptPath)
|
||||||
|
printf(cmd, "\n🚀 \033[1mRunning bootstrap script...\033[0m\n")
|
||||||
|
printf(cmd, "\n")
|
||||||
|
|
||||||
|
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||||
|
printf(cmd, "\n⚠️ \033[33mBootstrap script failed, but repository was initialized successfully\033[0m\n")
|
||||||
|
printf(cmd, " 💡 You can run it manually with: \033[1mlnk bootstrap\033[0m\n")
|
||||||
|
printf(cmd, " 🔧 Error: %v\n", err)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
printf(cmd, " 💡 No bootstrap script found\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||||
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
||||||
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
||||||
@@ -50,5 +76,7 @@ func newInitCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
||||||
|
cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning")
|
||||||
|
cmd.Flags().Bool("force", false, "Force initialization even if directory contains managed files (WARNING: This will overwrite existing content)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
106
cmd/list.go
106
cmd/list.go
@@ -1,14 +1,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
"github.com/yarlson/lnk/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newListCmd() *cobra.Command {
|
func newListCmd() *cobra.Command {
|
||||||
@@ -17,6 +15,7 @@ func newListCmd() *cobra.Command {
|
|||||||
Short: "📋 List files managed by lnk",
|
Short: "📋 List files managed by lnk",
|
||||||
Long: "Display all files and directories currently managed by lnk.",
|
Long: "Display all files and directories currently managed by lnk.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
host, _ := cmd.Flags().GetString("host")
|
host, _ := cmd.Flags().GetString("host")
|
||||||
all, _ := cmd.Flags().GetBool("all")
|
all, _ := cmd.Flags().GetBool("all")
|
||||||
@@ -42,31 +41,26 @@ func newListCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listCommonConfig(cmd *cobra.Command) error {
|
func listCommonConfig(cmd *cobra.Command) error {
|
||||||
ctx := context.Background()
|
lnk := core.NewLnk()
|
||||||
lnkService, err := service.New()
|
managedItems, err := lnk.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wrapServiceError("initialize lnk service", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
managedFiles, err := lnkService.ListManagedFiles(ctx, "")
|
if len(managedItems) == 0 {
|
||||||
if err != nil {
|
|
||||||
return formatError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(managedFiles) == 0 {
|
|
||||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n")
|
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")
|
printf(cmd, " 💡 Use \033[1mlnk add <file>\033[0m to start managing files\n")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedFiles))
|
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
|
||||||
if len(managedFiles) > 1 {
|
if len(managedItems) > 1 {
|
||||||
printf(cmd, "s")
|
printf(cmd, "s")
|
||||||
}
|
}
|
||||||
printf(cmd, "\033[0m):\n\n")
|
printf(cmd, "\033[0m):\n\n")
|
||||||
|
|
||||||
for _, file := range managedFiles {
|
for _, item := range managedItems {
|
||||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||||
@@ -74,31 +68,26 @@ func listCommonConfig(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listHostConfig(cmd *cobra.Command, host string) error {
|
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||||
ctx := context.Background()
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
lnkService, err := service.New()
|
managedItems, err := lnk.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wrapServiceError("initialize lnk service", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
managedFiles, err := lnkService.ListManagedFiles(ctx, host)
|
if len(managedItems) == 0 {
|
||||||
if err != nil {
|
|
||||||
return formatError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(managedFiles) == 0 {
|
|
||||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host)
|
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)
|
printf(cmd, " 💡 Use \033[1mlnk add --host %s <file>\033[0m to start managing files\n", host)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedFiles))
|
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
|
||||||
if len(managedFiles) > 1 {
|
if len(managedItems) > 1 {
|
||||||
printf(cmd, "s")
|
printf(cmd, "s")
|
||||||
}
|
}
|
||||||
printf(cmd, "\033[0m):\n\n")
|
printf(cmd, "\033[0m):\n\n")
|
||||||
|
|
||||||
for _, file := range managedFiles {
|
for _, item := range managedItems {
|
||||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||||
@@ -106,60 +95,56 @@ func listHostConfig(cmd *cobra.Command, host string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listAllConfigs(cmd *cobra.Command) error {
|
func listAllConfigs(cmd *cobra.Command) error {
|
||||||
ctx := context.Background()
|
|
||||||
lnkService, err := service.New()
|
|
||||||
if err != nil {
|
|
||||||
return wrapServiceError("initialize lnk service", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List common configuration
|
// List common configuration
|
||||||
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
||||||
|
|
||||||
commonFiles, err := lnkService.ListManagedFiles(ctx, "")
|
lnk := core.NewLnk()
|
||||||
|
commonItems, err := lnk.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return formatError(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonFiles))
|
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
|
||||||
if len(commonFiles) > 1 {
|
if len(commonItems) > 1 {
|
||||||
printf(cmd, "s")
|
printf(cmd, "s")
|
||||||
}
|
}
|
||||||
printf(cmd, "\033[0m):\n")
|
printf(cmd, "\033[0m):\n")
|
||||||
|
|
||||||
if len(commonFiles) == 0 {
|
if len(commonItems) == 0 {
|
||||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||||
} else {
|
} else {
|
||||||
for _, file := range commonFiles {
|
for _, item := range commonItems {
|
||||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all host-specific configurations
|
// Find all host-specific configurations
|
||||||
hosts, err := findHostConfigs(lnkService)
|
hosts, err := findHostConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return formatError(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, host := range hosts {
|
for _, host := range hosts {
|
||||||
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||||
|
|
||||||
hostFiles, err := lnkService.ListManagedFiles(ctx, host)
|
hostLnk := core.NewLnk(core.WithHost(host))
|
||||||
|
hostItems, err := hostLnk.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, " (\033[36m%d item", len(hostFiles))
|
printf(cmd, " (\033[36m%d item", len(hostItems))
|
||||||
if len(hostFiles) > 1 {
|
if len(hostItems) > 1 {
|
||||||
printf(cmd, "s")
|
printf(cmd, "s")
|
||||||
}
|
}
|
||||||
printf(cmd, "\033[0m):\n")
|
printf(cmd, "\033[0m):\n")
|
||||||
|
|
||||||
if len(hostFiles) == 0 {
|
if len(hostItems) == 0 {
|
||||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||||
} else {
|
} else {
|
||||||
for _, file := range hostFiles {
|
for _, item := range hostItems {
|
||||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,8 +153,8 @@ func listAllConfigs(cmd *cobra.Command) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findHostConfigs(service *service.Service) ([]string, error) {
|
func findHostConfigs() ([]string, error) {
|
||||||
repoPath := service.GetRepoPath()
|
repoPath := getRepoPath()
|
||||||
|
|
||||||
// Check if repo exists
|
// Check if repo exists
|
||||||
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
|
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
|
||||||
@@ -178,7 +163,7 @@ func findHostConfigs(service *service.Service) ([]string, error) {
|
|||||||
|
|
||||||
entries, err := os.ReadDir(repoPath)
|
entries, err := os.ReadDir(repoPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read repository directory: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var hosts []string
|
var hosts []string
|
||||||
@@ -193,3 +178,16 @@ func findHostConfigs(service *service.Service) ([]string, error) {
|
|||||||
|
|
||||||
return hosts, nil
|
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")
|
||||||
|
}
|
||||||
|
20
cmd/pull.go
20
cmd/pull.go
@@ -1,11 +1,8 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
"github.com/yarlson/lnk/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newPullCmd() *cobra.Command {
|
func newPullCmd() *cobra.Command {
|
||||||
@@ -14,20 +11,15 @@ func newPullCmd() *cobra.Command {
|
|||||||
Short: "⬇️ Pull changes from remote and restore symlinks",
|
Short: "⬇️ Pull changes from remote and restore symlinks",
|
||||||
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
host, _ := cmd.Flags().GetString("host")
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
// Create service instance
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
lnkService, err := service.New()
|
|
||||||
if err != nil {
|
|
||||||
return wrapServiceError("initialize lnk service", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull changes using the service
|
restored, err := lnk.Pull()
|
||||||
ctx := context.Background()
|
|
||||||
restored, err := lnkService.PullChanges(ctx, host)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return formatError(err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(restored) > 0 {
|
if len(restored) > 0 {
|
||||||
@@ -42,7 +34,7 @@ func newPullCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
printf(cmd, "\033[0m:\n")
|
printf(cmd, "\033[0m:\n")
|
||||||
for _, file := range restored {
|
for _, file := range restored {
|
||||||
printf(cmd, " ✨ \033[36m%s\033[0m\n", file.RelativePath)
|
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
|
||||||
}
|
}
|
||||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||||
} else {
|
} else {
|
||||||
|
19
cmd/push.go
19
cmd/push.go
@@ -1,11 +1,8 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
"github.com/yarlson/lnk/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newPushCmd() *cobra.Command {
|
func newPushCmd() *cobra.Command {
|
||||||
@@ -15,22 +12,16 @@ func newPushCmd() *cobra.Command {
|
|||||||
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
message := "lnk: sync configuration files"
|
message := "lnk: sync configuration files"
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
message = args[0]
|
message = args[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create service instance
|
lnk := core.NewLnk()
|
||||||
lnkService, err := service.New()
|
if err := lnk.Push(message); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return wrapServiceError("initialize lnk service", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push changes using the service
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := lnkService.PushChanges(ctx, message); err != nil {
|
|
||||||
return formatError(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
||||||
|
17
cmd/rm.go
17
cmd/rm.go
@@ -1,12 +1,10 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
"github.com/yarlson/lnk/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRemoveCmd() *cobra.Command {
|
func newRemoveCmd() *cobra.Command {
|
||||||
@@ -16,20 +14,15 @@ func newRemoveCmd() *cobra.Command {
|
|||||||
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
host, _ := cmd.Flags().GetString("host")
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
// Create service instance
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
lnkService, err := service.New()
|
|
||||||
if err != nil {
|
|
||||||
return wrapServiceError("initialize lnk service", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the file using the service
|
if err := lnk.Remove(filePath); err != nil {
|
||||||
ctx := context.Background()
|
return err
|
||||||
if err := lnkService.RemoveFile(ctx, filePath, host); err != nil {
|
|
||||||
return formatError(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
|
16
cmd/root.go
16
cmd/root.go
@@ -20,16 +20,23 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
||||||
|
|
||||||
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
||||||
Supports both common configurations and host-specific setups.
|
Supports both common configurations, host-specific setups, and bulk operations for multiple files.
|
||||||
|
|
||||||
✨ Examples:
|
✨ Examples:
|
||||||
lnk init # Fresh start
|
lnk init # Fresh start
|
||||||
lnk init -r <repo-url> # Clone existing dotfiles
|
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
|
||||||
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
||||||
|
lnk add --recursive ~/.config/nvim # Add directory contents individually
|
||||||
|
lnk add --dry-run ~/.gitconfig # Preview changes without applying
|
||||||
lnk add --host work ~/.ssh/config # Manage host-specific files
|
lnk add --host work ~/.ssh/config # Manage host-specific files
|
||||||
lnk list --all # Show all configurations
|
lnk list --all # Show all configurations
|
||||||
lnk pull --host work # Pull host-specific changes
|
lnk pull --host work # Pull host-specific changes
|
||||||
lnk push "setup complete" # Sync to remote
|
lnk push "setup complete" # Sync to remote
|
||||||
|
lnk bootstrap # Run bootstrap script manually
|
||||||
|
|
||||||
|
🚀 Bootstrap Support:
|
||||||
|
Automatically runs bootstrap.sh when cloning a repository.
|
||||||
|
Use --no-bootstrap to disable.
|
||||||
|
|
||||||
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
@@ -45,6 +52,7 @@ Supports both common configurations and host-specific setups.
|
|||||||
rootCmd.AddCommand(newStatusCmd())
|
rootCmd.AddCommand(newStatusCmd())
|
||||||
rootCmd.AddCommand(newPushCmd())
|
rootCmd.AddCommand(newPushCmd())
|
||||||
rootCmd.AddCommand(newPullCmd())
|
rootCmd.AddCommand(newPullCmd())
|
||||||
|
rootCmd.AddCommand(newBootstrapCmd())
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
@@ -58,9 +66,7 @@ func SetVersion(v, bt string) {
|
|||||||
func Execute() {
|
func Execute() {
|
||||||
rootCmd := NewRootCommand()
|
rootCmd := NewRootCommand()
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
// Format the error nicely for the user
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||||
formattedErr := formatError(err)
|
|
||||||
fmt.Fprintln(os.Stderr, formattedErr)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
998
cmd/root_test.go
998
cmd/root_test.go
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,8 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
"github.com/yarlson/lnk/internal/models"
|
|
||||||
"github.com/yarlson/lnk/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newStatusCmd() *cobra.Command {
|
func newStatusCmd() *cobra.Command {
|
||||||
@@ -15,16 +11,12 @@ func newStatusCmd() *cobra.Command {
|
|||||||
Short: "📊 Show repository sync status",
|
Short: "📊 Show repository sync status",
|
||||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
lnkService, err := service.New()
|
lnk := core.NewLnk()
|
||||||
|
status, err := lnk.Status()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return wrapServiceError("initialize lnk service", err)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
status, err := lnkService.GetStatus(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return formatError(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.Dirty {
|
if status.Dirty {
|
||||||
@@ -43,9 +35,9 @@ func newStatusCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayDirtyStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
||||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status))
|
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||||
|
|
||||||
if status.Ahead == 0 && status.Behind == 0 {
|
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")
|
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")
|
||||||
@@ -57,14 +49,14 @@ func displayDirtyStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
|||||||
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")
|
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 *models.SyncStatus) {
|
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
||||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", getRemoteDisplay(status))
|
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
func displaySyncStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
||||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status))
|
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||||
printf(cmd, "\n")
|
printf(cmd, "\n")
|
||||||
|
|
||||||
displayAheadBehindInfo(cmd, status, false)
|
displayAheadBehindInfo(cmd, status, false)
|
||||||
@@ -76,7 +68,7 @@ func displaySyncStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayAheadBehindInfo(cmd *cobra.Command, status *models.SyncStatus, isDirty bool) {
|
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
||||||
if status.Ahead > 0 {
|
if status.Ahead > 0 {
|
||||||
commitText := getCommitText(status.Ahead)
|
commitText := getCommitText(status.Ahead)
|
||||||
if isDirty {
|
if isDirty {
|
||||||
@@ -98,13 +90,3 @@ func getCommitText(count int) string {
|
|||||||
}
|
}
|
||||||
return "commits"
|
return "commits"
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRemoteDisplay(status *models.SyncStatus) string {
|
|
||||||
if status.HasRemote && status.RemoteBranch != "" {
|
|
||||||
return status.RemoteBranch
|
|
||||||
}
|
|
||||||
if status.HasRemote && status.RemoteURL != "" {
|
|
||||||
return status.RemoteURL
|
|
||||||
}
|
|
||||||
return "no remote configured"
|
|
||||||
}
|
|
||||||
|
163
cmd/utils.go
163
cmd/utils.go
@@ -1,175 +1,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
stderrors "errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// printf is a helper function to simplify output formatting in commands
|
// printf is a helper function to simplify output formatting in commands
|
||||||
func printf(cmd *cobra.Command, format string, args ...interface{}) {
|
func printf(cmd *cobra.Command, format string, args ...interface{}) {
|
||||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...)
|
_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatError provides user-friendly error formatting while preserving specific error messages for tests
|
|
||||||
func formatError(err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle typed LnkError first
|
|
||||||
var lnkErr *errors.LnkError
|
|
||||||
if stderrors.As(err, &lnkErr) {
|
|
||||||
return formatLnkError(lnkErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle other error patterns with improved messages
|
|
||||||
errMsg := err.Error()
|
|
||||||
|
|
||||||
// Git-related errors
|
|
||||||
if strings.Contains(errMsg, "git") {
|
|
||||||
if strings.Contains(errMsg, "no remote configured") {
|
|
||||||
return fmt.Errorf("🚫 no remote configured\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
|
|
||||||
}
|
|
||||||
if strings.Contains(errMsg, "authentication") || strings.Contains(errMsg, "permission denied") {
|
|
||||||
return fmt.Errorf("🔐 \033[31mGit authentication failed\033[0m\n 💡 Check your SSH keys or credentials: \033[36mhttps://docs.github.com/en/authentication\033[0m")
|
|
||||||
}
|
|
||||||
if strings.Contains(errMsg, "not found") && strings.Contains(errMsg, "remote") {
|
|
||||||
return fmt.Errorf("🌐 \033[31mRemote repository not found\033[0m\n 💡 Verify the repository URL is correct and you have access")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service initialization errors
|
|
||||||
if strings.Contains(errMsg, "failed to initialize lnk service") {
|
|
||||||
return fmt.Errorf("⚠️ \033[31mFailed to initialize lnk\033[0m\n 💡 This is likely a system configuration issue. Please check permissions and try again.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return original error for unhandled cases to maintain test compatibility
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatLnkError formats typed LnkError instances with user-friendly messages
|
|
||||||
func formatLnkError(lnkErr *errors.LnkError) error {
|
|
||||||
switch lnkErr.Code {
|
|
||||||
case errors.ErrorCodeFileNotFound:
|
|
||||||
// Preserve "File does not exist" for test compatibility but add consistent colors
|
|
||||||
if path, ok := lnkErr.Context["path"].(string); ok {
|
|
||||||
return fmt.Errorf("❌ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", path)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("❌ \033[31mFile does not exist\033[0m\n 💡 Check the file path and try again")
|
|
||||||
|
|
||||||
case errors.ErrorCodeRepoNotInitialized:
|
|
||||||
// Preserve "Lnk repository not initialized" for test compatibility but add consistent colors
|
|
||||||
return fmt.Errorf("📦 \033[31mLnk repository not initialized\033[0m\n 💡 Run \033[1mlnk init\033[0m to get started")
|
|
||||||
|
|
||||||
case errors.ErrorCodeNotSymlink:
|
|
||||||
// Preserve "not a symlink" for test compatibility but add consistent colors
|
|
||||||
return fmt.Errorf("🔗 \033[31mnot a symlink\033[0m\n 💡 Only files managed by lnk can be removed. Use \033[1mlnk list\033[0m to see managed files")
|
|
||||||
|
|
||||||
case errors.ErrorCodeFileAlreadyManaged:
|
|
||||||
if path, ok := lnkErr.Context["path"].(string); ok {
|
|
||||||
return fmt.Errorf("✨ \033[33mFile is already managed by lnk:\033[0m \033[36m%s\033[0m\n 💡 Use \033[1mlnk list\033[0m to see all managed files", path)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("✨ \033[33mFile is already managed by lnk\033[0m\n 💡 Use \033[1mlnk list\033[0m to see all managed files")
|
|
||||||
|
|
||||||
case errors.ErrorCodeNoRemoteConfigured:
|
|
||||||
// Preserve "no remote configured" for test compatibility but add consistent colors
|
|
||||||
return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
|
|
||||||
|
|
||||||
case errors.ErrorCodePermissionDenied:
|
|
||||||
if path, ok := lnkErr.Context["path"].(string); ok {
|
|
||||||
return fmt.Errorf("🔒 \033[31mPermission denied:\033[0m \033[36m%s\033[0m\n 💡 Check file permissions or run with appropriate privileges", path)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("🔒 \033[31mPermission denied\033[0m\n 💡 Check file permissions or run with appropriate privileges")
|
|
||||||
|
|
||||||
case errors.ErrorCodeGitOperation:
|
|
||||||
// Check if this is a "no remote configured" case by examining the underlying error first
|
|
||||||
if lnkErr.Cause != nil && strings.Contains(lnkErr.Cause.Error(), "no remote configured") {
|
|
||||||
return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
|
|
||||||
}
|
|
||||||
|
|
||||||
operation := lnkErr.Context["operation"]
|
|
||||||
if op, ok := operation.(string); ok {
|
|
||||||
switch op {
|
|
||||||
case "get_status", "status":
|
|
||||||
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
|
||||||
case "push_to_remote", "push":
|
|
||||||
return fmt.Errorf("🚀 \033[31mGit operation failed\033[0m\n 💡 Check your internet connection and Git credentials\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
|
||||||
case "pull_from_remote", "pull":
|
|
||||||
return fmt.Errorf("⬇️ \033[31mGit operation failed\033[0m\n 💡 Check your internet connection and resolve any conflicts\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
|
||||||
case "clone_repository", "clone":
|
|
||||||
return fmt.Errorf("📥 \033[31mGit operation failed\033[0m\n 💡 Check the repository URL and your access permissions\n 💡 Ensure you have the correct SSH keys or credentials")
|
|
||||||
case "commit_changes", "commit":
|
|
||||||
return fmt.Errorf("💾 \033[31mGit operation failed\033[0m\n 💡 Check if you have Git user.name and user.email configured\n 💡 Run \033[1mgit config --global user.name \"Your Name\"\033[0m")
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
|
||||||
|
|
||||||
case errors.ErrorCodeFileSystemOperation:
|
|
||||||
operation := lnkErr.Context["operation"]
|
|
||||||
path := lnkErr.Context["path"]
|
|
||||||
|
|
||||||
// Determine user-friendly message based on operation and underlying cause
|
|
||||||
if op, ok := operation.(string); ok {
|
|
||||||
switch op {
|
|
||||||
case "stat_symlink", "check_file_exists":
|
|
||||||
// Use consistent "File does not exist" messaging
|
|
||||||
if pathStr, pathOk := path.(string); pathOk {
|
|
||||||
return fmt.Errorf("❌ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", pathStr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("❌ \033[31mFile does not exist\033[0m\n 💡 Check the file path and try again")
|
|
||||||
case "move_file":
|
|
||||||
return fmt.Errorf("📁 \033[31mFile operation failed\033[0m\n 💡 Check file permissions and available disk space")
|
|
||||||
case "create_symlink":
|
|
||||||
return fmt.Errorf("🔗 \033[31mFile operation failed\033[0m\n 💡 Check directory permissions and ensure target file exists")
|
|
||||||
case "remove_symlink", "remove_file":
|
|
||||||
return fmt.Errorf("🗑️ \033[31mFile operation failed\033[0m\n 💡 Check file permissions and ensure file exists")
|
|
||||||
case "read_symlink":
|
|
||||||
return fmt.Errorf("🔗 \033[31mFile operation failed\033[0m\n 💡 The symlink may be broken or you don't have permission to read it")
|
|
||||||
case "resolve_path", "get_relative_path":
|
|
||||||
if pathStr, pathOk := path.(string); pathOk {
|
|
||||||
return fmt.Errorf("📂 \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", pathStr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("📂 \033[31mInvalid file path\033[0m\n 💡 Check the file path and try again")
|
|
||||||
case "create_dest_dir", "create_repo_dir":
|
|
||||||
return fmt.Errorf("📁 \033[31mFile operation failed\033[0m\n 💡 Check permissions and available disk space")
|
|
||||||
default:
|
|
||||||
// Don't expose cryptic operation names - give generic but helpful message
|
|
||||||
return fmt.Errorf("💽 \033[31mFile operation failed\033[0m\n 💡 Check file permissions, paths, and available disk space")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("💽 \033[31mFile operation failed\033[0m\n 💡 Check file permissions and available disk space")
|
|
||||||
|
|
||||||
case errors.ErrorCodeInvalidPath:
|
|
||||||
if path, ok := lnkErr.Context["path"].(string); ok {
|
|
||||||
return fmt.Errorf("📂 \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", path)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("📂 \033[31mInvalid file path\033[0m\n 💡 Check the file path and try again")
|
|
||||||
|
|
||||||
default:
|
|
||||||
// For unknown LnkError types, preserve original message but add context
|
|
||||||
return fmt.Errorf("⚠️ \033[31m%s\033[0m", lnkErr.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrapServiceError wraps service errors with consistent messaging while preserving specific errors for tests
|
|
||||||
func wrapServiceError(operation string, err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// For typed errors, format them nicely
|
|
||||||
var lnkErr *errors.LnkError
|
|
||||||
if stderrors.As(err, &lnkErr) {
|
|
||||||
return formatLnkError(lnkErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other errors, provide operation context but preserve original message for tests
|
|
||||||
return fmt.Errorf("failed to %s: %w", operation, err)
|
|
||||||
}
|
|
||||||
|
@@ -18,7 +18,7 @@ INSTALL_DIR="/usr/local/bin"
|
|||||||
BINARY_NAME="lnk"
|
BINARY_NAME="lnk"
|
||||||
|
|
||||||
# Fallback version if redirect fails
|
# Fallback version if redirect fails
|
||||||
FALLBACK_VERSION="v0.0.2"
|
FALLBACK_VERSION="v0.3.0"
|
||||||
|
|
||||||
# Detect OS and architecture
|
# Detect OS and architecture
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
|
@@ -1,232 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/errors"
|
|
||||||
"github.com/yarlson/lnk/internal/fs"
|
|
||||||
"github.com/yarlson/lnk/internal/models"
|
|
||||||
"github.com/yarlson/lnk/internal/pathresolver"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config implements the service.ConfigManager interface
|
|
||||||
type Config struct {
|
|
||||||
fileManager *fs.FileManager
|
|
||||||
pathResolver *pathresolver.Resolver
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new ConfigManager instance
|
|
||||||
func New(fileManager *fs.FileManager, pathResolver *pathresolver.Resolver) *Config {
|
|
||||||
return &Config{
|
|
||||||
fileManager: fileManager,
|
|
||||||
pathResolver: pathResolver,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadHostConfig loads the configuration for a specific host
|
|
||||||
func (cm *Config) LoadHostConfig(ctx context.Context, repoPath, host string) (*models.HostConfig, error) {
|
|
||||||
managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &models.HostConfig{
|
|
||||||
Name: host,
|
|
||||||
ManagedFiles: managedFiles,
|
|
||||||
LastUpdate: time.Now(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveHostConfig saves the configuration for a specific host
|
|
||||||
func (cm *Config) SaveHostConfig(ctx context.Context, repoPath string, config *models.HostConfig) error {
|
|
||||||
// Convert managed files to relative paths for storage
|
|
||||||
var relativePaths []string
|
|
||||||
for _, file := range config.ManagedFiles {
|
|
||||||
relativePaths = append(relativePaths, file.RelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort for consistent ordering
|
|
||||||
sort.Strings(relativePaths)
|
|
||||||
|
|
||||||
return cm.writeManagedItems(ctx, repoPath, config.Name, relativePaths)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddManagedFileToHost adds a managed file to a host's configuration
|
|
||||||
func (cm *Config) AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error {
|
|
||||||
// Get current managed files
|
|
||||||
managedFiles, err := cm.getManagedItems(ctx, repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already exists
|
|
||||||
for _, item := range managedFiles {
|
|
||||||
if item == file.RelativePath {
|
|
||||||
return nil // Already managed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new item
|
|
||||||
managedFiles = append(managedFiles, file.RelativePath)
|
|
||||||
|
|
||||||
// Sort for consistent ordering
|
|
||||||
sort.Strings(managedFiles)
|
|
||||||
|
|
||||||
return cm.writeManagedItems(ctx, repoPath, host, managedFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveManagedFileFromHost removes a managed file from a host's configuration
|
|
||||||
func (cm *Config) RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error {
|
|
||||||
// Get current managed files
|
|
||||||
managedFiles, err := cm.getManagedItems(ctx, repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove item
|
|
||||||
var newManagedFiles []string
|
|
||||||
for _, item := range managedFiles {
|
|
||||||
if item != relativePath {
|
|
||||||
newManagedFiles = append(newManagedFiles, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cm.writeManagedItems(ctx, repoPath, host, newManagedFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListManagedFiles returns all files managed by a specific host
|
|
||||||
func (cm *Config) ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) {
|
|
||||||
relativePaths, err := cm.getManagedItems(ctx, repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var managedFiles []models.ManagedFile
|
|
||||||
for _, relativePath := range relativePaths {
|
|
||||||
// Get file storage path
|
|
||||||
fileStoragePath, err := cm.pathResolver.GetFileStoragePathInRepo(repoPath, host, relativePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewConfigNotFoundError(host).
|
|
||||||
WithContext("relative_path", relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get original path (where symlink should be)
|
|
||||||
originalPath, err := cm.pathResolver.GetAbsolutePathInHome(relativePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewInvalidPathError(relativePath, "cannot convert to absolute path")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file exists and get info
|
|
||||||
var isDirectory bool
|
|
||||||
var mode os.FileMode
|
|
||||||
if exists, err := cm.fileManager.Exists(ctx, fileStoragePath); err == nil && exists {
|
|
||||||
if info, err := cm.fileManager.Stat(ctx, fileStoragePath); err == nil {
|
|
||||||
isDirectory = info.IsDir()
|
|
||||||
mode = info.Mode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
managedFile := models.ManagedFile{
|
|
||||||
OriginalPath: originalPath,
|
|
||||||
RepoPath: fileStoragePath,
|
|
||||||
RelativePath: relativePath,
|
|
||||||
Host: host,
|
|
||||||
IsDirectory: isDirectory,
|
|
||||||
Mode: mode,
|
|
||||||
}
|
|
||||||
|
|
||||||
managedFiles = append(managedFiles, managedFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return managedFiles, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetManagedFile retrieves a specific managed file by relative path
|
|
||||||
func (cm *Config) GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) {
|
|
||||||
managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range managedFiles {
|
|
||||||
if file.RelativePath == relativePath {
|
|
||||||
return &file, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, errors.NewFileNotFoundError(relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigExists checks if a configuration file exists for the host
|
|
||||||
func (cm *Config) ConfigExists(ctx context.Context, repoPath, host string) (bool, error) {
|
|
||||||
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return cm.fileManager.Exists(ctx, trackingFilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getManagedItems returns the list of managed files and directories from .lnk file
|
|
||||||
// This is the core method that reads the plain text format
|
|
||||||
func (cm *Config) getManagedItems(ctx context.Context, repoPath, host string) ([]string, error) {
|
|
||||||
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewConfigNotFoundError(host).
|
|
||||||
WithContext("repo_path", repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If .lnk file doesn't exist, return empty list
|
|
||||||
exists, err := cm.fileManager.Exists(ctx, trackingFilePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("check_exists", trackingFilePath, err)
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := cm.fileManager.ReadFile(ctx, trackingFilePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("read", trackingFilePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(content) == 0 {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
|
||||||
var items []string
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line != "" {
|
|
||||||
items = append(items, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeManagedItems writes the list of managed items to .lnk file
|
|
||||||
// This maintains the plain text line-by-line format for compatibility
|
|
||||||
func (cm *Config) writeManagedItems(ctx context.Context, repoPath, host string, items []string) error {
|
|
||||||
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewConfigNotFoundError(host).
|
|
||||||
WithContext("repo_path", repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := strings.Join(items, "\n")
|
|
||||||
if len(items) > 0 {
|
|
||||||
content += "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cm.fileManager.WriteFile(ctx, trackingFilePath, []byte(content), 0644); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("write", trackingFilePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@@ -1,278 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/errors"
|
|
||||||
"github.com/yarlson/lnk/internal/fs"
|
|
||||||
"github.com/yarlson/lnk/internal/models"
|
|
||||||
"github.com/yarlson/lnk/internal/pathresolver"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ConfigTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
tempDir string
|
|
||||||
configManager *Config
|
|
||||||
fileManager *fs.FileManager
|
|
||||||
pathResolver *pathresolver.Resolver
|
|
||||||
ctx context.Context
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) SetupTest() {
|
|
||||||
// Create temp directory for testing
|
|
||||||
tempDir, err := os.MkdirTemp("", "lnk_config_test_*")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.tempDir = tempDir
|
|
||||||
|
|
||||||
// Create file manager and path resolver
|
|
||||||
suite.fileManager = fs.New()
|
|
||||||
suite.pathResolver = pathresolver.New()
|
|
||||||
|
|
||||||
// Create config manager
|
|
||||||
suite.configManager = New(suite.fileManager, suite.pathResolver)
|
|
||||||
|
|
||||||
// Create context
|
|
||||||
suite.ctx = context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TearDownTest() {
|
|
||||||
err := os.RemoveAll(suite.tempDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TestAddAndListManagedFiles() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
|
||||||
host := "testhost"
|
|
||||||
|
|
||||||
// Create a managed file
|
|
||||||
managedFile := models.ManagedFile{
|
|
||||||
RelativePath: ".vimrc",
|
|
||||||
Host: host,
|
|
||||||
IsDirectory: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add managed file
|
|
||||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// List managed files
|
|
||||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
suite.Len(files, 1)
|
|
||||||
suite.Equal(".vimrc", files[0].RelativePath)
|
|
||||||
suite.Equal(host, files[0].Host)
|
|
||||||
|
|
||||||
// Verify tracking file was created
|
|
||||||
trackingPath, err := suite.pathResolver.GetTrackingFilePath(repoPath, host)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
exists, err := suite.fileManager.Exists(suite.ctx, trackingPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.True(exists)
|
|
||||||
|
|
||||||
// Read tracking file content
|
|
||||||
content, err := suite.fileManager.ReadFile(suite.ctx, trackingPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
expectedContent := ".vimrc\n"
|
|
||||||
suite.Equal(expectedContent, string(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TestAddDuplicateFile() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
|
||||||
host := "testhost"
|
|
||||||
|
|
||||||
managedFile := models.ManagedFile{
|
|
||||||
RelativePath: ".bashrc",
|
|
||||||
Host: host,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add file twice
|
|
||||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Should still have only one file
|
|
||||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Len(files, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TestRemoveManagedFile() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
|
||||||
host := "testhost"
|
|
||||||
|
|
||||||
// Add two managed files
|
|
||||||
file1 := models.ManagedFile{RelativePath: ".vimrc", Host: host}
|
|
||||||
file2 := models.ManagedFile{RelativePath: ".bashrc", Host: host}
|
|
||||||
|
|
||||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file1)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file2)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Remove one file
|
|
||||||
err = suite.configManager.RemoveManagedFileFromHost(suite.ctx, repoPath, host, ".vimrc")
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Should have only one file left
|
|
||||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
suite.Len(files, 1)
|
|
||||||
suite.Equal(".bashrc", files[0].RelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TestLoadAndSaveHostConfig() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
|
||||||
host := "workstation"
|
|
||||||
|
|
||||||
// Create host config with managed files
|
|
||||||
config := &models.HostConfig{
|
|
||||||
Name: host,
|
|
||||||
ManagedFiles: []models.ManagedFile{
|
|
||||||
{RelativePath: ".vimrc", Host: host},
|
|
||||||
{RelativePath: ".bashrc", Host: host},
|
|
||||||
},
|
|
||||||
LastUpdate: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save config
|
|
||||||
err := suite.configManager.SaveHostConfig(suite.ctx, repoPath, config)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Load config
|
|
||||||
loadedConfig, err := suite.configManager.LoadHostConfig(suite.ctx, repoPath, host)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
suite.Equal(host, loadedConfig.Name)
|
|
||||||
suite.Len(loadedConfig.ManagedFiles, 2)
|
|
||||||
|
|
||||||
// Check files are sorted
|
|
||||||
suite.Equal(".bashrc", loadedConfig.ManagedFiles[0].RelativePath)
|
|
||||||
suite.Equal(".vimrc", loadedConfig.ManagedFiles[1].RelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TestGetManagedFile() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
|
||||||
host := "testhost"
|
|
||||||
|
|
||||||
managedFile := models.ManagedFile{
|
|
||||||
RelativePath: ".gitconfig",
|
|
||||||
Host: host,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add managed file
|
|
||||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Get specific managed file
|
|
||||||
file, err := suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".gitconfig")
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(".gitconfig", file.RelativePath)
|
|
||||||
|
|
||||||
// Try to get non-existent file
|
|
||||||
_, err = suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".nonexistent")
|
|
||||||
suite.Error(err)
|
|
||||||
suite.True(errors.NewFileNotFoundError("").Is(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TestConfigExists() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
|
||||||
host := "testhost"
|
|
||||||
|
|
||||||
// Initially should not exist
|
|
||||||
exists, err := suite.configManager.ConfigExists(suite.ctx, repoPath, host)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.False(exists)
|
|
||||||
|
|
||||||
// Add a managed file
|
|
||||||
managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host}
|
|
||||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Now should exist
|
|
||||||
exists, err = suite.configManager.ConfigExists(suite.ctx, repoPath, host)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.True(exists)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TestEmptyConfig() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
|
||||||
host := "emptyhost"
|
|
||||||
|
|
||||||
// List files from non-existent config
|
|
||||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Len(files, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TestCommonAndHostConfigs() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
|
||||||
|
|
||||||
// Add file to common config (empty host)
|
|
||||||
commonFile := models.ManagedFile{RelativePath: ".bashrc", Host: ""}
|
|
||||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "", commonFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Add file to host-specific config
|
|
||||||
hostFile := models.ManagedFile{RelativePath: ".vimrc", Host: "workstation"}
|
|
||||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "workstation", hostFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// List common files
|
|
||||||
commonFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "")
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Len(commonFiles, 1)
|
|
||||||
suite.Equal(".bashrc", commonFiles[0].RelativePath)
|
|
||||||
|
|
||||||
// List host files
|
|
||||||
hostFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "workstation")
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Len(hostFiles, 1)
|
|
||||||
suite.Equal(".vimrc", hostFiles[0].RelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ConfigTestSuite) TestFileWithMetadata() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
|
||||||
host := "testhost"
|
|
||||||
|
|
||||||
// Create actual file in repository storage area
|
|
||||||
hostStoragePath := filepath.Join(repoPath, host+".lnk")
|
|
||||||
testFilePath := filepath.Join(hostStoragePath, ".vimrc")
|
|
||||||
|
|
||||||
err := suite.fileManager.WriteFile(suite.ctx, testFilePath, []byte("set number"), 0644)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Add managed file
|
|
||||||
managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host}
|
|
||||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// List files should include metadata
|
|
||||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Len(files, 1)
|
|
||||||
|
|
||||||
file := files[0]
|
|
||||||
suite.False(file.IsDirectory)
|
|
||||||
suite.NotZero(file.Mode)
|
|
||||||
|
|
||||||
// Expected paths
|
|
||||||
expectedRepoPath := testFilePath
|
|
||||||
suite.Equal(expectedRepoPath, file.RepoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(ConfigTestSuite))
|
|
||||||
}
|
|
1189
internal/core/lnk.go
Normal file
1189
internal/core/lnk.go
Normal file
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
@@ -1,241 +0,0 @@
|
|||||||
package errors
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Standard error variables
|
|
||||||
var (
|
|
||||||
// ErrFileNotFound indicates a file or directory was not found
|
|
||||||
ErrFileNotFound = errors.New("file not found")
|
|
||||||
|
|
||||||
// ErrFileAlreadyManaged indicates a file is already being managed by lnk
|
|
||||||
ErrFileAlreadyManaged = errors.New("file already managed")
|
|
||||||
|
|
||||||
// ErrNotSymlink indicates the file is not a symbolic link
|
|
||||||
ErrNotSymlink = errors.New("not a symlink")
|
|
||||||
|
|
||||||
// ErrRepoNotInitialized indicates the lnk repository has not been initialized
|
|
||||||
ErrRepoNotInitialized = errors.New("repository not initialized")
|
|
||||||
|
|
||||||
// ErrNoRemoteConfigured indicates no Git remote has been configured
|
|
||||||
ErrNoRemoteConfigured = errors.New("no remote configured")
|
|
||||||
|
|
||||||
// ErrOperationAborted indicates an operation was aborted by the user
|
|
||||||
ErrOperationAborted = errors.New("operation aborted")
|
|
||||||
|
|
||||||
// ErrConfigNotFound indicates a configuration file was not found
|
|
||||||
ErrConfigNotFound = errors.New("configuration not found")
|
|
||||||
|
|
||||||
// ErrInvalidPath indicates an invalid file path was provided
|
|
||||||
ErrInvalidPath = errors.New("invalid path")
|
|
||||||
|
|
||||||
// ErrPermissionDenied indicates insufficient permissions for the operation
|
|
||||||
ErrPermissionDenied = errors.New("permission denied")
|
|
||||||
|
|
||||||
// ErrGitOperation indicates a Git operation failed
|
|
||||||
ErrGitOperation = errors.New("git operation failed")
|
|
||||||
|
|
||||||
// ErrFileSystemOperation indicates a file system operation failed
|
|
||||||
ErrFileSystemOperation = errors.New("file system operation failed")
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrorCode represents different types of errors that can occur
|
|
||||||
type ErrorCode int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ErrorCodeUnknown represents an unknown error
|
|
||||||
ErrorCodeUnknown ErrorCode = iota
|
|
||||||
|
|
||||||
// ErrorCodeFileNotFound represents file not found errors
|
|
||||||
ErrorCodeFileNotFound
|
|
||||||
|
|
||||||
// ErrorCodeFileAlreadyManaged represents file already managed errors
|
|
||||||
ErrorCodeFileAlreadyManaged
|
|
||||||
|
|
||||||
// ErrorCodeNotSymlink represents not a symlink errors
|
|
||||||
ErrorCodeNotSymlink
|
|
||||||
|
|
||||||
// ErrorCodeRepoNotInitialized represents repository not initialized errors
|
|
||||||
ErrorCodeRepoNotInitialized
|
|
||||||
|
|
||||||
// ErrorCodeNoRemoteConfigured represents no remote configured errors
|
|
||||||
ErrorCodeNoRemoteConfigured
|
|
||||||
|
|
||||||
// ErrorCodeOperationAborted represents operation aborted errors
|
|
||||||
ErrorCodeOperationAborted
|
|
||||||
|
|
||||||
// ErrorCodeConfigNotFound represents configuration not found errors
|
|
||||||
ErrorCodeConfigNotFound
|
|
||||||
|
|
||||||
// ErrorCodeInvalidPath represents invalid path errors
|
|
||||||
ErrorCodeInvalidPath
|
|
||||||
|
|
||||||
// ErrorCodePermissionDenied represents permission denied errors
|
|
||||||
ErrorCodePermissionDenied
|
|
||||||
|
|
||||||
// ErrorCodeGitOperation represents Git operation errors
|
|
||||||
ErrorCodeGitOperation
|
|
||||||
|
|
||||||
// ErrorCodeFileSystemOperation represents file system operation errors
|
|
||||||
ErrorCodeFileSystemOperation
|
|
||||||
)
|
|
||||||
|
|
||||||
// String returns a string representation of the error code
|
|
||||||
func (e ErrorCode) String() string {
|
|
||||||
switch e {
|
|
||||||
case ErrorCodeFileNotFound:
|
|
||||||
return "FILE_NOT_FOUND"
|
|
||||||
case ErrorCodeFileAlreadyManaged:
|
|
||||||
return "FILE_ALREADY_MANAGED"
|
|
||||||
case ErrorCodeNotSymlink:
|
|
||||||
return "NOT_SYMLINK"
|
|
||||||
case ErrorCodeRepoNotInitialized:
|
|
||||||
return "REPO_NOT_INITIALIZED"
|
|
||||||
case ErrorCodeNoRemoteConfigured:
|
|
||||||
return "NO_REMOTE_CONFIGURED"
|
|
||||||
case ErrorCodeOperationAborted:
|
|
||||||
return "OPERATION_ABORTED"
|
|
||||||
case ErrorCodeConfigNotFound:
|
|
||||||
return "CONFIG_NOT_FOUND"
|
|
||||||
case ErrorCodeInvalidPath:
|
|
||||||
return "INVALID_PATH"
|
|
||||||
case ErrorCodePermissionDenied:
|
|
||||||
return "PERMISSION_DENIED"
|
|
||||||
case ErrorCodeGitOperation:
|
|
||||||
return "GIT_OPERATION"
|
|
||||||
case ErrorCodeFileSystemOperation:
|
|
||||||
return "FILE_SYSTEM_OPERATION"
|
|
||||||
default:
|
|
||||||
return "UNKNOWN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LnkError represents a structured error with additional context
|
|
||||||
type LnkError struct {
|
|
||||||
// Code represents the type of error
|
|
||||||
Code ErrorCode
|
|
||||||
|
|
||||||
// Message is the human-readable error message
|
|
||||||
Message string
|
|
||||||
|
|
||||||
// Cause is the underlying error that caused this error
|
|
||||||
Cause error
|
|
||||||
|
|
||||||
// Context provides additional context about when/where the error occurred
|
|
||||||
Context map[string]interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error implements the error interface
|
|
||||||
func (e *LnkError) Error() string {
|
|
||||||
if e.Cause != nil {
|
|
||||||
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
|
||||||
}
|
|
||||||
return e.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap returns the underlying cause error for Go 1.13+ error handling
|
|
||||||
func (e *LnkError) Unwrap() error {
|
|
||||||
return e.Cause
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is implements error comparison for Go 1.13+ error handling
|
|
||||||
func (e *LnkError) Is(target error) bool {
|
|
||||||
if lnkErr, ok := target.(*LnkError); ok {
|
|
||||||
return e.Code == lnkErr.Code
|
|
||||||
}
|
|
||||||
return errors.Is(e.Cause, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithContext adds context information to the error
|
|
||||||
func (e *LnkError) WithContext(key string, value interface{}) *LnkError {
|
|
||||||
if e.Context == nil {
|
|
||||||
e.Context = make(map[string]interface{})
|
|
||||||
}
|
|
||||||
e.Context[key] = value
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLnkError creates a new LnkError with the given code and message
|
|
||||||
func NewLnkError(code ErrorCode, message string) *LnkError {
|
|
||||||
return &LnkError{
|
|
||||||
Code: code,
|
|
||||||
Message: message,
|
|
||||||
Context: make(map[string]interface{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WrapError wraps an existing error with LnkError context
|
|
||||||
func WrapError(code ErrorCode, message string, cause error) *LnkError {
|
|
||||||
return &LnkError{
|
|
||||||
Code: code,
|
|
||||||
Message: message,
|
|
||||||
Cause: cause,
|
|
||||||
Context: make(map[string]interface{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for creating common errors
|
|
||||||
|
|
||||||
// NewFileNotFoundError creates a file not found error
|
|
||||||
func NewFileNotFoundError(path string) *LnkError {
|
|
||||||
return NewLnkError(ErrorCodeFileNotFound, fmt.Sprintf("❌ File does not exist: \033[31m%s\033[0m", path)).
|
|
||||||
WithContext("path", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileAlreadyManagedError creates a file already managed error
|
|
||||||
func NewFileAlreadyManagedError(path string) *LnkError {
|
|
||||||
return NewLnkError(ErrorCodeFileAlreadyManaged, fmt.Sprintf("file already managed: %s", path)).
|
|
||||||
WithContext("path", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewNotSymlinkError creates a not symlink error
|
|
||||||
func NewNotSymlinkError(path string) *LnkError {
|
|
||||||
return NewLnkError(ErrorCodeNotSymlink, fmt.Sprintf("not a symlink: %s", path)).
|
|
||||||
WithContext("path", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRepoNotInitializedError creates a repository not initialized error
|
|
||||||
func NewRepoNotInitializedError(repoPath string) *LnkError {
|
|
||||||
return NewLnkError(ErrorCodeRepoNotInitialized, "Lnk repository not initialized").
|
|
||||||
WithContext("repo_path", repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewNoRemoteConfiguredError creates a no remote configured error
|
|
||||||
func NewNoRemoteConfiguredError() *LnkError {
|
|
||||||
return NewLnkError(ErrorCodeNoRemoteConfigured, "no git remote configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConfigNotFoundError creates a configuration not found error
|
|
||||||
func NewConfigNotFoundError(host string) *LnkError {
|
|
||||||
return NewLnkError(ErrorCodeConfigNotFound, fmt.Sprintf("configuration not found for host: %s", host)).
|
|
||||||
WithContext("host", host)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewInvalidPathError creates an invalid path error
|
|
||||||
func NewInvalidPathError(path string, reason string) *LnkError {
|
|
||||||
return NewLnkError(ErrorCodeInvalidPath, fmt.Sprintf("invalid path %s: %s", path, reason)).
|
|
||||||
WithContext("path", path).
|
|
||||||
WithContext("reason", reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPermissionDeniedError creates a permission denied error
|
|
||||||
func NewPermissionDeniedError(operation, path string) *LnkError {
|
|
||||||
return NewLnkError(ErrorCodePermissionDenied, fmt.Sprintf("permission denied for %s: %s", operation, path)).
|
|
||||||
WithContext("operation", operation).
|
|
||||||
WithContext("path", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGitOperationError creates a Git operation error
|
|
||||||
func NewGitOperationError(operation string, cause error) *LnkError {
|
|
||||||
return WrapError(ErrorCodeGitOperation, fmt.Sprintf("git %s failed", operation), cause).
|
|
||||||
WithContext("operation", operation)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFileSystemOperationError creates a file system operation error
|
|
||||||
func NewFileSystemOperationError(operation, path string, cause error) *LnkError {
|
|
||||||
return WrapError(ErrorCodeFileSystemOperation, fmt.Sprintf("file system %s failed for %s", operation, path), cause).
|
|
||||||
WithContext("operation", operation).
|
|
||||||
WithContext("path", path)
|
|
||||||
}
|
|
@@ -1,126 +0,0 @@
|
|||||||
package errors
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ErrorsTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ErrorsTestSuite) TestErrorCodeString() {
|
|
||||||
tests := []struct {
|
|
||||||
code ErrorCode
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{ErrorCodeFileNotFound, "FILE_NOT_FOUND"},
|
|
||||||
{ErrorCodeFileAlreadyManaged, "FILE_ALREADY_MANAGED"},
|
|
||||||
{ErrorCodeNotSymlink, "NOT_SYMLINK"},
|
|
||||||
{ErrorCodeRepoNotInitialized, "REPO_NOT_INITIALIZED"},
|
|
||||||
{ErrorCodeNoRemoteConfigured, "NO_REMOTE_CONFIGURED"},
|
|
||||||
{ErrorCodeOperationAborted, "OPERATION_ABORTED"},
|
|
||||||
{ErrorCodeConfigNotFound, "CONFIG_NOT_FOUND"},
|
|
||||||
{ErrorCodeInvalidPath, "INVALID_PATH"},
|
|
||||||
{ErrorCodePermissionDenied, "PERMISSION_DENIED"},
|
|
||||||
{ErrorCodeGitOperation, "GIT_OPERATION"},
|
|
||||||
{ErrorCodeFileSystemOperation, "FILE_SYSTEM_OPERATION"},
|
|
||||||
{ErrorCodeUnknown, "UNKNOWN"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.expected, func() {
|
|
||||||
result := tt.code.String()
|
|
||||||
suite.Equal(tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ErrorsTestSuite) TestLnkErrorError() {
|
|
||||||
suite.Run("without_cause", func() {
|
|
||||||
err := NewLnkError(ErrorCodeFileNotFound, "test file not found")
|
|
||||||
expected := "test file not found"
|
|
||||||
suite.Equal(expected, err.Error())
|
|
||||||
})
|
|
||||||
|
|
||||||
suite.Run("with_cause", func() {
|
|
||||||
cause := errors.New("underlying error")
|
|
||||||
err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause)
|
|
||||||
expected := "file operation failed: underlying error"
|
|
||||||
suite.Equal(expected, err.Error())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ErrorsTestSuite) TestLnkErrorUnwrap() {
|
|
||||||
cause := errors.New("underlying error")
|
|
||||||
err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause)
|
|
||||||
|
|
||||||
unwrapped := err.Unwrap()
|
|
||||||
suite.Equal(cause, unwrapped)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ErrorsTestSuite) TestLnkErrorIs() {
|
|
||||||
err1 := NewLnkError(ErrorCodeFileNotFound, "file not found")
|
|
||||||
err2 := NewLnkError(ErrorCodeFileNotFound, "another file not found")
|
|
||||||
err3 := NewLnkError(ErrorCodeFileAlreadyManaged, "file already managed")
|
|
||||||
|
|
||||||
// Same error code should match
|
|
||||||
suite.True(errors.Is(err1, err2), "expected errors with same code to match")
|
|
||||||
|
|
||||||
// Different error codes should not match
|
|
||||||
suite.False(errors.Is(err1, err3), "expected errors with different codes to not match")
|
|
||||||
|
|
||||||
// Test with wrapped errors
|
|
||||||
cause := errors.New("io error")
|
|
||||||
wrappedErr := WrapError(ErrorCodeFileSystemOperation, "wrapped", cause)
|
|
||||||
suite.True(errors.Is(wrappedErr, cause), "expected wrapped error to match its cause")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ErrorsTestSuite) TestLnkErrorWithContext() {
|
|
||||||
err := NewLnkError(ErrorCodeFileNotFound, "file not found")
|
|
||||||
err = err.WithContext("path", "/test/file.txt")
|
|
||||||
err = err.WithContext("operation", "read")
|
|
||||||
|
|
||||||
suite.Equal("/test/file.txt", err.Context["path"])
|
|
||||||
suite.Equal("read", err.Context["operation"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ErrorsTestSuite) TestNewFileNotFoundError() {
|
|
||||||
path := "/test/file.txt"
|
|
||||||
err := NewFileNotFoundError(path)
|
|
||||||
|
|
||||||
suite.Equal(ErrorCodeFileNotFound, err.Code)
|
|
||||||
suite.Equal(path, err.Context["path"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ErrorsTestSuite) TestNewFileAlreadyManagedError() {
|
|
||||||
path := "/test/file.txt"
|
|
||||||
err := NewFileAlreadyManagedError(path)
|
|
||||||
|
|
||||||
suite.Equal(ErrorCodeFileAlreadyManaged, err.Code)
|
|
||||||
suite.Equal(path, err.Context["path"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ErrorsTestSuite) TestNewRepoNotInitializedError() {
|
|
||||||
repoPath := "/test/repo"
|
|
||||||
err := NewRepoNotInitializedError(repoPath)
|
|
||||||
|
|
||||||
suite.Equal(ErrorCodeRepoNotInitialized, err.Code)
|
|
||||||
suite.Equal(repoPath, err.Context["repo_path"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ErrorsTestSuite) TestNewGitOperationError() {
|
|
||||||
operation := "push"
|
|
||||||
cause := errors.New("network error")
|
|
||||||
err := NewGitOperationError(operation, cause)
|
|
||||||
|
|
||||||
suite.Equal(ErrorCodeGitOperation, err.Code)
|
|
||||||
suite.Equal(cause, err.Cause)
|
|
||||||
suite.Equal(operation, err.Context["operation"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorsSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(ErrorsTestSuite))
|
|
||||||
}
|
|
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,254 +0,0 @@
|
|||||||
package fs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileManager implements the models.FileManager interface
|
|
||||||
type FileManager struct{}
|
|
||||||
|
|
||||||
// New creates a new FileManager instance
|
|
||||||
func New() *FileManager {
|
|
||||||
return &FileManager{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exists checks if a file or directory exists
|
|
||||||
func (fm *FileManager) Exists(ctx context.Context, path string) (bool, error) {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return false, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.NewFileSystemOperationError("stat", path, err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsDirectory checks if the path points to a directory
|
|
||||||
func (fm *FileManager) IsDirectory(ctx context.Context, path string) (bool, error) {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return false, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return false, errors.NewFileNotFoundError(path)
|
|
||||||
}
|
|
||||||
return false, errors.NewFileSystemOperationError("stat", path, err)
|
|
||||||
}
|
|
||||||
return info.IsDir(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move moves a file or directory from src to dst
|
|
||||||
func (fm *FileManager) Move(ctx context.Context, src, dst string) error {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure destination directory exists
|
|
||||||
dstDir := filepath.Dir(dst)
|
|
||||||
if err := fm.MkdirAll(ctx, dstDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for context cancellation before move
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move the file or directory
|
|
||||||
if err := os.Rename(src, dst); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("move", src, err).
|
|
||||||
WithContext("destination", dst)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSymlink creates a symlink pointing from linkPath to target
|
|
||||||
func (fm *FileManager) CreateSymlink(ctx context.Context, target, linkPath string) error {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate relative path from linkPath to target
|
|
||||||
linkDir := filepath.Dir(linkPath)
|
|
||||||
relTarget, err := filepath.Rel(linkDir, target)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("calculate_relative_path", linkPath, err).
|
|
||||||
WithContext("target", target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the symlink
|
|
||||||
if err := os.Symlink(relTarget, linkPath); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("create_symlink", linkPath, err).
|
|
||||||
WithContext("target", relTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove removes a file or directory
|
|
||||||
func (fm *FileManager) Remove(ctx context.Context, path string) error {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.RemoveAll(path); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("remove", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadFile reads the contents of a file
|
|
||||||
func (fm *FileManager) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, errors.NewFileNotFoundError(path)
|
|
||||||
}
|
|
||||||
return nil, errors.NewFileSystemOperationError("read", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteFile writes data to a file with the given permissions
|
|
||||||
func (fm *FileManager) WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode) error {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure parent directory exists
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
if err := fm.MkdirAll(ctx, dir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create parent directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for context cancellation before write
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, data, perm); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("write", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MkdirAll creates a directory and all necessary parent directories
|
|
||||||
func (fm *FileManager) MkdirAll(ctx context.Context, path string, perm os.FileMode) error {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(path, perm); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("mkdir", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Readlink returns the target of a symbolic link
|
|
||||||
func (fm *FileManager) Readlink(ctx context.Context, path string) (string, error) {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
target, err := os.Readlink(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return "", errors.NewFileNotFoundError(path)
|
|
||||||
}
|
|
||||||
return "", errors.NewFileSystemOperationError("readlink", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return target, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lstat returns file info without following symbolic links
|
|
||||||
func (fm *FileManager) Lstat(ctx context.Context, path string) (os.FileInfo, error) {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, errors.NewFileNotFoundError(path)
|
|
||||||
}
|
|
||||||
return nil, errors.NewFileSystemOperationError("lstat", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stat returns file info, following symbolic links
|
|
||||||
func (fm *FileManager) Stat(ctx context.Context, path string) (os.FileInfo, error) {
|
|
||||||
// Check for context cancellation
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, errors.NewFileNotFoundError(path)
|
|
||||||
}
|
|
||||||
return nil, errors.NewFileSystemOperationError("stat", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
@@ -1,261 +0,0 @@
|
|||||||
package fs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FileManagerTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
tempDir string
|
|
||||||
fileManager *FileManager
|
|
||||||
ctx context.Context
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) SetupTest() {
|
|
||||||
// Create temp directory for testing
|
|
||||||
tempDir, err := os.MkdirTemp("", "lnk_test_*")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.tempDir = tempDir
|
|
||||||
|
|
||||||
// Create file manager
|
|
||||||
suite.fileManager = New()
|
|
||||||
|
|
||||||
// Create context
|
|
||||||
suite.ctx = context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TearDownTest() {
|
|
||||||
err := os.RemoveAll(suite.tempDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestExists() {
|
|
||||||
// Test existing file
|
|
||||||
testFile := filepath.Join(suite.tempDir, "test.txt")
|
|
||||||
err := os.WriteFile(testFile, []byte("test"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
exists, err := suite.fileManager.Exists(suite.ctx, testFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.True(exists)
|
|
||||||
|
|
||||||
// Test non-existing file
|
|
||||||
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
|
||||||
exists, err = suite.fileManager.Exists(suite.ctx, nonExistentFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.False(exists)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestExistsWithCancellation() {
|
|
||||||
// Create cancelled context
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
_, err := suite.fileManager.Exists(ctx, "/any/path")
|
|
||||||
suite.Equal(context.Canceled, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestIsDirectory() {
|
|
||||||
// Test directory
|
|
||||||
isDir, err := suite.fileManager.IsDirectory(suite.ctx, suite.tempDir)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.True(isDir)
|
|
||||||
|
|
||||||
// Test file
|
|
||||||
testFile := filepath.Join(suite.tempDir, "test.txt")
|
|
||||||
err = os.WriteFile(testFile, []byte("test"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
isDir, err = suite.fileManager.IsDirectory(suite.ctx, testFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.False(isDir)
|
|
||||||
|
|
||||||
// Test non-existing file
|
|
||||||
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
|
||||||
_, err = suite.fileManager.IsDirectory(suite.ctx, nonExistentFile)
|
|
||||||
suite.Error(err)
|
|
||||||
|
|
||||||
// Check that it's a models error
|
|
||||||
suite.True(errors.NewFileNotFoundError("").Is(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestMove() {
|
|
||||||
// Create test file
|
|
||||||
srcFile := filepath.Join(suite.tempDir, "source.txt")
|
|
||||||
testContent := []byte("test content")
|
|
||||||
err := os.WriteFile(srcFile, testContent, 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test moving file
|
|
||||||
dstFile := filepath.Join(suite.tempDir, "subdir", "destination.txt")
|
|
||||||
err = suite.fileManager.Move(suite.ctx, srcFile, dstFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Verify source doesn't exist
|
|
||||||
_, err = os.Stat(srcFile)
|
|
||||||
suite.True(os.IsNotExist(err))
|
|
||||||
|
|
||||||
// Verify destination exists with correct content
|
|
||||||
content, err := os.ReadFile(dstFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(string(testContent), string(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestCreateSymlink() {
|
|
||||||
// Create target file
|
|
||||||
targetFile := filepath.Join(suite.tempDir, "target.txt")
|
|
||||||
err := os.WriteFile(targetFile, []byte("test"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create symlink
|
|
||||||
linkFile := filepath.Join(suite.tempDir, "link.txt")
|
|
||||||
err = suite.fileManager.CreateSymlink(suite.ctx, targetFile, linkFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Verify symlink exists and points to target
|
|
||||||
info, err := os.Lstat(linkFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.NotZero(info.Mode() & os.ModeSymlink)
|
|
||||||
|
|
||||||
// Verify symlink target
|
|
||||||
target, err := os.Readlink(linkFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
expectedTarget := "target.txt" // Should be relative
|
|
||||||
suite.Equal(expectedTarget, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestReadWriteFile() {
|
|
||||||
// Test writing file
|
|
||||||
testFile := filepath.Join(suite.tempDir, "subdir", "test.txt")
|
|
||||||
testContent := []byte("test content")
|
|
||||||
err := suite.fileManager.WriteFile(suite.ctx, testFile, testContent, 0644)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Test reading file
|
|
||||||
content, err := suite.fileManager.ReadFile(suite.ctx, testFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(string(testContent), string(content))
|
|
||||||
|
|
||||||
// Test reading non-existent file
|
|
||||||
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
|
||||||
_, err = suite.fileManager.ReadFile(suite.ctx, nonExistentFile)
|
|
||||||
suite.Error(err)
|
|
||||||
suite.True(errors.NewFileNotFoundError("").Is(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestRemove() {
|
|
||||||
// Create test file
|
|
||||||
testFile := filepath.Join(suite.tempDir, "test.txt")
|
|
||||||
err := os.WriteFile(testFile, []byte("test"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Remove file
|
|
||||||
err = suite.fileManager.Remove(suite.ctx, testFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Verify file doesn't exist
|
|
||||||
_, err = os.Stat(testFile)
|
|
||||||
suite.True(os.IsNotExist(err))
|
|
||||||
|
|
||||||
// Test removing non-existent file (should not error)
|
|
||||||
err = suite.fileManager.Remove(suite.ctx, testFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestMkdirAll() {
|
|
||||||
// Create nested directory
|
|
||||||
nestedDir := filepath.Join(suite.tempDir, "a", "b", "c")
|
|
||||||
err := suite.fileManager.MkdirAll(suite.ctx, nestedDir, 0755)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Verify directory exists
|
|
||||||
info, err := os.Stat(nestedDir)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.True(info.IsDir())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestReadlink() {
|
|
||||||
// Create target file
|
|
||||||
targetFile := filepath.Join(suite.tempDir, "target.txt")
|
|
||||||
err := os.WriteFile(targetFile, []byte("test"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create symlink
|
|
||||||
linkFile := filepath.Join(suite.tempDir, "link.txt")
|
|
||||||
err = os.Symlink("target.txt", linkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test reading symlink
|
|
||||||
target, err := suite.fileManager.Readlink(suite.ctx, linkFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal("target.txt", target)
|
|
||||||
|
|
||||||
// Test reading non-symlink
|
|
||||||
_, err = suite.fileManager.Readlink(suite.ctx, targetFile)
|
|
||||||
suite.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestStatAndLstat() {
|
|
||||||
// Create target file
|
|
||||||
targetFile := filepath.Join(suite.tempDir, "target.txt")
|
|
||||||
err := os.WriteFile(targetFile, []byte("test"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create symlink
|
|
||||||
linkFile := filepath.Join(suite.tempDir, "link.txt")
|
|
||||||
err = os.Symlink("target.txt", linkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test Stat on regular file
|
|
||||||
info, err := suite.fileManager.Stat(suite.ctx, targetFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.False(info.IsDir())
|
|
||||||
|
|
||||||
// Test Stat on symlink (should follow link)
|
|
||||||
info, err = suite.fileManager.Stat(suite.ctx, linkFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.False(info.IsDir())
|
|
||||||
|
|
||||||
// Test Lstat on symlink (should not follow link)
|
|
||||||
info, err = suite.fileManager.Lstat(suite.ctx, linkFile)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.NotZero(info.Mode() & os.ModeSymlink)
|
|
||||||
|
|
||||||
// Test on non-existent file
|
|
||||||
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
|
||||||
_, err = suite.fileManager.Stat(suite.ctx, nonExistentFile)
|
|
||||||
suite.Error(err)
|
|
||||||
suite.True(errors.NewFileNotFoundError("").Is(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *FileManagerTestSuite) TestContextCancellation() {
|
|
||||||
// Test with timeout context
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Allow time for context to expire
|
|
||||||
time.Sleep(1 * time.Millisecond)
|
|
||||||
|
|
||||||
// Test various operations with cancelled context
|
|
||||||
_, err := suite.fileManager.Exists(ctx, "/any/path")
|
|
||||||
suite.Equal(context.DeadlineExceeded, err)
|
|
||||||
|
|
||||||
_, err = suite.fileManager.IsDirectory(ctx, "/any/path")
|
|
||||||
suite.Equal(context.DeadlineExceeded, err)
|
|
||||||
|
|
||||||
err = suite.fileManager.Move(ctx, "/src", "/dst")
|
|
||||||
suite.Equal(context.DeadlineExceeded, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileManagerSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(FileManagerTestSuite))
|
|
||||||
}
|
|
114
internal/fs/filesystem.go
Normal file
114
internal/fs/filesystem.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package fs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileSystem handles file system operations
|
||||||
|
type FileSystem struct{}
|
||||||
|
|
||||||
|
// New creates a new FileSystem instance
|
||||||
|
func New() *FileSystem {
|
||||||
|
return &FileSystem{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFileForAdd validates that a file or directory can be added to lnk
|
||||||
|
func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
|
||||||
|
// Check if file exists and get its info
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &FileNotExistsError{Path: filePath, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FileCheckError{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow both regular files and directories
|
||||||
|
if !info.Mode().IsRegular() && !info.IsDir() {
|
||||||
|
return &UnsupportedFileTypeError{Path: filePath}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk
|
||||||
|
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
|
||||||
|
// Check if file exists and is a symlink
|
||||||
|
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &FileNotExistsError{Path: filePath, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FileCheckError{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
return &NotManagedByLnkError{Path: filePath}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get symlink target and resolve to absolute path
|
||||||
|
target, err := os.Readlink(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return &SymlinkReadError{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(target) {
|
||||||
|
target = filepath.Join(filepath.Dir(filePath), target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean paths and check if target is inside the repository
|
||||||
|
target = filepath.Clean(target)
|
||||||
|
repoPath = filepath.Clean(repoPath)
|
||||||
|
|
||||||
|
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
|
||||||
|
return &NotManagedByLnkError{Path: filePath}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move moves a file or directory from source to destination based on the file info
|
||||||
|
func (fs *FileSystem) Move(src, dst string, info os.FileInfo) error {
|
||||||
|
if info.IsDir() {
|
||||||
|
return fs.MoveDirectory(src, dst)
|
||||||
|
}
|
||||||
|
return fs.MoveFile(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveFile moves a file from source to destination
|
||||||
|
func (fs *FileSystem) MoveFile(src, dst string) error {
|
||||||
|
// Ensure destination directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
|
return &DirectoryCreationError{Operation: "destination directory", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the file
|
||||||
|
return os.Rename(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSymlink creates a relative symlink from target to linkPath
|
||||||
|
func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
|
||||||
|
// Calculate relative path from linkPath to target
|
||||||
|
relTarget, err := filepath.Rel(filepath.Dir(linkPath), target)
|
||||||
|
if err != nil {
|
||||||
|
return &RelativePathCalculationError{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the symlink
|
||||||
|
return os.Symlink(relTarget, linkPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveDirectory moves a directory from source to destination recursively
|
||||||
|
func (fs *FileSystem) MoveDirectory(src, dst string) error {
|
||||||
|
// Ensure destination parent directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||||
|
return &DirectoryCreationError{Operation: "destination parent directory", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the directory
|
||||||
|
return os.Rename(src, dst)
|
||||||
|
}
|
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
|
||||||
|
}
|
508
internal/git/git.go
Normal file
508
internal/git/git.go
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Git handles Git operations
|
||||||
|
type Git struct {
|
||||||
|
repoPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Git instance
|
||||||
|
func New(repoPath string) *Git {
|
||||||
|
return &Git{
|
||||||
|
repoPath: repoPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes a new Git repository
|
||||||
|
func (g *Git) Init() error {
|
||||||
|
// Try using git init -b main first (Git 2.28+)
|
||||||
|
cmd := exec.Command("git", "init", "-b", "main")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
_, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to regular init + branch rename for older Git versions
|
||||||
|
cmd = exec.Command("git", "init")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &GitInitError{Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the default branch to main
|
||||||
|
cmd = exec.Command("git", "symbolic-ref", "HEAD", "refs/heads/main")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return &BranchSetupError{Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRemote adds a remote to the repository
|
||||||
|
func (g *Git) AddRemote(name, url string) error {
|
||||||
|
// Check if remote already exists
|
||||||
|
existingURL, err := g.getRemoteURL(name)
|
||||||
|
if err == nil {
|
||||||
|
// Remote exists, check if URL matches
|
||||||
|
if existingURL == url {
|
||||||
|
// Same URL, idempotent - do nothing
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Different URL, error
|
||||||
|
return &RemoteExistsError{Remote: name, ExistingURL: existingURL, NewURL: url}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote doesn't exist, add it
|
||||||
|
cmd := exec.Command("git", "remote", "add", name, url)
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &GitCommandError{Command: "remote add", Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRemoteURL returns the URL for a remote, or error if not found
|
||||||
|
func (g *Git) getRemoteURL(name string) (string, error) {
|
||||||
|
cmd := exec.Command("git", "remote", "get-url", name)
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsGitRepository checks if the directory contains a Git repository
|
||||||
|
func (g *Git) IsGitRepository() bool {
|
||||||
|
gitDir := filepath.Join(g.repoPath, ".git")
|
||||||
|
_, err := os.Stat(gitDir)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLnkRepository checks if the repository appears to be managed by lnk
|
||||||
|
func (g *Git) IsLnkRepository() bool {
|
||||||
|
if !g.IsGitRepository() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this looks like a lnk repository
|
||||||
|
// We consider it a lnk repo if:
|
||||||
|
// 1. It has no commits (fresh repo), OR
|
||||||
|
// 2. All commits start with "lnk:" pattern
|
||||||
|
|
||||||
|
commits, err := g.GetCommits()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no commits, it's a fresh repo - could be lnk
|
||||||
|
if len(commits) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all commits start with "lnk:", it's definitely ours
|
||||||
|
// If ANY commit doesn't start with "lnk:", it's probably not ours
|
||||||
|
for _, commit := range commits {
|
||||||
|
if !strings.HasPrefix(commit, "lnk:") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAndCommit stages a file and commits it
|
||||||
|
func (g *Git) AddAndCommit(filename, message string) error {
|
||||||
|
// Stage the file
|
||||||
|
if err := g.Add(filename); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the changes
|
||||||
|
if err := g.Commit(message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAndCommit removes a file from Git and commits the change
|
||||||
|
func (g *Git) RemoveAndCommit(filename, message string) error {
|
||||||
|
// Remove the file from Git
|
||||||
|
if err := g.Remove(filename); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the changes
|
||||||
|
if err := g.Commit(message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stages a file
|
||||||
|
func (g *Git) Add(filename string) error {
|
||||||
|
cmd := exec.Command("git", "add", filename)
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &GitCommandError{Command: "add", Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes a file from Git tracking
|
||||||
|
func (g *Git) Remove(filename string) error {
|
||||||
|
// Check if it's a directory that needs -r flag
|
||||||
|
fullPath := filepath.Join(g.repoPath, filename)
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
// Use -r and --cached flags for directories (only remove from git, not filesystem)
|
||||||
|
cmd = exec.Command("git", "rm", "-r", "--cached", filename)
|
||||||
|
} else {
|
||||||
|
// Regular file (only remove from git, not filesystem)
|
||||||
|
cmd = exec.Command("git", "rm", "--cached", filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &GitCommandError{Command: "rm", Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit creates a commit with the given message
|
||||||
|
func (g *Git) Commit(message string) error {
|
||||||
|
// Configure git user if not already configured
|
||||||
|
if err := g.ensureGitConfig(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "commit", "-m", message)
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &GitCommandError{Command: "commit", Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureGitConfig ensures that git user.name and user.email are configured
|
||||||
|
func (g *Git) ensureGitConfig() error {
|
||||||
|
// Check if user.name is configured
|
||||||
|
cmd := exec.Command("git", "config", "user.name")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
||||||
|
// Set a default user.name
|
||||||
|
cmd = exec.Command("git", "config", "user.name", "Lnk User")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return &GitConfigError{Setting: "user.name", Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user.email is configured
|
||||||
|
cmd = exec.Command("git", "config", "user.email")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
||||||
|
// Set a default user.email
|
||||||
|
cmd = exec.Command("git", "config", "user.email", "lnk@localhost")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return &GitConfigError{Setting: "user.email", Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommits returns the list of commit messages for testing purposes
|
||||||
|
func (g *Git) GetCommits() ([]string, error) {
|
||||||
|
// Check if .git directory exists
|
||||||
|
gitDir := filepath.Join(g.repoPath, ".git")
|
||||||
|
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "log", "--oneline", "--format=%s")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// If there are no commits yet, return empty slice
|
||||||
|
outputStr := string(output)
|
||||||
|
if strings.Contains(outputStr, "does not have any commits yet") {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return nil, &GitCommandError{Command: "log", Output: outputStr, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
if len(commits) == 1 && commits[0] == "" {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return commits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRemoteInfo returns information about the default remote
|
||||||
|
func (g *Git) GetRemoteInfo() (string, error) {
|
||||||
|
// First try to get origin remote
|
||||||
|
url, err := g.getRemoteURL("origin")
|
||||||
|
if err != nil {
|
||||||
|
// If origin doesn't exist, try to get any remote
|
||||||
|
cmd := exec.Command("git", "remote")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", &GitCommandError{Command: "remote", Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
if len(remotes) == 0 || remotes[0] == "" {
|
||||||
|
return "", &NoRemoteError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first remote
|
||||||
|
url, err = g.getRemoteURL(remotes[0])
|
||||||
|
if err != nil {
|
||||||
|
return "", &RemoteNotFoundError{Remote: remotes[0], Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusInfo contains repository status information
|
||||||
|
type StatusInfo struct {
|
||||||
|
Ahead int
|
||||||
|
Behind int
|
||||||
|
Remote string
|
||||||
|
Dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the repository status relative to remote
|
||||||
|
func (g *Git) GetStatus() (*StatusInfo, error) {
|
||||||
|
// Check if we have a remote
|
||||||
|
_, err := g.GetRemoteInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for uncommitted changes
|
||||||
|
dirty, err := g.HasChanges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &UncommittedChangesError{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the remote tracking branch
|
||||||
|
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
// No upstream branch set, assume origin/main
|
||||||
|
remoteBranch := "origin/main"
|
||||||
|
return &StatusInfo{
|
||||||
|
Ahead: g.getAheadCount(remoteBranch),
|
||||||
|
Behind: 0, // Can't be behind if no upstream
|
||||||
|
Remote: remoteBranch,
|
||||||
|
Dirty: dirty,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteBranch := strings.TrimSpace(string(output))
|
||||||
|
|
||||||
|
return &StatusInfo{
|
||||||
|
Ahead: g.getAheadCount(remoteBranch),
|
||||||
|
Behind: g.getBehindCount(remoteBranch),
|
||||||
|
Remote: remoteBranch,
|
||||||
|
Dirty: dirty,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAheadCount returns how many commits ahead of remote
|
||||||
|
func (g *Git) getAheadCount(remoteBranch string) int {
|
||||||
|
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch))
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
// If remote branch doesn't exist, count all local commits
|
||||||
|
cmd = exec.Command("git", "rev-list", "--count", "HEAD")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err = cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count := strings.TrimSpace(string(output))
|
||||||
|
if count == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to int
|
||||||
|
var ahead int
|
||||||
|
if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return ahead
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBehindCount returns how many commits behind remote
|
||||||
|
func (g *Git) getBehindCount(remoteBranch string) int {
|
||||||
|
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch))
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
count := strings.TrimSpace(string(output))
|
||||||
|
if count == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to int
|
||||||
|
var behind int
|
||||||
|
if _, err := fmt.Sscanf(count, "%d", &behind); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return behind
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasChanges checks if there are uncommitted changes
|
||||||
|
func (g *Git) HasChanges() (bool, error) {
|
||||||
|
cmd := exec.Command("git", "status", "--porcelain")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, &GitCommandError{Command: "status", Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAll stages all changes in the repository
|
||||||
|
func (g *Git) AddAll() error {
|
||||||
|
cmd := exec.Command("git", "add", "-A")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &GitCommandError{Command: "add", Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push pushes changes to remote
|
||||||
|
func (g *Git) Push() error {
|
||||||
|
// First ensure we have a remote configured
|
||||||
|
_, err := g.GetRemoteInfo()
|
||||||
|
if err != nil {
|
||||||
|
return &PushError{Reason: err.Error(), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "push", "-u", "origin", "main")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &PushError{Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull pulls changes from remote
|
||||||
|
func (g *Git) Pull() error {
|
||||||
|
// First ensure we have a remote configured
|
||||||
|
_, err := g.GetRemoteInfo()
|
||||||
|
if err != nil {
|
||||||
|
return &PullError{Reason: err.Error(), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "pull", "origin", "main")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &PullError{Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone clones a repository from the given URL
|
||||||
|
func (g *Git) Clone(url string) error {
|
||||||
|
// Remove the directory if it exists to ensure clean clone
|
||||||
|
if err := os.RemoveAll(g.repoPath); err != nil {
|
||||||
|
return &DirectoryRemovalError{Path: g.repoPath, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent directory
|
||||||
|
parentDir := filepath.Dir(g.repoPath)
|
||||||
|
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||||
|
return &DirectoryCreationError{Path: parentDir, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the repository
|
||||||
|
cmd := exec.Command("git", "clone", url, g.repoPath)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return &GitCommandError{Command: "clone", Output: string(output), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up upstream tracking for main branch
|
||||||
|
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/main", "main")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
_, err = cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// If main doesn't exist, try master
|
||||||
|
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/master", "master")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
_, err = cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// If that also fails, try to set upstream for current branch
|
||||||
|
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/HEAD")
|
||||||
|
cmd.Dir = g.repoPath
|
||||||
|
_, _ = cmd.CombinedOutput() // Ignore error as this is best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@@ -1,547 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/errors"
|
|
||||||
"github.com/yarlson/lnk/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GitManager implements the models.GitManager interface
|
|
||||||
type GitManager struct{}
|
|
||||||
|
|
||||||
// New creates a new GitManager instance
|
|
||||||
func New() *GitManager {
|
|
||||||
return &GitManager{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes a new Git repository at repoPath
|
|
||||||
func (g *GitManager) Init(ctx context.Context, repoPath string) error {
|
|
||||||
// Try using git init -b main first (Git 2.28+)
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "init", "-b", "main")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
_, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// Fallback to regular init + branch rename for older Git versions
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "init")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("init", fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the default branch to main
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "symbolic-ref", "HEAD", "refs/heads/main")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return errors.NewGitOperationError("init", fmt.Errorf("failed to set default branch to main: %w", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone clones a repository from url to repoPath
|
|
||||||
func (g *GitManager) Clone(ctx context.Context, repoPath, url string) error {
|
|
||||||
// Remove the directory if it exists to ensure clean clone
|
|
||||||
if err := os.RemoveAll(repoPath); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("remove_existing_dir", repoPath,
|
|
||||||
fmt.Errorf("failed to remove existing directory: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create parent directory
|
|
||||||
parentDir := filepath.Dir(repoPath)
|
|
||||||
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("create_parent_dir", parentDir,
|
|
||||||
fmt.Errorf("failed to create parent directory: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone the repository
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "clone", url, repoPath)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("clone", fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up upstream tracking for main branch
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/main", "main")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
_, err = cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// If main doesn't exist, try master
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/master", "master")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
_, err = cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// If that also fails, try to set upstream for current branch
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/HEAD")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
_, _ = cmd.CombinedOutput() // Ignore error as this is best effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add stages files for commit
|
|
||||||
func (g *GitManager) Add(ctx context.Context, repoPath string, files ...string) error {
|
|
||||||
args := append([]string{"add"}, files...)
|
|
||||||
cmd := exec.CommandContext(ctx, "git", args...)
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("add", fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove removes files from Git tracking
|
|
||||||
func (g *GitManager) Remove(ctx context.Context, repoPath string, files ...string) error {
|
|
||||||
for _, filename := range files {
|
|
||||||
// Check if it's a directory in the repository by checking the actual repo path
|
|
||||||
fullPath := filepath.Join(repoPath, filename)
|
|
||||||
info, err := os.Stat(fullPath)
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
useRecursive := false
|
|
||||||
if err == nil && info.IsDir() {
|
|
||||||
useRecursive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if useRecursive {
|
|
||||||
// Use -r and --cached flags for directories (only remove from git, not fs)
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename)
|
|
||||||
} else {
|
|
||||||
// Regular file (only remove from git, not fs)
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "rm", "--cached", filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// If we tried without -r and got a "recursively without -r" error, try with -r
|
|
||||||
if !useRecursive && strings.Contains(string(output), "recursively without -r") {
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename)
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
output, err = cmd.CombinedOutput()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("remove", fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit creates a commit with the given message
|
|
||||||
func (g *GitManager) Commit(ctx context.Context, repoPath, message string) error {
|
|
||||||
// Configure git user if not already configured
|
|
||||||
if err := g.ensureGitConfig(ctx, repoPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "commit", "-m", message)
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("commit", fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push pushes changes to the remote repository
|
|
||||||
func (g *GitManager) Push(ctx context.Context, repoPath string) error {
|
|
||||||
// First ensure we have a remote configured
|
|
||||||
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("push", fmt.Errorf("cannot push: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "push", "-u", "origin", "main")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("push", fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull pulls changes from the remote repository
|
|
||||||
func (g *GitManager) Pull(ctx context.Context, repoPath string) error {
|
|
||||||
// First ensure we have a remote configured
|
|
||||||
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("pull", fmt.Errorf("cannot pull: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "pull", "origin", "main")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("pull", fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status returns the current Git status
|
|
||||||
func (g *GitManager) Status(ctx context.Context, repoPath string) (*models.SyncStatus, error) {
|
|
||||||
// First check if we have a remote configured - this should match old behavior
|
|
||||||
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
|
||||||
if err != nil {
|
|
||||||
// If origin doesn't exist, check if we have any remotes at all
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "remote")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewGitOperationError("list_remotes", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
|
||||||
if len(remotes) == 0 || remotes[0] == "" {
|
|
||||||
return nil, errors.NewGitOperationError("status", fmt.Errorf("no remote configured"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current branch
|
|
||||||
currentBranch, err := g.getCurrentBranch(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewGitOperationError("get_current_branch", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for uncommitted changes
|
|
||||||
dirty, err := g.HasChanges(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewGitOperationError("check_changes", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the remote URL
|
|
||||||
remoteURL, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
|
||||||
hasRemote := err == nil
|
|
||||||
|
|
||||||
// Initialize status with basic information
|
|
||||||
status := &models.SyncStatus{
|
|
||||||
CurrentBranch: currentBranch,
|
|
||||||
Dirty: dirty,
|
|
||||||
HasRemote: hasRemote,
|
|
||||||
RemoteURL: remoteURL,
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no remote, we can't determine ahead/behind counts
|
|
||||||
if !hasRemote {
|
|
||||||
return status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the remote tracking branch
|
|
||||||
remoteBranch, err := g.getRemoteTrackingBranch(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
// No upstream branch set, assume origin/main
|
|
||||||
remoteBranch = "origin/main"
|
|
||||||
}
|
|
||||||
status.RemoteBranch = remoteBranch
|
|
||||||
|
|
||||||
// Get ahead/behind counts
|
|
||||||
status.Ahead = g.getAheadCount(ctx, repoPath, remoteBranch)
|
|
||||||
status.Behind = g.getBehindCount(ctx, repoPath, remoteBranch)
|
|
||||||
|
|
||||||
// Get last commit hash
|
|
||||||
lastCommitHash, err := g.getLastCommitHash(ctx, repoPath)
|
|
||||||
if err == nil {
|
|
||||||
status.LastCommitHash = lastCommitHash
|
|
||||||
}
|
|
||||||
|
|
||||||
return status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRepository checks if the path is a Git repository
|
|
||||||
func (g *GitManager) IsRepository(ctx context.Context, repoPath string) (bool, error) {
|
|
||||||
gitDir := filepath.Join(repoPath, ".git")
|
|
||||||
_, err := os.Stat(gitDir)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.NewFileSystemOperationError("check_git_dir", gitDir, err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HasChanges checks if there are uncommitted changes
|
|
||||||
func (g *GitManager) HasChanges(ctx context.Context, repoPath string) (bool, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.NewGitOperationError("status", fmt.Errorf("git status failed: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddRemote adds a remote to the repository
|
|
||||||
func (g *GitManager) AddRemote(ctx context.Context, repoPath, name, url string) error {
|
|
||||||
// Check if remote already exists
|
|
||||||
existingURL, err := g.GetRemoteURL(ctx, repoPath, name)
|
|
||||||
if err == nil {
|
|
||||||
// Remote exists, check if URL matches
|
|
||||||
if existingURL == url {
|
|
||||||
// Same URL, idempotent - do nothing
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Different URL, error
|
|
||||||
return errors.NewGitOperationError("add_remote",
|
|
||||||
fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote doesn't exist, add it
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "remote", "add", name, url)
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("add_remote", fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRemoteURL returns the URL of a remote
|
|
||||||
func (g *GitManager) GetRemoteURL(ctx context.Context, repoPath, name string) (string, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", name)
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.NewGitOperationError("get_remote_url", fmt.Errorf("failed to get remote URL for %s: %w", name, err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsLnkRepository checks if the repository appears to be managed by lnk
|
|
||||||
func (g *GitManager) IsLnkRepository(ctx context.Context, repoPath string) (bool, error) {
|
|
||||||
isRepo, err := g.IsRepository(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !isRepo {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this looks like a lnk repository
|
|
||||||
// We consider it a lnk repo if:
|
|
||||||
// 1. It has no commits (fresh repo), OR
|
|
||||||
// 2. All commits start with "lnk:" pattern
|
|
||||||
|
|
||||||
commits, err := g.getCommits(ctx, repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.NewGitOperationError("get_commits", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no commits, it's a fresh repo - could be lnk
|
|
||||||
if len(commits) == 0 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If all commits start with "lnk:", it's definitely ours
|
|
||||||
// If ANY commit doesn't start with "lnk:", it's probably not ours
|
|
||||||
for _, commit := range commits {
|
|
||||||
if !strings.HasPrefix(commit, "lnk:") {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper methods
|
|
||||||
|
|
||||||
// ensureGitConfig configures git user if not already configured
|
|
||||||
func (g *GitManager) ensureGitConfig(ctx context.Context, repoPath string) error {
|
|
||||||
// Check if user.name is configured
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "config", "user.name")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
// Set default user.name
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "config", "user.name", "lnk")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return errors.NewGitOperationError("config_user_name", fmt.Errorf("failed to set git user.name: %w", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user.email is configured
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "config", "user.email")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
// Set default user.email
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "config", "user.email", "lnk@local")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return errors.NewGitOperationError("config_user_email", fmt.Errorf("failed to set git user.email: %w", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCurrentBranch returns the current branch name
|
|
||||||
func (g *GitManager) getCurrentBranch(ctx context.Context, repoPath string) (string, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
// For empty repositories, HEAD might not exist yet, default to main
|
|
||||||
errStr := string(output)
|
|
||||||
|
|
||||||
if strings.Contains(errStr, "fatal: ambiguous argument 'HEAD'") ||
|
|
||||||
strings.Contains(errStr, "unknown revision") ||
|
|
||||||
strings.Contains(errStr, "not a valid ref") ||
|
|
||||||
strings.Contains(errStr, "bad revision") {
|
|
||||||
return "main", nil
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("failed to get current branch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
branch := strings.TrimSpace(string(output))
|
|
||||||
// If the branch is HEAD (detached state), try to get the default branch
|
|
||||||
if branch == "HEAD" {
|
|
||||||
return "main", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return branch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRemoteTrackingBranch returns the remote tracking branch
|
|
||||||
func (g *GitManager) getRemoteTrackingBranch(ctx context.Context, repoPath string) (string, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("no upstream branch set: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getAheadCount returns how many commits ahead of remote
|
|
||||||
func (g *GitManager) getAheadCount(ctx context.Context, repoPath, remoteBranch string) int {
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch))
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
// If remote branch doesn't exist, count all local commits
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err = cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
count := strings.TrimSpace(string(output))
|
|
||||||
if count == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to int
|
|
||||||
var ahead int
|
|
||||||
if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return ahead
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBehindCount returns how many commits behind remote
|
|
||||||
func (g *GitManager) getBehindCount(ctx context.Context, repoPath, remoteBranch string) int {
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch))
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
count := strings.TrimSpace(string(output))
|
|
||||||
if count == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to int
|
|
||||||
var behind int
|
|
||||||
if _, err := fmt.Sscanf(count, "%d", &behind); err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return behind
|
|
||||||
}
|
|
||||||
|
|
||||||
// getLastCommitHash returns the hash of the last commit
|
|
||||||
func (g *GitManager) getLastCommitHash(ctx context.Context, repoPath string) (string, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get last commit hash: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(string(output)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCommits returns commit messages
|
|
||||||
func (g *GitManager) getCommits(ctx context.Context, repoPath string) ([]string, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, "git", "log", "--pretty=format:%s")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
// If there are no commits, git log will fail
|
|
||||||
// Use CombinedOutput to get both stdout and stderr to check the error message
|
|
||||||
cmd = exec.CommandContext(ctx, "git", "log", "--pretty=format:%s")
|
|
||||||
cmd.Dir = repoPath
|
|
||||||
combinedOutput, _ := cmd.CombinedOutput()
|
|
||||||
errStr := string(combinedOutput)
|
|
||||||
|
|
||||||
if strings.Contains(errStr, "does not have any commits yet") ||
|
|
||||||
strings.Contains(errStr, "bad default revision") ||
|
|
||||||
strings.Contains(errStr, "unknown revision") ||
|
|
||||||
strings.Contains(errStr, "ambiguous argument") {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to get commits: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
outputStr := strings.TrimSpace(string(output))
|
|
||||||
if outputStr == "" {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
commitMessages := strings.Split(outputStr, "\n")
|
|
||||||
return commitMessages, nil
|
|
||||||
}
|
|
@@ -1,307 +0,0 @@
|
|||||||
package git
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GitManagerTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
tempDir string
|
|
||||||
gitManager *GitManager
|
|
||||||
ctx context.Context
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GitManagerTestSuite) SetupTest() {
|
|
||||||
// Create temp directory for testing
|
|
||||||
tempDir, err := os.MkdirTemp("", "lnk_git_test_*")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.tempDir = tempDir
|
|
||||||
|
|
||||||
// Create git manager
|
|
||||||
suite.gitManager = New()
|
|
||||||
|
|
||||||
// Create context
|
|
||||||
suite.ctx = context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GitManagerTestSuite) TearDownTest() {
|
|
||||||
err := os.RemoveAll(suite.tempDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to check if file exists
|
|
||||||
func (suite *GitManagerTestSuite) fileExists(path string) bool {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GitManagerTestSuite) TestInit() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
|
||||||
|
|
||||||
// Create the directory
|
|
||||||
err := os.MkdirAll(repoPath, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test init
|
|
||||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Verify repository was created
|
|
||||||
isRepo, err := suite.gitManager.IsRepository(suite.ctx, repoPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.True(isRepo)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GitManagerTestSuite) TestAddCommit() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
|
||||||
|
|
||||||
// Create and initialize repository
|
|
||||||
err := os.MkdirAll(repoPath, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
testFile := filepath.Join(repoPath, "test.txt")
|
|
||||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test adding file
|
|
||||||
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Test commit
|
|
||||||
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit")
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Verify no uncommitted changes
|
|
||||||
hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.False(hasChanges)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GitManagerTestSuite) TestStatus() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
|
||||||
|
|
||||||
// Create and initialize repository
|
|
||||||
err := os.MkdirAll(repoPath, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test status on empty repository should fail with no remote configured
|
|
||||||
_, err = suite.gitManager.Status(suite.ctx, repoPath)
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "no remote configured")
|
|
||||||
|
|
||||||
// Add a remote to make status work
|
|
||||||
testURL := "https://github.com/test/repo.git"
|
|
||||||
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test status with remote configured but no commits
|
|
||||||
status, err := suite.gitManager.Status(suite.ctx, repoPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
suite.Equal("main", status.CurrentBranch)
|
|
||||||
suite.False(status.Dirty)
|
|
||||||
suite.True(status.HasRemote)
|
|
||||||
|
|
||||||
// Create and commit a file
|
|
||||||
testFile := filepath.Join(repoPath, "test.txt")
|
|
||||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test dirty status
|
|
||||||
status, err = suite.gitManager.Status(suite.ctx, repoPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.True(status.Dirty)
|
|
||||||
|
|
||||||
// Add and commit
|
|
||||||
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test clean status
|
|
||||||
status, err = suite.gitManager.Status(suite.ctx, repoPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.False(status.Dirty)
|
|
||||||
suite.NotEmpty(status.LastCommitHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GitManagerTestSuite) TestRemoteOperations() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
|
||||||
|
|
||||||
// Create and initialize repository
|
|
||||||
err := os.MkdirAll(repoPath, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test adding remote
|
|
||||||
testURL := "https://github.com/test/repo.git"
|
|
||||||
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Test getting remote URL
|
|
||||||
remoteURL, err := suite.gitManager.GetRemoteURL(suite.ctx, repoPath, "origin")
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(testURL, remoteURL)
|
|
||||||
|
|
||||||
// Test idempotent add (same URL)
|
|
||||||
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Test adding remote with different URL should fail
|
|
||||||
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", "https://github.com/different/repo.git")
|
|
||||||
suite.Error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GitManagerTestSuite) TestIsLnkRepository() {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
setup func(string) error
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "not_a_repository",
|
|
||||||
setup: func(path string) error {
|
|
||||||
return os.MkdirAll(path, 0755)
|
|
||||||
},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty_git_repository",
|
|
||||||
setup: func(path string) error {
|
|
||||||
if err := os.MkdirAll(path, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return suite.gitManager.Init(suite.ctx, path)
|
|
||||||
},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "repository_with_lnk_commits",
|
|
||||||
setup: func(path string) error {
|
|
||||||
if err := os.MkdirAll(path, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := suite.gitManager.Init(suite.ctx, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and commit a file with lnk prefix
|
|
||||||
testFile := filepath.Join(path, "test.txt")
|
|
||||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return suite.gitManager.Commit(suite.ctx, path, "lnk: add test file")
|
|
||||||
},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "repository_with_non-lnk_commits",
|
|
||||||
setup: func(path string) error {
|
|
||||||
if err := os.MkdirAll(path, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := suite.gitManager.Init(suite.ctx, path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and commit a file without lnk prefix
|
|
||||||
testFile := filepath.Join(path, "test.txt")
|
|
||||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return suite.gitManager.Commit(suite.ctx, path, "regular commit")
|
|
||||||
},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, tt.name)
|
|
||||||
err := tt.setup(repoPath)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
isLnk, err := suite.gitManager.IsLnkRepository(suite.ctx, repoPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(tt.expected, isLnk)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GitManagerTestSuite) TestContextCancellation() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
|
||||||
|
|
||||||
err := os.MkdirAll(repoPath, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test context cancellation
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// This should fail due to context timeout
|
|
||||||
err = suite.gitManager.Init(ctx, repoPath)
|
|
||||||
suite.Error(err)
|
|
||||||
|
|
||||||
// Verify the error is context-related
|
|
||||||
suite.NotNil(ctx.Err())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *GitManagerTestSuite) TestRemove() {
|
|
||||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
|
||||||
|
|
||||||
// Create and initialize repository
|
|
||||||
err := os.MkdirAll(repoPath, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create and add files
|
|
||||||
testFile := filepath.Join(repoPath, "test.txt")
|
|
||||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: add test file")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test removing file
|
|
||||||
err = suite.gitManager.Remove(suite.ctx, repoPath, "test.txt")
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
// Verify file is removed from git but still exists on fs
|
|
||||||
suite.True(suite.fileExists(testFile))
|
|
||||||
|
|
||||||
// Verify repository has changes
|
|
||||||
hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.True(hasChanges)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGitManagerSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(GitManagerTestSuite))
|
|
||||||
}
|
|
@@ -1,108 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ManagedFile represents a file or directory managed by lnk
|
|
||||||
type ManagedFile struct {
|
|
||||||
// ID for potential future database use
|
|
||||||
ID string `json:"id,omitempty"`
|
|
||||||
|
|
||||||
// OriginalPath is the original absolute path where the file was located
|
|
||||||
OriginalPath string `json:"original_path"`
|
|
||||||
|
|
||||||
// RepoPath is the path within the lnk repository
|
|
||||||
RepoPath string `json:"repo_path"`
|
|
||||||
|
|
||||||
// RelativePath is the path relative to the home directory (or absolute for files outside home)
|
|
||||||
RelativePath string `json:"relative_path"`
|
|
||||||
|
|
||||||
// Host is the hostname where this file is managed
|
|
||||||
Host string `json:"host"`
|
|
||||||
|
|
||||||
// IsDirectory indicates whether this is a directory
|
|
||||||
IsDirectory bool `json:"is_directory"`
|
|
||||||
|
|
||||||
// SymlinkTarget is the current symlink target (if the original location is now a symlink)
|
|
||||||
SymlinkTarget string `json:"symlink_target,omitempty"`
|
|
||||||
|
|
||||||
// AddedAt is when the file was first added to lnk
|
|
||||||
AddedAt time.Time `json:"added_at,omitempty"`
|
|
||||||
|
|
||||||
// UpdatedAt is when the file was last updated
|
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
|
||||||
|
|
||||||
// Mode stores the file permissions
|
|
||||||
Mode os.FileMode `json:"mode,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RepositoryConfig represents the lnk repository settings
|
|
||||||
type RepositoryConfig struct {
|
|
||||||
// Path is the absolute path to the lnk repository
|
|
||||||
Path string `json:"path"`
|
|
||||||
|
|
||||||
// DefaultRemote is the default Git remote for sync operations
|
|
||||||
DefaultRemote string `json:"default_remote,omitempty"`
|
|
||||||
|
|
||||||
// Created is when the repository was created
|
|
||||||
Created time.Time `json:"created,omitempty"`
|
|
||||||
|
|
||||||
// LastSync is when the repository was last synced
|
|
||||||
LastSync time.Time `json:"last_sync,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HostConfig represents configuration specific to a host
|
|
||||||
type HostConfig struct {
|
|
||||||
// Name is the hostname
|
|
||||||
Name string `json:"name"`
|
|
||||||
|
|
||||||
// ManagedFiles is the list of files managed on this host
|
|
||||||
ManagedFiles []ManagedFile `json:"managed_files"`
|
|
||||||
|
|
||||||
// LastUpdate is when this host configuration was last updated
|
|
||||||
LastUpdate time.Time `json:"last_update,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncStatus represents Git repository sync status
|
|
||||||
type SyncStatus struct {
|
|
||||||
// Ahead is the number of commits ahead of remote
|
|
||||||
Ahead int `json:"ahead"`
|
|
||||||
|
|
||||||
// Behind is the number of commits behind remote
|
|
||||||
Behind int `json:"behind"`
|
|
||||||
|
|
||||||
// CurrentBranch is the currently checked out branch
|
|
||||||
CurrentBranch string `json:"current_branch"`
|
|
||||||
|
|
||||||
// RemoteBranch is the remote tracking branch
|
|
||||||
RemoteBranch string `json:"remote_branch"`
|
|
||||||
|
|
||||||
// RemoteURL is the URL of the remote repository
|
|
||||||
RemoteURL string `json:"remote_url"`
|
|
||||||
|
|
||||||
// Dirty indicates if there are uncommitted changes
|
|
||||||
Dirty bool `json:"dirty"`
|
|
||||||
|
|
||||||
// LastCommitHash is the hash of the last commit
|
|
||||||
LastCommitHash string `json:"last_commit_hash"`
|
|
||||||
|
|
||||||
// HasRemote indicates if a remote is configured
|
|
||||||
HasRemote bool `json:"has_remote"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsClean returns true if the repository is clean (no uncommitted changes)
|
|
||||||
func (s *SyncStatus) IsClean() bool {
|
|
||||||
return !s.Dirty
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSynced returns true if the repository is in sync with remote (ahead=0, behind=0)
|
|
||||||
func (s *SyncStatus) IsSynced() bool {
|
|
||||||
return s.Ahead == 0 && s.Behind == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// NeedsSync returns true if the repository needs to be synced with remote
|
|
||||||
func (s *SyncStatus) NeedsSync() bool {
|
|
||||||
return s.Ahead > 0 || s.Behind > 0
|
|
||||||
}
|
|
@@ -1,185 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ModelsTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ModelsTestSuite) TestManagedFile() {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
file := ManagedFile{
|
|
||||||
ID: "test-id",
|
|
||||||
OriginalPath: "/home/user/.vimrc",
|
|
||||||
RepoPath: "/home/user/.config/lnk/.vimrc",
|
|
||||||
RelativePath: ".vimrc",
|
|
||||||
Host: "workstation",
|
|
||||||
IsDirectory: false,
|
|
||||||
AddedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
suite.Equal("test-id", file.ID)
|
|
||||||
suite.Equal("/home/user/.vimrc", file.OriginalPath)
|
|
||||||
suite.Equal("workstation", file.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ModelsTestSuite) TestRepositoryConfig() {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
config := RepositoryConfig{
|
|
||||||
Path: "/home/user/.config/lnk",
|
|
||||||
DefaultRemote: "origin",
|
|
||||||
Created: now,
|
|
||||||
LastSync: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
suite.Equal("/home/user/.config/lnk", config.Path)
|
|
||||||
suite.Equal("origin", config.DefaultRemote)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ModelsTestSuite) TestHostConfig() {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
managedFile := ManagedFile{
|
|
||||||
RelativePath: ".vimrc",
|
|
||||||
Host: "workstation",
|
|
||||||
}
|
|
||||||
|
|
||||||
config := HostConfig{
|
|
||||||
Name: "workstation",
|
|
||||||
ManagedFiles: []ManagedFile{managedFile},
|
|
||||||
LastUpdate: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
suite.Equal("workstation", config.Name)
|
|
||||||
suite.Len(config.ManagedFiles, 1)
|
|
||||||
suite.Equal(".vimrc", config.ManagedFiles[0].RelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ModelsTestSuite) TestSyncStatusIsClean() {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
dirty bool
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "clean_repository",
|
|
||||||
dirty: false,
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dirty_repository",
|
|
||||||
dirty: true,
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
status := SyncStatus{Dirty: tt.dirty}
|
|
||||||
result := status.IsClean()
|
|
||||||
suite.Equal(tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ModelsTestSuite) TestSyncStatusIsSynced() {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ahead int
|
|
||||||
behind int
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "fully_synced",
|
|
||||||
ahead: 0,
|
|
||||||
behind: 0,
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ahead_of_remote",
|
|
||||||
ahead: 2,
|
|
||||||
behind: 0,
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "behind_remote",
|
|
||||||
ahead: 0,
|
|
||||||
behind: 3,
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "diverged",
|
|
||||||
ahead: 1,
|
|
||||||
behind: 2,
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
status := SyncStatus{
|
|
||||||
Ahead: tt.ahead,
|
|
||||||
Behind: tt.behind,
|
|
||||||
}
|
|
||||||
result := status.IsSynced()
|
|
||||||
suite.Equal(tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ModelsTestSuite) TestSyncStatusNeedsSync() {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ahead int
|
|
||||||
behind int
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "fully_synced",
|
|
||||||
ahead: 0,
|
|
||||||
behind: 0,
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ahead_of_remote",
|
|
||||||
ahead: 2,
|
|
||||||
behind: 0,
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "behind_remote",
|
|
||||||
ahead: 0,
|
|
||||||
behind: 3,
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "diverged",
|
|
||||||
ahead: 1,
|
|
||||||
behind: 2,
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
status := SyncStatus{
|
|
||||||
Ahead: tt.ahead,
|
|
||||||
Behind: tt.behind,
|
|
||||||
}
|
|
||||||
result := status.NeedsSync()
|
|
||||||
suite.Equal(tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModelsSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(ModelsTestSuite))
|
|
||||||
}
|
|
@@ -1,153 +0,0 @@
|
|||||||
package pathresolver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Resolver implements the models.PathResolver interface
|
|
||||||
type Resolver struct{}
|
|
||||||
|
|
||||||
// New creates a new PathResolver instance
|
|
||||||
func New() *Resolver {
|
|
||||||
return &Resolver{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRepoStoragePath returns the base path where lnk repositories are stored
|
|
||||||
// This is based on XDG Base Directory specification
|
|
||||||
func (r *Resolver) GetRepoStoragePath() (string, error) {
|
|
||||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
|
||||||
if xdgConfig == "" {
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
|
||||||
}
|
|
||||||
xdgConfig = filepath.Join(homeDir, ".config")
|
|
||||||
}
|
|
||||||
return filepath.Join(xdgConfig, "lnk"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFileStoragePathInRepo returns the path where a file should be stored in the repository
|
|
||||||
func (r *Resolver) GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error) {
|
|
||||||
hostPath, err := r.GetHostStoragePath(repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(hostPath, relativePath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTrackingFilePath returns the path to the tracking file for a host
|
|
||||||
func (r *Resolver) GetTrackingFilePath(repoPath, host string) (string, error) {
|
|
||||||
var fileName string
|
|
||||||
if host == "" {
|
|
||||||
// Common configuration
|
|
||||||
fileName = ".lnk"
|
|
||||||
} else {
|
|
||||||
// Host-specific configuration
|
|
||||||
fileName = ".lnk." + host
|
|
||||||
}
|
|
||||||
return filepath.Join(repoPath, fileName), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHomePath returns the user's home directory path
|
|
||||||
func (r *Resolver) GetHomePath() (string, error) {
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
|
||||||
}
|
|
||||||
return homeDir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRelativePathFromHome converts an absolute path to relative from home directory
|
|
||||||
// This is migrated from the original getRelativePath function
|
|
||||||
func (r *Resolver) GetRelativePathFromHome(absPath string) (string, error) {
|
|
||||||
homeDir, err := r.GetHomePath()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the file is under home directory
|
|
||||||
relPath, err := filepath.Rel(homeDir, absPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get relative path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the relative path starts with "..", the file is outside home directory
|
|
||||||
// In this case, use the absolute path as relative (without the leading slash)
|
|
||||||
if strings.HasPrefix(relPath, "..") {
|
|
||||||
// Use absolute path but remove leading slash and drive letter (for cross-platform)
|
|
||||||
cleanPath := strings.TrimPrefix(absPath, "/")
|
|
||||||
return cleanPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return relPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAbsolutePathInHome converts a relative path to absolute within home directory
|
|
||||||
func (r *Resolver) GetAbsolutePathInHome(relPath string) (string, error) {
|
|
||||||
homeDir, err := r.GetHomePath()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the relative path looks like an absolute path (starts with / or drive letter),
|
|
||||||
// it's probably a file outside home directory
|
|
||||||
if filepath.IsAbs(relPath) {
|
|
||||||
return relPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it starts with a drive letter on Windows or looks like an absolute path,
|
|
||||||
// treat it as absolute
|
|
||||||
if len(relPath) > 0 && !strings.HasPrefix(relPath, ".") {
|
|
||||||
// Check if it looks like an absolute path stored without leading slash
|
|
||||||
// This handles paths like "etc/hosts" which should become "/etc/hosts"
|
|
||||||
if strings.HasPrefix(relPath, "etc/") ||
|
|
||||||
strings.HasPrefix(relPath, "usr/") ||
|
|
||||||
strings.HasPrefix(relPath, "var/") ||
|
|
||||||
strings.HasPrefix(relPath, "opt/") ||
|
|
||||||
strings.HasPrefix(relPath, "tmp/") {
|
|
||||||
// Reconstruct the absolute path
|
|
||||||
return "/" + relPath, nil
|
|
||||||
}
|
|
||||||
// Windows drive patterns like "C:" or contains drive separator
|
|
||||||
if strings.Contains(relPath, ":") {
|
|
||||||
return relPath, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filepath.Join(homeDir, relPath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHostStoragePath returns the directory where files for a host are stored
|
|
||||||
// This is migrated from the original getHostStoragePath method
|
|
||||||
func (r *Resolver) GetHostStoragePath(repoPath, host string) (string, error) {
|
|
||||||
if host == "" {
|
|
||||||
// Common configuration - store in root of repo
|
|
||||||
return repoPath, nil
|
|
||||||
}
|
|
||||||
// Host-specific configuration - store in host subdirectory
|
|
||||||
return filepath.Join(repoPath, host+".lnk"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsUnderHome checks if a path is under the home directory
|
|
||||||
func (r *Resolver) IsUnderHome(path string) (bool, error) {
|
|
||||||
homeDir, err := r.GetHomePath()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean both paths to handle relative components like .. and .
|
|
||||||
cleanPath := filepath.Clean(path)
|
|
||||||
cleanHome := filepath.Clean(homeDir)
|
|
||||||
|
|
||||||
// Get relative path
|
|
||||||
relPath, err := filepath.Rel(cleanHome, cleanPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, nil // If we can't get relative path, assume not under home
|
|
||||||
}
|
|
||||||
|
|
||||||
// If relative path starts with "..", it's outside home directory
|
|
||||||
return !strings.HasPrefix(relPath, ".."), nil
|
|
||||||
}
|
|
@@ -1,250 +0,0 @@
|
|||||||
package pathresolver
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ResolverTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
resolver *Resolver
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ResolverTestSuite) SetupTest() {
|
|
||||||
suite.resolver = New()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ResolverTestSuite) TestGetRepoStoragePath() {
|
|
||||||
// Test with XDG_CONFIG_HOME set
|
|
||||||
originalXDG := os.Getenv("XDG_CONFIG_HOME")
|
|
||||||
defer os.Setenv("XDG_CONFIG_HOME", originalXDG)
|
|
||||||
|
|
||||||
suite.Run("with_XDG_CONFIG_HOME_set", func() {
|
|
||||||
testXDG := "/test/config"
|
|
||||||
os.Setenv("XDG_CONFIG_HOME", testXDG)
|
|
||||||
|
|
||||||
path, err := suite.resolver.GetRepoStoragePath()
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
expected := filepath.Join(testXDG, "lnk")
|
|
||||||
suite.Equal(expected, path)
|
|
||||||
})
|
|
||||||
|
|
||||||
suite.Run("without_XDG_CONFIG_HOME", func() {
|
|
||||||
os.Unsetenv("XDG_CONFIG_HOME")
|
|
||||||
|
|
||||||
path, err := suite.resolver.GetRepoStoragePath()
|
|
||||||
suite.NoError(err)
|
|
||||||
|
|
||||||
homeDir, _ := os.UserHomeDir()
|
|
||||||
expected := filepath.Join(homeDir, ".config", "lnk")
|
|
||||||
suite.Equal(expected, path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ResolverTestSuite) TestGetTrackingFilePath() {
|
|
||||||
repoPath := "/test/repo"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
host string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "common_config",
|
|
||||||
host: "",
|
|
||||||
expected: filepath.Join(repoPath, ".lnk"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "host-specific_config",
|
|
||||||
host: "myhost",
|
|
||||||
expected: filepath.Join(repoPath, ".lnk.myhost"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
path, err := suite.resolver.GetTrackingFilePath(repoPath, tt.host)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(tt.expected, path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ResolverTestSuite) TestGetHostStoragePath() {
|
|
||||||
repoPath := "/test/repo"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
host string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "common_config",
|
|
||||||
host: "",
|
|
||||||
expected: repoPath,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "host-specific_config",
|
|
||||||
host: "myhost",
|
|
||||||
expected: filepath.Join(repoPath, "myhost.lnk"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
path, err := suite.resolver.GetHostStoragePath(repoPath, tt.host)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(tt.expected, path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ResolverTestSuite) TestGetRelativePathFromHome() {
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
absPath string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "file_in_home",
|
|
||||||
absPath: filepath.Join(homeDir, "Documents", "test.txt"),
|
|
||||||
expected: filepath.Join("Documents", "test.txt"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "file_outside_home",
|
|
||||||
absPath: "/etc/hosts",
|
|
||||||
expected: "etc/hosts",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "home_directory_itself",
|
|
||||||
absPath: homeDir,
|
|
||||||
expected: ".",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
result, err := suite.resolver.GetRelativePathFromHome(tt.absPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ResolverTestSuite) TestGetAbsolutePathInHome() {
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
relPath string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "relative_path_in_home",
|
|
||||||
relPath: filepath.Join("Documents", "test.txt"),
|
|
||||||
expected: filepath.Join(homeDir, "Documents", "test.txt"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "already_absolute_path",
|
|
||||||
relPath: "/etc/hosts",
|
|
||||||
expected: "/etc/hosts",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "absolute-like_path_without_leading_slash",
|
|
||||||
relPath: "etc/hosts",
|
|
||||||
expected: "/etc/hosts",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
result, err := suite.resolver.GetAbsolutePathInHome(tt.relPath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ResolverTestSuite) TestIsUnderHome() {
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "file_in_home",
|
|
||||||
path: filepath.Join(homeDir, "Documents", "test.txt"),
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "file_outside_home",
|
|
||||||
path: "/etc/hosts",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "home_directory_itself",
|
|
||||||
path: homeDir,
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "parent_of_home",
|
|
||||||
path: filepath.Dir(homeDir),
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
result, err := suite.resolver.IsUnderHome(tt.path)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *ResolverTestSuite) TestGetFileStoragePathInRepo() {
|
|
||||||
repoPath := "/test/repo"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
host string
|
|
||||||
relativePath string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "common_config_file",
|
|
||||||
host: "",
|
|
||||||
relativePath: "Documents/test.txt",
|
|
||||||
expected: filepath.Join(repoPath, "Documents", "test.txt"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "host-specific_file",
|
|
||||||
host: "myhost",
|
|
||||||
relativePath: "Documents/test.txt",
|
|
||||||
expected: filepath.Join(repoPath, "myhost.lnk", "Documents", "test.txt"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
suite.Run(tt.name, func() {
|
|
||||||
result, err := suite.resolver.GetFileStoragePathInRepo(repoPath, tt.host, tt.relativePath)
|
|
||||||
suite.NoError(err)
|
|
||||||
suite.Equal(tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolverSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(ResolverTestSuite))
|
|
||||||
}
|
|
@@ -1,823 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/config"
|
|
||||||
"github.com/yarlson/lnk/internal/errors"
|
|
||||||
"github.com/yarlson/lnk/internal/fs"
|
|
||||||
"github.com/yarlson/lnk/internal/git"
|
|
||||||
"github.com/yarlson/lnk/internal/models"
|
|
||||||
"github.com/yarlson/lnk/internal/pathresolver"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FileManager handles file system operations
|
|
||||||
type FileManager interface {
|
|
||||||
Exists(ctx context.Context, path string) (bool, error)
|
|
||||||
Move(ctx context.Context, src, dst string) error
|
|
||||||
CreateSymlink(ctx context.Context, target, linkPath string) error
|
|
||||||
Remove(ctx context.Context, path string) error
|
|
||||||
MkdirAll(ctx context.Context, path string, perm os.FileMode) error
|
|
||||||
Readlink(ctx context.Context, path string) (string, error)
|
|
||||||
Lstat(ctx context.Context, path string) (os.FileInfo, error)
|
|
||||||
Stat(ctx context.Context, path string) (os.FileInfo, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigManager handles configuration persistence (reading and writing .lnk files)
|
|
||||||
type ConfigManager interface {
|
|
||||||
AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error
|
|
||||||
RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error
|
|
||||||
ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error)
|
|
||||||
GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitManager handles Git operations
|
|
||||||
type GitManager interface {
|
|
||||||
Init(ctx context.Context, repoPath string) error
|
|
||||||
Clone(ctx context.Context, repoPath, url string) error
|
|
||||||
Add(ctx context.Context, repoPath string, files ...string) error
|
|
||||||
Remove(ctx context.Context, repoPath string, files ...string) error
|
|
||||||
Commit(ctx context.Context, repoPath, message string) error
|
|
||||||
Push(ctx context.Context, repoPath string) error
|
|
||||||
Pull(ctx context.Context, repoPath string) error
|
|
||||||
Status(ctx context.Context, repoPath string) (*models.SyncStatus, error)
|
|
||||||
IsRepository(ctx context.Context, repoPath string) (bool, error)
|
|
||||||
HasChanges(ctx context.Context, repoPath string) (bool, error)
|
|
||||||
IsLnkRepository(ctx context.Context, repoPath string) (bool, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PathResolver handles path resolution and manipulation
|
|
||||||
type PathResolver interface {
|
|
||||||
GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error)
|
|
||||||
GetTrackingFilePath(repoPath, host string) (string, error)
|
|
||||||
GetHomePath() (string, error)
|
|
||||||
GetRelativePathFromHome(absPath string) (string, error)
|
|
||||||
GetAbsolutePathInHome(relPath string) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service encapsulates the business logic for lnk operations
|
|
||||||
type Service struct {
|
|
||||||
fileManager FileManager
|
|
||||||
gitManager GitManager // May be nil for some operations
|
|
||||||
configManager ConfigManager
|
|
||||||
pathResolver PathResolver
|
|
||||||
repoPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new Service instance with default dependencies
|
|
||||||
func New() (*Service, error) {
|
|
||||||
// Initialize adapters
|
|
||||||
fileManager := fs.New()
|
|
||||||
gitManager := git.New()
|
|
||||||
pathResolver := pathresolver.New()
|
|
||||||
configManager := config.New(fileManager, pathResolver)
|
|
||||||
|
|
||||||
// Get repository path
|
|
||||||
repoPath, err := pathResolver.GetRepoStoragePath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewInvalidPathError("", "failed to determine repository storage path").
|
|
||||||
WithContext("error", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Service{
|
|
||||||
fileManager: fileManager,
|
|
||||||
gitManager: gitManager,
|
|
||||||
configManager: configManager,
|
|
||||||
pathResolver: pathResolver,
|
|
||||||
repoPath: repoPath,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLnkServiceWithDeps creates a new Service instance with provided dependencies (for testing)
|
|
||||||
func NewLnkServiceWithDeps(
|
|
||||||
fileManager FileManager,
|
|
||||||
gitManager GitManager,
|
|
||||||
configManager ConfigManager,
|
|
||||||
pathResolver PathResolver,
|
|
||||||
repoPath string,
|
|
||||||
) *Service {
|
|
||||||
return &Service{
|
|
||||||
fileManager: fileManager,
|
|
||||||
gitManager: gitManager,
|
|
||||||
configManager: configManager,
|
|
||||||
pathResolver: pathResolver,
|
|
||||||
repoPath: repoPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListManagedFiles returns the list of files managed by lnk for a specific host
|
|
||||||
// If host is empty, returns common configuration files
|
|
||||||
func (s *Service) ListManagedFiles(ctx context.Context, host string) ([]models.ManagedFile, error) {
|
|
||||||
// Check if the repository exists
|
|
||||||
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the config manager to list managed files
|
|
||||||
managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err // ConfigManager already returns properly typed errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return managedFiles, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStatus returns the Git repository status
|
|
||||||
// Returns an error if the repository is not initialized or GitManager is not available
|
|
||||||
func (s *Service) GetStatus(ctx context.Context) (*models.SyncStatus, error) {
|
|
||||||
// Check if GitManager is available
|
|
||||||
if s.gitManager == nil {
|
|
||||||
return nil, errors.NewGitOperationError("get_status",
|
|
||||||
fmt.Errorf("git manager not available"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the repository exists
|
|
||||||
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a Git repository
|
|
||||||
isRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewGitOperationError("check_git_repo", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isRepo {
|
|
||||||
return nil, errors.NewRepoNotInitializedError(s.repoPath).
|
|
||||||
WithContext("reason", "directory exists but is not a git repository")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Git status
|
|
||||||
status, err := s.gitManager.Status(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err // GitManager already returns properly typed errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRepoPath returns the repository path
|
|
||||||
func (s *Service) GetRepoPath() string {
|
|
||||||
return s.repoPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRepositoryInitialized checks if the lnk repository has been initialized
|
|
||||||
func (s *Service) IsRepositoryInitialized(ctx context.Context) (bool, error) {
|
|
||||||
// Check if repository directory exists
|
|
||||||
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a Git repository (if GitManager is available)
|
|
||||||
if s.gitManager != nil {
|
|
||||||
isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, errors.NewGitOperationError("check_git_repo", err)
|
|
||||||
}
|
|
||||||
return isGitRepo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no GitManager, just check if the directory exists
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitializeRepository initializes a new lnk repository, optionally cloning from a remote URL
|
|
||||||
func (s *Service) InitializeRepository(ctx context.Context, remoteURL string) error {
|
|
||||||
// Check if GitManager is available
|
|
||||||
if s.gitManager == nil {
|
|
||||||
return errors.NewGitOperationError("initialize_repository",
|
|
||||||
fmt.Errorf("git manager not available"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteURL != "" {
|
|
||||||
// Clone from remote
|
|
||||||
return s.cloneRepository(ctx, remoteURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize empty repository
|
|
||||||
return s.initEmptyRepository(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// cloneRepository clones a repository from the given URL
|
|
||||||
func (s *Service) cloneRepository(ctx context.Context, remoteURL string) error {
|
|
||||||
// Clone using GitManager
|
|
||||||
if err := s.gitManager.Clone(ctx, s.repoPath, remoteURL); err != nil {
|
|
||||||
return errors.NewGitOperationError("clone_repository", err).
|
|
||||||
WithContext("remote_url", remoteURL).
|
|
||||||
WithContext("repo_path", s.repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// initEmptyRepository initializes an empty Git repository
|
|
||||||
func (s *Service) initEmptyRepository(ctx context.Context) error {
|
|
||||||
// Check if repository directory already exists
|
|
||||||
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
// Check if it's already a Git repository
|
|
||||||
isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("check_git_repo", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isGitRepo {
|
|
||||||
// Check if it's a lnk repository
|
|
||||||
isLnkRepo, err := s.gitManager.IsLnkRepository(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("check_lnk_repo", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLnkRepo {
|
|
||||||
// It's already a lnk repository, init is idempotent
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
// It's not a lnk repository, error to prevent data loss
|
|
||||||
return errors.NewRepoNotInitializedError(s.repoPath).
|
|
||||||
WithContext("reason", "directory contains an existing non-lnk Git repository")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the repository directory if it doesn't exist
|
|
||||||
if !exists {
|
|
||||||
if err := s.fileManager.MkdirAll(ctx, s.repoPath, 0755); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("create_repo_dir", s.repoPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Git repository
|
|
||||||
if err := s.gitManager.Init(ctx, s.repoPath); err != nil {
|
|
||||||
// Clean up directory if we created it
|
|
||||||
if !exists {
|
|
||||||
_ = s.fileManager.Remove(ctx, s.repoPath) // Ignore cleanup errors
|
|
||||||
}
|
|
||||||
return errors.NewGitOperationError("init_git_repo", err).
|
|
||||||
WithContext("repo_path", s.repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddFile adds a file or directory to lnk management for the specified host
|
|
||||||
// This involves moving the file to the repository, creating a symlink, updating tracking, and committing to Git
|
|
||||||
func (s *Service) AddFile(ctx context.Context, filePath, host string) (*models.ManagedFile, error) {
|
|
||||||
// Check if GitManager is available
|
|
||||||
if s.gitManager == nil {
|
|
||||||
return nil, errors.NewGitOperationError("add_file",
|
|
||||||
fmt.Errorf("git manager not available"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get absolute path
|
|
||||||
absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath)
|
|
||||||
if err != nil {
|
|
||||||
// If it fails, try as-is (might be already absolute)
|
|
||||||
var pathErr error
|
|
||||||
absPath, pathErr = filepath.Abs(filePath)
|
|
||||||
if pathErr != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("resolve_path", filePath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that the file exists and is accessible (check this FIRST like the old implementation)
|
|
||||||
exists, err := s.fileManager.Exists(ctx, absPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("check_file_exists", absPath, err)
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return nil, errors.NewFileNotFoundError(absPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if repository is initialized (after file existence check)
|
|
||||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !initialized {
|
|
||||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file information to determine if it's a directory
|
|
||||||
fileInfo, err := s.fileManager.Stat(ctx, absPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("stat_file", absPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relative path for tracking
|
|
||||||
relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("get_relative_path", absPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file is already managed
|
|
||||||
existingFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath)
|
|
||||||
if err == nil && existingFile != nil {
|
|
||||||
return nil, errors.NewFileAlreadyManagedError(relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create managed file model
|
|
||||||
managedFile := models.ManagedFile{
|
|
||||||
OriginalPath: absPath,
|
|
||||||
RelativePath: relativePath,
|
|
||||||
Host: host,
|
|
||||||
IsDirectory: fileInfo.IsDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get storage path in repository
|
|
||||||
storagePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, relativePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("get_storage_path", relativePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
managedFile.RepoPath = storagePath
|
|
||||||
|
|
||||||
// Execute the file addition with rollback support
|
|
||||||
if err := s.executeFileAddition(ctx, &managedFile); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &managedFile, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeFileAddition performs the actual file addition with rollback logic
|
|
||||||
func (s *Service) executeFileAddition(ctx context.Context, file *models.ManagedFile) error {
|
|
||||||
var rollbackActions []func() error
|
|
||||||
|
|
||||||
// Helper function to add rollback action
|
|
||||||
addRollback := func(action func() error) {
|
|
||||||
rollbackActions = append([]func() error{action}, rollbackActions...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute rollback if any step fails
|
|
||||||
defer func() {
|
|
||||||
if len(rollbackActions) > 0 {
|
|
||||||
for _, action := range rollbackActions {
|
|
||||||
_ = action() // Ignore rollback errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Step 1: Create destination directory
|
|
||||||
destDir := filepath.Dir(file.RepoPath)
|
|
||||||
if err := s.fileManager.MkdirAll(ctx, destDir, 0755); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("create_dest_dir", destDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Move file to repository
|
|
||||||
if err := s.fileManager.Move(ctx, file.OriginalPath, file.RepoPath); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("move_file", file.OriginalPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add rollback for move operation
|
|
||||||
addRollback(func() error {
|
|
||||||
return s.fileManager.Move(context.Background(), file.RepoPath, file.OriginalPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 3: Create symlink
|
|
||||||
if err := s.fileManager.CreateSymlink(ctx, file.RepoPath, file.OriginalPath); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("create_symlink", file.OriginalPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add rollback for symlink creation
|
|
||||||
addRollback(func() error {
|
|
||||||
return s.fileManager.Remove(context.Background(), file.OriginalPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 4: Add to config tracking
|
|
||||||
if err := s.configManager.AddManagedFileToHost(ctx, s.repoPath, file.Host, *file); err != nil {
|
|
||||||
return err // ConfigManager returns properly typed errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add rollback for config update
|
|
||||||
addRollback(func() error {
|
|
||||||
return s.configManager.RemoveManagedFileFromHost(context.Background(),
|
|
||||||
s.repoPath, file.Host, file.RelativePath)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 5: Add file to Git
|
|
||||||
gitPath := file.RelativePath
|
|
||||||
if file.Host != "" {
|
|
||||||
gitPath = filepath.Join(file.Host+".lnk", file.RelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.gitManager.Add(ctx, s.repoPath, gitPath); err != nil {
|
|
||||||
return errors.NewGitOperationError("add_file_to_git", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Add config file to Git
|
|
||||||
trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, file.Host)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("get_tracking_file", "", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relative path of tracking file from repo root
|
|
||||||
trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil {
|
|
||||||
return errors.NewGitOperationError("add_tracking_file_to_git", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Commit changes
|
|
||||||
basename := filepath.Base(file.RelativePath)
|
|
||||||
commitMessage := fmt.Sprintf("lnk: added %s", basename)
|
|
||||||
if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil {
|
|
||||||
return errors.NewGitOperationError("commit_changes", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we reach here, everything succeeded - clear rollback actions
|
|
||||||
rollbackActions = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveFile removes a file or directory from lnk management for the specified host
|
|
||||||
// This involves removing the symlink, restoring the original file, updating tracking, and committing to Git
|
|
||||||
func (s *Service) RemoveFile(ctx context.Context, filePath, host string) error {
|
|
||||||
// Check if GitManager is available
|
|
||||||
if s.gitManager == nil {
|
|
||||||
return errors.NewGitOperationError("remove_file",
|
|
||||||
fmt.Errorf("git manager not available"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if repository is initialized
|
|
||||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !initialized {
|
|
||||||
return errors.NewRepoNotInitializedError(s.repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get absolute path
|
|
||||||
absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath)
|
|
||||||
if err != nil {
|
|
||||||
// If it fails, try as-is (might be already absolute)
|
|
||||||
var pathErr error
|
|
||||||
absPath, pathErr = filepath.Abs(filePath)
|
|
||||||
if pathErr != nil {
|
|
||||||
return errors.NewFileSystemOperationError("resolve_path", filePath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that this is a symlink
|
|
||||||
linkInfo, err := s.fileManager.Lstat(ctx, absPath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return errors.NewFileNotFoundError(absPath)
|
|
||||||
}
|
|
||||||
return errors.NewFileSystemOperationError("stat_symlink", absPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if linkInfo.Mode()&os.ModeSymlink == 0 {
|
|
||||||
return errors.NewNotSymlinkError(absPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get symlink target
|
|
||||||
target, err := s.fileManager.Readlink(ctx, absPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("read_symlink", absPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert relative symlink target to absolute path
|
|
||||||
if !filepath.IsAbs(target) {
|
|
||||||
target = filepath.Join(filepath.Dir(absPath), target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that the target exists in our repository
|
|
||||||
targetAbs, err := filepath.Abs(target)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("resolve_target", target, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
repoPathAbs, err := filepath.Abs(s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("resolve_repo_path", s.repoPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(targetAbs, repoPathAbs) {
|
|
||||||
return errors.NewInvalidPathError(targetAbs, "symlink target is not in lnk repository")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relative path for tracking
|
|
||||||
relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("get_relative_path", absPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this file is actually managed
|
|
||||||
managedFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath)
|
|
||||||
if err != nil || managedFile == nil {
|
|
||||||
return errors.NewLnkError(errors.ErrorCodeFileNotFound, fmt.Sprintf("file is not managed by lnk: %s", relativePath))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get target file info to determine if it's a directory
|
|
||||||
targetInfo, err := s.fileManager.Stat(ctx, targetAbs)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("stat_target", targetAbs, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the file removal with rollback support
|
|
||||||
return s.executeFileRemoval(ctx, absPath, targetAbs, relativePath, host, targetInfo.IsDir())
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeFileRemoval performs the actual file removal with rollback logic
|
|
||||||
func (s *Service) executeFileRemoval(ctx context.Context, symlinkPath, targetPath, relativePath, host string, isDirectory bool) error {
|
|
||||||
var rollbackActions []func() error
|
|
||||||
|
|
||||||
// Helper function to add rollback action
|
|
||||||
addRollback := func(action func() error) {
|
|
||||||
rollbackActions = append([]func() error{action}, rollbackActions...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute rollback if any step fails
|
|
||||||
defer func() {
|
|
||||||
if len(rollbackActions) > 0 {
|
|
||||||
for _, action := range rollbackActions {
|
|
||||||
_ = action() // Ignore rollback errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Step 1: Remove the symlink
|
|
||||||
if err := s.fileManager.Remove(ctx, symlinkPath); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("remove_symlink", symlinkPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add rollback for symlink removal
|
|
||||||
addRollback(func() error {
|
|
||||||
return s.fileManager.CreateSymlink(context.Background(), targetPath, symlinkPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 2: Move file back from repository to original location
|
|
||||||
if err := s.fileManager.Move(ctx, targetPath, symlinkPath); err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("restore_file", targetPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add rollback for file restoration
|
|
||||||
addRollback(func() error {
|
|
||||||
return s.fileManager.Move(context.Background(), symlinkPath, targetPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 3: Remove from config tracking
|
|
||||||
if err := s.configManager.RemoveManagedFileFromHost(ctx, s.repoPath, host, relativePath); err != nil {
|
|
||||||
return err // ConfigManager returns properly typed errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add rollback for config update
|
|
||||||
managedFile := models.ManagedFile{
|
|
||||||
OriginalPath: symlinkPath,
|
|
||||||
RelativePath: relativePath,
|
|
||||||
RepoPath: targetPath,
|
|
||||||
Host: host,
|
|
||||||
IsDirectory: isDirectory,
|
|
||||||
}
|
|
||||||
addRollback(func() error {
|
|
||||||
return s.configManager.AddManagedFileToHost(context.Background(), s.repoPath, host, managedFile)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 4: Remove file from Git
|
|
||||||
gitPath := relativePath
|
|
||||||
if host != "" {
|
|
||||||
gitPath = filepath.Join(host+".lnk", relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.gitManager.Remove(ctx, s.repoPath, gitPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Add config file to Git (to commit the tracking change)
|
|
||||||
trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("get_tracking_file", "", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relative path of tracking file from repo root
|
|
||||||
trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil {
|
|
||||||
return errors.NewGitOperationError("add_tracking_file_to_git", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Commit changes
|
|
||||||
basename := filepath.Base(relativePath)
|
|
||||||
commitMessage := fmt.Sprintf("lnk: removed %s", basename)
|
|
||||||
if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil {
|
|
||||||
return errors.NewGitOperationError("commit_changes", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we reach here, everything succeeded - clear rollback actions
|
|
||||||
rollbackActions = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PushChanges stages all changes and pushes to remote repository
|
|
||||||
func (s *Service) PushChanges(ctx context.Context, message string) error {
|
|
||||||
// Check if GitManager is available
|
|
||||||
if s.gitManager == nil {
|
|
||||||
return errors.NewGitOperationError("push_changes",
|
|
||||||
fmt.Errorf("git manager not available"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if repository is initialized
|
|
||||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !initialized {
|
|
||||||
return errors.NewRepoNotInitializedError(s.repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there are any changes to commit
|
|
||||||
hasChanges, err := s.gitManager.HasChanges(ctx, s.repoPath)
|
|
||||||
if err != nil {
|
|
||||||
return errors.NewGitOperationError("check_changes", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasChanges {
|
|
||||||
// Add all changes (equivalent to git add .)
|
|
||||||
if err := s.gitManager.Add(ctx, s.repoPath, "."); err != nil {
|
|
||||||
return errors.NewGitOperationError("stage_changes", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a sync commit
|
|
||||||
if err := s.gitManager.Commit(ctx, s.repoPath, message); err != nil {
|
|
||||||
return errors.NewGitOperationError("commit_changes", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push to remote
|
|
||||||
if err := s.gitManager.Push(ctx, s.repoPath); err != nil {
|
|
||||||
return errors.NewGitOperationError("push_to_remote", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PullChanges pulls changes from remote and restores symlinks for the specified host
|
|
||||||
func (s *Service) PullChanges(ctx context.Context, host string) ([]models.ManagedFile, error) {
|
|
||||||
// Check if GitManager is available
|
|
||||||
if s.gitManager == nil {
|
|
||||||
return nil, errors.NewGitOperationError("pull_changes",
|
|
||||||
fmt.Errorf("git manager not available"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if repository is initialized
|
|
||||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !initialized {
|
|
||||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull changes from remote
|
|
||||||
if err := s.gitManager.Pull(ctx, s.repoPath); err != nil {
|
|
||||||
return nil, errors.NewGitOperationError("pull_from_remote", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore symlinks for the specified host
|
|
||||||
restored, err := s.RestoreSymlinksForHost(ctx, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return restored, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestoreSymlinksForHost restores symlinks for all managed files for the specified host
|
|
||||||
func (s *Service) RestoreSymlinksForHost(ctx context.Context, host string) ([]models.ManagedFile, error) {
|
|
||||||
// Check if repository is initialized
|
|
||||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !initialized {
|
|
||||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get list of managed files for this host
|
|
||||||
managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var restored []models.ManagedFile
|
|
||||||
homeDir, err := s.pathResolver.GetHomePath()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.NewFileSystemOperationError("get_home_dir", "", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, managedFile := range managedFiles {
|
|
||||||
// Determine symlink path (where the symlink should be created)
|
|
||||||
symlinkPath := filepath.Join(homeDir, managedFile.RelativePath)
|
|
||||||
|
|
||||||
// Determine repository file path (what the symlink should point to)
|
|
||||||
repoFilePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, managedFile.RelativePath)
|
|
||||||
if err != nil {
|
|
||||||
continue // Skip files with path resolution issues
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if repository file exists
|
|
||||||
repoExists, err := s.fileManager.Exists(ctx, repoFilePath)
|
|
||||||
if err != nil || !repoExists {
|
|
||||||
continue // Skip missing files
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if symlink already exists and is correct
|
|
||||||
if s.isValidSymlink(ctx, symlinkPath, repoFilePath) {
|
|
||||||
continue // Skip files that are already correctly symlinked
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure parent directory exists
|
|
||||||
symlinkDir := filepath.Dir(symlinkPath)
|
|
||||||
if err := s.fileManager.MkdirAll(ctx, symlinkDir, 0755); err != nil {
|
|
||||||
continue // Skip files with directory creation issues
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove existing file/symlink if it exists
|
|
||||||
exists, err := s.fileManager.Exists(ctx, symlinkPath)
|
|
||||||
if err == nil && exists {
|
|
||||||
if err := s.fileManager.Remove(ctx, symlinkPath); err != nil {
|
|
||||||
continue // Skip files that can't be removed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create symlink
|
|
||||||
if err := s.fileManager.CreateSymlink(ctx, repoFilePath, symlinkPath); err != nil {
|
|
||||||
continue // Skip files with symlink creation issues
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the managed file with current paths
|
|
||||||
restoredFile := managedFile
|
|
||||||
restoredFile.OriginalPath = symlinkPath
|
|
||||||
restoredFile.RepoPath = repoFilePath
|
|
||||||
restored = append(restored, restoredFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return restored, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidSymlink checks if the given path is a symlink pointing to the expected target
|
|
||||||
func (s *Service) isValidSymlink(ctx context.Context, symlinkPath, expectedTarget string) bool {
|
|
||||||
info, err := s.fileManager.Lstat(ctx, symlinkPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a symlink
|
|
||||||
if info.Mode()&os.ModeSymlink == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it points to the correct target
|
|
||||||
target, err := s.fileManager.Readlink(ctx, symlinkPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert relative path to absolute if needed
|
|
||||||
if !filepath.IsAbs(target) {
|
|
||||||
target = filepath.Join(filepath.Dir(symlinkPath), target)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean both paths for comparison
|
|
||||||
targetAbs, err := filepath.Abs(target)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedAbs, err := filepath.Abs(expectedTarget)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetAbs == expectedAbs
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user