mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-24 21:11:27 +02:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
30ab78d506 | ||
|
7f10e1ce8a | ||
|
57839c795e | ||
|
dc524607fa | ||
|
9bf2e70d13 | ||
|
65db5fe738 | ||
|
43b68bc071 | ||
|
ab97fa86dc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,4 +46,3 @@ desktop.ini
|
|||||||
goreleaser/
|
goreleaser/
|
||||||
*.md
|
*.md
|
||||||
!/README.md
|
!/README.md
|
||||||
!/RELEASE.md
|
|
||||||
|
115
README.md
115
README.md
@@ -26,7 +26,6 @@ curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Homebrew (macOS/Linux)
|
# Homebrew (macOS/Linux)
|
||||||
brew tap yarlson/lnk
|
|
||||||
brew install lnk
|
brew install lnk
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,6 +54,9 @@ lnk init -r git@github.com:user/dotfiles.git
|
|||||||
# Skip automatic bootstrap
|
# Skip automatic bootstrap
|
||||||
lnk init -r git@github.com:user/dotfiles.git --no-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
|
# Run bootstrap script manually
|
||||||
lnk bootstrap
|
lnk bootstrap
|
||||||
```
|
```
|
||||||
@@ -104,6 +106,33 @@ After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
|
|||||||
|
|
||||||
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||||
|
|
||||||
|
## Safety Features
|
||||||
|
|
||||||
|
Lnk includes built-in safety checks to prevent accidental data loss:
|
||||||
|
|
||||||
|
### Data Loss Prevention
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This will be blocked if you already have managed files
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git
|
||||||
|
# ❌ Directory ~/.config/lnk already contains managed files
|
||||||
|
# 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r'
|
||||||
|
|
||||||
|
# Use pull instead to safely update
|
||||||
|
lnk pull
|
||||||
|
|
||||||
|
# Or force if you understand the risks (shows warning only when needed)
|
||||||
|
lnk init -r git@github.com:user/dotfiles.git --force
|
||||||
|
# ⚠️ Using --force flag: This will overwrite existing managed files
|
||||||
|
# 💡 Only use this if you understand the risks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smart Warnings
|
||||||
|
|
||||||
|
- **Contextual alerts**: Warnings only appear when there are actually managed files to overwrite
|
||||||
|
- **Clear guidance**: Error messages suggest the correct command to use
|
||||||
|
- **Force override**: Advanced users can bypass safety checks when needed
|
||||||
|
|
||||||
## Bootstrap Support
|
## 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.
|
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.
|
||||||
@@ -277,7 +306,7 @@ lnk pull # Get updates (work config won't affe
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `lnk init [-r remote] [--no-bootstrap]` - Create repo (runs bootstrap automatically)
|
- `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 add [--host HOST] [--recursive] [--dry-run] <files>...` - Move files to repo, create symlinks
|
||||||
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
||||||
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
||||||
@@ -294,6 +323,73 @@ lnk pull # Get updates (work config won't affe
|
|||||||
- `--all` - Show all configurations (common + all hosts) when listing
|
- `--all` - Show all configurations (common + all hosts) when listing
|
||||||
- `-r, --remote URL` - Clone from remote URL when initializing
|
- `-r, --remote URL` - Clone from remote URL when initializing
|
||||||
- `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning
|
- `--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
|
### Add Command Examples
|
||||||
|
|
||||||
@@ -322,17 +418,18 @@ lnk add --host work ~/.gitconfig ~/.ssh/config ~/.npmrc
|
|||||||
- **Bulk operations** (multiple files, atomic transactions)
|
- **Bulk operations** (multiple files, atomic transactions)
|
||||||
- **Recursive processing** (directory contents individually)
|
- **Recursive processing** (directory contents individually)
|
||||||
- **Preview mode** (dry-run for safety)
|
- **Preview mode** (dry-run for safety)
|
||||||
|
- **Data loss prevention** (safety checks with contextual warnings)
|
||||||
- **Git-native** (standard Git repo, no special formats)
|
- **Git-native** (standard Git repo, no special formats)
|
||||||
|
|
||||||
## Alternatives
|
## Alternatives
|
||||||
|
|
||||||
| Tool | Complexity | Why choose it |
|
| Tool | Complexity | Why choose it |
|
||||||
| ------- | ---------- | -------------------------------------------------------------------------- |
|
| ------- | ---------- | ----------------------------------------------------------------------------------------- |
|
||||||
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run |
|
| **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks |
|
||||||
| chezmoi | High | Templates, encryption, cross-platform |
|
| chezmoi | High | Templates, encryption, cross-platform |
|
||||||
| yadm | Medium | Git power user, encryption |
|
| yadm | Medium | Git power user, encryption |
|
||||||
| dotbot | Low | YAML config, basic features |
|
| dotbot | Low | YAML config, basic features |
|
||||||
| stow | Low | Perl, symlink only |
|
| stow | Low | Perl, symlink only |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
190
RELEASE.md
190
RELEASE.md
@@ -1,190 +0,0 @@
|
|||||||
# Release Process
|
|
||||||
|
|
||||||
This document describes how to create releases for the lnk project using GoReleaser.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Push access to the main repository
|
|
||||||
- Git tags pushed to GitHub trigger releases automatically
|
|
||||||
- GoReleaser is configured in `.goreleaser.yml`
|
|
||||||
- GitHub Actions will handle the release process
|
|
||||||
- Access to the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap repository
|
|
||||||
- **Personal Access Token** set up as `HOMEBREW_TAP_TOKEN` secret (see Setup section)
|
|
||||||
|
|
||||||
## Setup (One-time)
|
|
||||||
|
|
||||||
### GitHub Personal Access Token
|
|
||||||
|
|
||||||
For GoReleaser to update the Homebrew formula, you need a Personal Access Token:
|
|
||||||
|
|
||||||
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
|
|
||||||
2. Click "Generate new token" → "Generate new token (classic)"
|
|
||||||
3. Name: "GoReleaser Homebrew Access"
|
|
||||||
4. Scopes: Select `repo` (Full control of private repositories)
|
|
||||||
5. Generate and copy the token
|
|
||||||
6. In your `yarlson/lnk` repository:
|
|
||||||
- Go to Settings → Secrets and variables → Actions
|
|
||||||
- Add new repository secret: `HOMEBREW_TAP_TOKEN`
|
|
||||||
- Paste the token as the value
|
|
||||||
|
|
||||||
This allows GoReleaser to automatically update the Homebrew formula in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk).
|
|
||||||
|
|
||||||
## Creating a Release
|
|
||||||
|
|
||||||
### 1. Ensure everything is ready
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all quality checks
|
|
||||||
make check
|
|
||||||
|
|
||||||
# Test GoReleaser configuration
|
|
||||||
make goreleaser-check
|
|
||||||
|
|
||||||
# Test build process
|
|
||||||
make goreleaser-snapshot
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create and push a version tag
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a new tag (replace x.y.z with actual version)
|
|
||||||
git tag -a v1.0.0 -m "Release v1.0.0"
|
|
||||||
|
|
||||||
# Push the tag to trigger the release
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Monitor the release
|
|
||||||
|
|
||||||
- GitHub Actions will automatically build and release when the tag is pushed
|
|
||||||
- Check the [Actions tab](https://github.com/yarlson/lnk/actions) for build status
|
|
||||||
- The release will appear in [GitHub Releases](https://github.com/yarlson/lnk/releases)
|
|
||||||
- The Homebrew formula will be automatically updated in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk)
|
|
||||||
|
|
||||||
## What GoReleaser Does
|
|
||||||
|
|
||||||
1. **Builds binaries** for multiple platforms:
|
|
||||||
|
|
||||||
- Linux (amd64, arm64)
|
|
||||||
- macOS (amd64, arm64)
|
|
||||||
- Windows (amd64)
|
|
||||||
|
|
||||||
2. **Creates archives** with consistent naming:
|
|
||||||
|
|
||||||
- `lnk_Linux_x86_64.tar.gz`
|
|
||||||
- `lnk_Darwin_arm64.tar.gz`
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
3. **Generates checksums** for verification
|
|
||||||
|
|
||||||
4. **Creates GitHub release** with:
|
|
||||||
|
|
||||||
- Automatic changelog from conventional commits
|
|
||||||
- Installation instructions
|
|
||||||
- Download links for all platforms
|
|
||||||
|
|
||||||
5. **Updates Homebrew formula** automatically in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap
|
|
||||||
|
|
||||||
## Manual Release (if needed)
|
|
||||||
|
|
||||||
If you need to create a release manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Export GitHub token
|
|
||||||
export GITHUB_TOKEN="your_token_here"
|
|
||||||
|
|
||||||
# Create release (requires a git tag)
|
|
||||||
goreleaser release --clean
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Releases Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test the build process without releasing
|
|
||||||
make goreleaser-snapshot
|
|
||||||
|
|
||||||
# Built artifacts will be in dist/
|
|
||||||
ls -la dist/
|
|
||||||
|
|
||||||
# Test a binary
|
|
||||||
./dist/lnk_<platform>/lnk --version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation Methods
|
|
||||||
|
|
||||||
After a release is published, users can install lnk using multiple methods:
|
|
||||||
|
|
||||||
### 1. Shell Script (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Homebrew (macOS/Linux)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap yarlson/lnk
|
|
||||||
brew install lnk
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Manual Download
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download from GitHub releases
|
|
||||||
wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz
|
|
||||||
tar -xzf lnk_Linux_x86_64.tar.gz
|
|
||||||
sudo mv lnk /usr/local/bin/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Version Numbering
|
|
||||||
|
|
||||||
We use [Semantic Versioning](https://semver.org/):
|
|
||||||
|
|
||||||
- `v1.0.0` - Major release (breaking changes)
|
|
||||||
- `v1.1.0` - Minor release (new features, backward compatible)
|
|
||||||
- `v1.1.1` - Patch release (bug fixes)
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
GoReleaser automatically generates changelogs from git commits using conventional commit format:
|
|
||||||
|
|
||||||
- `feat:` - New features
|
|
||||||
- `fix:` - Bug fixes
|
|
||||||
- `docs:` - Documentation changes (excluded from changelog)
|
|
||||||
- `test:` - Test changes (excluded from changelog)
|
|
||||||
- `ci:` - CI changes (excluded from changelog)
|
|
||||||
|
|
||||||
## Homebrew Tap
|
|
||||||
|
|
||||||
The Homebrew formula is automatically maintained in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) repository. When a new release is created:
|
|
||||||
|
|
||||||
1. GoReleaser automatically creates/updates the formula
|
|
||||||
2. The formula is committed to the tap repository
|
|
||||||
3. Users can immediately install the new version via `brew install yarlson/lnk/lnk`
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Release failed to create
|
|
||||||
|
|
||||||
1. Check that the tag follows the format `vX.Y.Z`
|
|
||||||
2. Ensure GitHub Actions has proper permissions
|
|
||||||
3. Check the Actions log for detailed error messages
|
|
||||||
|
|
||||||
### Missing binaries in release
|
|
||||||
|
|
||||||
1. Verify GoReleaser configuration: `make goreleaser-check`
|
|
||||||
2. Test build locally: `make goreleaser-snapshot`
|
|
||||||
3. Check the build matrix in `.goreleaser.yml`
|
|
||||||
|
|
||||||
### Changelog is empty
|
|
||||||
|
|
||||||
1. Ensure commits follow conventional commit format
|
|
||||||
2. Check that there are commits since the last tag
|
|
||||||
3. Verify changelog configuration in `.goreleaser.yml`
|
|
||||||
|
|
||||||
### Homebrew formula not updated
|
|
||||||
|
|
||||||
1. Check that the GITHUB_TOKEN has access to the homebrew-lnk repository
|
|
||||||
2. Verify the repository name and owner in `.goreleaser.yml`
|
|
||||||
3. Check the release workflow logs for Homebrew-related errors
|
|
||||||
4. Ensure the homebrew-lnk repository exists and is accessible
|
|
73
cmd/add.go
73
cmd/add.go
@@ -1,9 +1,11 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ changes to your system - perfect for verification before bulk operations.`,
|
|||||||
recursive, _ := cmd.Flags().GetBool("recursive")
|
recursive, _ := cmd.Flags().GetBool("recursive")
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
lnk := core.NewLnk(core.WithHost(host))
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
|
w := GetWriter(cmd)
|
||||||
|
|
||||||
// Handle dry-run mode
|
// Handle dry-run mode
|
||||||
if dryRun {
|
if dryRun {
|
||||||
@@ -43,19 +46,22 @@ changes to your system - perfect for verification before bulk operations.`,
|
|||||||
|
|
||||||
// Display preview output
|
// Display preview output
|
||||||
if recursive {
|
if recursive {
|
||||||
printf(cmd, "🔍 \033[1mWould add %d files recursively:\033[0m\n", len(files))
|
w.Writeln(Message{Text: fmt.Sprintf("Would add %d files recursively:", len(files)), Emoji: "🔍", Bold: true})
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "🔍 \033[1mWould add %d files:\033[0m\n", len(files))
|
w.Writeln(Message{Text: fmt.Sprintf("Would add %d files:", len(files)), Emoji: "🔍", Bold: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// List files that would be added
|
// List files that would be added
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
basename := filepath.Base(file)
|
basename := filepath.Base(file)
|
||||||
printf(cmd, " 📄 \033[90m%s\033[0m\n", basename)
|
w.WriteString(" ").
|
||||||
|
Writeln(Message{Text: basename, Emoji: "📄"})
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "\n💡 \033[33mTo proceed:\033[0m run without --dry-run flag\n")
|
w.WritelnString("").
|
||||||
return nil
|
Writeln(Info("To proceed: run without --dry-run flag"))
|
||||||
|
|
||||||
|
return w.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle recursive mode
|
// Handle recursive mode
|
||||||
@@ -68,7 +74,7 @@ changes to your system - perfect for verification before bulk operations.`,
|
|||||||
|
|
||||||
// Create progress callback for CLI display
|
// Create progress callback for CLI display
|
||||||
progressCallback := func(current, total int, currentFile string) {
|
progressCallback := func(current, total int, currentFile string) {
|
||||||
printf(cmd, "\r⏳ Processing %d/%d: %s", current, total, currentFile)
|
w.WriteString(fmt.Sprintf("\r⏳ Processing %d/%d: %s", current, total, currentFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil {
|
if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil {
|
||||||
@@ -76,7 +82,7 @@ changes to your system - perfect for verification before bulk operations.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear progress line and show completion
|
// Clear progress line and show completion
|
||||||
printf(cmd, "\r")
|
w.WriteString("\r")
|
||||||
|
|
||||||
// Store processed file count for display
|
// Store processed file count for display
|
||||||
args = previewFiles // Replace args with actual files for display
|
args = previewFiles // Replace args with actual files for display
|
||||||
@@ -99,9 +105,9 @@ changes to your system - perfect for verification before bulk operations.`,
|
|||||||
if recursive {
|
if recursive {
|
||||||
// Recursive mode - show enhanced message with count
|
// Recursive mode - show enhanced message with count
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk (host: %s)\033[0m\n", len(args), host)
|
w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk (host: %s)", len(args), host)))
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "✨ \033[1mAdded %d files recursively to lnk\033[0m\n", len(args))
|
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)
|
// Show some of the files that were added (limit to first few for readability)
|
||||||
@@ -113,47 +119,70 @@ changes to your system - perfect for verification before bulk operations.`,
|
|||||||
for i := 0; i < filesToShow; i++ {
|
for i := 0; i < filesToShow; i++ {
|
||||||
basename := filepath.Base(args[i])
|
basename := filepath.Base(args[i])
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
|
w.WriteString(" ").
|
||||||
|
Write(Link(basename)).
|
||||||
|
WriteString(" → ").
|
||||||
|
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan))
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
|
w.WriteString(" ").
|
||||||
|
Write(Link(basename)).
|
||||||
|
WriteString(" → ").
|
||||||
|
Writeln(Colored("~/.config/lnk/...", ColorCyan))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(args) > 5 {
|
if len(args) > 5 {
|
||||||
printf(cmd, " \033[90m... and %d more files\033[0m\n", len(args)-5)
|
w.WriteString(" ").
|
||||||
|
Writeln(Colored(fmt.Sprintf("... and %d more files", len(args)-5), ColorGray))
|
||||||
}
|
}
|
||||||
} else if len(args) == 1 {
|
} else if len(args) == 1 {
|
||||||
// Single file - maintain existing output format for backward compatibility
|
// Single file - maintain existing output format for backward compatibility
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk (host: %s)", basename, host)))
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath)
|
w.WriteString(" ").
|
||||||
|
Write(Link(filePath)).
|
||||||
|
WriteString(" → ").
|
||||||
|
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, filePath), ColorCyan))
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk", basename)))
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
|
w.WriteString(" ").
|
||||||
|
Write(Link(filePath)).
|
||||||
|
WriteString(" → ").
|
||||||
|
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s", filePath), ColorCyan))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Multiple files - show summary
|
// Multiple files - show summary
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host)
|
w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk (host: %s)", len(args), host)))
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args))
|
w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk", len(args))))
|
||||||
}
|
}
|
||||||
|
|
||||||
// List each added file
|
// List each added file
|
||||||
for _, filePath := range args {
|
for _, filePath := range args {
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host)
|
w.WriteString(" ").
|
||||||
|
Write(Link(basename)).
|
||||||
|
WriteString(" → ").
|
||||||
|
Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan))
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename)
|
w.WriteString(" ").
|
||||||
|
Write(Link(basename)).
|
||||||
|
WriteString(" → ").
|
||||||
|
Writeln(Colored("~/.config/lnk/...", ColorCyan))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
w.WriteString(" ").
|
||||||
return nil
|
Write(Message{Text: "Use ", Emoji: "📝"}).
|
||||||
|
Write(Bold("lnk push")).
|
||||||
|
WritelnString(" to sync to remote")
|
||||||
|
|
||||||
|
return w.Err()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -2,6 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ func newBootstrapCmd() *cobra.Command {
|
|||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
|
w := GetWriter(cmd)
|
||||||
|
|
||||||
scriptPath, err := lnk.FindBootstrapScript()
|
scriptPath, err := lnk.FindBootstrapScript()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -21,25 +23,40 @@ func newBootstrapCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if scriptPath == "" {
|
if scriptPath == "" {
|
||||||
printf(cmd, "💡 \033[33mNo bootstrap script found\033[0m\n")
|
w.Writeln(Info("No bootstrap script found")).
|
||||||
printf(cmd, " 📝 Create a \033[1mbootstrap.sh\033[0m file in your dotfiles repository:\n")
|
WriteString(" ").
|
||||||
printf(cmd, " \033[90m#!/bin/bash\033[0m\n")
|
Write(Message{Text: "Create a ", Emoji: "📝"}).
|
||||||
printf(cmd, " \033[90mecho \"Setting up environment...\"\033[0m\n")
|
Write(Bold("bootstrap.sh")).
|
||||||
printf(cmd, " \033[90m# Your setup commands here\033[0m\n")
|
WritelnString(" file in your dotfiles repository:").
|
||||||
return nil
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n")
|
w.Writeln(Rocket("Running bootstrap script")).
|
||||||
printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath)
|
WriteString(" ").
|
||||||
printf(cmd, "\n")
|
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 {
|
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
w.WritelnString("").
|
||||||
printf(cmd, " 🎉 Your environment is ready to use\n")
|
Writeln(Success("Bootstrap completed successfully!")).
|
||||||
return nil
|
WriteString(" ").
|
||||||
|
Writeln(Message{Text: "Your environment is ready to use", Emoji: "🎉"})
|
||||||
|
|
||||||
|
return w.Err()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
111
cmd/init.go
111
cmd/init.go
@@ -2,6 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,20 +16,47 @@ func newInitCmd() *cobra.Command {
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
remote, _ := cmd.Flags().GetString("remote")
|
remote, _ := cmd.Flags().GetString("remote")
|
||||||
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
|
noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap")
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
if err := lnk.InitWithRemote(remote); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if remote != "" {
|
if remote != "" {
|
||||||
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
w.Writeln(Target("Initialized lnk repository")).
|
||||||
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
WriteString(" ").
|
||||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
Write(Message{Text: "Cloned from: ", Emoji: "📦"}).
|
||||||
|
Writeln(Colored(remote, ColorCyan)).
|
||||||
|
WriteString(" ").
|
||||||
|
Write(Message{Text: "Location: ", Emoji: "📁"}).
|
||||||
|
Writeln(Colored("~/.config/lnk", ColorGray))
|
||||||
|
|
||||||
|
if err := w.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Try to run bootstrap script if not disabled
|
// Try to run bootstrap script if not disabled
|
||||||
if !noBootstrap {
|
if !noBootstrap {
|
||||||
printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n")
|
w.WritelnString("").
|
||||||
|
Writeln(Message{Text: "Looking for bootstrap script...", Emoji: "🔍", Bold: true})
|
||||||
|
|
||||||
|
if err := w.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
scriptPath, err := lnk.FindBootstrapScript()
|
scriptPath, err := lnk.FindBootstrapScript()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -36,38 +64,73 @@ func newInitCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if scriptPath != "" {
|
if scriptPath != "" {
|
||||||
printf(cmd, " ✅ Found bootstrap script: \033[36m%s\033[0m\n", scriptPath)
|
w.WriteString(" ").
|
||||||
printf(cmd, "\n🚀 \033[1mRunning bootstrap script...\033[0m\n")
|
Write(Success("Found bootstrap script: ")).
|
||||||
printf(cmd, "\n")
|
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 {
|
if err := lnk.RunBootstrapScript(scriptPath); err != nil {
|
||||||
printf(cmd, "\n⚠️ \033[33mBootstrap script failed, but repository was initialized successfully\033[0m\n")
|
w.WritelnString("").
|
||||||
printf(cmd, " 💡 You can run it manually with: \033[1mlnk bootstrap\033[0m\n")
|
Writeln(Warning("Bootstrap script failed, but repository was initialized successfully")).
|
||||||
printf(cmd, " 🔧 Error: %v\n", err)
|
WriteString(" ").
|
||||||
|
Write(Info("You can run it manually with: ")).
|
||||||
|
Writeln(Bold("lnk bootstrap")).
|
||||||
|
WriteString(" ").
|
||||||
|
Write(Message{Text: "Error: ", Emoji: "🔧"}).
|
||||||
|
Writeln(Plain(err.Error()))
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n")
|
w.WritelnString("").
|
||||||
|
Writeln(Success("Bootstrap completed successfully!"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Err(); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, " 💡 No bootstrap script found\n")
|
w.WriteString(" ").
|
||||||
|
Writeln(Info("No bootstrap script found"))
|
||||||
|
if err := w.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
w.WritelnString("").
|
||||||
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
Writeln(Info("Next steps:")).
|
||||||
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
WriteString(" • Run ").
|
||||||
} else {
|
Write(Bold("lnk pull")).
|
||||||
printf(cmd, "🎯 \033[1mInitialized empty lnk repository\033[0m\n")
|
Writeln(Plain(" to restore symlinks")).
|
||||||
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
WriteString(" • Use ").
|
||||||
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
Write(Bold("lnk add <file>")).
|
||||||
printf(cmd, " • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
|
Writeln(Plain(" to manage new files"))
|
||||||
printf(cmd, " • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
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().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("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning")
|
||||||
|
cmd.Flags().Bool("force", false, "Force initialization even if directory contains managed files (WARNING: This will overwrite existing content)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
109
cmd/list.go
109
cmd/list.go
@@ -1,11 +1,13 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,61 +44,88 @@ func newListCmd() *cobra.Command {
|
|||||||
|
|
||||||
func listCommonConfig(cmd *cobra.Command) error {
|
func listCommonConfig(cmd *cobra.Command) error {
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
|
w := GetWriter(cmd)
|
||||||
|
|
||||||
managedItems, err := lnk.List()
|
managedItems, err := lnk.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(managedItems) == 0 {
|
if len(managedItems) == 0 {
|
||||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n")
|
w.Writeln(Message{Text: "No files currently managed by lnk (common)", Emoji: "📋", Bold: true}).
|
||||||
printf(cmd, " 💡 Use \033[1mlnk add <file>\033[0m to start managing files\n")
|
WriteString(" ").
|
||||||
return nil
|
Write(Info("Use ")).
|
||||||
|
Write(Bold("lnk add <file>")).
|
||||||
|
WritelnString(" to start managing files")
|
||||||
|
return w.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
|
countText := fmt.Sprintf("Files managed by lnk (common) (%d item", len(managedItems))
|
||||||
if len(managedItems) > 1 {
|
if len(managedItems) > 1 {
|
||||||
printf(cmd, "s")
|
countText += "s"
|
||||||
}
|
}
|
||||||
printf(cmd, "\033[0m):\n\n")
|
countText += "):"
|
||||||
|
|
||||||
|
w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}).
|
||||||
|
WritelnString("")
|
||||||
|
|
||||||
for _, item := range managedItems {
|
for _, item := range managedItems {
|
||||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
w.WriteString(" ").
|
||||||
|
Writeln(Link(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
w.WritelnString("").
|
||||||
return nil
|
Write(Info("Use ")).
|
||||||
|
Write(Bold("lnk status")).
|
||||||
|
WritelnString(" to check sync status")
|
||||||
|
return w.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func listHostConfig(cmd *cobra.Command, host string) error {
|
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||||
lnk := core.NewLnk(core.WithHost(host))
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
|
w := GetWriter(cmd)
|
||||||
|
|
||||||
managedItems, err := lnk.List()
|
managedItems, err := lnk.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(managedItems) == 0 {
|
if len(managedItems) == 0 {
|
||||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host)
|
w.Writeln(Message{Text: fmt.Sprintf("No files currently managed by lnk (host: %s)", host), Emoji: "📋", Bold: true}).
|
||||||
printf(cmd, " 💡 Use \033[1mlnk add --host %s <file>\033[0m to start managing files\n", host)
|
WriteString(" ").
|
||||||
return nil
|
Write(Info("Use ")).
|
||||||
|
Write(Bold(fmt.Sprintf("lnk add --host %s <file>", host))).
|
||||||
|
WritelnString(" to start managing files")
|
||||||
|
return w.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
|
countText := fmt.Sprintf("Files managed by lnk (host: %s) (%d item", host, len(managedItems))
|
||||||
if len(managedItems) > 1 {
|
if len(managedItems) > 1 {
|
||||||
printf(cmd, "s")
|
countText += "s"
|
||||||
}
|
}
|
||||||
printf(cmd, "\033[0m):\n\n")
|
countText += "):"
|
||||||
|
|
||||||
|
w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}).
|
||||||
|
WritelnString("")
|
||||||
|
|
||||||
for _, item := range managedItems {
|
for _, item := range managedItems {
|
||||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
w.WriteString(" ").
|
||||||
|
Writeln(Link(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
w.WritelnString("").
|
||||||
return nil
|
Write(Info("Use ")).
|
||||||
|
Write(Bold("lnk status")).
|
||||||
|
WritelnString(" to check sync status")
|
||||||
|
return w.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func listAllConfigs(cmd *cobra.Command) error {
|
func listAllConfigs(cmd *cobra.Command) error {
|
||||||
|
w := GetWriter(cmd)
|
||||||
|
|
||||||
// List common configuration
|
// List common configuration
|
||||||
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
w.Writeln(Message{Text: "All configurations managed by lnk", Emoji: "📋", Bold: true}).
|
||||||
|
WritelnString("")
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
commonItems, err := lnk.List()
|
commonItems, err := lnk.List()
|
||||||
@@ -104,17 +133,21 @@ func listAllConfigs(cmd *cobra.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
|
countText := fmt.Sprintf("Common configuration (%d item", len(commonItems))
|
||||||
if len(commonItems) > 1 {
|
if len(commonItems) > 1 {
|
||||||
printf(cmd, "s")
|
countText += "s"
|
||||||
}
|
}
|
||||||
printf(cmd, "\033[0m):\n")
|
countText += "):"
|
||||||
|
|
||||||
|
w.Writeln(Message{Text: countText, Emoji: "🌐", Bold: true})
|
||||||
|
|
||||||
if len(commonItems) == 0 {
|
if len(commonItems) == 0 {
|
||||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
w.WriteString(" ").
|
||||||
|
Writeln(Colored("(no files)", ColorGray))
|
||||||
} else {
|
} else {
|
||||||
for _, item := range commonItems {
|
for _, item := range commonItems {
|
||||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
w.WriteString(" ").
|
||||||
|
Writeln(Link(item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,32 +158,42 @@ func listAllConfigs(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, host := range hosts {
|
for _, host := range hosts {
|
||||||
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
w.WritelnString("").
|
||||||
|
Write(Message{Text: fmt.Sprintf("Host: %s", host), Emoji: "🖥️", Bold: true})
|
||||||
|
|
||||||
hostLnk := core.NewLnk(core.WithHost(host))
|
hostLnk := core.NewLnk(core.WithHost(host))
|
||||||
hostItems, err := hostLnk.List()
|
hostItems, err := hostLnk.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
w.WriteString(" ").
|
||||||
|
Writeln(Colored(fmt.Sprintf("(error: %v)", err), ColorRed))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, " (\033[36m%d item", len(hostItems))
|
countText := fmt.Sprintf(" (%d item", len(hostItems))
|
||||||
if len(hostItems) > 1 {
|
if len(hostItems) > 1 {
|
||||||
printf(cmd, "s")
|
countText += "s"
|
||||||
}
|
}
|
||||||
printf(cmd, "\033[0m):\n")
|
countText += "):"
|
||||||
|
|
||||||
|
w.WriteString(countText).
|
||||||
|
WritelnString("")
|
||||||
|
|
||||||
if len(hostItems) == 0 {
|
if len(hostItems) == 0 {
|
||||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
w.WriteString(" ").
|
||||||
|
Writeln(Colored("(no files)", ColorGray))
|
||||||
} else {
|
} else {
|
||||||
for _, item := range hostItems {
|
for _, item := range hostItems {
|
||||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
w.WriteString(" ").
|
||||||
|
Writeln(Link(item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
|
w.WritelnString("").
|
||||||
return nil
|
Write(Info("Use ")).
|
||||||
|
Write(Bold("lnk list --host <hostname>")).
|
||||||
|
WritelnString(" to see specific host configuration")
|
||||||
|
return w.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func findHostConfigs() ([]string, error) {
|
func findHostConfigs() ([]string, error) {
|
||||||
|
230
cmd/output.go
Normal file
230
cmd/output.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OutputConfig controls formatting behavior
|
||||||
|
type OutputConfig struct {
|
||||||
|
Colors bool
|
||||||
|
Emoji bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer provides formatted output with configurable styling
|
||||||
|
type Writer struct {
|
||||||
|
out io.Writer
|
||||||
|
config OutputConfig
|
||||||
|
err error // first error encountered
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWriter creates a new Writer with the given configuration
|
||||||
|
func NewWriter(out io.Writer, config OutputConfig) *Writer {
|
||||||
|
return &Writer{
|
||||||
|
out: out,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message represents a structured message with optional formatting
|
||||||
|
type Message struct {
|
||||||
|
Text string
|
||||||
|
Color string
|
||||||
|
Emoji string
|
||||||
|
Bold bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write outputs a message according to the writer's configuration
|
||||||
|
func (w *Writer) Write(msg Message) *Writer {
|
||||||
|
if w.err != nil {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
var output string
|
||||||
|
|
||||||
|
// Add emoji if enabled
|
||||||
|
if w.config.Emoji && msg.Emoji != "" {
|
||||||
|
output = msg.Emoji + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add color/bold if enabled
|
||||||
|
if w.config.Colors {
|
||||||
|
if msg.Bold {
|
||||||
|
output += "\033[1m"
|
||||||
|
}
|
||||||
|
if msg.Color != "" {
|
||||||
|
output += msg.Color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output += msg.Text
|
||||||
|
|
||||||
|
// Close formatting if enabled
|
||||||
|
if w.config.Colors && (msg.Bold || msg.Color != "") {
|
||||||
|
output += "\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, w.err = fmt.Fprint(w.out, output)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printf is like Write but with format string
|
||||||
|
func (w *Writer) Printf(msg Message, args ...any) *Writer {
|
||||||
|
newMsg := msg
|
||||||
|
newMsg.Text = fmt.Sprintf(msg.Text, args...)
|
||||||
|
return w.Write(newMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writeln writes a message followed by a newline
|
||||||
|
func (w *Writer) Writeln(msg Message) *Writer {
|
||||||
|
return w.Write(msg).WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteString outputs plain text (no formatting)
|
||||||
|
func (w *Writer) WriteString(text string) *Writer {
|
||||||
|
if w.err != nil {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
_, w.err = fmt.Fprint(w.out, text)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritelnString outputs plain text followed by a newline
|
||||||
|
func (w *Writer) WritelnString(text string) *Writer {
|
||||||
|
if w.err != nil {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
_, w.err = fmt.Fprintln(w.out, text)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANSI color codes
|
||||||
|
const (
|
||||||
|
ColorRed = "\033[31m"
|
||||||
|
ColorYellow = "\033[33m"
|
||||||
|
ColorCyan = "\033[36m"
|
||||||
|
ColorGray = "\033[90m"
|
||||||
|
ColorBrightGreen = "\033[1;32m"
|
||||||
|
ColorBrightYellow = "\033[1;33m"
|
||||||
|
ColorBrightRed = "\033[1;31m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Predefined message constructors for common patterns
|
||||||
|
|
||||||
|
func Success(text string) Message {
|
||||||
|
return Message{Text: text, Color: ColorBrightGreen, Emoji: "✅", Bold: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Error(text string) Message {
|
||||||
|
return Message{Text: text, Emoji: "❌"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Warning(text string) Message {
|
||||||
|
return Message{Text: text, Color: ColorBrightYellow, Emoji: "⚠️", Bold: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Info(text string) Message {
|
||||||
|
return Message{Text: text, Color: ColorYellow, Emoji: "💡"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Target(text string) Message {
|
||||||
|
return Message{Text: text, Emoji: "🎯", Bold: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Rocket(text string) Message {
|
||||||
|
return Message{Text: text, Emoji: "🚀", Bold: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Sparkles(text string) Message {
|
||||||
|
return Message{Text: text, Emoji: "✨", Bold: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Link(text string) Message {
|
||||||
|
return Message{Text: text, Color: ColorCyan, Emoji: "🔗"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Plain(text string) Message {
|
||||||
|
return Message{Text: text}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Bold(text string) Message {
|
||||||
|
return Message{Text: text, Bold: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Colored(text, color string) Message {
|
||||||
|
return Message{Text: text, Color: color}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global output configuration
|
||||||
|
var (
|
||||||
|
globalConfig = OutputConfig{
|
||||||
|
Colors: true, // auto-detect on first use
|
||||||
|
Emoji: true,
|
||||||
|
}
|
||||||
|
autoDetected bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetGlobalConfig updates the global output configuration
|
||||||
|
func SetGlobalConfig(colors string, emoji bool) error {
|
||||||
|
switch colors {
|
||||||
|
case "auto":
|
||||||
|
globalConfig.Colors = isTerminal()
|
||||||
|
case "always":
|
||||||
|
globalConfig.Colors = true
|
||||||
|
case "never":
|
||||||
|
globalConfig.Colors = false
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid color mode: %s (valid: auto, always, never)", colors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check NO_COLOR environment variable (explicit flag takes precedence)
|
||||||
|
if os.Getenv("NO_COLOR") != "" && colors == "auto" {
|
||||||
|
globalConfig.Colors = false
|
||||||
|
}
|
||||||
|
|
||||||
|
globalConfig.Emoji = emoji
|
||||||
|
autoDetected = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTerminal checks if stdout is a terminal
|
||||||
|
func isTerminal() bool {
|
||||||
|
fileInfo, err := os.Stdout.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (fileInfo.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoDetectConfig performs one-time auto-detection if not explicitly configured
|
||||||
|
func autoDetectConfig() {
|
||||||
|
if !autoDetected {
|
||||||
|
if os.Getenv("NO_COLOR") != "" {
|
||||||
|
globalConfig.Colors = false
|
||||||
|
} else {
|
||||||
|
globalConfig.Colors = isTerminal()
|
||||||
|
}
|
||||||
|
autoDetected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWriter returns a writer for the given cobra command
|
||||||
|
func GetWriter(cmd *cobra.Command) *Writer {
|
||||||
|
autoDetectConfig()
|
||||||
|
return NewWriter(cmd.OutOrStdout(), globalConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetErrorWriter returns a writer for stderr
|
||||||
|
func GetErrorWriter() *Writer {
|
||||||
|
autoDetectConfig()
|
||||||
|
return NewWriter(os.Stderr, globalConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err returns the first error encountered during writing
|
||||||
|
func (w *Writer) Err() error {
|
||||||
|
return w.err
|
||||||
|
}
|
271
cmd/output_test.go
Normal file
271
cmd/output_test.go
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOutputConfig(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
colors string
|
||||||
|
emoji bool
|
||||||
|
expectError bool
|
||||||
|
expectedColors bool
|
||||||
|
expectedEmoji bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "auto mode",
|
||||||
|
colors: "auto",
|
||||||
|
emoji: true,
|
||||||
|
expectError: false,
|
||||||
|
expectedColors: false, // TTY detection will return false in tests
|
||||||
|
expectedEmoji: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "always mode",
|
||||||
|
colors: "always",
|
||||||
|
emoji: false,
|
||||||
|
expectError: false,
|
||||||
|
expectedColors: true,
|
||||||
|
expectedEmoji: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "never mode",
|
||||||
|
colors: "never",
|
||||||
|
emoji: true,
|
||||||
|
expectError: false,
|
||||||
|
expectedColors: false,
|
||||||
|
expectedEmoji: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid mode",
|
||||||
|
colors: "invalid",
|
||||||
|
emoji: true,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Clear NO_COLOR for consistent testing
|
||||||
|
_ = os.Unsetenv("NO_COLOR")
|
||||||
|
|
||||||
|
err := SetGlobalConfig(tt.colors, tt.emoji)
|
||||||
|
|
||||||
|
if tt.expectError && err == nil {
|
||||||
|
t.Errorf("expected error but got none")
|
||||||
|
}
|
||||||
|
if !tt.expectError && err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.expectError {
|
||||||
|
if globalConfig.Colors != tt.expectedColors {
|
||||||
|
t.Errorf("expected colors %v, got %v", tt.expectedColors, globalConfig.Colors)
|
||||||
|
}
|
||||||
|
if globalConfig.Emoji != tt.expectedEmoji {
|
||||||
|
t.Errorf("expected emoji %v, got %v", tt.expectedEmoji, globalConfig.Emoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNOCOLOREnvironmentVariable(t *testing.T) {
|
||||||
|
// Test NO_COLOR environment variable with auto mode
|
||||||
|
_ = os.Setenv("NO_COLOR", "1")
|
||||||
|
defer func() { _ = os.Unsetenv("NO_COLOR") }()
|
||||||
|
|
||||||
|
err := SetGlobalConfig("auto", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if globalConfig.Colors != false {
|
||||||
|
t.Errorf("expected colors disabled when NO_COLOR is set, got %v", globalConfig.Colors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterOutput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config OutputConfig
|
||||||
|
message Message
|
||||||
|
expectedOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "full formatting",
|
||||||
|
config: OutputConfig{Colors: true, Emoji: true},
|
||||||
|
message: Message{
|
||||||
|
Text: "test message",
|
||||||
|
Color: ColorRed,
|
||||||
|
Emoji: "✅",
|
||||||
|
Bold: true,
|
||||||
|
},
|
||||||
|
expectedOutput: "✅ \033[1m\033[31mtest message\033[0m",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "colors only",
|
||||||
|
config: OutputConfig{Colors: true, Emoji: false},
|
||||||
|
message: Message{
|
||||||
|
Text: "test message",
|
||||||
|
Color: ColorRed,
|
||||||
|
Emoji: "✅",
|
||||||
|
Bold: true,
|
||||||
|
},
|
||||||
|
expectedOutput: "\033[1m\033[31mtest message\033[0m",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "emoji only",
|
||||||
|
config: OutputConfig{Colors: false, Emoji: true},
|
||||||
|
message: Message{
|
||||||
|
Text: "test message",
|
||||||
|
Color: ColorRed,
|
||||||
|
Emoji: "✅",
|
||||||
|
Bold: true,
|
||||||
|
},
|
||||||
|
expectedOutput: "✅ test message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no formatting",
|
||||||
|
config: OutputConfig{Colors: false, Emoji: false},
|
||||||
|
message: Message{
|
||||||
|
Text: "test message",
|
||||||
|
Color: ColorRed,
|
||||||
|
Emoji: "✅",
|
||||||
|
Bold: true,
|
||||||
|
},
|
||||||
|
expectedOutput: "test message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain message",
|
||||||
|
config: OutputConfig{Colors: true, Emoji: true},
|
||||||
|
message: Plain("plain text"),
|
||||||
|
expectedOutput: "plain text",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := NewWriter(&buf, tt.config)
|
||||||
|
|
||||||
|
writer.Write(tt.message)
|
||||||
|
if err := writer.Err(); err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.String() != tt.expectedOutput {
|
||||||
|
t.Errorf("expected %q, got %q", tt.expectedOutput, buf.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPredefinedMessages(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
creator func(string) Message
|
||||||
|
text string
|
||||||
|
}{
|
||||||
|
{"Success", Success, "operation succeeded"},
|
||||||
|
{"Error", Error, "something failed"},
|
||||||
|
{"Warning", Warning, "be careful"},
|
||||||
|
{"Info", Info, "useful information"},
|
||||||
|
{"Target", Target, "target reached"},
|
||||||
|
{"Rocket", Rocket, "launching"},
|
||||||
|
{"Sparkles", Sparkles, "amazing"},
|
||||||
|
{"Link", Link, "connected"},
|
||||||
|
{"Plain", Plain, "no formatting"},
|
||||||
|
{"Bold", Bold, "emphasis"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := NewWriter(&buf, OutputConfig{Colors: true, Emoji: true})
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
buf.Reset()
|
||||||
|
msg := tt.creator(tt.text)
|
||||||
|
|
||||||
|
writer.Write(msg)
|
||||||
|
if err := writer.Err(); err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if !strings.Contains(output, tt.text) {
|
||||||
|
t.Errorf("output should contain text %q, got %q", tt.text, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStructuredErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err *core.LnkError
|
||||||
|
config OutputConfig
|
||||||
|
contains []string
|
||||||
|
notContains []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "structured error with full formatting",
|
||||||
|
err: &core.LnkError{
|
||||||
|
Message: "Something went wrong",
|
||||||
|
Suggestion: "Try this instead",
|
||||||
|
Path: "/some/path",
|
||||||
|
ErrorType: "test_error",
|
||||||
|
},
|
||||||
|
config: OutputConfig{Colors: true, Emoji: true},
|
||||||
|
contains: []string{"❌", "Something went wrong", "/some/path", "💡", "Try this instead"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "structured error without emojis",
|
||||||
|
err: &core.LnkError{
|
||||||
|
Message: "Something went wrong",
|
||||||
|
Suggestion: "Try this instead",
|
||||||
|
Path: "/some/path",
|
||||||
|
ErrorType: "test_error",
|
||||||
|
},
|
||||||
|
config: OutputConfig{Colors: true, Emoji: false},
|
||||||
|
contains: []string{"Something went wrong", "/some/path", "Try this instead"},
|
||||||
|
notContains: []string{"❌", "💡"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := NewWriter(&buf, tt.config)
|
||||||
|
|
||||||
|
// Test the component messages directly
|
||||||
|
_ = w.Write(Error(tt.err.Message))
|
||||||
|
if tt.err.Path != "" {
|
||||||
|
_ = w.WriteString("\n ")
|
||||||
|
_ = w.Write(Colored(tt.err.Path, ColorRed))
|
||||||
|
}
|
||||||
|
if tt.err.Suggestion != "" {
|
||||||
|
_ = w.WriteString("\n ")
|
||||||
|
_ = w.Write(Info(tt.err.Suggestion))
|
||||||
|
}
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
for _, expected := range tt.contains {
|
||||||
|
if !strings.Contains(output, expected) {
|
||||||
|
t.Errorf("output should contain %q, got %q", expected, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notExpected := range tt.notContains {
|
||||||
|
if strings.Contains(output, notExpected) {
|
||||||
|
t.Errorf("output should not contain %q, got %q", notExpected, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
45
cmd/pull.go
45
cmd/pull.go
@@ -1,7 +1,10 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,8 +17,8 @@ func newPullCmd() *cobra.Command {
|
|||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
host, _ := cmd.Flags().GetString("host")
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
lnk := core.NewLnk(core.WithHost(host))
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
|
w := GetWriter(cmd)
|
||||||
|
|
||||||
restored, err := lnk.Pull()
|
restored, err := lnk.Pull()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -23,31 +26,47 @@ func newPullCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(restored) > 0 {
|
if len(restored) > 0 {
|
||||||
|
var successMsg string
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host)
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
successMsg = "Successfully pulled changes"
|
||||||
}
|
}
|
||||||
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
|
||||||
|
symlinkText := fmt.Sprintf("Restored %d symlink", len(restored))
|
||||||
if len(restored) > 1 {
|
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 {
|
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 {
|
} else {
|
||||||
|
var successMsg string
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host)
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
successMsg = "Successfully pulled changes"
|
||||||
}
|
}
|
||||||
printf(cmd, " ✅ All symlinks already in place\n")
|
|
||||||
printf(cmd, " 🎉 Everything is up to date!\n")
|
w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}).
|
||||||
|
WriteString(" ").
|
||||||
|
Writeln(Success("All symlinks already in place")).
|
||||||
|
WriteString(" ").
|
||||||
|
Writeln(Message{Text: "Everything is up to date!", Emoji: "🎉"})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return w.Err()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
18
cmd/push.go
18
cmd/push.go
@@ -2,6 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,15 +21,22 @@ func newPushCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
|
w := GetWriter(cmd)
|
||||||
|
|
||||||
if err := lnk.Push(message); err != nil {
|
if err := lnk.Push(message); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
w.Writeln(Rocket("Successfully pushed changes")).
|
||||||
printf(cmd, " 💾 Commit: \033[90m%s\033[0m\n", message)
|
WriteString(" ").
|
||||||
printf(cmd, " 📡 Synced to remote\n")
|
Write(Message{Text: "Commit: ", Emoji: "💾"}).
|
||||||
printf(cmd, " ✨ Your dotfiles are up to date!\n")
|
Writeln(Colored(message, ColorGray)).
|
||||||
return nil
|
WriteString(" ").
|
||||||
|
Writeln(Message{Text: "Synced to remote", Emoji: "📡"}).
|
||||||
|
WriteString(" ").
|
||||||
|
Writeln(Sparkles("Your dotfiles are up to date!"))
|
||||||
|
|
||||||
|
return w.Err()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
25
cmd/rm.go
25
cmd/rm.go
@@ -1,9 +1,11 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,8 +20,8 @@ func newRemoveCmd() *cobra.Command {
|
|||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
host, _ := cmd.Flags().GetString("host")
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
lnk := core.NewLnk(core.WithHost(host))
|
lnk := core.NewLnk(core.WithHost(host))
|
||||||
|
w := GetWriter(cmd)
|
||||||
|
|
||||||
if err := lnk.Remove(filePath); err != nil {
|
if err := lnk.Remove(filePath); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -27,14 +29,23 @@ func newRemoveCmd() *cobra.Command {
|
|||||||
|
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
if host != "" {
|
if host != "" {
|
||||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host)
|
w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk (host: %s)", basename, host), Emoji: "🗑️", Bold: true}).
|
||||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath)
|
WriteString(" ").
|
||||||
|
Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, basename), Emoji: "↩️"}).
|
||||||
|
WriteString(" → ").
|
||||||
|
Writeln(Colored(filePath, ColorCyan))
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk", basename), Emoji: "🗑️", Bold: true}).
|
||||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
|
WriteString(" ").
|
||||||
|
Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s", basename), Emoji: "↩️"}).
|
||||||
|
WriteString(" → ").
|
||||||
|
Writeln(Colored(filePath, ColorCyan))
|
||||||
}
|
}
|
||||||
printf(cmd, " 📄 Original file restored\n")
|
|
||||||
return nil
|
w.WriteString(" ").
|
||||||
|
Writeln(Message{Text: "Original file restored", Emoji: "📄"})
|
||||||
|
|
||||||
|
return w.Err()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
125
cmd/root.go
125
cmd/root.go
@@ -1,10 +1,15 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
|
"github.com/yarlson/lnk/internal/fs"
|
||||||
|
"github.com/yarlson/lnk/internal/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -14,6 +19,12 @@ var (
|
|||||||
|
|
||||||
// NewRootCommand creates a new root command (testable)
|
// NewRootCommand creates a new root command (testable)
|
||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
|
var (
|
||||||
|
colors string
|
||||||
|
emoji bool
|
||||||
|
noEmoji bool
|
||||||
|
)
|
||||||
|
|
||||||
rootCmd := &cobra.Command{
|
rootCmd := &cobra.Command{
|
||||||
Use: "lnk",
|
Use: "lnk",
|
||||||
Short: "🔗 Dotfiles, linked. No fluff.",
|
Short: "🔗 Dotfiles, linked. No fluff.",
|
||||||
@@ -42,8 +53,29 @@ Supports both common configurations, host-specific setups, and bulk operations f
|
|||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
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
|
// Add subcommands
|
||||||
rootCmd.AddCommand(newInitCmd())
|
rootCmd.AddCommand(newInitCmd())
|
||||||
rootCmd.AddCommand(newAddCmd())
|
rootCmd.AddCommand(newAddCmd())
|
||||||
@@ -66,7 +98,98 @@ func SetVersion(v, bt string) {
|
|||||||
func Execute() {
|
func Execute() {
|
||||||
rootCmd := NewRootCommand()
|
rootCmd := NewRootCommand()
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
DisplayError(err)
|
||||||
os.Exit(1)
|
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()))
|
||||||
|
}
|
||||||
|
467
cmd/root_test.go
467
cmd/root_test.go
@@ -6,7 +6,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
)
|
)
|
||||||
@@ -1338,6 +1340,471 @@ func (suite *CLITestSuite) TestUpdatedHelpText() {
|
|||||||
suite.Contains(addHelpOutput, "without making changes", "Should explain dry-run flag")
|
suite.Contains(addHelpOutput, "without making changes", "Should explain dry-run flag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task 3.1: Tests for force flag functionality
|
||||||
|
func (suite *CLITestSuite) TestInitCmd_ForceFlag_BypassesSafetyCheck() {
|
||||||
|
// Setup: Create .lnk file to simulate existing content
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
err := os.MkdirAll(lnkDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Initialize git repo first
|
||||||
|
cmd := exec.Command("git", "init")
|
||||||
|
cmd.Dir = lnkDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||||
|
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a dummy remote directory for testing
|
||||||
|
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||||
|
err = os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
cmd = exec.Command("git", "init", "--bare")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Execute init command with --force flag
|
||||||
|
err = suite.runCommand("init", "-r", remoteDir, "--force")
|
||||||
|
suite.NoError(err, "Force flag should bypass safety check")
|
||||||
|
|
||||||
|
// Verify output shows warning
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "force", "Should show force warning")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestInitCmd_NoForceFlag_RespectsSafetyCheck() {
|
||||||
|
// Setup: Create .lnk file to simulate existing content
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
err := os.MkdirAll(lnkDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Initialize git repo first
|
||||||
|
cmd := exec.Command("git", "init")
|
||||||
|
cmd.Dir = lnkDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||||
|
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a dummy remote directory for testing
|
||||||
|
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||||
|
err = os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
cmd = exec.Command("git", "init", "--bare")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Execute init command without --force flag - should fail
|
||||||
|
err = suite.runCommand("init", "-r", remoteDir)
|
||||||
|
suite.Error(err, "Should respect safety check without force flag")
|
||||||
|
suite.Contains(err.Error(), "already contains managed files")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestInitCmd_ForceFlag_ShowsWarning() {
|
||||||
|
// Setup: Create .lnk file to simulate existing content
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
err := os.MkdirAll(lnkDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Initialize git repo first
|
||||||
|
cmd := exec.Command("git", "init")
|
||||||
|
cmd.Dir = lnkDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||||
|
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a dummy remote directory for testing
|
||||||
|
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||||
|
err = os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
cmd = exec.Command("git", "init", "--bare")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Execute init command with --force flag
|
||||||
|
err = suite.runCommand("init", "-r", remoteDir, "--force")
|
||||||
|
suite.NoError(err, "Force flag should bypass safety check")
|
||||||
|
|
||||||
|
// Verify output shows appropriate warning
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "⚠️", "Should show warning emoji")
|
||||||
|
suite.Contains(output, "overwrite", "Should warn about overwriting")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 4.1: Integration tests for end-to-end workflows
|
||||||
|
func (suite *CLITestSuite) TestE2E_InitAddInit_PreventDataLoss() {
|
||||||
|
// Run: lnk init
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create and add test file
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".testfile")
|
||||||
|
err = os.WriteFile(testFile, []byte("important content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = suite.runCommand("add", testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create dummy remote for testing
|
||||||
|
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||||
|
err = os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
cmd := exec.Command("git", "init", "--bare")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Run: lnk init -r <remote> → should FAIL
|
||||||
|
err = suite.runCommand("init", "-r", remoteDir)
|
||||||
|
suite.Error(err, "Should prevent data loss")
|
||||||
|
suite.Contains(err.Error(), "already contains managed files")
|
||||||
|
|
||||||
|
// Verify testfile still exists and is managed
|
||||||
|
suite.FileExists(testFile)
|
||||||
|
info, err := os.Lstat(testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "File should still be symlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestE2E_FreshInit_Success() {
|
||||||
|
// Create dummy remote for testing
|
||||||
|
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||||
|
err := os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
cmd := exec.Command("git", "init", "--bare")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Fresh init with remote should succeed
|
||||||
|
err = suite.runCommand("init", "-r", remoteDir)
|
||||||
|
suite.NoError(err, "Fresh init should succeed")
|
||||||
|
|
||||||
|
// Verify repository was created
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
suite.DirExists(lnkDir)
|
||||||
|
gitDir := filepath.Join(lnkDir, ".git")
|
||||||
|
suite.DirExists(gitDir)
|
||||||
|
|
||||||
|
// Verify success message
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Initialized lnk repository")
|
||||||
|
suite.Contains(output, "Cloned from:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestE2E_ForceInit_OverwritesContent() {
|
||||||
|
// Setup: init and add content first
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".testfile")
|
||||||
|
err = os.WriteFile(testFile, []byte("original content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = suite.runCommand("add", testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create dummy remote for testing
|
||||||
|
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||||
|
err = os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
cmd := exec.Command("git", "init", "--bare")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Force init should succeed and show warning
|
||||||
|
err = suite.runCommand("init", "-r", remoteDir, "--force")
|
||||||
|
suite.NoError(err, "Force init should succeed")
|
||||||
|
|
||||||
|
// Verify warning was shown
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "⚠️", "Should show warning")
|
||||||
|
suite.Contains(output, "overwrite", "Should warn about overwriting")
|
||||||
|
suite.Contains(output, "Initialized lnk repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestE2E_ErrorMessage_SuggestsCorrectCommand() {
|
||||||
|
// Setup: init and add content first
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".testfile")
|
||||||
|
err = os.WriteFile(testFile, []byte("important content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = suite.runCommand("add", testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Try init with remote - should fail with helpful message
|
||||||
|
err = suite.runCommand("init", "-r", "https://github.com/test/dotfiles.git")
|
||||||
|
suite.Error(err, "Should fail with helpful error")
|
||||||
|
|
||||||
|
// Verify error message suggests correct alternative
|
||||||
|
suite.Contains(err.Error(), "already contains managed files", "Should explain the problem")
|
||||||
|
suite.Contains(err.Error(), "lnk pull", "Should suggest pull command")
|
||||||
|
suite.Contains(err.Error(), "instead of", "Should explain the alternative")
|
||||||
|
suite.Contains(err.Error(), "lnk init -r", "Should show the problematic command")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 6.1: Regression tests to ensure existing functionality unchanged
|
||||||
|
func (suite *CLITestSuite) TestRegression_FreshInit_UnchangedBehavior() {
|
||||||
|
// Test that fresh init (no existing content) works exactly as before
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.NoError(err, "Fresh init should work unchanged")
|
||||||
|
|
||||||
|
// Verify same output format and behavior
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Initialized empty lnk repository")
|
||||||
|
suite.Contains(output, "Location:")
|
||||||
|
|
||||||
|
// Verify repository structure is created correctly
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
suite.DirExists(lnkDir)
|
||||||
|
gitDir := filepath.Join(lnkDir, ".git")
|
||||||
|
suite.DirExists(gitDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestRegression_ExistingWorkflows_StillWork() {
|
||||||
|
// Test that all existing workflows continue to function
|
||||||
|
|
||||||
|
// 1. Normal init → add → list → remove workflow
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.NoError(err, "Init should work")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create and add a file
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
|
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.runCommand("add", testFile)
|
||||||
|
suite.NoError(err, "Add should work")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// List files
|
||||||
|
err = suite.runCommand("list")
|
||||||
|
suite.NoError(err, "List should work")
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, ".bashrc", "Should list added file")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Remove file
|
||||||
|
err = suite.runCommand("rm", testFile)
|
||||||
|
suite.NoError(err, "Remove should work")
|
||||||
|
|
||||||
|
// Verify file is restored as regular file
|
||||||
|
info, err := os.Lstat(testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should be regular after remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestRegression_GitOperations_Unaffected() {
|
||||||
|
// Test that Git operations continue to work normally
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Add a file to create commits
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
||||||
|
err = os.WriteFile(testFile, []byte("set number"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.runCommand("add", testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Verify Git repository structure and commits are normal
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||||
|
|
||||||
|
// Check that commits are created normally
|
||||||
|
cmd := exec.Command("git", "log", "--oneline", "--format=%s")
|
||||||
|
cmd.Dir = lnkDir
|
||||||
|
output, err := cmd.Output()
|
||||||
|
suite.NoError(err, "Git log should work")
|
||||||
|
|
||||||
|
commits := string(output)
|
||||||
|
suite.Contains(commits, "lnk: added .vimrc", "Should have normal commit message")
|
||||||
|
|
||||||
|
// Check that git status works
|
||||||
|
cmd = exec.Command("git", "status", "--porcelain")
|
||||||
|
cmd.Dir = lnkDir
|
||||||
|
statusOutput, err := cmd.Output()
|
||||||
|
suite.NoError(err, "Git status should work")
|
||||||
|
suite.Empty(strings.TrimSpace(string(statusOutput)), "Working directory should be clean")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestRegression_PerformanceImpact_Minimal() {
|
||||||
|
// Test that the new safety checks don't significantly impact performance
|
||||||
|
|
||||||
|
// Simple performance check: ensure a single init completes quickly
|
||||||
|
start := time.Now()
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
suite.NoError(err, "Init should succeed")
|
||||||
|
suite.Less(elapsed, 2*time.Second, "Init should complete quickly")
|
||||||
|
|
||||||
|
// Test safety check performance on existing repository
|
||||||
|
suite.stdout.Reset()
|
||||||
|
start = time.Now()
|
||||||
|
err = suite.runCommand("init", "-r", "dummy-url")
|
||||||
|
elapsed = time.Since(start)
|
||||||
|
|
||||||
|
// Should fail quickly due to safety check (not hang)
|
||||||
|
suite.Error(err, "Should fail due to safety check")
|
||||||
|
suite.Less(elapsed, 1*time.Second, "Safety check should be fast")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 7.1: Tests for help documentation
|
||||||
|
func (suite *CLITestSuite) TestInitCommand_HelpText_MentionsForceFlag() {
|
||||||
|
err := suite.runCommand("init", "--help")
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "--force", "Help should mention force flag")
|
||||||
|
suite.Contains(output, "overwrite", "Help should explain force behavior")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestInitCommand_HelpText_ExplainsDataProtection() {
|
||||||
|
err := suite.runCommand("init", "--help")
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
|
||||||
|
// Should explain what the command does
|
||||||
|
suite.Contains(output, "Creates", "Should explain what init does")
|
||||||
|
suite.Contains(output, "lnk directory", "Should mention lnk directory")
|
||||||
|
|
||||||
|
// Should warn about the force flag risks
|
||||||
|
suite.Contains(output, "WARNING", "Should warn about force flag risks")
|
||||||
|
suite.Contains(output, "overwrite existing content", "Should mention overwrite risk")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPushPullWithDifferentBranches tests push/pull operations with different default branch names
|
||||||
|
func (suite *CLITestSuite) TestPushPullWithDifferentBranches() {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
branchName string
|
||||||
|
setupRemote func(remoteDir string) error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "master branch",
|
||||||
|
branchName: "master",
|
||||||
|
setupRemote: func(remoteDir string) error {
|
||||||
|
cmd := exec.Command("git", "init", "--bare", "--initial-branch=master")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
return cmd.Run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "main branch",
|
||||||
|
branchName: "main",
|
||||||
|
setupRemote: func(remoteDir string) error {
|
||||||
|
cmd := exec.Command("git", "init", "--bare", "--initial-branch=main")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
return cmd.Run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom branch",
|
||||||
|
branchName: "develop",
|
||||||
|
setupRemote: func(remoteDir string) error {
|
||||||
|
cmd := exec.Command("git", "init", "--bare", "--initial-branch=develop")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
return cmd.Run()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
suite.Run(tc.name, func() {
|
||||||
|
// Create a separate temp directory for this test case
|
||||||
|
testDir, err := os.MkdirTemp("", "lnk-push-pull-test-*")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
defer func() { _ = os.RemoveAll(testDir) }()
|
||||||
|
|
||||||
|
// Save current dir and change to test dir
|
||||||
|
originalDir, err := os.Getwd()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
defer func() { _ = os.Chdir(originalDir) }()
|
||||||
|
|
||||||
|
err = os.Chdir(testDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Set HOME to test directory
|
||||||
|
suite.T().Setenv("HOME", testDir)
|
||||||
|
suite.T().Setenv("XDG_CONFIG_HOME", testDir)
|
||||||
|
|
||||||
|
// Create remote repository
|
||||||
|
remoteDir := filepath.Join(testDir, "remote.git")
|
||||||
|
err = os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = tc.setupRemote(remoteDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Initialize lnk with remote
|
||||||
|
err = suite.runCommand("init", "--remote", remoteDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Switch to the test branch if not main/master (since init creates main by default)
|
||||||
|
if tc.branchName != "main" {
|
||||||
|
lnkDir := filepath.Join(testDir, "lnk")
|
||||||
|
cmd := exec.Command("git", "checkout", "-b", tc.branchName)
|
||||||
|
cmd.Dir = lnkDir
|
||||||
|
_, err = cmd.CombinedOutput()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a test file
|
||||||
|
testFile := filepath.Join(testDir, ".testrc")
|
||||||
|
err = os.WriteFile(testFile, []byte("test config"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.runCommand("add", testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test push operation
|
||||||
|
err = suite.runCommand("push", "test push with "+tc.branchName)
|
||||||
|
suite.Require().NoError(err, "Push should work with %s branch", tc.branchName)
|
||||||
|
|
||||||
|
// Create another test directory to simulate pulling from another machine
|
||||||
|
pullTestDir, err := os.MkdirTemp("", "lnk-pull-test-*")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
defer func() { _ = os.RemoveAll(pullTestDir) }()
|
||||||
|
|
||||||
|
err = os.Chdir(pullTestDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Set HOME for pull test
|
||||||
|
suite.T().Setenv("HOME", pullTestDir)
|
||||||
|
suite.T().Setenv("XDG_CONFIG_HOME", pullTestDir)
|
||||||
|
|
||||||
|
// Clone and test pull
|
||||||
|
err = suite.runCommand("init", "--remote", remoteDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.runCommand("pull")
|
||||||
|
suite.Require().NoError(err, "Pull should work with %s branch", tc.branchName)
|
||||||
|
|
||||||
|
// Verify the file was pulled correctly
|
||||||
|
lnkDir := filepath.Join(pullTestDir, "lnk")
|
||||||
|
pulledFile := filepath.Join(lnkDir, ".testrc")
|
||||||
|
suite.FileExists(pulledFile, "File should exist after pull with %s branch", tc.branchName)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(pulledFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal("test config", string(content), "File content should match after pull with %s branch", tc.branchName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCLISuite(t *testing.T) {
|
func TestCLISuite(t *testing.T) {
|
||||||
suite.Run(t, new(CLITestSuite))
|
suite.Run(t, new(CLITestSuite))
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,51 +39,93 @@ func newStatusCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
w := GetWriter(cmd)
|
||||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
|
||||||
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
printf(cmd, "\n")
|
w.WritelnString("")
|
||||||
displayAheadBehindInfo(cmd, status, true)
|
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) {
|
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
w := GetWriter(cmd)
|
||||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
|
||||||
|
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) {
|
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
w := GetWriter(cmd)
|
||||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
|
||||||
printf(cmd, "\n")
|
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)
|
displayAheadBehindInfo(cmd, status, false)
|
||||||
|
|
||||||
if status.Ahead > 0 && status.Behind == 0 {
|
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 {
|
} 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) {
|
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
||||||
|
w := GetWriter(cmd)
|
||||||
|
|
||||||
if status.Ahead > 0 {
|
if status.Ahead > 0 {
|
||||||
commitText := getCommitText(status.Ahead)
|
commitText := getCommitText(status.Ahead)
|
||||||
if isDirty {
|
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 {
|
} 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 {
|
if status.Behind > 0 {
|
||||||
commitText := getCommitText(status.Behind)
|
commitText := getCommitText(status.Behind)
|
||||||
printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
|
w.WriteString(" ").
|
||||||
|
Write(Message{Text: fmt.Sprintf("%d %s behind", status.Behind, commitText), Emoji: "⬇️", Color: ColorBrightRed, Bold: true}).
|
||||||
|
WriteString(" - run ").
|
||||||
|
Write(Bold("lnk pull")).
|
||||||
|
WritelnString("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
12
cmd/utils.go
12
cmd/utils.go
@@ -1,12 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// printf is a helper function to simplify output formatting in commands
|
|
||||||
func printf(cmd *cobra.Command, format string, args ...interface{}) {
|
|
||||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...)
|
|
||||||
}
|
|
2
go.mod
2
go.mod
@@ -11,6 +11,6 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
2
go.sum
2
go.sum
@@ -10,6 +10,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
|||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
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/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
84
internal/core/errors.go
Normal file
84
internal/core/errors.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// LnkError represents a structured error with separate content and formatting hints
|
||||||
|
type LnkError struct {
|
||||||
|
Message string
|
||||||
|
Suggestion string
|
||||||
|
Path string
|
||||||
|
ErrorType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LnkError) Error() string {
|
||||||
|
if e.Suggestion != "" {
|
||||||
|
return fmt.Sprintf("%s\n %s", e.Message, e.Suggestion)
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error constructors that separate content from presentation
|
||||||
|
|
||||||
|
func ErrDirectoryContainsManagedFiles(path string) error {
|
||||||
|
return &LnkError{
|
||||||
|
Message: fmt.Sprintf("Directory %s already contains managed files", path),
|
||||||
|
Suggestion: "Use 'lnk pull' to update from remote instead of 'lnk init -r'",
|
||||||
|
Path: path,
|
||||||
|
ErrorType: "managed_files_exist",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrDirectoryContainsGitRepo(path string) error {
|
||||||
|
return &LnkError{
|
||||||
|
Message: fmt.Sprintf("Directory %s contains an existing Git repository", path),
|
||||||
|
Suggestion: "Please backup or move the existing repository before initializing lnk",
|
||||||
|
Path: path,
|
||||||
|
ErrorType: "git_repo_exists",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrFileAlreadyManaged(path string) error {
|
||||||
|
return &LnkError{
|
||||||
|
Message: fmt.Sprintf("File is already managed by lnk: %s", path),
|
||||||
|
Path: path,
|
||||||
|
ErrorType: "already_managed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrFileNotManaged(path string) error {
|
||||||
|
return &LnkError{
|
||||||
|
Message: fmt.Sprintf("File is not managed by lnk: %s", path),
|
||||||
|
Path: path,
|
||||||
|
ErrorType: "not_managed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrRepositoryNotInitialized() error {
|
||||||
|
return &LnkError{
|
||||||
|
Message: "Lnk repository not initialized",
|
||||||
|
Suggestion: "Run 'lnk init' first",
|
||||||
|
ErrorType: "not_initialized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrBootstrapScriptNotFound(script string) error {
|
||||||
|
return &LnkError{
|
||||||
|
Message: fmt.Sprintf("Bootstrap script not found: %s", script),
|
||||||
|
Path: script,
|
||||||
|
ErrorType: "script_not_found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrBootstrapScriptFailed(err error) error {
|
||||||
|
return &LnkError{
|
||||||
|
Message: fmt.Sprintf("Bootstrap script failed with error: %v", err),
|
||||||
|
ErrorType: "script_failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ErrBootstrapScriptNotExecutable(err error) error {
|
||||||
|
return &LnkError{
|
||||||
|
Message: fmt.Sprintf("Failed to make bootstrap script executable: %v", err),
|
||||||
|
ErrorType: "script_permissions",
|
||||||
|
}
|
||||||
|
}
|
@@ -46,6 +46,34 @@ func NewLnk(opts ...Option) *Lnk {
|
|||||||
return lnk
|
return lnk
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasUserContent checks if the repository contains managed files
|
||||||
|
// by looking for .lnk tracker files (common or host-specific)
|
||||||
|
func (l *Lnk) HasUserContent() bool {
|
||||||
|
// Check for common tracker file
|
||||||
|
commonTracker := filepath.Join(l.repoPath, ".lnk")
|
||||||
|
if _, err := os.Stat(commonTracker); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for host-specific tracker files if host is set
|
||||||
|
if l.host != "" {
|
||||||
|
hostTracker := filepath.Join(l.repoPath, fmt.Sprintf(".lnk.%s", l.host))
|
||||||
|
if _, err := os.Stat(hostTracker); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no specific host is set, check for any host-specific tracker files
|
||||||
|
// This handles cases where we want to detect any managed content
|
||||||
|
pattern := filepath.Join(l.repoPath, ".lnk.*")
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err == nil && len(matches) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// GetCurrentHostname returns the current system hostname
|
// GetCurrentHostname returns the current system hostname
|
||||||
func GetCurrentHostname() (string, error) {
|
func GetCurrentHostname() (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
@@ -119,7 +147,18 @@ func (l *Lnk) Init() error {
|
|||||||
|
|
||||||
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
|
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
|
||||||
func (l *Lnk) InitWithRemote(remoteURL string) error {
|
func (l *Lnk) InitWithRemote(remoteURL string) error {
|
||||||
|
return l.InitWithRemoteForce(remoteURL, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitWithRemoteForce initializes the lnk repository with optional force override
|
||||||
|
func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error {
|
||||||
if remoteURL != "" {
|
if remoteURL != "" {
|
||||||
|
// Safety check: prevent data loss by checking for existing managed files
|
||||||
|
if l.HasUserContent() {
|
||||||
|
if !force {
|
||||||
|
return ErrDirectoryContainsManagedFiles(l.repoPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Clone from remote
|
// Clone from remote
|
||||||
return l.Clone(remoteURL)
|
return l.Clone(remoteURL)
|
||||||
}
|
}
|
||||||
@@ -137,7 +176,7 @@ func (l *Lnk) InitWithRemote(remoteURL string) error {
|
|||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
// It's not a lnk repository, error to prevent data loss
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +230,7 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
}
|
}
|
||||||
for _, item := range managedItems {
|
for _, item := range managedItems {
|
||||||
if item == relativePath {
|
if item == relativePath {
|
||||||
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
return ErrFileAlreadyManaged(relativePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +332,7 @@ func (l *Lnk) AddMultiple(paths []string) error {
|
|||||||
}
|
}
|
||||||
for _, item := range managedItems {
|
for _, item := range managedItems {
|
||||||
if item == relativePath {
|
if item == relativePath {
|
||||||
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
return ErrFileAlreadyManaged(relativePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +476,7 @@ func (l *Lnk) Remove(filePath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
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
|
// Get the target path in the repository
|
||||||
@@ -512,7 +551,7 @@ type StatusInfo struct {
|
|||||||
func (l *Lnk) Status() (*StatusInfo, error) {
|
func (l *Lnk) Status() (*StatusInfo, error) {
|
||||||
// Check if repository is initialized
|
// Check if repository is initialized
|
||||||
if !l.git.IsGitRepository() {
|
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()
|
gitStatus, err := l.git.GetStatus()
|
||||||
@@ -532,7 +571,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
|
|||||||
func (l *Lnk) Push(message string) error {
|
func (l *Lnk) Push(message string) error {
|
||||||
// Check if repository is initialized
|
// Check if repository is initialized
|
||||||
if !l.git.IsGitRepository() {
|
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
|
// Check if there are any changes
|
||||||
@@ -562,7 +601,7 @@ func (l *Lnk) Push(message string) error {
|
|||||||
func (l *Lnk) Pull() ([]string, error) {
|
func (l *Lnk) Pull() ([]string, error) {
|
||||||
// Check if repository is initialized
|
// Check if repository is initialized
|
||||||
if !l.git.IsGitRepository() {
|
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)
|
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
|
||||||
@@ -583,7 +622,7 @@ func (l *Lnk) Pull() ([]string, error) {
|
|||||||
func (l *Lnk) List() ([]string, error) {
|
func (l *Lnk) List() ([]string, error) {
|
||||||
// Check if repository is initialized
|
// Check if repository is initialized
|
||||||
if !l.git.IsGitRepository() {
|
if !l.git.IsGitRepository() {
|
||||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
return nil, ErrRepositoryNotInitialized()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get managed items from .lnk file
|
// Get managed items from .lnk file
|
||||||
@@ -783,7 +822,7 @@ func (l *Lnk) writeManagedItems(items []string) error {
|
|||||||
func (l *Lnk) FindBootstrapScript() (string, error) {
|
func (l *Lnk) FindBootstrapScript() (string, error) {
|
||||||
// Check if repository is initialized
|
// Check if repository is initialized
|
||||||
if !l.git.IsGitRepository() {
|
if !l.git.IsGitRepository() {
|
||||||
return "", fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
return "", ErrRepositoryNotInitialized()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for bootstrap.sh - simple, opinionated choice
|
// Look for bootstrap.sh - simple, opinionated choice
|
||||||
@@ -801,12 +840,12 @@ func (l *Lnk) RunBootstrapScript(scriptName string) error {
|
|||||||
|
|
||||||
// Verify the script exists
|
// Verify the script exists
|
||||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("❌ Bootstrap script not found: \033[31m%s\033[0m", scriptName)
|
return ErrBootstrapScriptNotFound(scriptName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure it's executable
|
// Make sure it's executable
|
||||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
if err := os.Chmod(scriptPath, 0755); err != nil {
|
||||||
return fmt.Errorf("❌ Failed to make bootstrap script executable: %w", err)
|
return ErrBootstrapScriptNotExecutable(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run with bash (since we only support bootstrap.sh)
|
// Run with bash (since we only support bootstrap.sh)
|
||||||
@@ -822,7 +861,7 @@ func (l *Lnk) RunBootstrapScript(scriptName string) error {
|
|||||||
|
|
||||||
// Run the script
|
// Run the script
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return fmt.Errorf("❌ Bootstrap script failed with error: %w", err)
|
return ErrBootstrapScriptFailed(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -949,7 +988,7 @@ func (l *Lnk) addMultipleWithProgress(paths []string, progress ProgressCallback)
|
|||||||
}
|
}
|
||||||
for _, item := range managedItems {
|
for _, item := range managedItems {
|
||||||
if item == relativePath {
|
if item == relativePath {
|
||||||
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
return ErrFileAlreadyManaged(relativePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@ package core
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -1432,6 +1433,170 @@ func (suite *CoreTestSuite) TestPreviewAddValidation() {
|
|||||||
suite.Contains(err.Error(), "already managed", "Error should mention already managed")
|
suite.Contains(err.Error(), "already managed", "Error should mention already managed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task 1.1: Tests for HasUserContent() method
|
||||||
|
func (suite *CoreTestSuite) TestHasUserContent_WithCommonTracker_ReturnsTrue() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create .lnk file to simulate existing content
|
||||||
|
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
||||||
|
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Call HasUserContent()
|
||||||
|
hasContent := suite.lnk.HasUserContent()
|
||||||
|
suite.True(hasContent, "Should detect common tracker file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestHasUserContent_WithHostTracker_ReturnsTrue() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create host-specific lnk instance
|
||||||
|
hostLnk := NewLnk(WithHost("testhost"))
|
||||||
|
|
||||||
|
// Create .lnk.hostname file to simulate host-specific content
|
||||||
|
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
|
||||||
|
err = os.WriteFile(lnkFile, []byte(".vimrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Call HasUserContent()
|
||||||
|
hasContent := hostLnk.HasUserContent()
|
||||||
|
suite.True(hasContent, "Should detect host-specific tracker file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestHasUserContent_WithBothTrackers_ReturnsTrue() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create both common and host-specific tracker files
|
||||||
|
commonLnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
||||||
|
err = os.WriteFile(commonLnkFile, []byte(".bashrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
hostLnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
|
||||||
|
err = os.WriteFile(hostLnkFile, []byte(".vimrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test with common instance
|
||||||
|
hasContent := suite.lnk.HasUserContent()
|
||||||
|
suite.True(hasContent, "Should detect common tracker file")
|
||||||
|
|
||||||
|
// Test with host-specific instance
|
||||||
|
hostLnk := NewLnk(WithHost("testhost"))
|
||||||
|
hasContent = hostLnk.HasUserContent()
|
||||||
|
suite.True(hasContent, "Should detect host-specific tracker file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestHasUserContent_EmptyDirectory_ReturnsFalse() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Call HasUserContent() on empty repository
|
||||||
|
hasContent := suite.lnk.HasUserContent()
|
||||||
|
suite.False(hasContent, "Should return false for empty repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestHasUserContent_NonTrackerFiles_ReturnsFalse() {
|
||||||
|
// Initialize lnk repository
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create non-tracker files
|
||||||
|
randomFile := filepath.Join(suite.tempDir, "lnk", "random.txt")
|
||||||
|
err = os.WriteFile(randomFile, []byte("some content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
configFile := filepath.Join(suite.tempDir, "lnk", ".gitignore")
|
||||||
|
err = os.WriteFile(configFile, []byte("*.log"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Call HasUserContent()
|
||||||
|
hasContent := suite.lnk.HasUserContent()
|
||||||
|
suite.False(hasContent, "Should return false when only non-tracker files exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 2.1: Tests for enhanced InitWithRemote() safety check
|
||||||
|
func (suite *CoreTestSuite) TestInitWithRemote_HasUserContent_ReturnsError() {
|
||||||
|
// Initialize and add content first
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create .lnk file to simulate existing content
|
||||||
|
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
||||||
|
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Try InitWithRemote - should fail
|
||||||
|
err = suite.lnk.InitWithRemote("https://github.com/test/dotfiles.git")
|
||||||
|
suite.Error(err, "Should fail when user content exists")
|
||||||
|
suite.Contains(err.Error(), "already contains managed files")
|
||||||
|
suite.Contains(err.Error(), "lnk pull")
|
||||||
|
|
||||||
|
// Verify .lnk file still exists (no deletion occurred)
|
||||||
|
suite.FileExists(lnkFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestInitWithRemote_EmptyDirectory_Success() {
|
||||||
|
// Create a dummy remote directory for testing
|
||||||
|
remoteDir := filepath.Join(suite.tempDir, "remote")
|
||||||
|
err := os.MkdirAll(remoteDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Initialize a bare git repository as remote
|
||||||
|
cmd := exec.Command("git", "init", "--bare")
|
||||||
|
cmd.Dir = remoteDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// InitWithRemote should succeed on empty directory
|
||||||
|
err = suite.lnk.InitWithRemote(remoteDir)
|
||||||
|
suite.NoError(err, "Should succeed when no user content exists")
|
||||||
|
|
||||||
|
// Verify repository was cloned
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
suite.DirExists(lnkDir)
|
||||||
|
gitDir := filepath.Join(lnkDir, ".git")
|
||||||
|
suite.DirExists(gitDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestInitWithRemote_NoRemoteURL_BypassesSafetyCheck() {
|
||||||
|
// Initialize and add content first
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create .lnk file to simulate existing content
|
||||||
|
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
||||||
|
err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// InitWithRemote with empty URL should bypass safety check (this is local init)
|
||||||
|
err = suite.lnk.InitWithRemote("")
|
||||||
|
suite.NoError(err, "Should bypass safety check when no remote URL provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TestInitWithRemote_ErrorMessage_ContainsSuggestedCommand() {
|
||||||
|
// Initialize and add content first
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create host-specific content
|
||||||
|
hostLnk := NewLnk(WithHost("testhost"))
|
||||||
|
hostLnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
|
||||||
|
err = os.WriteFile(hostLnkFile, []byte(".vimrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Try InitWithRemote - should fail with helpful message
|
||||||
|
err = hostLnk.InitWithRemote("https://github.com/test/dotfiles.git")
|
||||||
|
suite.Error(err, "Should fail when user content exists")
|
||||||
|
suite.Contains(err.Error(), "lnk pull", "Should suggest pull command")
|
||||||
|
suite.Contains(err.Error(), "instead of", "Should explain alternative")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreSuite(t *testing.T) {
|
func TestCoreSuite(t *testing.T) {
|
||||||
suite.Run(t, new(CoreTestSuite))
|
suite.Run(t, new(CoreTestSuite))
|
||||||
}
|
}
|
||||||
|
@@ -1,28 +1,7 @@
|
|||||||
package fs
|
package fs
|
||||||
|
|
||||||
import "fmt"
|
// Structured errors that separate content from presentation
|
||||||
|
// These will be formatted by the cmd package based on user preferences
|
||||||
// ANSI color codes for consistent formatting
|
|
||||||
const (
|
|
||||||
colorReset = "\033[0m"
|
|
||||||
colorRed = "\033[31m"
|
|
||||||
colorBold = "\033[1m"
|
|
||||||
)
|
|
||||||
|
|
||||||
// formatError creates a consistently formatted error message with ❌ prefix
|
|
||||||
func formatError(message string, args ...interface{}) string {
|
|
||||||
return fmt.Sprintf("❌ "+message, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatPath formats a file path with red color
|
|
||||||
func formatPath(path string) string {
|
|
||||||
return fmt.Sprintf("%s%s%s", colorRed, path, colorReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatCommand formats a command with bold styling
|
|
||||||
func formatCommand(command string) string {
|
|
||||||
return fmt.Sprintf("%s%s%s", colorBold, command, colorReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FileNotExistsError represents an error when a file does not exist
|
// FileNotExistsError represents an error when a file does not exist
|
||||||
type FileNotExistsError struct {
|
type FileNotExistsError struct {
|
||||||
@@ -31,20 +10,25 @@ type FileNotExistsError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *FileNotExistsError) Error() string {
|
func (e *FileNotExistsError) Error() string {
|
||||||
return formatError("File or directory not found: %s", formatPath(e.Path))
|
return "File or directory not found: " + e.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *FileNotExistsError) Unwrap() error {
|
func (e *FileNotExistsError) Unwrap() error {
|
||||||
return e.Err
|
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
|
// FileCheckError represents an error when failing to check a file
|
||||||
type FileCheckError struct {
|
type FileCheckError struct {
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *FileCheckError) Error() string {
|
func (e *FileCheckError) Error() string {
|
||||||
return formatError("Unable to access file. Please check file permissions and try again.")
|
return "Unable to access file. Please check file permissions and try again."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *FileCheckError) Unwrap() error {
|
func (e *FileCheckError) Unwrap() error {
|
||||||
@@ -57,7 +41,15 @@ type UnsupportedFileTypeError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *UnsupportedFileTypeError) Error() string {
|
func (e *UnsupportedFileTypeError) Error() string {
|
||||||
return formatError("Cannot manage this type of file: %s\n 💡 lnk can only manage regular files and directories", formatPath(e.Path))
|
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 {
|
func (e *UnsupportedFileTypeError) Unwrap() error {
|
||||||
@@ -70,8 +62,15 @@ type NotManagedByLnkError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *NotManagedByLnkError) Error() string {
|
func (e *NotManagedByLnkError) Error() string {
|
||||||
return formatError("File is not managed by lnk: %s\n 💡 Use %s to manage this file first",
|
return "File is not managed by lnk: " + e.Path
|
||||||
formatPath(e.Path), formatCommand("lnk add"))
|
}
|
||||||
|
|
||||||
|
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 {
|
func (e *NotManagedByLnkError) Unwrap() error {
|
||||||
@@ -84,7 +83,7 @@ type SymlinkReadError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *SymlinkReadError) Error() string {
|
func (e *SymlinkReadError) Error() string {
|
||||||
return formatError("Unable to read symlink. The file may be corrupted or have invalid permissions.")
|
return "Unable to read symlink. The file may be corrupted or have invalid permissions."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *SymlinkReadError) Unwrap() error {
|
func (e *SymlinkReadError) Unwrap() error {
|
||||||
@@ -98,7 +97,7 @@ type DirectoryCreationError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *DirectoryCreationError) Error() string {
|
func (e *DirectoryCreationError) Error() string {
|
||||||
return formatError("Failed to create directory. Please check permissions and available disk space.")
|
return "Failed to create directory. Please check permissions and available disk space."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *DirectoryCreationError) Unwrap() error {
|
func (e *DirectoryCreationError) Unwrap() error {
|
||||||
@@ -111,9 +110,21 @@ type RelativePathCalculationError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *RelativePathCalculationError) Error() string {
|
func (e *RelativePathCalculationError) Error() string {
|
||||||
return formatError("Unable to create symlink due to path configuration issues. Please check file locations.")
|
return "Unable to create symlink due to path configuration issues. Please check file locations."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *RelativePathCalculationError) Unwrap() error {
|
func (e *RelativePathCalculationError) Unwrap() error {
|
||||||
return e.Err
|
return e.Err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrorWithPath is an interface for errors that have an associated file path
|
||||||
|
type ErrorWithPath interface {
|
||||||
|
error
|
||||||
|
GetPath() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorWithSuggestion is an interface for errors that provide helpful suggestions
|
||||||
|
type ErrorWithSuggestion interface {
|
||||||
|
error
|
||||||
|
GetSuggestion() string
|
||||||
|
}
|
||||||
|
@@ -1,29 +1,7 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import "fmt"
|
// Structured errors that separate content from presentation
|
||||||
|
// These will be formatted by the cmd package based on user preferences
|
||||||
// ANSI color codes for consistent formatting
|
|
||||||
const (
|
|
||||||
colorReset = "\033[0m"
|
|
||||||
colorBold = "\033[1m"
|
|
||||||
colorGreen = "\033[32m"
|
|
||||||
colorYellow = "\033[33m"
|
|
||||||
)
|
|
||||||
|
|
||||||
// formatError creates a consistently formatted error message with ❌ prefix
|
|
||||||
func formatError(message string, args ...interface{}) string {
|
|
||||||
return fmt.Sprintf("❌ "+message, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatURL formats a URL with styling
|
|
||||||
func formatURL(url string) string {
|
|
||||||
return fmt.Sprintf("%s%s%s", colorBold, url, colorReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatRemote formats a remote name with styling
|
|
||||||
func formatRemote(remote string) string {
|
|
||||||
return fmt.Sprintf("%s%s%s", colorGreen, remote, colorReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitInitError represents an error during git initialization
|
// GitInitError represents an error during git initialization
|
||||||
type GitInitError struct {
|
type GitInitError struct {
|
||||||
@@ -32,7 +10,7 @@ type GitInitError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *GitInitError) Error() string {
|
func (e *GitInitError) Error() string {
|
||||||
return formatError("Failed to initialize git repository. Please ensure git is installed and try again.")
|
return "Failed to initialize git repository. Please ensure git is installed and try again."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *GitInitError) Unwrap() error {
|
func (e *GitInitError) Unwrap() error {
|
||||||
@@ -45,7 +23,7 @@ type BranchSetupError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *BranchSetupError) Error() string {
|
func (e *BranchSetupError) Error() string {
|
||||||
return formatError("Failed to set up the default branch. Please check your git installation.")
|
return "Failed to set up the default branch. Please check your git installation."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *BranchSetupError) Unwrap() error {
|
func (e *BranchSetupError) Unwrap() error {
|
||||||
@@ -60,8 +38,19 @@ type RemoteExistsError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *RemoteExistsError) Error() string {
|
func (e *RemoteExistsError) Error() string {
|
||||||
return formatError("Remote %s is already configured with a different repository (%s). Cannot add %s.",
|
return "Remote " + e.Remote + " is already configured with a different repository (" + e.ExistingURL + "). Cannot add " + e.NewURL + "."
|
||||||
formatRemote(e.Remote), formatURL(e.ExistingURL), formatURL(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 {
|
func (e *RemoteExistsError) Unwrap() error {
|
||||||
@@ -79,24 +68,28 @@ func (e *GitCommandError) Error() string {
|
|||||||
// Provide user-friendly messages based on common command types
|
// Provide user-friendly messages based on common command types
|
||||||
switch e.Command {
|
switch e.Command {
|
||||||
case "add":
|
case "add":
|
||||||
return formatError("Failed to stage files for commit. Please check file permissions and try again.")
|
return "Failed to stage files for commit. Please check file permissions and try again."
|
||||||
case "commit":
|
case "commit":
|
||||||
return formatError("Failed to create commit. Please ensure you have staged changes and try again.")
|
return "Failed to create commit. Please ensure you have staged changes and try again."
|
||||||
case "remote add":
|
case "remote add":
|
||||||
return formatError("Failed to add remote repository. Please check the repository URL and try again.")
|
return "Failed to add remote repository. Please check the repository URL and try again."
|
||||||
case "rm":
|
case "rm":
|
||||||
return formatError("Failed to remove file from git tracking. Please check if the file exists and try again.")
|
return "Failed to remove file from git tracking. Please check if the file exists and try again."
|
||||||
case "log":
|
case "log":
|
||||||
return formatError("Failed to retrieve commit history.")
|
return "Failed to retrieve commit history."
|
||||||
case "remote":
|
case "remote":
|
||||||
return formatError("Failed to retrieve remote repository information.")
|
return "Failed to retrieve remote repository information."
|
||||||
case "clone":
|
case "clone":
|
||||||
return formatError("Failed to clone repository. Please check the repository URL and your network connection.")
|
return "Failed to clone repository. Please check the repository URL and your network connection."
|
||||||
default:
|
default:
|
||||||
return formatError("Git operation failed. Please check your repository state and try again.")
|
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 {
|
func (e *GitCommandError) Unwrap() error {
|
||||||
return e.Err
|
return e.Err
|
||||||
}
|
}
|
||||||
@@ -105,7 +98,7 @@ func (e *GitCommandError) Unwrap() error {
|
|||||||
type NoRemoteError struct{}
|
type NoRemoteError struct{}
|
||||||
|
|
||||||
func (e *NoRemoteError) Error() string {
|
func (e *NoRemoteError) Error() string {
|
||||||
return formatError("No remote repository is configured. Please add a remote repository first.")
|
return "No remote repository is configured. Please add a remote repository first."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *NoRemoteError) Unwrap() error {
|
func (e *NoRemoteError) Unwrap() error {
|
||||||
@@ -119,7 +112,11 @@ type RemoteNotFoundError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *RemoteNotFoundError) Error() string {
|
func (e *RemoteNotFoundError) Error() string {
|
||||||
return formatError("Remote repository %s is not configured.", formatRemote(e.Remote))
|
return "Remote repository " + e.Remote + " is not configured."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RemoteNotFoundError) GetRemote() string {
|
||||||
|
return e.Remote
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *RemoteNotFoundError) Unwrap() error {
|
func (e *RemoteNotFoundError) Unwrap() error {
|
||||||
@@ -133,7 +130,7 @@ type GitConfigError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *GitConfigError) Error() string {
|
func (e *GitConfigError) Error() string {
|
||||||
return formatError("Failed to configure git settings. Please check your git installation.")
|
return "Failed to configure git settings. Please check your git installation."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *GitConfigError) Unwrap() error {
|
func (e *GitConfigError) Unwrap() error {
|
||||||
@@ -146,7 +143,7 @@ type UncommittedChangesError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *UncommittedChangesError) Error() string {
|
func (e *UncommittedChangesError) Error() string {
|
||||||
return formatError("Failed to check repository status. Please verify your git repository is valid.")
|
return "Failed to check repository status. Please verify your git repository is valid."
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *UncommittedChangesError) Unwrap() error {
|
func (e *UncommittedChangesError) Unwrap() error {
|
||||||
@@ -160,7 +157,11 @@ type DirectoryRemovalError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *DirectoryRemovalError) Error() string {
|
func (e *DirectoryRemovalError) Error() string {
|
||||||
return formatError("Failed to prepare directory for operation. Please check directory permissions.")
|
return "Failed to prepare directory for operation. Please check directory permissions."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DirectoryRemovalError) GetPath() string {
|
||||||
|
return e.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *DirectoryRemovalError) Unwrap() error {
|
func (e *DirectoryRemovalError) Unwrap() error {
|
||||||
@@ -174,7 +175,11 @@ type DirectoryCreationError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *DirectoryCreationError) Error() string {
|
func (e *DirectoryCreationError) Error() string {
|
||||||
return formatError("Failed to create directory. Please check permissions and available disk space.")
|
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 {
|
func (e *DirectoryCreationError) Unwrap() error {
|
||||||
@@ -190,9 +195,13 @@ type PushError struct {
|
|||||||
|
|
||||||
func (e *PushError) Error() string {
|
func (e *PushError) Error() string {
|
||||||
if e.Reason != "" {
|
if e.Reason != "" {
|
||||||
return formatError("Cannot push changes: %s", e.Reason)
|
return "Cannot push changes: " + e.Reason
|
||||||
}
|
}
|
||||||
return formatError("Failed to push changes to remote repository. Please check your network connection and repository permissions.")
|
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 {
|
func (e *PushError) Unwrap() error {
|
||||||
@@ -208,11 +217,33 @@ type PullError struct {
|
|||||||
|
|
||||||
func (e *PullError) Error() string {
|
func (e *PullError) Error() string {
|
||||||
if e.Reason != "" {
|
if e.Reason != "" {
|
||||||
return formatError("Cannot pull changes: %s", e.Reason)
|
return "Cannot pull changes: " + e.Reason
|
||||||
}
|
}
|
||||||
return formatError("Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts.")
|
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 {
|
func (e *PullError) Unwrap() error {
|
||||||
return e.Err
|
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
|
||||||
|
}
|
||||||
|
@@ -437,7 +437,7 @@ func (g *Git) Push() error {
|
|||||||
return &PushError{Reason: err.Error(), Err: err}
|
return &PushError{Reason: err.Error(), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("git", "push", "-u", "origin", "main")
|
cmd := exec.Command("git", "push", "-u", "origin")
|
||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
@@ -456,7 +456,7 @@ func (g *Git) Pull() error {
|
|||||||
return &PullError{Reason: err.Error(), Err: err}
|
return &PullError{Reason: err.Error(), Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("git", "pull", "origin", "main")
|
cmd := exec.Command("git", "pull", "origin")
|
||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
Reference in New Issue
Block a user