37 Commits

Author SHA1 Message Date
dependabot[bot]
f09f96a729 chore(deps): bump github.com/stretchr/testify from 1.11.0 to 1.11.1
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.0 to 1.11.1.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 17:48:10 +00:00
Yar Kravtsov
430619b7e8 Merge pull request #20 from yarlson/dependabot/go_modules/github.com/stretchr/testify-1.11.0
chore(deps): bump github.com/stretchr/testify from 1.10.0 to 1.11.0
2025-08-27 08:09:21 +03:00
Yar Kravtsov
48535a68d3 Merge pull request #19 from yarlson/dependabot/github_actions/actions-a331d3ec2d
chore(deps): bump actions/checkout from 4 to 5 in the actions group
2025-08-27 08:08:52 +03:00
dependabot[bot]
0a3e522457 chore(deps): bump github.com/stretchr/testify from 1.10.0 to 1.11.0
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.10.0 to 1.11.0.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.10.0...v1.11.0)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-25 18:20:53 +00:00
dependabot[bot]
b94870b91a chore(deps): bump actions/checkout from 4 to 5 in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-25 15:40:47 +00:00
Yar Kravtsov
2aac515606 docs(README): clarify color modes and emoji flags with improved formatting 2025-08-13 21:36:40 +03:00
Yar Kravtsov
30ab78d506 Merge pull request #18 from yarlson/no-color
feat(output): implement no-color and no-emoji flags for customizable output
2025-08-03 14:36:44 +03:00
Yar Kravtsov
7f10e1ce8a feat(output): implement configurable color and emoji output
Add new output formatting system with flags for color and emoji control:
- Introduce OutputConfig and Writer structs for flexible output handling
- Add --colors and --emoji/--no-emoji global flags
- Refactor commands to use new Writer for consistent formatting
- Separate error content from presentation for better flexibility
2025-08-03 14:33:44 +03:00
Yar Kravtsov
57839c795e Merge pull request #15 from yarlson/fix/dynamic-branch-detection
fix: remove hardcoded branch names from push/pull operations
2025-08-01 06:49:24 +03:00
Yar Kravtsov
dc524607fa fix: remove hardcoded branch names from push/pull operations
- Remove hardcoded "main" branch from git push and pull commands
- Let Git automatically detect and use current branch
- Add comprehensive tests for different branch names (main, master, develop)
- Fixes GitHub issue #14 where operations failed on repos using "master"
2025-08-01 06:45:56 +03:00
Yar Kravtsov
9bf2e70d13 docs: remove RELEASE.md in favor of automated process 2025-07-30 10:57:32 +03:00
Yar Kravtsov
65db5fe738 Merge pull request #13 from yarlson/force
fix(init): prevent data loss when reinitializing with existing content
2025-07-30 10:42:54 +03:00
Yar Kravtsov
43b68bc071 fix(init): prevent data loss when reinitializing with existing content 2025-07-30 10:41:03 +03:00
Yar Kravtsov
ab97fa86dc chore(brew): move lnk formula to core Homebrew repository 2025-07-29 12:29:09 +03:00
Yar Kravtsov
4cd8191805 style: standardize code formatting and whitespace 2025-07-29 09:04:57 +03:00
Yar Kravtsov
6830c06eb4 docs(README): enhance usage examples and feature descriptions 2025-07-29 08:59:39 +03:00
Yar Kravtsov
8a29b7fe43 feat(add): implement dry-run mode and enhance output formatting 2025-07-29 08:56:33 +03:00
Yar Kravtsov
a6852e5ad5 feat(add): implement recursive file addition with progress tracking 2025-07-29 08:47:14 +03:00
Yar Kravtsov
36d76c881c feat(add): implement multiple file addition with atomic operation 2025-07-29 08:32:33 +03:00
Yar Kravtsov
6de387797e chore(gitignore): refine file exclusions for better repo management 2025-07-29 08:14:51 +03:00
Yar Kravtsov
9cbad5e593 Merge pull request #10 from reneleonhardt/chore/update-actions
ci: update github-actions
2025-07-28 14:20:31 +03:00
Rene Leonhardt
150e8adf8b ci: update github-actions 2025-07-28 09:10:51 +02:00
Yar Kravtsov
4b11563bdf Update README.md 2025-06-03 15:06:15 +03:00
Yar Kravtsov
b476ce503b docs(README): refine project description and highlight key benefits 2025-06-03 15:02:34 +03:00
Yar Kravtsov
ae9cc175ce feat(bootstrap): add automatic environment setup with bootstrap scripts
Implement bootstrap functionality for streamlined dotfiles setup:
- Add 'bootstrap' command to run setup scripts manually
- Auto-execute bootstrap on 'init' with remote (--no-bootstrap to skip)
- Update README with bootstrap usage and examples
- Extend tests to cover bootstrap scenarios
2025-06-03 08:33:59 +03:00
Yar Kravtsov
1e2c9704f3 refactor(errors): implement structured error handling for improved debugging 2025-06-03 07:58:21 +03:00
Yar Kravtsov
3cba309c05 refactor(core): simplify Lnk creation with functional options pattern 2025-06-03 06:50:52 +03:00
Yar Kravtsov
3e6b426a19 test(cmd): improve test coverage for file storage and .lnk tracking 2025-05-27 08:33:23 +03:00
Yar Kravtsov
02f342b02b refactor(core): simplify path handling and remove redundant generateRepoName function 2025-05-27 08:00:04 +03:00
Yar Kravtsov
92f2575090 fix: preserve directory structure for common files and fix display paths 2025-05-26 09:23:46 +03:00
Yar Kravtsov
0f74723a03 docs(README): update examples for host-specific configurations and usage patterns 2025-05-26 08:38:21 +03:00
Yar Kravtsov
093cc8ebe7 feat(multihost): add support for host-specific configurations
Implement multihost functionality allowing separate management of common and host-specific dotfiles. Add new commands and flags for handling host-specific files, update core logic for file storage and tracking, and enhance documentation to reflect new capabilities.
2025-05-26 08:26:06 +03:00
Yar Kravtsov
ff3cddc065 docs: Update README.md 2025-05-26 07:58:29 +03:00
Yar Kravtsov
4a275ce4ca feat(cmd): add 'list' command to display managed files
Implements a new 'list' command that shows all files and directories managed by lnk, improving visibility and user experience.

fixes #4
2025-05-26 05:59:33 +03:00
Yar Kravtsov
69c1038f3e Merge pull request #6 from yarlson/alert-autofix-4
Potential fix for code scanning alert no. 4: Workflow does not contain permissions
2025-05-26 05:46:31 +03:00
Yar Kravtsov
c670ac1fd8 Potential fix for code scanning alert no. 4: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-26 05:44:46 +03:00
Yar Kravtsov
27196e3341 docs(README): remove outdated technical details for accuracy 2025-05-25 07:50:47 +03:00
30 changed files with 5486 additions and 646 deletions

17
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/optimizing-pr-creation-version-updates#setting-up-a-cooldown-period-for-dependency-updates
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#groups--
groups:
actions:
# Combine all images of last week
patterns: ["*"]
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly

View File

@@ -6,20 +6,26 @@ on:
pull_request:
branches: [ main ]
permissions:
contents: read
env:
GO_VERSION: '1.24'
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: ${{ env.GO_VERSION }}
- name: Cache Go modules
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
@@ -41,23 +47,23 @@ jobs:
run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v5
with:
file: ./coverage.out
files: ./coverage.out
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: ${{ env.GO_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v8
with:
version: latest
@@ -66,12 +72,12 @@ jobs:
needs: [test, lint]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: ${{ env.GO_VERSION }}
- name: Build
run: go build -v ./...
@@ -81,4 +87,4 @@ jobs:
with:
distribution: goreleaser
version: latest
args: build --snapshot --clean
args: build --snapshot --clean

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

4
.gitignore vendored
View File

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

375
README.md
View File

@@ -2,11 +2,18 @@
**Git-native dotfiles management that doesn't suck.**
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. That's it.
Lnk makes managing your dotfiles straightforward, no tedious setups, no complex configurations. Just tell Lnk what files you want tracked, and it'll automatically move them into a tidy Git repository under `~/.config/lnk`. It then creates clean, portable symlinks back to their original locations. Easy.
Why bother with Lnk instead of plain old Git or other dotfile managers? Unlike traditional methods, Lnk automates the boring parts: safely relocating files, handling host-specific setups, bulk operations for multiple files, recursive directory processing, and even running your custom bootstrap scripts automatically. This means fewer manual steps and less chance of accidentally overwriting something important.
With Lnk, your dotfiles setup stays organized and effortlessly portable, letting you spend more time doing real work, not wrestling with configuration files.
```bash
lnk init
lnk add ~/.vimrc ~/.bashrc
lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Multiple files at once
lnk add --recursive ~/.config/nvim # Process directory contents
lnk add --dry-run ~/.tmux.conf # Preview changes first
lnk add --host work ~/.ssh/config # Host-specific config
lnk push "setup"
```
@@ -19,7 +26,6 @@ curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
```bash
# Homebrew (macOS/Linux)
brew tap yarlson/lnk
brew install lnk
```
@@ -42,107 +48,390 @@ git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv
# Fresh start
lnk init
# With existing repo
# With existing repo (runs bootstrap automatically)
lnk init -r git@github.com:user/dotfiles.git
# Skip automatic bootstrap
lnk init -r git@github.com:user/dotfiles.git --no-bootstrap
# Force initialization (WARNING: overwrites existing managed files)
lnk init -r git@github.com:user/dotfiles.git --force
# Run bootstrap script manually
lnk bootstrap
```
### Daily workflow
```bash
# Add files/directories
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
# Add multiple files at once (common config)
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
# Add directory contents individually
lnk add --recursive ~/.config/nvim ~/.config/zsh
# Preview changes before applying
lnk add --dry-run ~/.config/git/config
lnk add --dry-run --recursive ~/.config/kitty
# Add host-specific files (supports bulk operations)
lnk add --host laptop ~/.ssh/config ~/.aws/credentials
lnk add --host work ~/.gitconfig ~/.ssh/config
# List managed files
lnk list # Common config only
lnk list --host laptop # Laptop-specific config
lnk list --all # All configurations
# Check status
lnk status
# Sync changes
lnk push "updated vim config"
lnk pull
lnk pull # Pull common config
lnk pull --host laptop # Pull laptop-specific config
```
## How it works
```
Common files:
Before: ~/.vimrc (file)
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
Host-specific files:
Before: ~/.ssh/config (file)
After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
```
Your files live in `~/.config/lnk` (a Git repo). Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
## Safety Features
Lnk includes built-in safety checks to prevent accidental data loss:
### Data Loss Prevention
```bash
# This will be blocked if you already have managed files
lnk init -r git@github.com:user/dotfiles.git
# ❌ Directory ~/.config/lnk already contains managed files
# 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r'
# Use pull instead to safely update
lnk pull
# Or force if you understand the risks (shows warning only when needed)
lnk init -r git@github.com:user/dotfiles.git --force
# ⚠️ Using --force flag: This will overwrite existing managed files
# 💡 Only use this if you understand the risks
```
### Smart Warnings
- **Contextual alerts**: Warnings only appear when there are actually managed files to overwrite
- **Clear guidance**: Error messages suggest the correct command to use
- **Force override**: Advanced users can bypass safety checks when needed
## Bootstrap Support
Lnk automatically runs bootstrap scripts when cloning dotfiles repositories, making it easy to set up your development environment. Just add a `bootstrap.sh` file to your dotfiles repo.
### Examples
**Simple bootstrap script:**
```bash
#!/bin/bash
# bootstrap.sh
echo "Setting up development environment..."
# Install Homebrew (macOS)
if ! command -v brew &> /dev/null; then
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
# Install packages
brew install git vim tmux
echo "✅ Setup complete!"
```
**Usage:**
```bash
# Automatic bootstrap on clone
lnk init -r git@github.com:you/dotfiles.git
# → Clones repo and runs bootstrap script automatically
# Skip bootstrap if needed
lnk init -r git@github.com:you/dotfiles.git --no-bootstrap
# Run bootstrap manually later
lnk bootstrap
```
## Multihost Support
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
### File Organization
```
~/.config/lnk/
├── .lnk # Tracks common files
├── .lnk.laptop # Tracks laptop-specific files
├── .lnk.work # Tracks work-specific files
├── .vimrc # Common file
├── .gitconfig # Common file
├── laptop.lnk/ # Laptop-specific storage
│ ├── .ssh/
│ │ └── config
│ └── .tmux.conf
└── work.lnk/ # Work-specific storage
├── .ssh/
│ └── config
└── .gitconfig
```
### Usage Patterns
```bash
# Common config (shared everywhere) - supports multiple files
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf
# Process directory contents individually
lnk add --recursive ~/.config/nvim ~/.config/zsh
# Preview operations before making changes
lnk add --dry-run ~/.config/alacritty/alacritty.yml
lnk add --dry-run --recursive ~/.config/i3
# Host-specific config (unique per machine) - supports bulk operations
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
lnk add --host work ~/.gitconfig ~/.npmrc
# List configurations
lnk list # Common only
lnk list --host work # Work host only
lnk list --all # Everything
# Pull configurations
lnk pull # Common config
lnk pull --host work # Work-specific config
```
## Why not just Git?
You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts:
- Moving files safely
- Moving files safely (with atomic operations)
- Creating relative symlinks
- Handling conflicts
- Handling conflicts and rollback
- Tracking what's managed
- Processing multiple files efficiently
- Recursive directory traversal
- Preview mode for safety
## Examples
### First time setup
```bash
# Clone dotfiles and run bootstrap automatically
lnk init -r git@github.com:you/dotfiles.git
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
# → Downloads dependencies, installs packages, configures environment
# Add common config (shared across all machines) - multiple files at once
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig ~/.tmux.conf
# Add configuration directories individually
lnk add --recursive ~/.config/nvim ~/.config/zsh
# Preview before adding sensitive files
lnk add --dry-run ~/.ssh/id_rsa.pub
lnk add ~/.ssh/id_rsa.pub # Add after verification
# Add host-specific config (supports bulk operations)
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
lnk push "initial setup"
```
### On a new machine
```bash
# Bootstrap runs automatically
lnk init -r git@github.com:you/dotfiles.git
lnk pull # auto-creates symlinks
# → Sets up environment, installs dependencies
# Pull common config
lnk pull
# Pull host-specific config (if it exists)
lnk pull --host $(hostname)
# Or run bootstrap manually if needed
lnk bootstrap
```
### Daily edits
```bash
vim ~/.vimrc # edit normally
lnk status # check what changed
lnk push "new plugins" # commit & push
vim ~/.vimrc # edit normally
lnk list # see common config
lnk list --host $(hostname) # see host-specific config
lnk list --all # see everything
lnk status # check what changed
lnk push "new plugins" # commit & push
```
### Multi-machine workflow
```bash
# On your laptop - use bulk operations for efficiency
lnk add --host laptop ~/.ssh/config ~/.aws/credentials ~/.npmrc
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Common config (multiple files)
lnk push "laptop configuration"
# On your work machine
lnk pull # Get common config
lnk add --host work ~/.gitconfig ~/.ssh/config
lnk add --recursive ~/.config/work-tools # Work-specific tools
lnk push "work configuration"
# Back on laptop
lnk pull # Get updates (work config won't affect laptop)
```
## Commands
- `lnk init [-r remote]` - Create repo
- `lnk add <files>` - Move files to repo, create symlinks
- `lnk rm <files>` - Move files back, remove symlinks
- `lnk init [-r remote] [--no-bootstrap] [--force]` - Create repo (runs bootstrap automatically)
- `lnk add [--host HOST] [--recursive] [--dry-run] <files>...` - Move files to repo, create symlinks
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
- `lnk list [--host HOST] [--all]` - List files managed by lnk
- `lnk status` - Git status + sync info
- `lnk push [msg]` - Stage all, commit, push
- `lnk pull` - Pull + restore missing symlinks
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
- `lnk bootstrap` - Run bootstrap script manually
### Command Options
- `--host HOST` - Manage files for specific host (default: common configuration)
- `--recursive, -r` - Add directory contents individually instead of the directory as a whole
- `--dry-run, -n` - Show what would be added without making changes
- `--all` - Show all configurations (common + all hosts) when listing
- `-r, --remote URL` - Clone from remote URL when initializing
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
- `--force` - Force initialization even if directory contains managed files (WARNING: overwrites existing content)
### Output Formatting
Lnk provides flexible output formatting options to suit different environments and preferences:
#### Color Output
Control when ANSI colors are used in output:
```bash
# Default: auto-detect based on TTY
lnk init
# Force colors regardless of environment
lnk init --colors=always
# Disable colors completely
lnk init --colors=never
# Environment variable support
NO_COLOR=1 lnk init # Disables colors (acts like --colors=never)
```
**Color modes:**
- `auto` (default): Use colors only when stdout is a TTY
- `always`: Force color output regardless of TTY
- `never`: Disable color output regardless of TTY
The `NO_COLOR` environment variable acts like `--colors=never` when set, but explicit `--colors` flags take precedence.
#### Emoji Output
Control emoji usage in output messages:
```bash
# Default: emojis enabled
lnk init
# Disable emojis
lnk init --no-emoji
# Explicitly enable emojis
lnk init --emoji
```
**Emoji flags:**
- `--emoji` (default: true): Enable emoji in output
- `--no-emoji`: Disable emoji in output
The `--emoji` and `--no-emoji` flags are mutually exclusive.
#### Examples
```bash
# Clean output for scripts/pipes
lnk init --colors=never --no-emoji
# Force colorful output in non-TTY environments
lnk init --colors=always
# Disable colors but keep emojis
lnk init --colors=never
# Disable emojis but keep colors
lnk init --no-emoji
```
### Add Command Examples
```bash
# Multiple files at once
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
# Recursive directory processing
lnk add --recursive ~/.config/nvim
# Preview changes first
lnk add --dry-run ~/.ssh/config
lnk add --dry-run --recursive ~/.config/kitty
# Host-specific bulk operations
lnk add --host work ~/.gitconfig ~/.ssh/config ~/.npmrc
```
## Technical bits
- **Single binary** (~8MB, no deps)
- **Atomic operations** (rollback on failure)
- **Relative symlinks** (portable)
- **XDG compliant** (`~/.config/lnk`)
- **20 integration tests**
- **Multihost support** (common + host-specific configs)
- **Bootstrap support** (automatic environment setup)
- **Bulk operations** (multiple files, atomic transactions)
- **Recursive processing** (directory contents individually)
- **Preview mode** (dry-run for safety)
- **Data loss prevention** (safety checks with contextual warnings)
- **Git-native** (standard Git repo, no special formats)
## Alternatives
| Tool | Complexity | Why choose it |
| ------- | ---------- | ------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native |
| chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only |
## FAQ
**Q: What if I already have dotfiles in Git?**
A: `git clone your-repo ~/.config/lnk && lnk add ~/.vimrc` (adopts existing files)
**Q: How do I handle machine-specific configs?**
A: Git branches, or just don't manage machine-specific files with lnk
**Q: Windows support?**
A: Symlinks work on Windows 10+, but untested
**Q: Production ready?**
A: I use it daily. It won't break your files. API might change (pre-1.0).
| Tool | Complexity | Why choose it |
| ------- | ---------- | ----------------------------------------------------------------------------------------- |
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks |
| chezmoi | High | Templates, encryption, cross-platform |
| yadm | Medium | Git power user, encryption |
| dotbot | Low | YAML config, basic features |
| stow | Low | Perl, symlink only |
## Contributing

View File

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

View File

@@ -5,29 +5,189 @@ import (
"path/filepath"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add <file>",
Short: "✨ Add a file to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
cmd := &cobra.Command{
Use: "add <file>...",
Short: "✨ Add files to lnk management",
Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.
lnk := core.NewLnk()
if err := lnk.Add(filePath); err != nil {
return fmt.Errorf("failed to add file: %w", err)
Examples:
lnk add ~/.bashrc ~/.vimrc # Add multiple files at once
lnk add --recursive ~/.config/nvim # Add directory contents individually
lnk add --dry-run ~/.gitconfig # Preview what would be added
lnk add --host work ~/.ssh/config # Add host-specific configuration
The --recursive flag processes directory contents individually instead of treating
the directory as a single unit. This is useful for configuration directories where
you want each file managed separately.
The --dry-run flag shows you exactly what files would be added without making any
changes to your system - perfect for verification before bulk operations.`,
Args: cobra.MinimumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host")
recursive, _ := cmd.Flags().GetBool("recursive")
dryRun, _ := cmd.Flags().GetBool("dry-run")
lnk := core.NewLnk(core.WithHost(host))
w := GetWriter(cmd)
// Handle dry-run mode
if dryRun {
files, err := lnk.PreviewAdd(args, recursive)
if err != nil {
return err
}
// Display preview output
if recursive {
w.Writeln(Message{Text: fmt.Sprintf("Would add %d files recursively:", len(files)), Emoji: "🔍", Bold: true})
} else {
w.Writeln(Message{Text: fmt.Sprintf("Would add %d files:", len(files)), Emoji: "🔍", Bold: true})
}
// List files that would be added
for _, file := range files {
basename := filepath.Base(file)
w.WriteString(" ").
Writeln(Message{Text: basename, Emoji: "📄"})
}
w.WritelnString("").
Writeln(Info("To proceed: run without --dry-run flag"))
return w.Err()
}
basename := filepath.Base(filePath)
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\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) {
w.WriteString(fmt.Sprintf("\r⏳ Processing %d/%d: %s", current, total, currentFile))
}
if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil {
return err
}
// Clear progress line and show completion
w.WriteString("\r")
// Store processed file count for display
args = previewFiles // Replace args with actual files for display
} else {
// 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 != "" {
w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk (host: %s)", len(args), host)))
} else {
w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk", len(args))))
}
// Show some of the files that were added (limit to first few for readability)
filesToShow := len(args)
if filesToShow > 5 {
filesToShow = 5
}
for i := 0; i < filesToShow; i++ {
basename := filepath.Base(args[i])
if host != "" {
w.WriteString(" ").
Write(Link(basename)).
WriteString(" → ").
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan))
} else {
w.WriteString(" ").
Write(Link(basename)).
WriteString(" → ").
Writeln(Colored("~/.config/lnk/...", ColorCyan))
}
}
if len(args) > 5 {
w.WriteString(" ").
Writeln(Colored(fmt.Sprintf("... and %d more files", len(args)-5), ColorGray))
}
} else if len(args) == 1 {
// Single file - maintain existing output format for backward compatibility
filePath := args[0]
basename := filepath.Base(filePath)
if host != "" {
w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk (host: %s)", basename, host)))
w.WriteString(" ").
Write(Link(filePath)).
WriteString(" → ").
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, filePath), ColorCyan))
} else {
w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk", basename)))
w.WriteString(" ").
Write(Link(filePath)).
WriteString(" → ").
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s", filePath), ColorCyan))
}
} else {
// Multiple files - show summary
if host != "" {
w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk (host: %s)", len(args), host)))
} else {
w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk", len(args))))
}
// List each added file
for _, filePath := range args {
basename := filepath.Base(filePath)
if host != "" {
w.WriteString(" ").
Write(Link(basename)).
WriteString(" → ").
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan))
} else {
w.WriteString(" ").
Write(Link(basename)).
WriteString(" → ").
Writeln(Colored("~/.config/lnk/...", ColorCyan))
}
}
}
w.WriteString(" ").
Write(Message{Text: "Use ", Emoji: "📝"}).
Write(Bold("lnk push")).
WritelnString(" to sync to remote")
return w.Err()
},
}
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
cmd.Flags().BoolP("recursive", "r", false, "Add directory contents individually instead of the directory as a whole")
cmd.Flags().BoolP("dry-run", "n", false, "Show what would be added without making changes")
return cmd
}

62
cmd/bootstrap.go Normal file
View File

@@ -0,0 +1,62 @@
package cmd
import (
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newBootstrapCmd() *cobra.Command {
return &cobra.Command{
Use: "bootstrap",
Short: "🚀 Run the bootstrap script to set up your environment",
Long: "Executes the bootstrap script from your dotfiles repository to install dependencies and configure your system.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
w := GetWriter(cmd)
scriptPath, err := lnk.FindBootstrapScript()
if err != nil {
return err
}
if scriptPath == "" {
w.Writeln(Info("No bootstrap script found")).
WriteString(" ").
Write(Message{Text: "Create a ", Emoji: "📝"}).
Write(Bold("bootstrap.sh")).
WritelnString(" file in your dotfiles repository:").
WriteString(" ").
Writeln(Colored("#!/bin/bash", ColorGray)).
WriteString(" ").
Writeln(Colored("echo \"Setting up environment...\"", ColorGray)).
WriteString(" ").
Writeln(Colored("# Your setup commands here", ColorGray))
return w.Err()
}
w.Writeln(Rocket("Running bootstrap script")).
WriteString(" ").
Write(Message{Text: "Script: ", Emoji: "📄"}).
Writeln(Colored(scriptPath, ColorCyan)).
WritelnString("")
if err := w.Err(); err != nil {
return err
}
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
return err
}
w.WritelnString("").
Writeln(Success("Bootstrap completed successfully!")).
WriteString(" ").
Writeln(Message{Text: "Your environment is ready to use", Emoji: "🎉"})
return w.Err()
},
}
}

View File

@@ -1,45 +1,136 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newInitCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "init",
Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true,
Use: "init",
Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote")
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
force, _ := cmd.Flags().GetBool("force")
lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil {
return fmt.Errorf("failed to initialize lnk: %w", err)
w := GetWriter(cmd)
// Show warning when force is used and there are managed files to overwrite
if force && remote != "" && lnk.HasUserContent() {
w.Writeln(Warning("Using --force flag: This will overwrite existing managed files")).
WriteString(" ").
Writeln(Info("Only use this if you understand the risks")).
WritelnString("")
if err := w.Err(); err != nil {
return err
}
}
if err := lnk.InitWithRemoteForce(remote, force); err != nil {
return err
}
if remote != "" {
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
} else {
printf(cmd, "🎯 \033[1mInitialized empty lnk repository\033[0m\n")
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
printf(cmd, " • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
printf(cmd, " • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
}
w.Writeln(Target("Initialized lnk repository")).
WriteString(" ").
Write(Message{Text: "Cloned from: ", Emoji: "📦"}).
Writeln(Colored(remote, ColorCyan)).
WriteString(" ").
Write(Message{Text: "Location: ", Emoji: "📁"}).
Writeln(Colored("~/.config/lnk", ColorGray))
return nil
if err := w.Err(); err != nil {
return err
}
// Try to run bootstrap script if not disabled
if !noBootstrap {
w.WritelnString("").
Writeln(Message{Text: "Looking for bootstrap script...", Emoji: "🔍", Bold: true})
if err := w.Err(); err != nil {
return err
}
scriptPath, err := lnk.FindBootstrapScript()
if err != nil {
return err
}
if scriptPath != "" {
w.WriteString(" ").
Write(Success("Found bootstrap script: ")).
Writeln(Colored(scriptPath, ColorCyan)).
WritelnString("").
Writeln(Rocket("Running bootstrap script...")).
WritelnString("")
if err := w.Err(); err != nil {
return err
}
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
w.WritelnString("").
Writeln(Warning("Bootstrap script failed, but repository was initialized successfully")).
WriteString(" ").
Write(Info("You can run it manually with: ")).
Writeln(Bold("lnk bootstrap")).
WriteString(" ").
Write(Message{Text: "Error: ", Emoji: "🔧"}).
Writeln(Plain(err.Error()))
} else {
w.WritelnString("").
Writeln(Success("Bootstrap completed successfully!"))
}
if err := w.Err(); err != nil {
return err
}
} else {
w.WriteString(" ").
Writeln(Info("No bootstrap script found"))
if err := w.Err(); err != nil {
return err
}
}
}
w.WritelnString("").
Writeln(Info("Next steps:")).
WriteString(" • Run ").
Write(Bold("lnk pull")).
Writeln(Plain(" to restore symlinks")).
WriteString(" • Use ").
Write(Bold("lnk add <file>")).
Writeln(Plain(" to manage new files"))
return w.Err()
} else {
w.Writeln(Target("Initialized empty lnk repository")).
WriteString(" ").
Write(Message{Text: "Location: ", Emoji: "📁"}).
Writeln(Colored("~/.config/lnk", ColorGray)).
WritelnString("").
Writeln(Info("Next steps:")).
WriteString(" • Run ").
Write(Bold("lnk add <file>")).
Writeln(Plain(" to start managing dotfiles")).
WriteString(" • Add a remote with: ").
Writeln(Bold("git remote add origin <url>"))
return w.Err()
}
},
}
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning")
cmd.Flags().Bool("force", false, "Force initialization even if directory contains managed files (WARNING: This will overwrite existing content)")
return cmd
}

236
cmd/list.go Normal file
View File

@@ -0,0 +1,236 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "📋 List files managed by lnk",
Long: "Display all files and directories currently managed by lnk.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host")
all, _ := cmd.Flags().GetBool("all")
if host != "" {
// Show specific host configuration
return listHostConfig(cmd, host)
}
if all {
// Show all configurations (common + all hosts)
return listAllConfigs(cmd)
}
// Default: show common configuration
return listCommonConfig(cmd)
},
}
cmd.Flags().StringP("host", "H", "", "List files for specific host")
cmd.Flags().BoolP("all", "a", false, "List files for all hosts and common configuration")
return cmd
}
func listCommonConfig(cmd *cobra.Command) error {
lnk := core.NewLnk()
w := GetWriter(cmd)
managedItems, err := lnk.List()
if err != nil {
return err
}
if len(managedItems) == 0 {
w.Writeln(Message{Text: "No files currently managed by lnk (common)", Emoji: "📋", Bold: true}).
WriteString(" ").
Write(Info("Use ")).
Write(Bold("lnk add <file>")).
WritelnString(" to start managing files")
return w.Err()
}
countText := fmt.Sprintf("Files managed by lnk (common) (%d item", len(managedItems))
if len(managedItems) > 1 {
countText += "s"
}
countText += "):"
w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}).
WritelnString("")
for _, item := range managedItems {
w.WriteString(" ").
Writeln(Link(item))
}
w.WritelnString("").
Write(Info("Use ")).
Write(Bold("lnk status")).
WritelnString(" to check sync status")
return w.Err()
}
func listHostConfig(cmd *cobra.Command, host string) error {
lnk := core.NewLnk(core.WithHost(host))
w := GetWriter(cmd)
managedItems, err := lnk.List()
if err != nil {
return err
}
if len(managedItems) == 0 {
w.Writeln(Message{Text: fmt.Sprintf("No files currently managed by lnk (host: %s)", host), Emoji: "📋", Bold: true}).
WriteString(" ").
Write(Info("Use ")).
Write(Bold(fmt.Sprintf("lnk add --host %s <file>", host))).
WritelnString(" to start managing files")
return w.Err()
}
countText := fmt.Sprintf("Files managed by lnk (host: %s) (%d item", host, len(managedItems))
if len(managedItems) > 1 {
countText += "s"
}
countText += "):"
w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}).
WritelnString("")
for _, item := range managedItems {
w.WriteString(" ").
Writeln(Link(item))
}
w.WritelnString("").
Write(Info("Use ")).
Write(Bold("lnk status")).
WritelnString(" to check sync status")
return w.Err()
}
func listAllConfigs(cmd *cobra.Command) error {
w := GetWriter(cmd)
// List common configuration
w.Writeln(Message{Text: "All configurations managed by lnk", Emoji: "📋", Bold: true}).
WritelnString("")
lnk := core.NewLnk()
commonItems, err := lnk.List()
if err != nil {
return err
}
countText := fmt.Sprintf("Common configuration (%d item", len(commonItems))
if len(commonItems) > 1 {
countText += "s"
}
countText += "):"
w.Writeln(Message{Text: countText, Emoji: "🌐", Bold: true})
if len(commonItems) == 0 {
w.WriteString(" ").
Writeln(Colored("(no files)", ColorGray))
} else {
for _, item := range commonItems {
w.WriteString(" ").
Writeln(Link(item))
}
}
// Find all host-specific configurations
hosts, err := findHostConfigs()
if err != nil {
return err
}
for _, host := range hosts {
w.WritelnString("").
Write(Message{Text: fmt.Sprintf("Host: %s", host), Emoji: "🖥️", Bold: true})
hostLnk := core.NewLnk(core.WithHost(host))
hostItems, err := hostLnk.List()
if err != nil {
w.WriteString(" ").
Writeln(Colored(fmt.Sprintf("(error: %v)", err), ColorRed))
continue
}
countText := fmt.Sprintf(" (%d item", len(hostItems))
if len(hostItems) > 1 {
countText += "s"
}
countText += "):"
w.WriteString(countText).
WritelnString("")
if len(hostItems) == 0 {
w.WriteString(" ").
Writeln(Colored("(no files)", ColorGray))
} else {
for _, item := range hostItems {
w.WriteString(" ").
Writeln(Link(item))
}
}
}
w.WritelnString("").
Write(Info("Use ")).
Write(Bold("lnk list --host <hostname>")).
WritelnString(" to see specific host configuration")
return w.Err()
}
func findHostConfigs() ([]string, error) {
repoPath := getRepoPath()
// Check if repo exists
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
return []string{}, nil
}
entries, err := os.ReadDir(repoPath)
if err != nil {
return nil, err
}
var hosts []string
for _, entry := range entries {
name := entry.Name()
// Look for .lnk.<hostname> files
if strings.HasPrefix(name, ".lnk.") && name != ".lnk" {
host := strings.TrimPrefix(name, ".lnk.")
hosts = append(hosts, host)
}
}
return hosts, nil
}
func getRepoPath() string {
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
if xdgConfig == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
xdgConfig = "."
} else {
xdgConfig = filepath.Join(homeDir, ".config")
}
}
return filepath.Join(xdgConfig, "lnk")
}

230
cmd/output.go Normal file
View File

@@ -0,0 +1,230 @@
package cmd
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
)
// OutputConfig controls formatting behavior
type OutputConfig struct {
Colors bool
Emoji bool
}
// Writer provides formatted output with configurable styling
type Writer struct {
out io.Writer
config OutputConfig
err error // first error encountered
}
// NewWriter creates a new Writer with the given configuration
func NewWriter(out io.Writer, config OutputConfig) *Writer {
return &Writer{
out: out,
config: config,
}
}
// Message represents a structured message with optional formatting
type Message struct {
Text string
Color string
Emoji string
Bold bool
}
// Write outputs a message according to the writer's configuration
func (w *Writer) Write(msg Message) *Writer {
if w.err != nil {
return w
}
var output string
// Add emoji if enabled
if w.config.Emoji && msg.Emoji != "" {
output = msg.Emoji + " "
}
// Add color/bold if enabled
if w.config.Colors {
if msg.Bold {
output += "\033[1m"
}
if msg.Color != "" {
output += msg.Color
}
}
output += msg.Text
// Close formatting if enabled
if w.config.Colors && (msg.Bold || msg.Color != "") {
output += "\033[0m"
}
_, w.err = fmt.Fprint(w.out, output)
return w
}
// Printf is like Write but with format string
func (w *Writer) Printf(msg Message, args ...any) *Writer {
newMsg := msg
newMsg.Text = fmt.Sprintf(msg.Text, args...)
return w.Write(newMsg)
}
// Writeln writes a message followed by a newline
func (w *Writer) Writeln(msg Message) *Writer {
return w.Write(msg).WriteString("\n")
}
// WriteString outputs plain text (no formatting)
func (w *Writer) WriteString(text string) *Writer {
if w.err != nil {
return w
}
_, w.err = fmt.Fprint(w.out, text)
return w
}
// WritelnString outputs plain text followed by a newline
func (w *Writer) WritelnString(text string) *Writer {
if w.err != nil {
return w
}
_, w.err = fmt.Fprintln(w.out, text)
return w
}
// ANSI color codes
const (
ColorRed = "\033[31m"
ColorYellow = "\033[33m"
ColorCyan = "\033[36m"
ColorGray = "\033[90m"
ColorBrightGreen = "\033[1;32m"
ColorBrightYellow = "\033[1;33m"
ColorBrightRed = "\033[1;31m"
)
// Predefined message constructors for common patterns
func Success(text string) Message {
return Message{Text: text, Color: ColorBrightGreen, Emoji: "✅", Bold: true}
}
func Error(text string) Message {
return Message{Text: text, Emoji: "❌"}
}
func Warning(text string) Message {
return Message{Text: text, Color: ColorBrightYellow, Emoji: "⚠️", Bold: true}
}
func Info(text string) Message {
return Message{Text: text, Color: ColorYellow, Emoji: "💡"}
}
func Target(text string) Message {
return Message{Text: text, Emoji: "🎯", Bold: true}
}
func Rocket(text string) Message {
return Message{Text: text, Emoji: "🚀", Bold: true}
}
func Sparkles(text string) Message {
return Message{Text: text, Emoji: "✨", Bold: true}
}
func Link(text string) Message {
return Message{Text: text, Color: ColorCyan, Emoji: "🔗"}
}
func Plain(text string) Message {
return Message{Text: text}
}
func Bold(text string) Message {
return Message{Text: text, Bold: true}
}
func Colored(text, color string) Message {
return Message{Text: text, Color: color}
}
// Global output configuration
var (
globalConfig = OutputConfig{
Colors: true, // auto-detect on first use
Emoji: true,
}
autoDetected bool
)
// SetGlobalConfig updates the global output configuration
func SetGlobalConfig(colors string, emoji bool) error {
switch colors {
case "auto":
globalConfig.Colors = isTerminal()
case "always":
globalConfig.Colors = true
case "never":
globalConfig.Colors = false
default:
return fmt.Errorf("invalid color mode: %s (valid: auto, always, never)", colors)
}
// Check NO_COLOR environment variable (explicit flag takes precedence)
if os.Getenv("NO_COLOR") != "" && colors == "auto" {
globalConfig.Colors = false
}
globalConfig.Emoji = emoji
autoDetected = true
return nil
}
// isTerminal checks if stdout is a terminal
func isTerminal() bool {
fileInfo, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fileInfo.Mode() & os.ModeCharDevice) != 0
}
// autoDetectConfig performs one-time auto-detection if not explicitly configured
func autoDetectConfig() {
if !autoDetected {
if os.Getenv("NO_COLOR") != "" {
globalConfig.Colors = false
} else {
globalConfig.Colors = isTerminal()
}
autoDetected = true
}
}
// GetWriter returns a writer for the given cobra command
func GetWriter(cmd *cobra.Command) *Writer {
autoDetectConfig()
return NewWriter(cmd.OutOrStdout(), globalConfig)
}
// GetErrorWriter returns a writer for stderr
func GetErrorWriter() *Writer {
autoDetectConfig()
return NewWriter(os.Stderr, globalConfig)
}
// Err returns the first error encountered during writing
func (w *Writer) Err() error {
return w.err
}

271
cmd/output_test.go Normal file
View File

@@ -0,0 +1,271 @@
package cmd
import (
"bytes"
"os"
"strings"
"testing"
"github.com/yarlson/lnk/internal/core"
)
func TestOutputConfig(t *testing.T) {
tests := []struct {
name string
colors string
emoji bool
expectError bool
expectedColors bool
expectedEmoji bool
}{
{
name: "auto mode",
colors: "auto",
emoji: true,
expectError: false,
expectedColors: false, // TTY detection will return false in tests
expectedEmoji: true,
},
{
name: "always mode",
colors: "always",
emoji: false,
expectError: false,
expectedColors: true,
expectedEmoji: false,
},
{
name: "never mode",
colors: "never",
emoji: true,
expectError: false,
expectedColors: false,
expectedEmoji: true,
},
{
name: "invalid mode",
colors: "invalid",
emoji: true,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clear NO_COLOR for consistent testing
_ = os.Unsetenv("NO_COLOR")
err := SetGlobalConfig(tt.colors, tt.emoji)
if tt.expectError && err == nil {
t.Errorf("expected error but got none")
}
if !tt.expectError && err != nil {
t.Errorf("unexpected error: %v", err)
}
if !tt.expectError {
if globalConfig.Colors != tt.expectedColors {
t.Errorf("expected colors %v, got %v", tt.expectedColors, globalConfig.Colors)
}
if globalConfig.Emoji != tt.expectedEmoji {
t.Errorf("expected emoji %v, got %v", tt.expectedEmoji, globalConfig.Emoji)
}
}
})
}
}
func TestNOCOLOREnvironmentVariable(t *testing.T) {
// Test NO_COLOR environment variable with auto mode
_ = os.Setenv("NO_COLOR", "1")
defer func() { _ = os.Unsetenv("NO_COLOR") }()
err := SetGlobalConfig("auto", true)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if globalConfig.Colors != false {
t.Errorf("expected colors disabled when NO_COLOR is set, got %v", globalConfig.Colors)
}
}
func TestWriterOutput(t *testing.T) {
tests := []struct {
name string
config OutputConfig
message Message
expectedOutput string
}{
{
name: "full formatting",
config: OutputConfig{Colors: true, Emoji: true},
message: Message{
Text: "test message",
Color: ColorRed,
Emoji: "✅",
Bold: true,
},
expectedOutput: "✅ \033[1m\033[31mtest message\033[0m",
},
{
name: "colors only",
config: OutputConfig{Colors: true, Emoji: false},
message: Message{
Text: "test message",
Color: ColorRed,
Emoji: "✅",
Bold: true,
},
expectedOutput: "\033[1m\033[31mtest message\033[0m",
},
{
name: "emoji only",
config: OutputConfig{Colors: false, Emoji: true},
message: Message{
Text: "test message",
Color: ColorRed,
Emoji: "✅",
Bold: true,
},
expectedOutput: "✅ test message",
},
{
name: "no formatting",
config: OutputConfig{Colors: false, Emoji: false},
message: Message{
Text: "test message",
Color: ColorRed,
Emoji: "✅",
Bold: true,
},
expectedOutput: "test message",
},
{
name: "plain message",
config: OutputConfig{Colors: true, Emoji: true},
message: Plain("plain text"),
expectedOutput: "plain text",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
writer := NewWriter(&buf, tt.config)
writer.Write(tt.message)
if err := writer.Err(); err != nil {
t.Errorf("unexpected error: %v", err)
}
if buf.String() != tt.expectedOutput {
t.Errorf("expected %q, got %q", tt.expectedOutput, buf.String())
}
})
}
}
func TestPredefinedMessages(t *testing.T) {
tests := []struct {
name string
creator func(string) Message
text string
}{
{"Success", Success, "operation succeeded"},
{"Error", Error, "something failed"},
{"Warning", Warning, "be careful"},
{"Info", Info, "useful information"},
{"Target", Target, "target reached"},
{"Rocket", Rocket, "launching"},
{"Sparkles", Sparkles, "amazing"},
{"Link", Link, "connected"},
{"Plain", Plain, "no formatting"},
{"Bold", Bold, "emphasis"},
}
var buf bytes.Buffer
writer := NewWriter(&buf, OutputConfig{Colors: true, Emoji: true})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf.Reset()
msg := tt.creator(tt.text)
writer.Write(msg)
if err := writer.Err(); err != nil {
t.Errorf("unexpected error: %v", err)
}
output := buf.String()
if !strings.Contains(output, tt.text) {
t.Errorf("output should contain text %q, got %q", tt.text, output)
}
})
}
}
func TestStructuredErrors(t *testing.T) {
tests := []struct {
name string
err *core.LnkError
config OutputConfig
contains []string
notContains []string
}{
{
name: "structured error with full formatting",
err: &core.LnkError{
Message: "Something went wrong",
Suggestion: "Try this instead",
Path: "/some/path",
ErrorType: "test_error",
},
config: OutputConfig{Colors: true, Emoji: true},
contains: []string{"❌", "Something went wrong", "/some/path", "💡", "Try this instead"},
},
{
name: "structured error without emojis",
err: &core.LnkError{
Message: "Something went wrong",
Suggestion: "Try this instead",
Path: "/some/path",
ErrorType: "test_error",
},
config: OutputConfig{Colors: true, Emoji: false},
contains: []string{"Something went wrong", "/some/path", "Try this instead"},
notContains: []string{"❌", "💡"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
w := NewWriter(&buf, tt.config)
// Test the component messages directly
_ = w.Write(Error(tt.err.Message))
if tt.err.Path != "" {
_ = w.WriteString("\n ")
_ = w.Write(Colored(tt.err.Path, ColorRed))
}
if tt.err.Suggestion != "" {
_ = w.WriteString("\n ")
_ = w.Write(Info(tt.err.Suggestion))
}
output := buf.String()
for _, expected := range tt.contains {
if !strings.Contains(output, expected) {
t.Errorf("output should contain %q, got %q", expected, output)
}
}
for _, notExpected := range tt.notContains {
if strings.Contains(output, notExpected) {
t.Errorf("output should not contain %q, got %q", notExpected, output)
}
}
})
}
}

View File

@@ -4,40 +4,72 @@ import (
"fmt"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newPullCmd() *cobra.Command {
return &cobra.Command{
Use: "pull",
Short: "⬇️ Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
SilenceUsage: true,
cmd := &cobra.Command{
Use: "pull",
Short: "⬇️ Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
w := GetWriter(cmd)
restored, err := lnk.Pull()
if err != nil {
return fmt.Errorf("failed to pull changes: %w", err)
return err
}
if len(restored) > 0 {
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
var successMsg string
if host != "" {
successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host)
} else {
successMsg = "Successfully pulled changes"
}
symlinkText := fmt.Sprintf("Restored %d symlink", len(restored))
if len(restored) > 1 {
printf(cmd, "s")
symlinkText += "s"
}
printf(cmd, "\033[0m:\n")
symlinkText += ":"
w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}).
WriteString(" ").
Writeln(Link(symlinkText))
for _, file := range restored {
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
w.WriteString(" ").
Writeln(Sparkles(file))
}
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
w.WritelnString("").
WriteString(" ").
Writeln(Message{Text: "Your dotfiles are synced and ready!", Emoji: "🎉"})
} else {
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
printf(cmd, " ✅ All symlinks already in place\n")
printf(cmd, " 🎉 Everything is up to date!\n")
var successMsg string
if host != "" {
successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host)
} else {
successMsg = "Successfully pulled changes"
}
w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}).
WriteString(" ").
Writeln(Success("All symlinks already in place")).
WriteString(" ").
Writeln(Message{Text: "Everything is up to date!", Emoji: "🎉"})
}
return nil
return w.Err()
},
}
cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)")
return cmd
}

View File

@@ -1,19 +1,19 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newPushCmd() *cobra.Command {
return &cobra.Command{
Use: "push [message]",
Short: "🚀 Push local changes to remote repository",
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
Use: "push [message]",
Short: "🚀 Push local changes to remote repository",
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
message := "lnk: sync configuration files"
if len(args) > 0 {
@@ -21,15 +21,22 @@ func newPushCmd() *cobra.Command {
}
lnk := core.NewLnk()
w := GetWriter(cmd)
if err := lnk.Push(message); err != nil {
return fmt.Errorf("failed to push changes: %w", err)
return err
}
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
printf(cmd, " 💾 Commit: \033[90m%s\033[0m\n", message)
printf(cmd, " 📡 Synced to remote\n")
printf(cmd, " ✨ Your dotfiles are up to date!\n")
return nil
w.Writeln(Rocket("Successfully pushed changes")).
WriteString(" ").
Write(Message{Text: "Commit: ", Emoji: "💾"}).
Writeln(Colored(message, ColorGray)).
WriteString(" ").
Writeln(Message{Text: "Synced to remote", Emoji: "📡"}).
WriteString(" ").
Writeln(Sparkles("Your dotfiles are up to date!"))
return w.Err()
},
}
}

View File

@@ -5,29 +5,50 @@ import (
"path/filepath"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "rm <file>",
Short: "🗑️ Remove a file from lnk management",
Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
cmd := &cobra.Command{
Use: "rm <file>",
Short: "🗑️ Remove a file from lnk management",
Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
host, _ := cmd.Flags().GetString("host")
lnk := core.NewLnk(core.WithHost(host))
w := GetWriter(cmd)
lnk := core.NewLnk()
if err := lnk.Remove(filePath); err != nil {
return fmt.Errorf("failed to remove file: %w", err)
return err
}
basename := filepath.Base(filePath)
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
printf(cmd, " 📄 Original file restored\n")
return nil
if host != "" {
w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk (host: %s)", basename, host), Emoji: "🗑️", Bold: true}).
WriteString(" ").
Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, basename), Emoji: "↩️"}).
WriteString(" → ").
Writeln(Colored(filePath, ColorCyan))
} else {
w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk", basename), Emoji: "🗑️", Bold: true}).
WriteString(" ").
Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s", basename), Emoji: "↩️"}).
WriteString(" → ").
Writeln(Colored(filePath, ColorCyan))
}
w.WriteString(" ").
Writeln(Message{Text: "Original file restored", Emoji: "📄"})
return w.Err()
},
}
cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)")
return cmd
}

View File

@@ -1,10 +1,15 @@
package cmd
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
"github.com/yarlson/lnk/internal/fs"
"github.com/yarlson/lnk/internal/git"
)
var (
@@ -14,33 +19,72 @@ var (
// NewRootCommand creates a new root command (testable)
func NewRootCommand() *cobra.Command {
var (
colors string
emoji bool
noEmoji bool
)
rootCmd := &cobra.Command{
Use: "lnk",
Short: "🔗 Dotfiles, linked. No fluff.",
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
That's it.
Supports both common configurations, host-specific setups, and bulk operations for multiple files.
✨ Examples:
lnk init # Fresh start
lnk init -r <repo-url> # Clone existing dotfiles
lnk add ~/.vimrc ~/.bashrc # Start managing files
lnk push "setup complete" # Sync to remote
lnk pull # Get latest changes
lnk init # Fresh start
lnk init -r <repo-url> # Clone existing dotfiles (runs bootstrap automatically)
lnk add ~/.vimrc ~/.bashrc # Start managing common files
lnk add --recursive ~/.config/nvim # Add directory contents individually
lnk add --dry-run ~/.gitconfig # Preview changes without applying
lnk add --host work ~/.ssh/config # Manage host-specific files
lnk list --all # Show all configurations
lnk pull --host work # Pull host-specific changes
lnk push "setup complete" # Sync to remote
lnk bootstrap # Run bootstrap script manually
🎯 Simple, fast, and Git-native.`,
SilenceUsage: true,
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
🚀 Bootstrap Support:
Automatically runs bootstrap.sh when cloning a repository.
Use --no-bootstrap to disable.
🎯 Simple, fast, Git-native, and multi-host ready.`,
SilenceUsage: true,
SilenceErrors: true,
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Handle emoji flag logic
emojiEnabled := emoji
if noEmoji {
emojiEnabled = false
}
err := SetGlobalConfig(colors, emojiEnabled)
if err != nil {
return err
}
return nil
},
}
// Add global flags for output formatting
rootCmd.PersistentFlags().StringVar(&colors, "colors", "auto", "when to use colors (auto, always, never)")
rootCmd.PersistentFlags().BoolVar(&emoji, "emoji", true, "enable emoji in output")
rootCmd.PersistentFlags().BoolVar(&noEmoji, "no-emoji", false, "disable emoji in output")
// Mark emoji flags as mutually exclusive
rootCmd.MarkFlagsMutuallyExclusive("emoji", "no-emoji")
// Add subcommands
rootCmd.AddCommand(newInitCmd())
rootCmd.AddCommand(newAddCmd())
rootCmd.AddCommand(newRemoveCmd())
rootCmd.AddCommand(newListCmd())
rootCmd.AddCommand(newStatusCmd())
rootCmd.AddCommand(newPushCmd())
rootCmd.AddCommand(newPullCmd())
rootCmd.AddCommand(newBootstrapCmd())
return rootCmd
}
@@ -54,7 +98,98 @@ func SetVersion(v, bt string) {
func Execute() {
rootCmd := NewRootCommand()
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
DisplayError(err)
os.Exit(1)
}
}
// DisplayError formats and displays an error with appropriate styling
func DisplayError(err error) {
w := GetErrorWriter()
// Handle structured errors from core package
var lnkErr *core.LnkError
if errors.As(err, &lnkErr) {
w.Write(Error(lnkErr.Message))
if lnkErr.Path != "" {
w.WritelnString("").
WriteString(" ").
Write(Colored(lnkErr.Path, ColorRed))
}
if lnkErr.Suggestion != "" {
w.WritelnString("").
WriteString(" ").
Write(Info(lnkErr.Suggestion))
}
w.WritelnString("")
return
}
// Handle structured errors from fs package
var pathErr fs.ErrorWithPath
if errors.As(err, &pathErr) {
w.Write(Error(err.Error()))
if pathErr.GetPath() != "" {
w.WritelnString("").
WriteString(" ").
Write(Colored(pathErr.GetPath(), ColorRed))
}
var suggErr fs.ErrorWithSuggestion
if errors.As(err, &suggErr) {
w.WritelnString("").
WriteString(" ").
Write(Info(suggErr.GetSuggestion()))
}
w.WritelnString("")
return
}
// Handle fs errors that only have suggestions
var suggErr fs.ErrorWithSuggestion
if errors.As(err, &suggErr) {
w.Write(Error(err.Error())).
WritelnString("").
WriteString(" ").
Write(Info(suggErr.GetSuggestion())).
WritelnString("")
return
}
// Handle git errors with paths
var gitPathErr git.ErrorWithPath
if errors.As(err, &gitPathErr) {
w.Write(Error(err.Error())).
WritelnString("").
WriteString(" ").
Write(Colored(gitPathErr.GetPath(), ColorRed)).
WritelnString("")
return
}
// Handle git errors with remotes
var gitRemoteErr git.ErrorWithRemote
if errors.As(err, &gitRemoteErr) {
w.Write(Error(err.Error())).
WritelnString("").
WriteString(" Remote: ").
Write(Colored(gitRemoteErr.GetRemote(), ColorCyan)).
WritelnString("")
return
}
// Handle git errors with reasons
var gitReasonErr git.ErrorWithReason
if errors.As(err, &gitReasonErr) {
w.Write(Error(err.Error()))
if gitReasonErr.GetReason() != "" {
w.WritelnString("").
WriteString(" Reason: ").
Write(Colored(gitReasonErr.GetReason(), ColorYellow))
}
w.WritelnString("")
return
}
// Handle generic errors
w.Writeln(Error(err.Error()))
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,20 +4,22 @@ import (
"fmt"
"github.com/spf13/cobra"
"github.com/yarlson/lnk/internal/core"
)
func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "📊 Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
SilenceUsage: true,
Use: "status",
Short: "📊 Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
status, err := lnk.Status()
if err != nil {
return fmt.Errorf("failed to get status: %w", err)
return err
}
if status.Dirty {
@@ -37,51 +39,93 @@ func newStatusCmd() *cobra.Command {
}
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
w := GetWriter(cmd)
w.Writeln(Warning("Repository has uncommitted changes")).
WriteString(" ").
Write(Message{Text: "Remote: ", Emoji: "📡"}).
Writeln(Colored(status.Remote, ColorCyan))
if status.Ahead == 0 && status.Behind == 0 {
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
w.WritelnString("").
Write(Info("Run ")).
Write(Bold("git add && git commit")).
WriteString(" in ").
Write(Colored("~/.config/lnk", ColorCyan)).
WriteString(" or ").
Write(Bold("lnk push")).
WritelnString(" to commit changes")
return
}
printf(cmd, "\n")
w.WritelnString("")
displayAheadBehindInfo(cmd, status, true)
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
w.WritelnString("").
Write(Info("Run ")).
Write(Bold("git add && git commit")).
WriteString(" in ").
Write(Colored("~/.config/lnk", ColorCyan)).
WriteString(" or ").
Write(Bold("lnk push")).
WritelnString(" to commit changes")
}
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
w := GetWriter(cmd)
w.Writeln(Success("Repository is up to date")).
WriteString(" ").
Write(Message{Text: "Synced with ", Emoji: "📡"}).
Writeln(Colored(status.Remote, ColorCyan))
}
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
printf(cmd, "\n")
w := GetWriter(cmd)
w.Writeln(Message{Text: "Repository Status", Emoji: "📊", Bold: true}).
WriteString(" ").
Write(Message{Text: "Remote: ", Emoji: "📡"}).
Writeln(Colored(status.Remote, ColorCyan)).
WritelnString("")
displayAheadBehindInfo(cmd, status, false)
if status.Ahead > 0 && status.Behind == 0 {
printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes\n")
w.WritelnString("").
Write(Info("Run ")).
Write(Bold("lnk push")).
WritelnString(" to sync your changes")
} else if status.Behind > 0 {
printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes\n")
w.WritelnString("").
Write(Info("Run ")).
Write(Bold("lnk pull")).
WritelnString(" to get latest changes")
}
}
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
w := GetWriter(cmd)
if status.Ahead > 0 {
commitText := getCommitText(status.Ahead)
if isDirty {
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m (excluding uncommitted changes)\n", status.Ahead, commitText)
w.WriteString(" ").
Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}).
WritelnString(" (excluding uncommitted changes)")
} else {
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
w.WriteString(" ").
Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}).
WritelnString(" - ready to push")
}
}
if status.Behind > 0 {
commitText := getCommitText(status.Behind)
printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
w.WriteString(" ").
Write(Message{Text: fmt.Sprintf("%d %s behind", status.Behind, commitText), Emoji: "⬇️", Color: ColorBrightRed, Bold: true}).
WriteString(" - run ").
Write(Bold("lnk pull")).
WritelnString("")
}
}

View File

@@ -1,12 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// printf is a helper function to simplify output formatting in commands
func printf(cmd *cobra.Command, format string, args ...interface{}) {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...)
}

4
go.mod
View File

@@ -4,13 +4,13 @@ go 1.24
require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/pflag v1.0.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

7
go.sum
View File

@@ -8,10 +8,11 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

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

84
internal/core/errors.go Normal file
View File

@@ -0,0 +1,84 @@
package core
import "fmt"
// LnkError represents a structured error with separate content and formatting hints
type LnkError struct {
Message string
Suggestion string
Path string
ErrorType string
}
func (e *LnkError) Error() string {
if e.Suggestion != "" {
return fmt.Sprintf("%s\n %s", e.Message, e.Suggestion)
}
return e.Message
}
// Error constructors that separate content from presentation
func ErrDirectoryContainsManagedFiles(path string) error {
return &LnkError{
Message: fmt.Sprintf("Directory %s already contains managed files", path),
Suggestion: "Use 'lnk pull' to update from remote instead of 'lnk init -r'",
Path: path,
ErrorType: "managed_files_exist",
}
}
func ErrDirectoryContainsGitRepo(path string) error {
return &LnkError{
Message: fmt.Sprintf("Directory %s contains an existing Git repository", path),
Suggestion: "Please backup or move the existing repository before initializing lnk",
Path: path,
ErrorType: "git_repo_exists",
}
}
func ErrFileAlreadyManaged(path string) error {
return &LnkError{
Message: fmt.Sprintf("File is already managed by lnk: %s", path),
Path: path,
ErrorType: "already_managed",
}
}
func ErrFileNotManaged(path string) error {
return &LnkError{
Message: fmt.Sprintf("File is not managed by lnk: %s", path),
Path: path,
ErrorType: "not_managed",
}
}
func ErrRepositoryNotInitialized() error {
return &LnkError{
Message: "Lnk repository not initialized",
Suggestion: "Run 'lnk init' first",
ErrorType: "not_initialized",
}
}
func ErrBootstrapScriptNotFound(script string) error {
return &LnkError{
Message: fmt.Sprintf("Bootstrap script not found: %s", script),
Path: script,
ErrorType: "script_not_found",
}
}
func ErrBootstrapScriptFailed(err error) error {
return &LnkError{
Message: fmt.Sprintf("Bootstrap script failed with error: %v", err),
ErrorType: "script_failed",
}
}
func ErrBootstrapScriptNotExecutable(err error) error {
return &LnkError{
Message: fmt.Sprintf("Failed to make bootstrap script executable: %v", err),
ErrorType: "script_permissions",
}
}

View File

@@ -3,6 +3,7 @@ package core
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
@@ -14,18 +15,72 @@ import (
// Lnk represents the main application logic
type Lnk struct {
repoPath string
host string // Host-specific configuration
git *git.Git
fs *fs.FileSystem
}
// NewLnk creates a new Lnk instance
func NewLnk() *Lnk {
type Option func(*Lnk)
// WithHost sets the host for host-specific configuration
func WithHost(host string) Option {
return func(l *Lnk) {
l.host = host
}
}
// NewLnk creates a new Lnk instance with optional configuration
func NewLnk(opts ...Option) *Lnk {
repoPath := getRepoPath()
return &Lnk{
lnk := &Lnk{
repoPath: repoPath,
host: "",
git: git.New(repoPath),
fs: fs.New(),
}
for _, opt := range opts {
opt(lnk)
}
return lnk
}
// HasUserContent checks if the repository contains managed files
// by looking for .lnk tracker files (common or host-specific)
func (l *Lnk) HasUserContent() bool {
// Check for common tracker file
commonTracker := filepath.Join(l.repoPath, ".lnk")
if _, err := os.Stat(commonTracker); err == nil {
return true
}
// Check for host-specific tracker files if host is set
if l.host != "" {
hostTracker := filepath.Join(l.repoPath, fmt.Sprintf(".lnk.%s", l.host))
if _, err := os.Stat(hostTracker); err == nil {
return true
}
} else {
// If no specific host is set, check for any host-specific tracker files
// This handles cases where we want to detect any managed content
pattern := filepath.Join(l.repoPath, ".lnk.*")
matches, err := filepath.Glob(pattern)
if err == nil && len(matches) > 0 {
return true
}
}
return false
}
// GetCurrentHostname returns the current system hostname
func GetCurrentHostname() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
return hostname, nil
}
// getRepoPath returns the path to the lnk repository directory
@@ -43,12 +98,22 @@ func getRepoPath() string {
return filepath.Join(xdgConfig, "lnk")
}
// generateRepoName creates a unique repository filename from a relative path
func generateRepoName(relativePath string) string {
// Replace slashes and backslashes with underscores to create valid filename
repoName := strings.ReplaceAll(relativePath, "/", "_")
repoName = strings.ReplaceAll(repoName, "\\", "_")
return repoName
// getHostStoragePath returns the storage path for host-specific or common files
func (l *Lnk) getHostStoragePath() string {
if l.host == "" {
// Common configuration - store in root of repo
return l.repoPath
}
// Host-specific configuration - store in host subdirectory
return filepath.Join(l.repoPath, l.host+".lnk")
}
// getLnkFileName returns the appropriate .lnk tracking file name
func (l *Lnk) getLnkFileName() string {
if l.host == "" {
return ".lnk"
}
return ".lnk." + l.host
}
// getRelativePath converts an absolute path to a relative path from home directory
@@ -82,7 +147,18 @@ func (l *Lnk) Init() error {
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
func (l *Lnk) InitWithRemote(remoteURL string) error {
return l.InitWithRemoteForce(remoteURL, false)
}
// InitWithRemoteForce initializes the lnk repository with optional force override
func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error {
if remoteURL != "" {
// Safety check: prevent data loss by checking for existing managed files
if l.HasUserContent() {
if !force {
return ErrDirectoryContainsManagedFiles(l.repoPath)
}
}
// Clone from remote
return l.Clone(remoteURL)
}
@@ -100,32 +176,22 @@ func (l *Lnk) InitWithRemote(remoteURL string) error {
return nil
} else {
// It's not a lnk repository, error to prevent data loss
return fmt.Errorf("❌ Directory \033[31m%s\033[0m contains an existing Git repository\n 💡 Please backup or move the existing repository before initializing lnk", l.repoPath)
return ErrDirectoryContainsGitRepo(l.repoPath)
}
}
// No existing repository, initialize Git repository
if err := l.git.Init(); err != nil {
return fmt.Errorf("failed to initialize git repository: %w", err)
}
return nil
return l.git.Init()
}
// Clone clones a repository from the given URL
func (l *Lnk) Clone(url string) error {
if err := l.git.Clone(url); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
return l.git.Clone(url)
}
// AddRemote adds a remote to the repository
func (l *Lnk) AddRemote(name, url string) error {
if err := l.git.AddRemote(name, url); err != nil {
return fmt.Errorf("failed to add remote %s: %w", name, err)
}
return nil
return l.git.AddRemote(name, url)
}
// Add moves a file or directory to the repository and creates a symlink
@@ -147,9 +213,15 @@ func (l *Lnk) Add(filePath string) error {
return fmt.Errorf("failed to get relative path: %w", err)
}
// Generate unique repository name from relative path
repoName := generateRepoName(relativePath)
destPath := filepath.Join(l.repoPath, repoName)
// Generate repository path from relative path
storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, relativePath)
// Ensure destination directory exists (including parent directories for host-specific files)
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Check if this relative path is already managed
managedItems, err := l.getManagedItems()
@@ -158,7 +230,7 @@ func (l *Lnk) Add(filePath string) error {
}
for _, item := range managedItems {
if item == relativePath {
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
return ErrFileAlreadyManaged(relativePath)
}
}
@@ -169,82 +241,208 @@ func (l *Lnk) Add(filePath string) error {
}
// Move to repository (handles both files and directories)
if info.IsDir() {
if err := l.fs.MoveDirectory(absPath, destPath); err != nil {
return fmt.Errorf("failed to move directory to repository: %w", err)
}
} else {
if err := l.fs.MoveFile(absPath, destPath); err != nil {
return fmt.Errorf("failed to move file to repository: %w", err)
}
if err := l.fs.Move(absPath, destPath, info); err != nil {
return err
}
// Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the original if symlink creation fails
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to create symlink: %w", err)
_ = l.fs.Move(destPath, absPath, info)
return err
}
// Add to .lnk tracking file using relative path
if err := l.addManagedItem(relativePath); err != nil {
// Try to restore the original state if tracking fails
_ = os.Remove(absPath) // Ignore error in cleanup
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
_ = os.Remove(absPath)
_ = l.fs.Move(destPath, absPath, info)
return fmt.Errorf("failed to update tracking file: %w", err)
}
// Add both the item and .lnk file to git in a single commit
if err := l.git.Add(repoName); err != nil {
// For host-specific files, we need to add the relative path from repo root
gitPath := relativePath
if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
if err := l.git.Add(gitPath); err != nil {
// Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to add item to git: %w", err)
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
_ = l.fs.Move(destPath, absPath, info)
return err
}
// Add .lnk file to the same commit
if err := l.git.Add(".lnk"); err != nil {
if err := l.git.Add(l.getLnkFileName()); err != nil {
// Try to restore the original state if git add fails
_ = os.Remove(absPath) // Ignore error in cleanup
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
}
return fmt.Errorf("failed to add .lnk file to git: %w", err)
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
_ = l.fs.Move(destPath, absPath, info)
return err
}
// Commit both changes together
basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
// Try to restore the original state if commit fails
_ = os.Remove(absPath) // Ignore error in cleanup
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
if info.IsDir() {
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
} else {
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
_ = l.fs.Move(destPath, absPath, info)
return err
}
return nil
}
// AddMultiple adds multiple files or directories to the repository in a single transaction
func (l *Lnk) AddMultiple(paths []string) error {
if len(paths) == 0 {
return nil
}
// Phase 1: Validate all paths first
var relativePaths []string
var absolutePaths []string
var infos []os.FileInfo
for _, filePath := range paths {
// Validate the file or directory
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
return fmt.Errorf("validation failed for %s: %w", filePath, err)
}
// Get absolute path
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err)
}
// Get relative path for tracking
relativePath, err := getRelativePath(absPath)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
}
// Check if this relative path is already managed
managedItems, err := l.getManagedItems()
if err != nil {
return fmt.Errorf("failed to get managed items: %w", err)
}
for _, item := range managedItems {
if item == relativePath {
return ErrFileAlreadyManaged(relativePath)
}
}
// Get file info
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat path %s: %w", filePath, err)
}
relativePaths = append(relativePaths, relativePath)
absolutePaths = append(absolutePaths, absPath)
infos = append(infos, info)
}
// Phase 2: Process all files - move to repository and create symlinks
var rollbackActions []func() error
for i, absPath := range absolutePaths {
relativePath := relativePaths[i]
info := infos[i]
// Generate repository path from relative path
storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, relativePath)
// Ensure destination directory exists
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Move to repository
if err := l.fs.Move(absPath, destPath, info); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to move %s: %w", absPath, err)
}
// Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the file we just moved, then rollback others
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create symlink for %s: %w", absPath, err)
}
// Add to tracking
if err := l.addManagedItem(relativePath); err != nil {
// Restore this file and rollback others
_ = os.Remove(absPath)
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err)
}
// Add rollback action for this file
rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info)
rollbackActions = append(rollbackActions, rollbackAction)
}
// Phase 3: Git operations - add all files and create single commit
for i, relativePath := range relativePaths {
// For host-specific files, we need to add the relative path from repo root
gitPath := relativePath
if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
if err := l.git.Add(gitPath); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err)
}
}
// Add .lnk file to the same commit
if err := l.git.Add(l.getLnkFileName()); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add tracking file to git: %w", err)
}
// Commit all changes together
commitMessage := fmt.Sprintf("lnk: added %d files", len(paths))
if err := l.git.Commit(commitMessage); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to commit changes: %w", err)
}
return nil
}
// createRollbackAction creates a rollback function for a single file operation
func (l *Lnk) createRollbackAction(absPath, destPath, relativePath string, info os.FileInfo) func() error {
return func() error {
_ = os.Remove(absPath)
_ = l.removeManagedItem(relativePath)
return l.fs.Move(destPath, absPath, info)
}
}
// rollbackOperations executes rollback actions in reverse order
func (l *Lnk) rollbackOperations(rollbackActions []func() error) {
for i := len(rollbackActions) - 1; i >= 0; i-- {
_ = rollbackActions[i]()
}
}
// Remove removes a symlink and restores the original file or directory
func (l *Lnk) Remove(filePath string) error {
// Get absolute path
@@ -278,7 +476,7 @@ func (l *Lnk) Remove(filePath string) error {
}
}
if !found {
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", relativePath)
return ErrFileNotManaged(relativePath)
}
// Get the target path in the repository
@@ -292,8 +490,6 @@ func (l *Lnk) Remove(filePath string) error {
target = filepath.Join(filepath.Dir(absPath), target)
}
repoName := filepath.Base(target)
// Check if target is a directory or file
info, err := os.Stat(target)
if err != nil {
@@ -310,31 +506,29 @@ func (l *Lnk) Remove(filePath string) error {
return fmt.Errorf("failed to update tracking file: %w", err)
}
// Remove from Git first (while the item is still in the repository)
if err := l.git.Remove(repoName); err != nil {
return fmt.Errorf("failed to remove from git: %w", err)
// Generate the correct git path for removal
gitPath := relativePath
if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
if err := l.git.Remove(gitPath); err != nil {
return err
}
// Add .lnk file to the same commit
if err := l.git.Add(".lnk"); err != nil {
return fmt.Errorf("failed to add .lnk file to git: %w", err)
if err := l.git.Add(l.getLnkFileName()); err != nil {
return err
}
// Commit both changes together
basename := filepath.Base(relativePath)
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
return err
}
// Move back from repository (handles both files and directories)
if info.IsDir() {
if err := l.fs.MoveDirectory(target, absPath); err != nil {
return fmt.Errorf("failed to restore directory: %w", err)
}
} else {
if err := l.fs.MoveFile(target, absPath); err != nil {
return fmt.Errorf("failed to restore file: %w", err)
}
if err := l.fs.Move(target, absPath, info); err != nil {
return err
}
return nil
@@ -357,12 +551,12 @@ type StatusInfo struct {
func (l *Lnk) Status() (*StatusInfo, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
return nil, ErrRepositoryNotInitialized()
}
gitStatus, err := l.git.GetStatus()
if err != nil {
return nil, fmt.Errorf("failed to get repository status: %w", err)
return nil, err
}
return &StatusInfo{
@@ -377,46 +571,42 @@ func (l *Lnk) Status() (*StatusInfo, error) {
func (l *Lnk) Push(message string) error {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
return ErrRepositoryNotInitialized()
}
// Check if there are any changes
hasChanges, err := l.git.HasChanges()
if err != nil {
return fmt.Errorf("failed to check for changes: %w", err)
return err
}
if hasChanges {
// Stage all changes
if err := l.git.AddAll(); err != nil {
return fmt.Errorf("failed to stage changes: %w", err)
return err
}
// Create a sync commit
if err := l.git.Commit(message); err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
return err
}
}
// Push to remote (this will be a no-op in tests since we don't have real remotes)
// In real usage, this would push to the actual remote repository
if err := l.git.Push(); err != nil {
return fmt.Errorf("failed to push to remote: %w", err)
}
return nil
return l.git.Push()
}
// Pull fetches changes from remote and restores symlinks as needed
func (l *Lnk) Pull() ([]string, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
return nil, ErrRepositoryNotInitialized()
}
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
if err := l.git.Pull(); err != nil {
return nil, fmt.Errorf("failed to pull from remote: %w", err)
return nil, err
}
// Find all managed files in the repository and restore symlinks
@@ -428,6 +618,22 @@ func (l *Lnk) Pull() ([]string, error) {
return restored, nil
}
// List returns the list of files and directories currently managed by lnk
func (l *Lnk) List() ([]string, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return nil, ErrRepositoryNotInitialized()
}
// Get managed items from .lnk file
managedItems, err := l.getManagedItems()
if err != nil {
return nil, fmt.Errorf("failed to get managed items: %w", err)
}
return managedItems, nil
}
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks
func (l *Lnk) RestoreSymlinks() ([]string, error) {
var restored []string
@@ -445,8 +651,8 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
for _, relativePath := range managedItems {
// Generate repository name from relative path
repoName := generateRepoName(relativePath)
repoItem := filepath.Join(l.repoPath, repoName)
storagePath := l.getHostStoragePath()
repoItem := filepath.Join(storagePath, relativePath)
// Check if item exists in repository
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
@@ -476,7 +682,7 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
// Create symlink
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err)
return nil, err
}
restored = append(restored, relativePath)
@@ -524,7 +730,7 @@ func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
// getManagedItems returns the list of managed files and directories from .lnk file
func (l *Lnk) getManagedItems() ([]string, error) {
lnkFile := filepath.Join(l.repoPath, ".lnk")
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
// If .lnk file doesn't exist, return empty list
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
@@ -597,7 +803,7 @@ func (l *Lnk) removeManagedItem(relativePath string) error {
// writeManagedItems writes the list of managed items to .lnk file
func (l *Lnk) writeManagedItems(items []string) error {
lnkFile := filepath.Join(l.repoPath, ".lnk")
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
content := strings.Join(items, "\n")
if len(items) > 0 {
@@ -611,3 +817,373 @@ func (l *Lnk) writeManagedItems(items []string) error {
return nil
}
// FindBootstrapScript searches for a bootstrap script in the repository
func (l *Lnk) FindBootstrapScript() (string, error) {
// Check if repository is initialized
if !l.git.IsGitRepository() {
return "", ErrRepositoryNotInitialized()
}
// Look for bootstrap.sh - simple, opinionated choice
scriptPath := filepath.Join(l.repoPath, "bootstrap.sh")
if _, err := os.Stat(scriptPath); err == nil {
return "bootstrap.sh", nil
}
return "", nil // No bootstrap script found
}
// RunBootstrapScript executes the bootstrap script
func (l *Lnk) RunBootstrapScript(scriptName string) error {
scriptPath := filepath.Join(l.repoPath, scriptName)
// Verify the script exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return ErrBootstrapScriptNotFound(scriptName)
}
// Make sure it's executable
if err := os.Chmod(scriptPath, 0755); err != nil {
return ErrBootstrapScriptNotExecutable(err)
}
// Run with bash (since we only support bootstrap.sh)
cmd := exec.Command("bash", scriptPath)
// Set working directory to the repository
cmd.Dir = l.repoPath
// Connect to stdout/stderr for user to see output
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
// Run the script
if err := cmd.Run(); err != nil {
return ErrBootstrapScriptFailed(err)
}
return nil
}
// walkDirectory walks through a directory and returns all regular files
func (l *Lnk) walkDirectory(dirPath string) ([]string, error) {
var files []string
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip directories - we only want files
if info.IsDir() {
return nil
}
// Handle symlinks: include them as files if they point to regular files
if info.Mode()&os.ModeSymlink != 0 {
// For symlinks, we'll include them but the AddMultiple logic
// will handle validation appropriately
files = append(files, path)
return nil
}
// Include regular files
if info.Mode().IsRegular() {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory %s: %w", dirPath, err)
}
return files, nil
}
// ProgressCallback defines the signature for progress reporting callbacks
type ProgressCallback func(current, total int, currentFile string)
// AddRecursiveWithProgress adds directory contents individually with progress reporting
func (l *Lnk) AddRecursiveWithProgress(paths []string, progress ProgressCallback) error {
var allFiles []string
for _, path := range paths {
// Get absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
// Check if it's a directory
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat %s: %w", path, err)
}
if info.IsDir() {
// Walk directory to get all files
files, err := l.walkDirectory(absPath)
if err != nil {
return fmt.Errorf("failed to walk directory %s: %w", path, err)
}
allFiles = append(allFiles, files...)
} else {
// It's a regular file, add it directly
allFiles = append(allFiles, absPath)
}
}
// Use AddMultiple for batch processing
if len(allFiles) == 0 {
return fmt.Errorf("no files found to add")
}
// Apply progress threshold: only show progress for >10 files
const progressThreshold = 10
if len(allFiles) > progressThreshold && progress != nil {
return l.addMultipleWithProgress(allFiles, progress)
}
// For small operations, use regular AddMultiple without progress
return l.AddMultiple(allFiles)
}
// addMultipleWithProgress adds multiple files with progress reporting
func (l *Lnk) addMultipleWithProgress(paths []string, progress ProgressCallback) error {
if len(paths) == 0 {
return nil
}
// Phase 1: Validate all paths first (same as AddMultiple)
var relativePaths []string
var absolutePaths []string
var infos []os.FileInfo
for _, filePath := range paths {
// Validate the file or directory
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
return fmt.Errorf("validation failed for %s: %w", filePath, err)
}
// Get absolute path
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err)
}
// Get relative path for tracking
relativePath, err := getRelativePath(absPath)
if err != nil {
return fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
}
// Check if this relative path is already managed
managedItems, err := l.getManagedItems()
if err != nil {
return fmt.Errorf("failed to get managed items: %w", err)
}
for _, item := range managedItems {
if item == relativePath {
return ErrFileAlreadyManaged(relativePath)
}
}
// Get file info
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat path %s: %w", filePath, err)
}
relativePaths = append(relativePaths, relativePath)
absolutePaths = append(absolutePaths, absPath)
infos = append(infos, info)
}
// Phase 2: Process all files with progress reporting
var rollbackActions []func() error
total := len(absolutePaths)
for i, absPath := range absolutePaths {
// Report progress
if progress != nil {
progress(i+1, total, filepath.Base(absPath))
}
relativePath := relativePaths[i]
info := infos[i]
// Generate repository path from relative path
storagePath := l.getHostStoragePath()
destPath := filepath.Join(storagePath, relativePath)
// Ensure destination directory exists
destDir := filepath.Dir(destPath)
if err := os.MkdirAll(destDir, 0755); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create destination directory: %w", err)
}
// Move to repository
if err := l.fs.Move(absPath, destPath, info); err != nil {
// Rollback previous operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to move %s: %w", absPath, err)
}
// Create symlink
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
// Try to restore the file we just moved, then rollback others
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to create symlink for %s: %w", absPath, err)
}
// Add to tracking
if err := l.addManagedItem(relativePath); err != nil {
// Restore this file and rollback others
_ = os.Remove(absPath)
_ = l.fs.Move(destPath, absPath, info)
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err)
}
// Add rollback action for this file
rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info)
rollbackActions = append(rollbackActions, rollbackAction)
}
// Phase 3: Git operations - add all files and create single commit
for i, relativePath := range relativePaths {
// For host-specific files, we need to add the relative path from repo root
gitPath := relativePath
if l.host != "" {
gitPath = filepath.Join(l.host+".lnk", relativePath)
}
if err := l.git.Add(gitPath); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err)
}
}
// Add .lnk file to the same commit
if err := l.git.Add(l.getLnkFileName()); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to add tracking file to git: %w", err)
}
// Commit all changes together
commitMessage := fmt.Sprintf("lnk: added %d files recursively", len(paths))
if err := l.git.Commit(commitMessage); err != nil {
// Rollback all operations
l.rollbackOperations(rollbackActions)
return fmt.Errorf("failed to commit changes: %w", err)
}
return nil
}
// AddRecursive adds directory contents individually instead of the directory as a whole
func (l *Lnk) AddRecursive(paths []string) error {
var allFiles []string
for _, path := range paths {
// Get absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
// Check if it's a directory
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat %s: %w", path, err)
}
if info.IsDir() {
// Walk directory to get all files
files, err := l.walkDirectory(absPath)
if err != nil {
return fmt.Errorf("failed to walk directory %s: %w", path, err)
}
allFiles = append(allFiles, files...)
} else {
// It's a regular file, add it directly
allFiles = append(allFiles, absPath)
}
}
// Use AddMultiple for batch processing
if len(allFiles) == 0 {
return fmt.Errorf("no files found to add")
}
return l.AddMultiple(allFiles)
}
// PreviewAdd simulates an add operation and returns files that would be affected
func (l *Lnk) PreviewAdd(paths []string, recursive bool) ([]string, error) {
var allFiles []string
for _, path := range paths {
// Get absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
// Check if it's a directory
info, err := os.Stat(absPath)
if err != nil {
return nil, fmt.Errorf("failed to stat %s: %w", path, err)
}
if info.IsDir() && recursive {
// Walk directory to get all files (same logic as AddRecursive)
files, err := l.walkDirectory(absPath)
if err != nil {
return nil, fmt.Errorf("failed to walk directory %s: %w", path, err)
}
allFiles = append(allFiles, files...)
} else {
// It's a regular file or non-recursive directory, add it directly
allFiles = append(allFiles, absPath)
}
}
// Validate files (same validation as AddMultiple but without making changes)
var validFiles []string
for _, filePath := range allFiles {
// Validate the file or directory
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
return nil, fmt.Errorf("validation failed for %s: %w", filePath, err)
}
// Get relative path for tracking
relativePath, err := getRelativePath(filePath)
if err != nil {
return nil, fmt.Errorf("failed to get relative path for %s: %w", filePath, err)
}
// Check if this relative path is already managed
managedItems, err := l.getManagedItems()
if err != nil {
return nil, fmt.Errorf("failed to get managed items: %w", err)
}
for _, item := range managedItems {
if item == relativePath {
return nil, fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
}
}
validFiles = append(validFiles, filePath)
}
return validFiles, nil
}

File diff suppressed because it is too large Load Diff

130
internal/fs/errors.go Normal file
View File

@@ -0,0 +1,130 @@
package fs
// Structured errors that separate content from presentation
// These will be formatted by the cmd package based on user preferences
// FileNotExistsError represents an error when a file does not exist
type FileNotExistsError struct {
Path string
Err error
}
func (e *FileNotExistsError) Error() string {
return "File or directory not found: " + e.Path
}
func (e *FileNotExistsError) Unwrap() error {
return e.Err
}
// GetPath returns the path for formatting purposes
func (e *FileNotExistsError) GetPath() string {
return e.Path
}
// FileCheckError represents an error when failing to check a file
type FileCheckError struct {
Err error
}
func (e *FileCheckError) Error() string {
return "Unable to access file. Please check file permissions and try again."
}
func (e *FileCheckError) Unwrap() error {
return e.Err
}
// UnsupportedFileTypeError represents an error when a file type is not supported
type UnsupportedFileTypeError struct {
Path string
}
func (e *UnsupportedFileTypeError) Error() string {
return "Cannot manage this type of file: " + e.Path
}
func (e *UnsupportedFileTypeError) GetPath() string {
return e.Path
}
func (e *UnsupportedFileTypeError) GetSuggestion() string {
return "lnk can only manage regular files and directories"
}
func (e *UnsupportedFileTypeError) Unwrap() error {
return nil
}
// NotManagedByLnkError represents an error when a file is not managed by lnk
type NotManagedByLnkError struct {
Path string
}
func (e *NotManagedByLnkError) Error() string {
return "File is not managed by lnk: " + e.Path
}
func (e *NotManagedByLnkError) GetPath() string {
return e.Path
}
func (e *NotManagedByLnkError) GetSuggestion() string {
return "Use 'lnk add' to manage this file first"
}
func (e *NotManagedByLnkError) Unwrap() error {
return nil
}
// SymlinkReadError represents an error when failing to read a symlink
type SymlinkReadError struct {
Err error
}
func (e *SymlinkReadError) Error() string {
return "Unable to read symlink. The file may be corrupted or have invalid permissions."
}
func (e *SymlinkReadError) Unwrap() error {
return e.Err
}
// DirectoryCreationError represents an error when failing to create a directory
type DirectoryCreationError struct {
Operation string
Err error
}
func (e *DirectoryCreationError) Error() string {
return "Failed to create directory. Please check permissions and available disk space."
}
func (e *DirectoryCreationError) Unwrap() error {
return e.Err
}
// RelativePathCalculationError represents an error when failing to calculate relative path
type RelativePathCalculationError struct {
Err error
}
func (e *RelativePathCalculationError) Error() string {
return "Unable to create symlink due to path configuration issues. Please check file locations."
}
func (e *RelativePathCalculationError) Unwrap() error {
return e.Err
}
// ErrorWithPath is an interface for errors that have an associated file path
type ErrorWithPath interface {
error
GetPath() string
}
// ErrorWithSuggestion is an interface for errors that provide helpful suggestions
type ErrorWithSuggestion interface {
error
GetSuggestion() string
}

View File

@@ -1,7 +1,6 @@
package fs
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -17,18 +16,19 @@ func New() *FileSystem {
// ValidateFileForAdd validates that a file or directory can be added to lnk
func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
// Check if file exists
// Check if file exists and get its info
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
return &FileNotExistsError{Path: filePath, Err: err}
}
return fmt.Errorf("❌ Failed to check file: %w", err)
return &FileCheckError{Err: err}
}
// Allow both regular files and directories
if !info.Mode().IsRegular() && !info.IsDir() {
return fmt.Errorf("❌ Only regular files and directories are supported: \033[31m%s\033[0m", filePath)
return &UnsupportedFileTypeError{Path: filePath}
}
return nil
@@ -36,98 +36,79 @@ func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
// Check if file exists
// Check if file exists and is a symlink
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
return &FileNotExistsError{Path: filePath, Err: err}
}
return fmt.Errorf("❌ Failed to check file: %w", err)
return &FileCheckError{Err: err}
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink == 0 {
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m\n 💡 Use \033[1mlnk add\033[0m to manage this file first", filePath)
return &NotManagedByLnkError{Path: filePath}
}
// Check if symlink points to the repository
// Get symlink target and resolve to absolute path
target, err := os.Readlink(filePath)
if err != nil {
return fmt.Errorf("failed to read symlink: %w", err)
return &SymlinkReadError{Err: err}
}
// Convert relative path to absolute if needed
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(filePath), target)
}
// Clean the path to resolve any .. or . components
// Clean paths and check if target is inside the repository
target = filepath.Clean(target)
repoPath = filepath.Clean(repoPath)
// Check if target is inside the repository
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", filePath)
return &NotManagedByLnkError{Path: filePath}
}
return nil
}
// Move moves a file or directory from source to destination based on the file info
func (fs *FileSystem) Move(src, dst string, info os.FileInfo) error {
if info.IsDir() {
return fs.MoveDirectory(src, dst)
}
return fs.MoveFile(src, dst)
}
// MoveFile moves a file from source to destination
func (fs *FileSystem) MoveFile(src, dst string) error {
// Ensure destination directory exists
dstDir := filepath.Dir(dst)
if err := os.MkdirAll(dstDir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return &DirectoryCreationError{Operation: "destination directory", Err: err}
}
// Move the file
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("failed to move file from %s to %s: %w", src, dst, err)
}
return nil
return os.Rename(src, dst)
}
// CreateSymlink creates a relative symlink from target to linkPath
func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
// Calculate relative path from linkPath to target
linkDir := filepath.Dir(linkPath)
relTarget, err := filepath.Rel(linkDir, target)
relTarget, err := filepath.Rel(filepath.Dir(linkPath), target)
if err != nil {
return fmt.Errorf("failed to calculate relative path: %w", err)
return &RelativePathCalculationError{Err: err}
}
// Create the symlink
if err := os.Symlink(relTarget, linkPath); err != nil {
return fmt.Errorf("failed to create symlink: %w", err)
}
return nil
return os.Symlink(relTarget, linkPath)
}
// MoveDirectory moves a directory from source to destination recursively
func (fs *FileSystem) MoveDirectory(src, dst string) error {
// Check if source is a directory
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("failed to stat source: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("source is not a directory: %s", src)
}
// Ensure destination parent directory exists
dstParent := filepath.Dir(dst)
if err := os.MkdirAll(dstParent, 0755); err != nil {
return fmt.Errorf("failed to create destination parent directory: %w", err)
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return &DirectoryCreationError{Operation: "destination parent directory", Err: err}
}
// Use os.Rename which works for directories
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("failed to move directory from %s to %s: %w", src, dst, err)
}
return nil
// Move the directory
return os.Rename(src, dst)
}

249
internal/git/errors.go Normal file
View File

@@ -0,0 +1,249 @@
package git
// Structured errors that separate content from presentation
// These will be formatted by the cmd package based on user preferences
// GitInitError represents an error during git initialization
type GitInitError struct {
Output string
Err error
}
func (e *GitInitError) Error() string {
return "Failed to initialize git repository. Please ensure git is installed and try again."
}
func (e *GitInitError) Unwrap() error {
return e.Err
}
// BranchSetupError represents an error setting up the default branch
type BranchSetupError struct {
Err error
}
func (e *BranchSetupError) Error() string {
return "Failed to set up the default branch. Please check your git installation."
}
func (e *BranchSetupError) Unwrap() error {
return e.Err
}
// RemoteExistsError represents an error when a remote already exists with different URL
type RemoteExistsError struct {
Remote string
ExistingURL string
NewURL string
}
func (e *RemoteExistsError) Error() string {
return "Remote " + e.Remote + " is already configured with a different repository (" + e.ExistingURL + "). Cannot add " + e.NewURL + "."
}
func (e *RemoteExistsError) GetRemote() string {
return e.Remote
}
func (e *RemoteExistsError) GetExistingURL() string {
return e.ExistingURL
}
func (e *RemoteExistsError) GetNewURL() string {
return e.NewURL
}
func (e *RemoteExistsError) Unwrap() error {
return nil
}
// GitCommandError represents a generic git command execution error
type GitCommandError struct {
Command string
Output string
Err error
}
func (e *GitCommandError) Error() string {
// Provide user-friendly messages based on common command types
switch e.Command {
case "add":
return "Failed to stage files for commit. Please check file permissions and try again."
case "commit":
return "Failed to create commit. Please ensure you have staged changes and try again."
case "remote add":
return "Failed to add remote repository. Please check the repository URL and try again."
case "rm":
return "Failed to remove file from git tracking. Please check if the file exists and try again."
case "log":
return "Failed to retrieve commit history."
case "remote":
return "Failed to retrieve remote repository information."
case "clone":
return "Failed to clone repository. Please check the repository URL and your network connection."
default:
return "Git operation failed. Please check your repository state and try again."
}
}
func (e *GitCommandError) GetCommand() string {
return e.Command
}
func (e *GitCommandError) Unwrap() error {
return e.Err
}
// NoRemoteError represents an error when no remote is configured
type NoRemoteError struct{}
func (e *NoRemoteError) Error() string {
return "No remote repository is configured. Please add a remote repository first."
}
func (e *NoRemoteError) Unwrap() error {
return nil
}
// RemoteNotFoundError represents an error when a specific remote is not found
type RemoteNotFoundError struct {
Remote string
Err error
}
func (e *RemoteNotFoundError) Error() string {
return "Remote repository " + e.Remote + " is not configured."
}
func (e *RemoteNotFoundError) GetRemote() string {
return e.Remote
}
func (e *RemoteNotFoundError) Unwrap() error {
return e.Err
}
// GitConfigError represents an error with git configuration
type GitConfigError struct {
Setting string
Err error
}
func (e *GitConfigError) Error() string {
return "Failed to configure git settings. Please check your git installation."
}
func (e *GitConfigError) Unwrap() error {
return e.Err
}
// UncommittedChangesError represents an error checking for uncommitted changes
type UncommittedChangesError struct {
Err error
}
func (e *UncommittedChangesError) Error() string {
return "Failed to check repository status. Please verify your git repository is valid."
}
func (e *UncommittedChangesError) Unwrap() error {
return e.Err
}
// DirectoryRemovalError represents an error removing a directory
type DirectoryRemovalError struct {
Path string
Err error
}
func (e *DirectoryRemovalError) Error() string {
return "Failed to prepare directory for operation. Please check directory permissions."
}
func (e *DirectoryRemovalError) GetPath() string {
return e.Path
}
func (e *DirectoryRemovalError) Unwrap() error {
return e.Err
}
// DirectoryCreationError represents an error creating a directory
type DirectoryCreationError struct {
Path string
Err error
}
func (e *DirectoryCreationError) Error() string {
return "Failed to create directory. Please check permissions and available disk space."
}
func (e *DirectoryCreationError) GetPath() string {
return e.Path
}
func (e *DirectoryCreationError) Unwrap() error {
return e.Err
}
// PushError represents an error during git push operation
type PushError struct {
Reason string
Output string
Err error
}
func (e *PushError) Error() string {
if e.Reason != "" {
return "Cannot push changes: " + e.Reason
}
return "Failed to push changes to remote repository. Please check your network connection and repository permissions."
}
func (e *PushError) GetReason() string {
return e.Reason
}
func (e *PushError) Unwrap() error {
return e.Err
}
// PullError represents an error during git pull operation
type PullError struct {
Reason string
Output string
Err error
}
func (e *PullError) Error() string {
if e.Reason != "" {
return "Cannot pull changes: " + e.Reason
}
return "Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts."
}
func (e *PullError) GetReason() string {
return e.Reason
}
func (e *PullError) Unwrap() error {
return e.Err
}
// ErrorWithPath is an interface for git errors that have an associated file path
type ErrorWithPath interface {
error
GetPath() string
}
// ErrorWithRemote is an interface for git errors that involve a remote
type ErrorWithRemote interface {
error
GetRemote() string
}
// ErrorWithReason is an interface for git errors that have a specific reason
type ErrorWithReason interface {
error
GetReason() string
}

View File

@@ -34,7 +34,7 @@ func (g *Git) Init() error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output))
return &GitInitError{Output: string(output), Err: err}
}
// Set the default branch to main
@@ -42,7 +42,7 @@ func (g *Git) Init() error {
cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set default branch to main: %w", err)
return &BranchSetupError{Err: err}
}
}
@@ -60,7 +60,7 @@ func (g *Git) AddRemote(name, url string) error {
return nil
}
// Different URL, error
return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url)
return &RemoteExistsError{Remote: name, ExistingURL: existingURL, NewURL: url}
}
// Remote doesn't exist, add it
@@ -69,7 +69,7 @@ func (g *Git) AddRemote(name, url string) error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "remote add", Output: string(output), Err: err}
}
return nil
@@ -164,7 +164,7 @@ func (g *Git) Add(filename string) error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "add", Output: string(output), Err: err}
}
return nil
@@ -189,7 +189,7 @@ func (g *Git) Remove(filename string) error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "rm", Output: string(output), Err: err}
}
return nil
@@ -207,7 +207,7 @@ func (g *Git) Commit(message string) error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "commit", Output: string(output), Err: err}
}
return nil
@@ -223,7 +223,7 @@ func (g *Git) ensureGitConfig() error {
cmd = exec.Command("git", "config", "user.name", "Lnk User")
cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set git user.name: %w", err)
return &GitConfigError{Setting: "user.name", Err: err}
}
}
@@ -235,7 +235,7 @@ func (g *Git) ensureGitConfig() error {
cmd = exec.Command("git", "config", "user.email", "lnk@localhost")
cmd.Dir = g.repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to set git user.email: %w", err)
return &GitConfigError{Setting: "user.email", Err: err}
}
}
@@ -260,7 +260,7 @@ func (g *Git) GetCommits() ([]string, error) {
if strings.Contains(outputStr, "does not have any commits yet") {
return []string{}, nil
}
return nil, fmt.Errorf("git log failed: %w", err)
return nil, &GitCommandError{Command: "log", Output: outputStr, Err: err}
}
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
@@ -282,18 +282,18 @@ func (g *Git) GetRemoteInfo() (string, error) {
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to list remotes: %w", err)
return "", &GitCommandError{Command: "remote", Output: string(output), Err: err}
}
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(remotes) == 0 || remotes[0] == "" {
return "", fmt.Errorf("no remote configured")
return "", &NoRemoteError{}
}
// Use the first remote
url, err = g.getRemoteURL(remotes[0])
if err != nil {
return "", fmt.Errorf("failed to get remote URL: %w", err)
return "", &RemoteNotFoundError{Remote: remotes[0], Err: err}
}
}
@@ -319,7 +319,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
// Check for uncommitted changes
dirty, err := g.HasChanges()
if err != nil {
return nil, fmt.Errorf("failed to check for uncommitted changes: %w", err)
return nil, &UncommittedChangesError{Err: err}
}
// Get the remote tracking branch
@@ -410,7 +410,7 @@ func (g *Git) HasChanges() (bool, error) {
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
return false, &GitCommandError{Command: "status", Output: string(output), Err: err}
}
return len(strings.TrimSpace(string(output))) > 0, nil
@@ -423,7 +423,7 @@ func (g *Git) AddAll() error {
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "add", Output: string(output), Err: err}
}
return nil
@@ -434,15 +434,15 @@ func (g *Git) Push() error {
// First ensure we have a remote configured
_, err := g.GetRemoteInfo()
if err != nil {
return fmt.Errorf("cannot push: %w", err)
return &PushError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "push", "-u", "origin", "main")
cmd := exec.Command("git", "push", "-u", "origin")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output))
return &PushError{Output: string(output), Err: err}
}
return nil
@@ -453,15 +453,15 @@ func (g *Git) Pull() error {
// First ensure we have a remote configured
_, err := g.GetRemoteInfo()
if err != nil {
return fmt.Errorf("cannot pull: %w", err)
return &PullError{Reason: err.Error(), Err: err}
}
cmd := exec.Command("git", "pull", "origin", "main")
cmd := exec.Command("git", "pull", "origin")
cmd.Dir = g.repoPath
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output))
return &PullError{Output: string(output), Err: err}
}
return nil
@@ -471,20 +471,20 @@ func (g *Git) Pull() error {
func (g *Git) Clone(url string) error {
// Remove the directory if it exists to ensure clean clone
if err := os.RemoveAll(g.repoPath); err != nil {
return fmt.Errorf("failed to remove existing directory: %w", err)
return &DirectoryRemovalError{Path: g.repoPath, Err: err}
}
// Create parent directory
parentDir := filepath.Dir(g.repoPath)
if err := os.MkdirAll(parentDir, 0755); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
return &DirectoryCreationError{Path: parentDir, Err: err}
}
// Clone the repository
cmd := exec.Command("git", "clone", url, g.repoPath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output))
return &GitCommandError{Command: "clone", Output: string(output), Err: err}
}
// Set up upstream tracking for main branch