diff --git a/README.md b/README.md index 80ddd2c..9a26d37 100644 --- a/README.md +++ b/README.md @@ -1,387 +1,165 @@ # Lnk -**The missing middle: Safer than simple, simpler than complex.** +**Git-native dotfiles management that doesn't suck.** -Git-native dotfiles management that won't break your setup. Zero config, zero bloat, zero surprises. +Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. That's it. ```bash -# The power of Git, the safety of proper engineering -lnk init && lnk add ~/.vimrc && lnk push +lnk init +lnk add ~/.vimrc ~/.bashrc +lnk push "setup" ``` -[![Tests](https://img.shields.io/badge/tests-20%20passing-green)](./test) [![Go](https://img.shields.io/badge/go-1.21+-blue)](https://golang.org) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) - -## Why Lnk? - -**The dotfiles manager that fills the missing gap.** - -While chezmoi offers 100+ features and Home Manager requires learning Nix, **Lnk focuses on doing the essentials perfectly**: - -- 🎯 **Safe simplicity**: More robust than Dotbot, simpler than chezmoi -- 🛡️ **Bulletproof operations**: Comprehensive edge case handling (unlike minimal tools) -- ⚡ **Zero friction**: No YAML configs, no templates, no learning curve -- 🔧 **Git-native**: Clean commits, standard workflow, no abstractions -- 📦 **Zero dependencies**: Single binary vs Python/Node/Ruby runtimes -- 🚀 **Production ready**: 20 integration tests, proper error handling -- 🔄 **Smart sync**: Built-in status tracking and seamless multi-machine workflow -- 📁 **Directory support**: Manage entire config directories or individual files - -**The market gap**: Tools are either too simple (and unsafe) or too complex (and overwhelming). Lnk is the **Goldilocks solution** – just right for developers who want reliability without complexity. - -## Quick Start +## Install ```bash -# Install (30 seconds) -curl -sSL https://github.com/yarlson/lnk/releases/latest/download/lnk-linux-amd64 -o lnk -chmod +x lnk && sudo mv lnk /usr/local/bin/ - -# Use (60 seconds) -lnk init -r git@github.com:you/dotfiles.git -lnk add ~/.bashrc ~/.vimrc ~/.gitconfig -lnk push "Initial dotfiles setup" -``` - -**That's it.** Your dotfiles are now version-controlled and synced. - -## Installation - -### Quick Install (Recommended) - -```bash -# Linux/macOS +# Quick curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash -# Or manually download from releases +# Manual wget https://github.com/yarlson/lnk/releases/latest/download/lnk-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64 +chmod +x lnk-* && sudo mv lnk-* /usr/local/bin/lnk + +# From source +git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/ ``` -### From Source - -```bash -git clone https://github.com/yarlson/lnk.git && cd lnk -go build -ldflags="-s -w" -o lnk . -sudo mv lnk /usr/local/bin/ -``` - -### Package Managers - -```bash -# Homebrew (macOS/Linux) -brew install yarlson/tap/lnk - -# Arch Linux -yay -S lnk-git -``` - -## How It Works - -**The mental model is simple**: Lnk moves your dotfiles to `~/.config/lnk/` and replaces them with symlinks. - -``` -Before: ~/.vimrc (actual file) -After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink) -``` - -Every change gets a Git commit with descriptive messages like `lnk: added .vimrc`. - ## Usage -### Initialize Once +### Setup ```bash -lnk init # Local repository -lnk init -r git@github.com:username/dotfiles.git # With remote +# Fresh start +lnk init + +# With existing repo +lnk init -r git@github.com:user/dotfiles.git ``` -**Safety features** (because your dotfiles matter): - -- ✅ Idempotent - run multiple times safely -- ✅ Protects existing repositories from overwrite -- ✅ Validates remote conflicts before changes - -### Manage Files & Directories +### Daily workflow ```bash -lnk add ~/.bashrc ~/.vimrc ~/.tmux.conf # Add multiple files -lnk add ~/.config/nvim ~/.ssh # Add entire directories -lnk rm ~/.bashrc # Remove from management +# Add files/directories +lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig + +# Check status +lnk status + +# Sync changes +lnk push "updated vim config" +lnk pull ``` -### Sync Commands +## How it works -```bash -lnk status # Check sync status with remote -lnk push "Update vim configuration" # Stage, commit, and push changes -lnk pull # Pull changes and restore symlinks +``` +Before: ~/.vimrc (file) +After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink) ``` -**Smart sync features**: +Your files live in `~/.config/lnk` (a Git repo). Lnk creates symlinks back to original locations. Edit files normally, use Git normally. -- ✅ Only commits when there are actual changes -- ✅ Automatic symlink restoration after pull -- ✅ Clear status reporting (commits ahead/behind) -- ✅ Graceful error handling for missing remotes +## Why not just Git? -### Real-World Workflow +You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts: -```bash -# Set up on new machine -lnk init -r git@github.com:you/dotfiles.git -lnk pull # Get your existing dotfiles with automatic symlink restoration - -# Or clone existing manually for complex setups -git clone git@github.com:you/dotfiles.git ~/.config/lnk -cd ~/.config/lnk && find . -name ".*" -exec ln -sf ~/.config/lnk/{} ~/{} \; -``` +- Moving files safely +- Creating relative symlinks +- Handling conflicts +- Tracking what's managed ## Examples -
-📁 Common Development Setup +### First time setup ```bash -# Initialize with remote (recommended) lnk init -r git@github.com:you/dotfiles.git - -# Shell & terminal -lnk add ~/.bashrc ~/.zshrc ~/.tmux.conf - -# Development tools -lnk add ~/.vimrc ~/.gitconfig ~/.ssh/config - -# Language-specific -lnk add ~/.npmrc ~/.cargo/config.toml ~/.pylintrc - -# Push to remote with sync command -lnk push "Initial dotfiles setup" - -# Check what's managed and sync status -lnk status -cd ~/.config/lnk && git log --oneline -# 7f3a12c lnk: Initial dotfiles setup -# 4e8b33d lnk: added .cargo/config.toml -# 2a9c45e lnk: added .npmrc +lnk add ~/.bashrc ~/.vimrc ~/.gitconfig +lnk push "initial setup" ``` -
- -
-🔄 Multi-Machine Sync +### On a new machine ```bash -# Machine 1: Initial setup lnk init -r git@github.com:you/dotfiles.git -lnk add ~/.vimrc ~/.bashrc -lnk push "Setup from machine 1" - -# Machine 2: Clone existing -lnk init -r git@github.com:you/dotfiles.git -lnk pull # Automatically restores symlinks - -# Daily workflow: Keep machines in sync -lnk status # Check if changes need syncing -lnk push "Updated vim configuration" # Share your changes -lnk pull # Get changes from other machines - -# Example sync session -lnk status -# Your branch is ahead of 'origin/main' by 2 commit(s) - -lnk push "Added new aliases and vim plugins" -# Successfully pushed changes to remote - -lnk pull # On other machine -# Successfully pulled changes and restored 0 symlink(s) +lnk pull # auto-creates symlinks ``` -
- -
-🔄 Smart Sync Workflow +### Daily edits ```bash -# Check current status -lnk status -# Repository is up to date with remote - -# Make changes to your dotfiles -vim ~/.vimrc # Edit managed file - -# Check what needs syncing -lnk status -# Your branch is ahead of 'origin/main' by 1 commit(s) - -# Sync changes with descriptive message -lnk push "Added syntax highlighting and line numbers" -# Successfully pushed changes to remote - -# On another machine -lnk pull -# Successfully pulled changes and restored 1 symlink(s): -# - .vimrc - -# Verify sync status -lnk status -# Repository is up to date with remote +vim ~/.vimrc # edit normally +lnk status # check what changed +lnk push "new plugins" # commit & push ``` -
+## Commands -## Technical Details +- `lnk init [-r remote]` - Create repo +- `lnk add ` - Move files to repo, create symlinks +- `lnk rm ` - Move files back, remove symlinks +- `lnk status` - Git status + sync info +- `lnk push [msg]` - Stage all, commit, push +- `lnk pull` - Pull + restore missing symlinks -### Architecture +## Technical bits -``` -cmd/ # CLI layer (Cobra) -├── init.go # Repository initialization -├── add.go # File adoption & symlinking -├── rm.go # File restoration -├── status.go # Sync status reporting -├── push.go # Smart commit and push -└── pull.go # Pull with symlink restoration +- **Single binary** (~8MB, no deps) +- **Atomic operations** (rollback on failure) +- **Relative symlinks** (portable) +- **XDG compliant** (`~/.config/lnk`) +- **20 integration tests** -internal/ -├── core/ # Business logic -├── fs/ # File system operations -└── git/ # Git automation & sync -``` +## Alternatives -### What Makes It Robust - -- **20 integration tests** covering edge cases and error conditions -- **Zero external dependencies** at runtime -- **Atomic operations** with automatic rollback on failure -- **Relative symlinks** for cross-platform compatibility -- **XDG compliance** with fallback to `~/.config` - -### Feature Positioning - -| Feature | Lnk | Dotbot | yadm | chezmoi | Home Manager | -| ----------------------- | ------- | ------- | ----- | -------- | ------------ | -| **Simplicity** | ✅ | ✅ | ❌ | ❌ | ❌ | -| **Safety/Edge Cases** | ✅ | ❌ | ⚠️ | ✅ | ✅ | -| **Git Integration** | ✅ | ❌ | ✅ | ⚠️ | ❌ | -| **Zero Dependencies** | ✅ | ❌ | ❌ | ✅ | ❌ | -| **Cross-Platform** | ✅ | ✅ | ⚠️ | ✅ | ⚠️ | -| **Learning Curve** | Minutes | Minutes | Hours | Days | Weeks | -| **File Templating** | ❌ | ❌ | Basic | Advanced | Advanced | -| **Built-in Encryption** | ❌ | ❌ | ✅ | ✅ | Plugin | -| **Package Management** | ❌ | ❌ | ❌ | ❌ | ✅ | - -**Lnk's niche**: Maximum safety and Git integration with minimum complexity. - -### Performance - -- **Single binary**: ~8MB, starts in <10ms -- **Minimal I/O**: Only touches files being managed -- **Git efficiency**: Uses native Git commands, not libraries +| Tool | Complexity | Why choose it | +| ------- | ---------- | ------------------------------------- | +| **lnk** | Minimal | Just works, no config, Git-native | +| chezmoi | High | Templates, encryption, cross-platform | +| yadm | Medium | Git power user, encryption | +| dotbot | Low | YAML config, basic features | +| stow | Low | Perl, symlink only | ## FAQ -
-How is this different from other dotfiles managers? +**Q: What if I already have dotfiles in Git?** +A: `git clone your-repo ~/.config/lnk && lnk add ~/.vimrc` (adopts existing files) -| Tool | Stars | Approach | Complexity | Learning Curve | Git Integration | Cross-Platform | Key Strength | -| ------------ | ----- | ------------------------ | ------------- | -------------- | --------------- | -------------- | ---------------------- | -| **Lnk** | - | Simple symlinks + safety | **Minimal** | **Minutes** | **Native** | ✅ | **Safe simplicity** | -| chezmoi | 15k | Templates + encryption | High | Hours/Days | Abstracted | ✅ | Feature completeness | -| Mackup | 14.9k | App config sync | Medium | Hours | Manual | macOS/Linux | GUI app settings | -| Home Manager | 8.1k | Declarative Nix | **Very High** | **Weeks** | Manual | Linux/macOS | Package + config unity | -| Dotbot | 7.4k | YAML symlinks | Low | Minutes | Manual | ✅ | Pure simplicity | -| yadm | 5.7k | Git wrapper | Medium | Hours | **Native** | Unix-like | Git-centric power | +**Q: How do I handle machine-specific configs?** +A: Git branches, or just don't manage machine-specific files with lnk -**Lnk fills the "safe simplicity" gap** – easier than chezmoi/yadm, safer than Dotbot, more capable than plain Git. +**Q: Windows support?** +A: Symlinks work on Windows 10+, but untested -
+**Q: Production ready?** +A: I use it daily. It won't break your files. API might change (pre-1.0). -
-Why choose Lnk over the alternatives? - -**Choose Lnk if you want:** - -- ✅ **Safety first**: Bulletproof edge case handling, won't break existing setups -- ✅ **Git-native workflow**: No abstractions, just clean commits with clear messages -- ✅ **Zero learning curve**: 3 commands, works like Git, no configuration files -- ✅ **Zero dependencies**: Single binary, no Python/Node/Ruby runtime requirements -- ✅ **Production ready**: Comprehensive test suite, proper error handling - -**Choose others if you need:** - -- **chezmoi**: Heavy templating, password manager integration, Windows-first -- **Mackup**: GUI app settings sync via Dropbox/iCloud (macOS focus) -- **Home Manager**: Nix ecosystem, package management, declarative everything -- **Dotbot**: Ultra-minimal YAML configuration (no safety features) -- **yadm**: Git power user features, encryption, bare repo workflow - -**The sweet spot**: Lnk is for developers who want dotfiles management **without the ceremony** – all the safety and Git integration you need, none of the complexity you don't. - -
- -
-When NOT to use Lnk? - -**Lnk might not be for you if you need:** - -❌ **File templating**: Different configs per machine → use **chezmoi** -❌ **Built-in encryption**: Secrets in dotfiles → use **chezmoi** or **yadm** -❌ **GUI app settings**: Mac app preferences → use **Mackup** -❌ **Package management**: Installing software → use **Home Manager** (Nix) -❌ **Complex workflows**: Multi-step bootstrapping → use **chezmoi** or custom scripts -❌ **Windows-first**: Native Windows support → use **chezmoi** - -**Lnk's philosophy**: Do one thing (symlink management) extremely well, let other tools handle their specialties. You can always combine Lnk with other tools as needed. - -
- -
-What if I already have a dotfiles repo? +## Contributing ```bash -# Clone your existing repo to the lnk location -git clone your-repo ~/.config/lnk - -# Lnk works with any Git repo structure -lnk add ~/.vimrc # Adopts existing files safely +git clone https://github.com/yarlson/lnk.git +cd lnk +make deps # Install golangci-lint +make check # Runs fmt, vet, lint, test ``` -
+**What we use:** -
-Is this production ready? +- **Runtime deps**: Only `cobra` (CLI framework) +- **Test deps**: `testify` for assertions +- **Build pipeline**: Standard Makefile with quality checks -**Yes, with caveats.** Lnk is thoroughly tested and handles edge cases well, but it's actively developed. - -✅ **Safe to use**: Won't corrupt your files -✅ **Well tested**: Comprehensive integration test suite -⚠️ **API stability**: Commands may evolve (following semver) - -**Recommendation**: Try it on non-critical dotfiles first. - -
- -## Development - -### Quick Dev Setup +**Before submitting:** ```bash -git clone https://github.com/yarlson/lnk.git && cd lnk -make test # Run integration tests -make build # Build binary -make dev # Watch & rebuild +make check # Runs all quality checks + tests ``` -### Contributing +**Adding features:** -We follow standard Go practices: - -- **Tests first**: All features need integration tests -- **Conventional commits**: `feat:`, `fix:`, `docs:`, etc. -- **No dependencies**: Keep the runtime dependency-free +- Put integration tests in `test/integration_test.go` +- Use conventional commits: `feat:`, `fix:`, `docs:` ## License -MIT License - see [LICENSE](LICENSE) file for details. - ---- - -**Made by developers, for developers.** Star ⭐ if this saves you time. +[MIT](LICENSE) diff --git a/cmd/init.go b/cmd/init.go index e931f16..2a204f5 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -15,15 +15,12 @@ var initCmd = &cobra.Command{ remote, _ := cmd.Flags().GetString("remote") lnk := core.NewLnk() - if err := lnk.Init(); err != nil { + if err := lnk.InitWithRemote(remote); err != nil { return fmt.Errorf("failed to initialize lnk: %w", err) } if remote != "" { - if err := lnk.AddRemote("origin", remote); err != nil { - return fmt.Errorf("failed to add remote: %w", err) - } - fmt.Printf("Initialized lnk repository with remote: %s\n", remote) + fmt.Printf("Initialized lnk repository by cloning: %s\n", remote) } else { fmt.Println("Initialized lnk repository") } @@ -33,6 +30,6 @@ var initCmd = &cobra.Command{ } func init() { - initCmd.Flags().StringP("remote", "r", "", "Add origin remote URL to the repository") + initCmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository") rootCmd.AddCommand(initCmd) } diff --git a/internal/core/lnk.go b/internal/core/lnk.go index ac9eb43..f7bf1a1 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -45,6 +45,16 @@ func getRepoPath() string { // Init initializes the lnk repository func (l *Lnk) Init() error { + return l.InitWithRemote("") +} + +// InitWithRemote initializes the lnk repository, optionally cloning from a remote +func (l *Lnk) InitWithRemote(remoteURL string) error { + if remoteURL != "" { + // Clone from remote + return l.Clone(remoteURL) + } + // Create the repository directory if err := os.MkdirAll(l.repoPath, 0755); err != nil { return fmt.Errorf("failed to create lnk directory: %w", err) @@ -70,6 +80,14 @@ func (l *Lnk) Init() error { return nil } +// Clone clones a repository from the given URL +func (l *Lnk) Clone(url string) error { + if err := l.git.Clone(url); err != nil { + return fmt.Errorf("failed to clone repository: %w", err) + } + return nil +} + // AddRemote adds a remote to the repository func (l *Lnk) AddRemote(name, url string) error { if err := l.git.AddRemote(name, url); err != nil { diff --git a/internal/git/git.go b/internal/git/git.go index 8e801bc..9ab5f15 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -457,3 +457,43 @@ func (g *Git) Pull() error { return nil } + +// Clone clones a repository from the given URL +func (g *Git) Clone(url string) error { + // Remove the directory if it exists to ensure clean clone + if err := os.RemoveAll(g.repoPath); err != nil { + return fmt.Errorf("failed to remove existing directory: %w", err) + } + + // Create parent directory + parentDir := filepath.Dir(g.repoPath) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + // Clone the repository + cmd := exec.Command("git", "clone", url, g.repoPath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output)) + } + + // Set up upstream tracking for main branch + cmd = exec.Command("git", "branch", "--set-upstream-to=origin/main", "main") + cmd.Dir = g.repoPath + _, err = cmd.CombinedOutput() + if err != nil { + // If main doesn't exist, try master + cmd = exec.Command("git", "branch", "--set-upstream-to=origin/master", "master") + cmd.Dir = g.repoPath + _, err = cmd.CombinedOutput() + if err != nil { + // If that also fails, try to set upstream for current branch + cmd = exec.Command("git", "branch", "--set-upstream-to=origin/HEAD") + cmd.Dir = g.repoPath + _, _ = cmd.CombinedOutput() // Ignore error as this is best effort + } + } + + return nil +}