mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-24 21:11:27 +02:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
093cc8ebe7 | ||
|
ff3cddc065 | ||
|
4a275ce4ca | ||
|
69c1038f3e | ||
|
c670ac1fd8 | ||
|
27196e3341 | ||
|
84c507828d | ||
|
d02f112200 |
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
155
README.md
155
README.md
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
**Git-native dotfiles management that doesn't suck.**
|
**Git-native dotfiles management that doesn't suck.**
|
||||||
|
|
||||||
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. That's it.
|
Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lnk init
|
lnk init
|
||||||
lnk add ~/.vimrc ~/.bashrc
|
lnk add ~/.vimrc ~/.bashrc # Common config
|
||||||
|
lnk add --host work ~/.ssh/config # Host-specific config
|
||||||
lnk push "setup"
|
lnk push "setup"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -49,25 +50,85 @@ lnk init -r git@github.com:user/dotfiles.git
|
|||||||
### Daily workflow
|
### Daily workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add files/directories
|
# Add files/directories (common config)
|
||||||
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig
|
||||||
|
|
||||||
|
# Add host-specific files
|
||||||
|
lnk add --host laptop ~/.ssh/config
|
||||||
|
lnk add --host work ~/.aws/credentials
|
||||||
|
|
||||||
|
# List managed files
|
||||||
|
lnk list # Common config only
|
||||||
|
lnk list --host laptop # Laptop-specific config
|
||||||
|
lnk list --all # All configurations
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
lnk status
|
lnk status
|
||||||
|
|
||||||
# Sync changes
|
# Sync changes
|
||||||
lnk push "updated vim config"
|
lnk push "updated vim config"
|
||||||
lnk pull
|
lnk pull # Pull common config
|
||||||
|
lnk pull --host laptop # Pull laptop-specific config
|
||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
|
Common files:
|
||||||
Before: ~/.vimrc (file)
|
Before: ~/.vimrc (file)
|
||||||
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
|
After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink)
|
||||||
|
|
||||||
|
Host-specific files:
|
||||||
|
Before: ~/.ssh/config (file)
|
||||||
|
After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink)
|
||||||
```
|
```
|
||||||
|
|
||||||
Your files live in `~/.config/lnk` (a Git repo). Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `<host>.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally.
|
||||||
|
|
||||||
|
## Multihost Support
|
||||||
|
|
||||||
|
Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine).
|
||||||
|
|
||||||
|
### File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/lnk/
|
||||||
|
├── .lnk # Tracks common files
|
||||||
|
├── .lnk.laptop # Tracks laptop-specific files
|
||||||
|
├── .lnk.work # Tracks work-specific files
|
||||||
|
├── .vimrc # Common file
|
||||||
|
├── .gitconfig # Common file
|
||||||
|
├── laptop.lnk/ # Laptop-specific storage
|
||||||
|
│ ├── .ssh/
|
||||||
|
│ │ └── config
|
||||||
|
│ └── .aws/
|
||||||
|
│ └── credentials
|
||||||
|
└── work.lnk/ # Work-specific storage
|
||||||
|
├── .ssh/
|
||||||
|
│ └── config
|
||||||
|
└── .company/
|
||||||
|
└── config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Patterns
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Common config (shared everywhere)
|
||||||
|
lnk add ~/.vimrc ~/.bashrc ~/.gitconfig
|
||||||
|
|
||||||
|
# Host-specific config (unique per machine)
|
||||||
|
lnk add --host $(hostname) ~/.ssh/config
|
||||||
|
lnk add --host work ~/.aws/credentials
|
||||||
|
|
||||||
|
# List configurations
|
||||||
|
lnk list # Common only
|
||||||
|
lnk list --host work # Work host only
|
||||||
|
lnk list --all # Everything
|
||||||
|
|
||||||
|
# Pull configurations
|
||||||
|
lnk pull # Common config
|
||||||
|
lnk pull --host work # Work-specific config
|
||||||
|
```
|
||||||
|
|
||||||
## Why not just Git?
|
## Why not just Git?
|
||||||
|
|
||||||
@@ -84,7 +145,13 @@ You could `git init ~/.config/lnk` and manually symlink everything. Lnk just aut
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
|
|
||||||
|
# Add common config (shared across all machines)
|
||||||
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
lnk add ~/.bashrc ~/.vimrc ~/.gitconfig
|
||||||
|
|
||||||
|
# Add host-specific config
|
||||||
|
lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials
|
||||||
|
|
||||||
lnk push "initial setup"
|
lnk push "initial setup"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,57 +159,75 @@ lnk push "initial setup"
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
lnk init -r git@github.com:you/dotfiles.git
|
lnk init -r git@github.com:you/dotfiles.git
|
||||||
lnk pull # auto-creates symlinks
|
|
||||||
|
# Pull common config
|
||||||
|
lnk pull
|
||||||
|
|
||||||
|
# Pull host-specific config (if it exists)
|
||||||
|
lnk pull --host $(hostname)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Daily edits
|
### Daily edits
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vim ~/.vimrc # edit normally
|
vim ~/.vimrc # edit normally
|
||||||
lnk status # check what changed
|
lnk list # see common config
|
||||||
lnk push "new plugins" # commit & push
|
lnk list --host $(hostname) # see host-specific config
|
||||||
|
lnk list --all # see everything
|
||||||
|
lnk status # check what changed
|
||||||
|
lnk push "new plugins" # commit & push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-machine workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On your laptop
|
||||||
|
lnk add --host laptop ~/.ssh/config
|
||||||
|
lnk add ~/.vimrc # Common config
|
||||||
|
lnk push "laptop ssh config"
|
||||||
|
|
||||||
|
# On your work machine
|
||||||
|
lnk pull # Get common config
|
||||||
|
lnk add --host work ~/.aws/credentials
|
||||||
|
lnk push "work aws config"
|
||||||
|
|
||||||
|
# Back on laptop
|
||||||
|
lnk pull # Get updates (work config won't affect laptop)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `lnk init [-r remote]` - Create repo
|
- `lnk init [-r remote]` - Create repo
|
||||||
- `lnk add <files>` - Move files to repo, create symlinks
|
- `lnk add [--host HOST] <files>` - Move files to repo, create symlinks
|
||||||
- `lnk rm <files>` - Move files back, remove symlinks
|
- `lnk rm [--host HOST] <files>` - Move files back, remove symlinks
|
||||||
|
- `lnk list [--host HOST] [--all]` - List files managed by lnk
|
||||||
- `lnk status` - Git status + sync info
|
- `lnk status` - Git status + sync info
|
||||||
- `lnk push [msg]` - Stage all, commit, push
|
- `lnk push [msg]` - Stage all, commit, push
|
||||||
- `lnk pull` - Pull + restore missing symlinks
|
- `lnk pull [--host HOST]` - Pull + restore missing symlinks
|
||||||
|
|
||||||
|
### Command Options
|
||||||
|
|
||||||
|
- `--host HOST` - Manage files for specific host (default: common configuration)
|
||||||
|
- `--all` - Show all configurations (common + all hosts) when listing
|
||||||
|
- `-r, --remote URL` - Clone from remote URL when initializing
|
||||||
|
|
||||||
## Technical bits
|
## Technical bits
|
||||||
|
|
||||||
- **Single binary** (~8MB, no deps)
|
- **Single binary** (~8MB, no deps)
|
||||||
- **Atomic operations** (rollback on failure)
|
|
||||||
- **Relative symlinks** (portable)
|
- **Relative symlinks** (portable)
|
||||||
- **XDG compliant** (`~/.config/lnk`)
|
- **XDG compliant** (`~/.config/lnk`)
|
||||||
- **20 integration tests**
|
- **Multihost support** (common + host-specific configs)
|
||||||
|
- **Git-native** (standard Git repo, no special formats)
|
||||||
|
|
||||||
## Alternatives
|
## Alternatives
|
||||||
|
|
||||||
| Tool | Complexity | Why choose it |
|
| Tool | Complexity | Why choose it |
|
||||||
| ------- | ---------- | ------------------------------------- |
|
| ------- | ---------- | -------------------------------------------- |
|
||||||
| **lnk** | Minimal | Just works, no config, Git-native |
|
| **lnk** | Minimal | Just works, no config, Git-native, multihost |
|
||||||
| chezmoi | High | Templates, encryption, cross-platform |
|
| chezmoi | High | Templates, encryption, cross-platform |
|
||||||
| yadm | Medium | Git power user, encryption |
|
| yadm | Medium | Git power user, encryption |
|
||||||
| dotbot | Low | YAML config, basic features |
|
| dotbot | Low | YAML config, basic features |
|
||||||
| stow | Low | Perl, symlink only |
|
| stow | Low | Perl, symlink only |
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: What if I already have dotfiles in Git?**
|
|
||||||
A: `git clone your-repo ~/.config/lnk && lnk add ~/.vimrc` (adopts existing files)
|
|
||||||
|
|
||||||
**Q: How do I handle machine-specific configs?**
|
|
||||||
A: Git branches, or just don't manage machine-specific files with lnk
|
|
||||||
|
|
||||||
**Q: Windows support?**
|
|
||||||
A: Symlinks work on Windows 10+, but untested
|
|
||||||
|
|
||||||
**Q: Production ready?**
|
|
||||||
A: I use it daily. It won't break your files. API might change (pre-1.0).
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
23
cmd/add.go
23
cmd/add.go
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newAddCmd() *cobra.Command {
|
func newAddCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <file>",
|
Use: "add <file>",
|
||||||
Short: "✨ Add a file to lnk management",
|
Short: "✨ Add a file to lnk management",
|
||||||
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
||||||
@@ -17,17 +17,32 @@ func newAddCmd() *cobra.Command {
|
|||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
|
var lnk *core.Lnk
|
||||||
|
if host != "" {
|
||||||
|
lnk = core.NewLnkWithHost(host)
|
||||||
|
} else {
|
||||||
|
lnk = core.NewLnk()
|
||||||
|
}
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
|
||||||
if err := lnk.Add(filePath); err != nil {
|
if err := lnk.Add(filePath); err != nil {
|
||||||
return fmt.Errorf("failed to add file: %w", err)
|
return fmt.Errorf("failed to add file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
if host != "" {
|
||||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
|
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, basename)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||||
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
|
||||||
|
}
|
||||||
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
193
cmd/list.go
Normal file
193
cmd/list.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yarlson/lnk/internal/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newListCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "📋 List files managed by lnk",
|
||||||
|
Long: "Display all files and directories currently managed by lnk.",
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
all, _ := cmd.Flags().GetBool("all")
|
||||||
|
|
||||||
|
if host != "" {
|
||||||
|
// Show specific host configuration
|
||||||
|
return listHostConfig(cmd, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if all {
|
||||||
|
// Show all configurations (common + all hosts)
|
||||||
|
return listAllConfigs(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: show common configuration
|
||||||
|
return listCommonConfig(cmd)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "List files for specific host")
|
||||||
|
cmd.Flags().BoolP("all", "a", false, "List files for all hosts and common configuration")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func listCommonConfig(cmd *cobra.Command) error {
|
||||||
|
lnk := core.NewLnk()
|
||||||
|
managedItems, err := lnk.List()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list managed items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(managedItems) == 0 {
|
||||||
|
printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n")
|
||||||
|
printf(cmd, " 💡 Use \033[1mlnk add <file>\033[0m to start managing files\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
|
||||||
|
if len(managedItems) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n\n")
|
||||||
|
|
||||||
|
for _, item := range managedItems {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||||
|
lnk := core.NewLnkWithHost(host)
|
||||||
|
managedItems, err := lnk.List()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list managed items for host %s: %w", host, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(managedItems) == 0 {
|
||||||
|
printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host)
|
||||||
|
printf(cmd, " 💡 Use \033[1mlnk add --host %s <file>\033[0m to start managing files\n", host)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
|
||||||
|
if len(managedItems) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n\n")
|
||||||
|
|
||||||
|
for _, item := range managedItems {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAllConfigs(cmd *cobra.Command) error {
|
||||||
|
// List common configuration
|
||||||
|
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
||||||
|
|
||||||
|
lnk := core.NewLnk()
|
||||||
|
commonItems, err := lnk.List()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list common managed items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
|
||||||
|
if len(commonItems) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n")
|
||||||
|
|
||||||
|
if len(commonItems) == 0 {
|
||||||
|
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||||
|
} else {
|
||||||
|
for _, item := range commonItems {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all host-specific configurations
|
||||||
|
hosts, err := findHostConfigs()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find host configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||||
|
|
||||||
|
hostLnk := core.NewLnkWithHost(host)
|
||||||
|
hostItems, err := hostLnk.List()
|
||||||
|
if err != nil {
|
||||||
|
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, " (\033[36m%d item", len(hostItems))
|
||||||
|
if len(hostItems) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m):\n")
|
||||||
|
|
||||||
|
if len(hostItems) == 0 {
|
||||||
|
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||||
|
} else {
|
||||||
|
for _, item := range hostItems {
|
||||||
|
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n💡 Use \033[1mlnk list --host <hostname>\033[0m to see specific host configuration\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findHostConfigs() ([]string, error) {
|
||||||
|
repoPath := getRepoPath()
|
||||||
|
|
||||||
|
// Check if repo exists
|
||||||
|
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read repository directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hosts []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
// Look for .lnk.<hostname> files
|
||||||
|
if strings.HasPrefix(name, ".lnk.") && name != ".lnk" {
|
||||||
|
host := strings.TrimPrefix(name, ".lnk.")
|
||||||
|
hosts = append(hosts, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRepoPath() string {
|
||||||
|
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if xdgConfig == "" {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
xdgConfig = "."
|
||||||
|
} else {
|
||||||
|
xdgConfig = filepath.Join(homeDir, ".config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(xdgConfig, "lnk")
|
||||||
|
}
|
27
cmd/pull.go
27
cmd/pull.go
@@ -8,20 +8,32 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newPullCmd() *cobra.Command {
|
func newPullCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "pull",
|
Use: "pull",
|
||||||
Short: "⬇️ Pull changes from remote and restore symlinks",
|
Short: "⬇️ Pull changes from remote and restore symlinks",
|
||||||
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
lnk := core.NewLnk()
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
|
var lnk *core.Lnk
|
||||||
|
if host != "" {
|
||||||
|
lnk = core.NewLnkWithHost(host)
|
||||||
|
} else {
|
||||||
|
lnk = core.NewLnk()
|
||||||
|
}
|
||||||
|
|
||||||
restored, err := lnk.Pull()
|
restored, err := lnk.Pull()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to pull changes: %w", err)
|
return fmt.Errorf("failed to pull changes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(restored) > 0 {
|
if len(restored) > 0 {
|
||||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
if host != "" {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||||
|
}
|
||||||
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
||||||
if len(restored) > 1 {
|
if len(restored) > 1 {
|
||||||
printf(cmd, "s")
|
printf(cmd, "s")
|
||||||
@@ -32,7 +44,11 @@ func newPullCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||||
} else {
|
} else {
|
||||||
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
if host != "" {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||||
|
}
|
||||||
printf(cmd, " ✅ All symlinks already in place\n")
|
printf(cmd, " ✅ All symlinks already in place\n")
|
||||||
printf(cmd, " 🎉 Everything is up to date!\n")
|
printf(cmd, " 🎉 Everything is up to date!\n")
|
||||||
}
|
}
|
||||||
@@ -40,4 +56,7 @@ func newPullCmd() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
23
cmd/rm.go
23
cmd/rm.go
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newRemoveCmd() *cobra.Command {
|
func newRemoveCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm <file>",
|
Use: "rm <file>",
|
||||||
Short: "🗑️ Remove a file from lnk management",
|
Short: "🗑️ Remove a file from lnk management",
|
||||||
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
||||||
@@ -17,17 +17,32 @@ func newRemoveCmd() *cobra.Command {
|
|||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
filePath := args[0]
|
filePath := args[0]
|
||||||
|
host, _ := cmd.Flags().GetString("host")
|
||||||
|
|
||||||
|
var lnk *core.Lnk
|
||||||
|
if host != "" {
|
||||||
|
lnk = core.NewLnkWithHost(host)
|
||||||
|
} else {
|
||||||
|
lnk = core.NewLnk()
|
||||||
|
}
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
|
||||||
if err := lnk.Remove(filePath); err != nil {
|
if err := lnk.Remove(filePath); err != nil {
|
||||||
return fmt.Errorf("failed to remove file: %w", err)
|
return fmt.Errorf("failed to remove file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
basename := filepath.Base(filePath)
|
basename := filepath.Base(filePath)
|
||||||
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
if host != "" {
|
||||||
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
|
printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host)
|
||||||
|
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath)
|
||||||
|
} else {
|
||||||
|
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
||||||
|
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
|
||||||
|
}
|
||||||
printf(cmd, " 📄 Original file restored\n")
|
printf(cmd, " 📄 Original file restored\n")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
17
cmd/root.go
17
cmd/root.go
@@ -20,16 +20,18 @@ func NewRootCommand() *cobra.Command {
|
|||||||
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
||||||
|
|
||||||
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
||||||
That's it.
|
Supports both common configurations and host-specific setups.
|
||||||
|
|
||||||
✨ Examples:
|
✨ Examples:
|
||||||
lnk init # Fresh start
|
lnk init # Fresh start
|
||||||
lnk init -r <repo-url> # Clone existing dotfiles
|
lnk init -r <repo-url> # Clone existing dotfiles
|
||||||
lnk add ~/.vimrc ~/.bashrc # Start managing files
|
lnk add ~/.vimrc ~/.bashrc # Start managing common files
|
||||||
lnk push "setup complete" # Sync to remote
|
lnk add --host work ~/.ssh/config # Manage host-specific files
|
||||||
lnk pull # Get latest changes
|
lnk list --all # Show all configurations
|
||||||
|
lnk pull --host work # Pull host-specific changes
|
||||||
|
lnk push "setup complete" # Sync to remote
|
||||||
|
|
||||||
🎯 Simple, fast, and Git-native.`,
|
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,7 @@ That's it.
|
|||||||
rootCmd.AddCommand(newInitCmd())
|
rootCmd.AddCommand(newInitCmd())
|
||||||
rootCmd.AddCommand(newAddCmd())
|
rootCmd.AddCommand(newAddCmd())
|
||||||
rootCmd.AddCommand(newRemoveCmd())
|
rootCmd.AddCommand(newRemoveCmd())
|
||||||
|
rootCmd.AddCommand(newListCmd())
|
||||||
rootCmd.AddCommand(newStatusCmd())
|
rootCmd.AddCommand(newStatusCmd())
|
||||||
rootCmd.AddCommand(newPushCmd())
|
rootCmd.AddCommand(newPushCmd())
|
||||||
rootCmd.AddCommand(newPullCmd())
|
rootCmd.AddCommand(newPullCmd())
|
||||||
|
211
cmd/root_test.go
211
cmd/root_test.go
@@ -3,6 +3,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -162,6 +163,60 @@ func (suite *CLITestSuite) TestStatusCommand() {
|
|||||||
suite.Contains(err.Error(), "no remote configured")
|
suite.Contains(err.Error(), "no remote configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestListCommand() {
|
||||||
|
// Test list without init - should fail
|
||||||
|
err := suite.runCommand("list")
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "Lnk repository not initialized")
|
||||||
|
|
||||||
|
// Initialize first
|
||||||
|
err = suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test list with no managed files
|
||||||
|
err = suite.runCommand("list")
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "No files currently managed by lnk")
|
||||||
|
suite.Contains(output, "lnk add <file>")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// 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.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test list with one managed file
|
||||||
|
err = suite.runCommand("list")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Files managed by lnk")
|
||||||
|
suite.Contains(output, "1 item")
|
||||||
|
suite.Contains(output, ".bashrc")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Add another file
|
||||||
|
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||||
|
err = os.WriteFile(testFile2, []byte("set number"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = suite.runCommand("add", testFile2)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test list with multiple managed files
|
||||||
|
err = suite.runCommand("list")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Files managed by lnk")
|
||||||
|
suite.Contains(output, "2 items")
|
||||||
|
suite.Contains(output, ".bashrc")
|
||||||
|
suite.Contains(output, ".vimrc")
|
||||||
|
}
|
||||||
|
|
||||||
func (suite *CLITestSuite) TestErrorHandling() {
|
func (suite *CLITestSuite) TestErrorHandling() {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -206,6 +261,12 @@ func (suite *CLITestSuite) TestErrorHandling() {
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
outContains: "Moves a file to the lnk repository",
|
outContains: "Moves a file to the lnk repository",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "list help",
|
||||||
|
args: []string{"list", "--help"},
|
||||||
|
wantErr: false,
|
||||||
|
outContains: "Display all files and directories",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -424,6 +485,156 @@ func (suite *CLITestSuite) TestSameBasenameFilesBug() {
|
|||||||
suite.Equal(contentB, string(restoredContentB), "Restored second file should have original content")
|
suite.Equal(contentB, string(restoredContentB), "Restored second file should have original content")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestStatusDirtyRepo() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Add and commit a file
|
||||||
|
testFile := filepath.Join(suite.tempDir, "a")
|
||||||
|
err = os.WriteFile(testFile, []byte("abc"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.runCommand("add", testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Add a remote so status works
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test/dotfiles.git")
|
||||||
|
cmd.Dir = lnkDir
|
||||||
|
err = cmd.Run()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Status should show clean but ahead
|
||||||
|
err = suite.runCommand("status")
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "1 commit ahead")
|
||||||
|
suite.NotContains(output, "uncommitted changes")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Now edit the managed file (simulating the issue scenario)
|
||||||
|
err = os.WriteFile(testFile, []byte("def"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Status should now detect dirty state and NOT say "up to date"
|
||||||
|
err = suite.runCommand("status")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Repository has uncommitted changes")
|
||||||
|
suite.NotContains(output, "Repository is up to date")
|
||||||
|
suite.Contains(output, "lnk push")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestMultihostCommands() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
|
err = os.WriteFile(testFile1, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||||
|
err = os.WriteFile(testFile2, []byte("set number"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add file to common configuration
|
||||||
|
err = suite.runCommand("add", testFile1)
|
||||||
|
suite.NoError(err)
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Added .bashrc to lnk")
|
||||||
|
suite.NotContains(output, "host:")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Add file to host-specific configuration
|
||||||
|
err = suite.runCommand("add", "--host", "workstation", testFile2)
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Added .vimrc to lnk (host: workstation)")
|
||||||
|
suite.Contains(output, "workstation.lnk")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test list command - common only
|
||||||
|
err = suite.runCommand("list")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Files managed by lnk (common)")
|
||||||
|
suite.Contains(output, ".bashrc")
|
||||||
|
suite.NotContains(output, ".vimrc")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test list command - specific host
|
||||||
|
err = suite.runCommand("list", "--host", "workstation")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Files managed by lnk (host: workstation)")
|
||||||
|
suite.Contains(output, ".vimrc")
|
||||||
|
suite.NotContains(output, ".bashrc")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test list command - all configurations
|
||||||
|
err = suite.runCommand("list", "--all")
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "All configurations managed by lnk")
|
||||||
|
suite.Contains(output, "Common configuration")
|
||||||
|
suite.Contains(output, "Host: workstation")
|
||||||
|
suite.Contains(output, ".bashrc")
|
||||||
|
suite.Contains(output, ".vimrc")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test remove from host-specific
|
||||||
|
err = suite.runCommand("rm", "--host", "workstation", testFile2)
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Removed .vimrc from lnk (host: workstation)")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test remove from common
|
||||||
|
err = suite.runCommand("rm", testFile1)
|
||||||
|
suite.NoError(err)
|
||||||
|
output = suite.stdout.String()
|
||||||
|
suite.Contains(output, "Removed .bashrc from lnk")
|
||||||
|
suite.NotContains(output, "host:")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Verify files are restored
|
||||||
|
info1, err := os.Lstat(testFile1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
info2, err := os.Lstat(testFile2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestMultihostErrorHandling() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Try to remove from non-existent host config
|
||||||
|
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("rm", "--host", "nonexistent", testFile)
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "File is not managed by lnk")
|
||||||
|
|
||||||
|
// Try to list non-existent host config
|
||||||
|
err = suite.runCommand("list", "--host", "nonexistent")
|
||||||
|
suite.NoError(err) // Should not error, just show empty
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "No files currently managed by lnk (host: nonexistent)")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCLISuite(t *testing.T) {
|
func TestCLISuite(t *testing.T) {
|
||||||
suite.Run(t, new(CLITestSuite))
|
suite.Run(t, new(CLITestSuite))
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,7 @@ func newStatusCmd() *cobra.Command {
|
|||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
Short: "📊 Show repository sync status",
|
Short: "📊 Show repository sync status",
|
||||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
|
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
@@ -20,37 +20,74 @@ func newStatusCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to get status: %w", err)
|
return fmt.Errorf("failed to get status: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.Ahead == 0 && status.Behind == 0 {
|
if status.Dirty {
|
||||||
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
displayDirtyStatus(cmd, status)
|
||||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
return nil
|
||||||
} else {
|
|
||||||
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
|
||||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
|
||||||
printf(cmd, "\n")
|
|
||||||
|
|
||||||
if status.Ahead > 0 {
|
|
||||||
commitText := "commit"
|
|
||||||
if status.Ahead > 1 {
|
|
||||||
commitText = "commits"
|
|
||||||
}
|
|
||||||
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
|
||||||
}
|
|
||||||
if status.Behind > 0 {
|
|
||||||
commitText := "commit"
|
|
||||||
if status.Behind > 1 {
|
|
||||||
commitText = "commits"
|
|
||||||
}
|
|
||||||
printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
|
|
||||||
}
|
|
||||||
|
|
||||||
if status.Ahead > 0 && status.Behind == 0 {
|
|
||||||
printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes")
|
|
||||||
} else if status.Behind > 0 {
|
|
||||||
printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status.Ahead == 0 && status.Behind == 0 {
|
||||||
|
displayUpToDateStatus(cmd, status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
displaySyncStatus(cmd, status)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
|
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
||||||
|
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||||
|
|
||||||
|
if status.Ahead == 0 && status.Behind == 0 {
|
||||||
|
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(cmd, "\n")
|
||||||
|
displayAheadBehindInfo(cmd, status, true)
|
||||||
|
printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
|
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
||||||
|
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||||
|
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
||||||
|
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||||
|
printf(cmd, "\n")
|
||||||
|
|
||||||
|
displayAheadBehindInfo(cmd, status, false)
|
||||||
|
|
||||||
|
if status.Ahead > 0 && status.Behind == 0 {
|
||||||
|
printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes\n")
|
||||||
|
} else if status.Behind > 0 {
|
||||||
|
printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
||||||
|
if status.Ahead > 0 {
|
||||||
|
commitText := getCommitText(status.Ahead)
|
||||||
|
if isDirty {
|
||||||
|
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m (excluding uncommitted changes)\n", status.Ahead, commitText)
|
||||||
|
} else {
|
||||||
|
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Behind > 0 {
|
||||||
|
commitText := getCommitText(status.Behind)
|
||||||
|
printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommitText(count int) string {
|
||||||
|
if count == 1 {
|
||||||
|
return "commit"
|
||||||
|
}
|
||||||
|
return "commits"
|
||||||
|
}
|
||||||
|
@@ -14,20 +14,42 @@ import (
|
|||||||
// Lnk represents the main application logic
|
// Lnk represents the main application logic
|
||||||
type Lnk struct {
|
type Lnk struct {
|
||||||
repoPath string
|
repoPath string
|
||||||
|
host string // Host-specific configuration
|
||||||
git *git.Git
|
git *git.Git
|
||||||
fs *fs.FileSystem
|
fs *fs.FileSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLnk creates a new Lnk instance
|
// NewLnk creates a new Lnk instance for common configuration
|
||||||
func NewLnk() *Lnk {
|
func NewLnk() *Lnk {
|
||||||
repoPath := getRepoPath()
|
repoPath := getRepoPath()
|
||||||
return &Lnk{
|
return &Lnk{
|
||||||
repoPath: repoPath,
|
repoPath: repoPath,
|
||||||
|
host: "", // Empty host means common configuration
|
||||||
git: git.New(repoPath),
|
git: git.New(repoPath),
|
||||||
fs: fs.New(),
|
fs: fs.New(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewLnkWithHost creates a new Lnk instance for host-specific configuration
|
||||||
|
func NewLnkWithHost(host string) *Lnk {
|
||||||
|
repoPath := getRepoPath()
|
||||||
|
return &Lnk{
|
||||||
|
repoPath: repoPath,
|
||||||
|
host: host,
|
||||||
|
git: git.New(repoPath),
|
||||||
|
fs: fs.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentHostname returns the current system hostname
|
||||||
|
func GetCurrentHostname() (string, error) {
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
|
}
|
||||||
|
return hostname, nil
|
||||||
|
}
|
||||||
|
|
||||||
// getRepoPath returns the path to the lnk repository directory
|
// getRepoPath returns the path to the lnk repository directory
|
||||||
func getRepoPath() string {
|
func getRepoPath() string {
|
||||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||||
@@ -43,14 +65,38 @@ func getRepoPath() string {
|
|||||||
return filepath.Join(xdgConfig, "lnk")
|
return filepath.Join(xdgConfig, "lnk")
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRepoName creates a unique repository filename from a relative path
|
// generateRepoName creates a repository path from a relative path
|
||||||
func generateRepoName(relativePath string) string {
|
func generateRepoName(relativePath string, host string) string {
|
||||||
// Replace slashes and backslashes with underscores to create valid filename
|
if host != "" {
|
||||||
|
// For host-specific files, preserve the directory structure
|
||||||
|
return relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// For common files, replace slashes and backslashes with underscores to create valid filename
|
||||||
repoName := strings.ReplaceAll(relativePath, "/", "_")
|
repoName := strings.ReplaceAll(relativePath, "/", "_")
|
||||||
repoName = strings.ReplaceAll(repoName, "\\", "_")
|
repoName = strings.ReplaceAll(repoName, "\\", "_")
|
||||||
|
|
||||||
return repoName
|
return repoName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getHostStoragePath returns the storage path for host-specific or common files
|
||||||
|
func (l *Lnk) getHostStoragePath() string {
|
||||||
|
if l.host == "" {
|
||||||
|
// Common configuration - store in root of repo
|
||||||
|
return l.repoPath
|
||||||
|
}
|
||||||
|
// Host-specific configuration - store in host subdirectory
|
||||||
|
return filepath.Join(l.repoPath, l.host+".lnk")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLnkFileName returns the appropriate .lnk tracking file name
|
||||||
|
func (l *Lnk) getLnkFileName() string {
|
||||||
|
if l.host == "" {
|
||||||
|
return ".lnk"
|
||||||
|
}
|
||||||
|
return ".lnk." + l.host
|
||||||
|
}
|
||||||
|
|
||||||
// getRelativePath converts an absolute path to a relative path from home directory
|
// getRelativePath converts an absolute path to a relative path from home directory
|
||||||
func getRelativePath(absPath string) (string, error) {
|
func getRelativePath(absPath string) (string, error) {
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
@@ -69,9 +115,6 @@ func getRelativePath(absPath string) (string, error) {
|
|||||||
if strings.HasPrefix(relPath, "..") {
|
if strings.HasPrefix(relPath, "..") {
|
||||||
// Use absolute path but remove leading slash and drive letter (for cross-platform)
|
// Use absolute path but remove leading slash and drive letter (for cross-platform)
|
||||||
cleanPath := strings.TrimPrefix(absPath, "/")
|
cleanPath := strings.TrimPrefix(absPath, "/")
|
||||||
if len(cleanPath) > 1 && cleanPath[1] == ':' {
|
|
||||||
// Windows drive letter, keep as is
|
|
||||||
}
|
|
||||||
return cleanPath, nil
|
return cleanPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,9 +193,16 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
return fmt.Errorf("failed to get relative path: %w", err)
|
return fmt.Errorf("failed to get relative path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique repository name from relative path
|
// Generate repository path from relative path
|
||||||
repoName := generateRepoName(relativePath)
|
repoName := generateRepoName(relativePath, l.host)
|
||||||
destPath := filepath.Join(l.repoPath, repoName)
|
storagePath := l.getHostStoragePath()
|
||||||
|
destPath := filepath.Join(storagePath, repoName)
|
||||||
|
|
||||||
|
// Ensure destination directory exists (including parent directories for host-specific files)
|
||||||
|
destDir := filepath.Dir(destPath)
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this relative path is already managed
|
// Check if this relative path is already managed
|
||||||
managedItems, err := l.getManagedItems()
|
managedItems, err := l.getManagedItems()
|
||||||
@@ -206,7 +256,12 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add both the item and .lnk file to git in a single commit
|
// Add both the item and .lnk file to git in a single commit
|
||||||
if err := l.git.Add(repoName); err != nil {
|
// For host-specific files, we need to add the relative path from repo root
|
||||||
|
gitPath := repoName
|
||||||
|
if l.host != "" {
|
||||||
|
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||||
|
}
|
||||||
|
if err := l.git.Add(gitPath); err != nil {
|
||||||
// Try to restore the original state if git add fails
|
// Try to restore the original state if git add fails
|
||||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||||
@@ -219,7 +274,7 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add .lnk file to the same commit
|
// Add .lnk file to the same commit
|
||||||
if err := l.git.Add(".lnk"); err != nil {
|
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||||
// Try to restore the original state if git add fails
|
// Try to restore the original state if git add fails
|
||||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||||
@@ -295,8 +350,6 @@ func (l *Lnk) Remove(filePath string) error {
|
|||||||
target = filepath.Join(filepath.Dir(absPath), target)
|
target = filepath.Join(filepath.Dir(absPath), target)
|
||||||
}
|
}
|
||||||
|
|
||||||
repoName := filepath.Base(target)
|
|
||||||
|
|
||||||
// Check if target is a directory or file
|
// Check if target is a directory or file
|
||||||
info, err := os.Stat(target)
|
info, err := os.Stat(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -313,13 +366,18 @@ func (l *Lnk) Remove(filePath string) error {
|
|||||||
return fmt.Errorf("failed to update tracking file: %w", err)
|
return fmt.Errorf("failed to update tracking file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from Git first (while the item is still in the repository)
|
// Generate the correct git path for removal
|
||||||
if err := l.git.Remove(repoName); err != nil {
|
repoName := generateRepoName(relativePath, l.host)
|
||||||
|
gitPath := repoName
|
||||||
|
if l.host != "" {
|
||||||
|
gitPath = filepath.Join(l.host+".lnk", repoName)
|
||||||
|
}
|
||||||
|
if err := l.git.Remove(gitPath); err != nil {
|
||||||
return fmt.Errorf("failed to remove from git: %w", err)
|
return fmt.Errorf("failed to remove from git: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add .lnk file to the same commit
|
// Add .lnk file to the same commit
|
||||||
if err := l.git.Add(".lnk"); err != nil {
|
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||||
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +411,7 @@ type StatusInfo struct {
|
|||||||
Ahead int
|
Ahead int
|
||||||
Behind int
|
Behind int
|
||||||
Remote string
|
Remote string
|
||||||
|
Dirty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status returns the repository sync status
|
// Status returns the repository sync status
|
||||||
@@ -371,6 +430,7 @@ func (l *Lnk) Status() (*StatusInfo, error) {
|
|||||||
Ahead: gitStatus.Ahead,
|
Ahead: gitStatus.Ahead,
|
||||||
Behind: gitStatus.Behind,
|
Behind: gitStatus.Behind,
|
||||||
Remote: gitStatus.Remote,
|
Remote: gitStatus.Remote,
|
||||||
|
Dirty: gitStatus.Dirty,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +489,22 @@ func (l *Lnk) Pull() ([]string, error) {
|
|||||||
return restored, nil
|
return restored, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List returns the list of files and directories currently managed by lnk
|
||||||
|
func (l *Lnk) List() ([]string, error) {
|
||||||
|
// Check if repository is initialized
|
||||||
|
if !l.git.IsGitRepository() {
|
||||||
|
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get managed items from .lnk file
|
||||||
|
managedItems, err := l.getManagedItems()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get managed items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return managedItems, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks
|
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks
|
||||||
func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||||
var restored []string
|
var restored []string
|
||||||
@@ -446,8 +522,9 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
|||||||
|
|
||||||
for _, relativePath := range managedItems {
|
for _, relativePath := range managedItems {
|
||||||
// Generate repository name from relative path
|
// Generate repository name from relative path
|
||||||
repoName := generateRepoName(relativePath)
|
repoName := generateRepoName(relativePath, l.host)
|
||||||
repoItem := filepath.Join(l.repoPath, repoName)
|
storagePath := l.getHostStoragePath()
|
||||||
|
repoItem := filepath.Join(storagePath, repoName)
|
||||||
|
|
||||||
// Check if item exists in repository
|
// Check if item exists in repository
|
||||||
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
|
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
|
||||||
@@ -525,7 +602,7 @@ func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
|
|||||||
|
|
||||||
// getManagedItems returns the list of managed files and directories from .lnk file
|
// getManagedItems returns the list of managed files and directories from .lnk file
|
||||||
func (l *Lnk) getManagedItems() ([]string, error) {
|
func (l *Lnk) getManagedItems() ([]string, error) {
|
||||||
lnkFile := filepath.Join(l.repoPath, ".lnk")
|
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
|
||||||
|
|
||||||
// If .lnk file doesn't exist, return empty list
|
// If .lnk file doesn't exist, return empty list
|
||||||
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
|
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
|
||||||
@@ -598,7 +675,7 @@ func (l *Lnk) removeManagedItem(relativePath string) error {
|
|||||||
|
|
||||||
// writeManagedItems writes the list of managed items to .lnk file
|
// writeManagedItems writes the list of managed items to .lnk file
|
||||||
func (l *Lnk) writeManagedItems(items []string) error {
|
func (l *Lnk) writeManagedItems(items []string) error {
|
||||||
lnkFile := filepath.Join(l.repoPath, ".lnk")
|
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
|
||||||
|
|
||||||
content := strings.Join(items, "\n")
|
content := strings.Join(items, "\n")
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
|
@@ -480,6 +480,291 @@ func (suite *CoreTestSuite) TestSameBasenameSequentialAdd() {
|
|||||||
suite.Require().NoError(err, "Second .bashrc should be removable")
|
suite.Require().NoError(err, "Second .bashrc should be removable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test dirty repository status detection
|
||||||
|
func (suite *CoreTestSuite) TestStatusDetectsDirtyRepo() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add and commit a file
|
||||||
|
testFile := filepath.Join(suite.tempDir, "a")
|
||||||
|
err = os.WriteFile(testFile, []byte("abc"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.lnk.Add(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add a remote so status works
|
||||||
|
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Check status - should be clean but ahead of remote
|
||||||
|
status, err := suite.lnk.Status()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(1, status.Ahead)
|
||||||
|
suite.Equal(0, status.Behind)
|
||||||
|
suite.False(status.Dirty, "Repository should not be dirty after commit")
|
||||||
|
|
||||||
|
// Now edit the managed file (simulating the issue scenario)
|
||||||
|
err = os.WriteFile(testFile, []byte("def"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Check status again - should detect dirty state
|
||||||
|
status, err = suite.lnk.Status()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(1, status.Ahead)
|
||||||
|
suite.Equal(0, status.Behind)
|
||||||
|
suite.True(status.Dirty, "Repository should be dirty after editing managed file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test list functionality
|
||||||
|
func (suite *CoreTestSuite) TestListManagedItems() {
|
||||||
|
// Test list without init - should fail
|
||||||
|
_, err := suite.lnk.List()
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "Lnk repository not initialized")
|
||||||
|
|
||||||
|
// Initialize repository
|
||||||
|
err = suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test list with no managed files
|
||||||
|
items, err := suite.lnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Empty(items)
|
||||||
|
|
||||||
|
// Add a file
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
|
content := "export PATH=$PATH:/usr/local/bin"
|
||||||
|
err = os.WriteFile(testFile, []byte(content), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.lnk.Add(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test list with one managed file
|
||||||
|
items, err = suite.lnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(items, 1)
|
||||||
|
suite.Contains(items[0], ".bashrc")
|
||||||
|
|
||||||
|
// Add a directory
|
||||||
|
testDir := filepath.Join(suite.tempDir, ".config")
|
||||||
|
err = os.MkdirAll(testDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
configFile := filepath.Join(testDir, "app.conf")
|
||||||
|
err = os.WriteFile(configFile, []byte("setting=value"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.lnk.Add(testDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test list with multiple managed items
|
||||||
|
items, err = suite.lnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(items, 2)
|
||||||
|
|
||||||
|
// Check that both items are present
|
||||||
|
found := make(map[string]bool)
|
||||||
|
for _, item := range items {
|
||||||
|
if strings.Contains(item, ".bashrc") {
|
||||||
|
found[".bashrc"] = true
|
||||||
|
}
|
||||||
|
if strings.Contains(item, ".config") {
|
||||||
|
found[".config"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suite.True(found[".bashrc"], "Should contain .bashrc")
|
||||||
|
suite.True(found[".config"], "Should contain .config")
|
||||||
|
|
||||||
|
// Remove one item and verify list is updated
|
||||||
|
err = suite.lnk.Remove(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
items, err = suite.lnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(items, 1)
|
||||||
|
suite.Contains(items[0], ".config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test multihost functionality
|
||||||
|
func (suite *CoreTestSuite) TestMultihostFileOperations() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create test files for different hosts
|
||||||
|
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
|
content1 := "export PATH=$PATH:/usr/local/bin"
|
||||||
|
err = os.WriteFile(testFile1, []byte(content1), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||||
|
content2 := "set number"
|
||||||
|
err = os.WriteFile(testFile2, []byte(content2), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add file to common configuration
|
||||||
|
commonLnk := NewLnk()
|
||||||
|
err = commonLnk.Add(testFile1)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add file to host-specific configuration
|
||||||
|
hostLnk := NewLnkWithHost("workstation")
|
||||||
|
err = hostLnk.Add(testFile2)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify both files are symlinks
|
||||||
|
info1, err := os.Lstat(testFile1)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
info2, err := os.Lstat(testFile2)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
// Verify common configuration tracking
|
||||||
|
commonItems, err := commonLnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(commonItems, 1)
|
||||||
|
suite.Contains(commonItems[0], ".bashrc")
|
||||||
|
|
||||||
|
// Verify host-specific configuration tracking
|
||||||
|
hostItems, err := hostLnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(hostItems, 1)
|
||||||
|
suite.Contains(hostItems[0], ".vimrc")
|
||||||
|
|
||||||
|
// Verify files are stored in correct locations
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
|
||||||
|
// Common file should be in root
|
||||||
|
commonFile := filepath.Join(lnkDir, ".lnk")
|
||||||
|
suite.FileExists(commonFile)
|
||||||
|
|
||||||
|
// Host-specific file should be in host subdirectory
|
||||||
|
hostDir := filepath.Join(lnkDir, "workstation.lnk")
|
||||||
|
suite.DirExists(hostDir)
|
||||||
|
|
||||||
|
hostTrackingFile := filepath.Join(lnkDir, ".lnk.workstation")
|
||||||
|
suite.FileExists(hostTrackingFile)
|
||||||
|
|
||||||
|
// Test removal
|
||||||
|
err = commonLnk.Remove(testFile1)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = hostLnk.Remove(testFile2)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify files are restored
|
||||||
|
info1, err = os.Lstat(testFile1)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
info2, err = os.Lstat(testFile2)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hostname detection
|
||||||
|
func (suite *CoreTestSuite) TestHostnameDetection() {
|
||||||
|
hostname, err := GetCurrentHostname()
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.NotEmpty(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test host-specific symlink restoration
|
||||||
|
func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create files directly in host-specific storage (simulating a pull)
|
||||||
|
hostLnk := NewLnkWithHost("testhost")
|
||||||
|
|
||||||
|
// Ensure host storage directory exists
|
||||||
|
hostStoragePath := hostLnk.getHostStoragePath()
|
||||||
|
err = os.MkdirAll(hostStoragePath, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a file in host storage
|
||||||
|
repoFile := filepath.Join(hostStoragePath, ".bashrc")
|
||||||
|
content := "export HOST=testhost"
|
||||||
|
err = os.WriteFile(repoFile, []byte(content), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create host tracking file
|
||||||
|
trackingFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
|
||||||
|
err = os.WriteFile(trackingFile, []byte(".bashrc\n"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Get home directory for the test
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
targetFile := filepath.Join(homeDir, ".bashrc")
|
||||||
|
|
||||||
|
// Clean up the test file after the test
|
||||||
|
defer func() {
|
||||||
|
_ = os.Remove(targetFile)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Test symlink restoration
|
||||||
|
restored, err := hostLnk.RestoreSymlinks()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Should have restored the symlink
|
||||||
|
suite.Len(restored, 1)
|
||||||
|
suite.Equal(".bashrc", restored[0])
|
||||||
|
|
||||||
|
// Check that file is now a symlink
|
||||||
|
info, err := os.Lstat(targetFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that common and host-specific configurations don't interfere
|
||||||
|
func (suite *CoreTestSuite) TestMultihostIsolation() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create same file for common and host-specific
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".gitconfig")
|
||||||
|
commonContent := "[user]\n\tname = Common User"
|
||||||
|
err = os.WriteFile(testFile, []byte(commonContent), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add to common
|
||||||
|
commonLnk := NewLnk()
|
||||||
|
err = commonLnk.Add(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Remove and recreate with different content
|
||||||
|
err = commonLnk.Remove(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
hostContent := "[user]\n\tname = Work User"
|
||||||
|
err = os.WriteFile(testFile, []byte(hostContent), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add to host-specific
|
||||||
|
hostLnk := NewLnkWithHost("work")
|
||||||
|
err = hostLnk.Add(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify tracking files are separate
|
||||||
|
commonItems, err := commonLnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(commonItems, 0) // Should be empty after removal
|
||||||
|
|
||||||
|
hostItems, err := hostLnk.List()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(hostItems, 1)
|
||||||
|
suite.Contains(hostItems[0], ".gitconfig")
|
||||||
|
|
||||||
|
// Verify content is correct
|
||||||
|
symlinkContent, err := os.ReadFile(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(hostContent, string(symlinkContent))
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreSuite(t *testing.T) {
|
func TestCoreSuite(t *testing.T) {
|
||||||
suite.Run(t, new(CoreTestSuite))
|
suite.Run(t, new(CoreTestSuite))
|
||||||
}
|
}
|
||||||
|
@@ -305,6 +305,7 @@ type StatusInfo struct {
|
|||||||
Ahead int
|
Ahead int
|
||||||
Behind int
|
Behind int
|
||||||
Remote string
|
Remote string
|
||||||
|
Dirty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns the repository status relative to remote
|
// GetStatus returns the repository status relative to remote
|
||||||
@@ -315,6 +316,12 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for uncommitted changes
|
||||||
|
dirty, err := g.HasChanges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check for uncommitted changes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Get the remote tracking branch
|
// Get the remote tracking branch
|
||||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||||
cmd.Dir = g.repoPath
|
cmd.Dir = g.repoPath
|
||||||
@@ -327,6 +334,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
|||||||
Ahead: g.getAheadCount(remoteBranch),
|
Ahead: g.getAheadCount(remoteBranch),
|
||||||
Behind: 0, // Can't be behind if no upstream
|
Behind: 0, // Can't be behind if no upstream
|
||||||
Remote: remoteBranch,
|
Remote: remoteBranch,
|
||||||
|
Dirty: dirty,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +344,7 @@ func (g *Git) GetStatus() (*StatusInfo, error) {
|
|||||||
Ahead: g.getAheadCount(remoteBranch),
|
Ahead: g.getAheadCount(remoteBranch),
|
||||||
Behind: g.getBehindCount(remoteBranch),
|
Behind: g.getBehindCount(remoteBranch),
|
||||||
Remote: remoteBranch,
|
Remote: remoteBranch,
|
||||||
|
Dirty: dirty,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user