mirror of
https://github.com/yarlson/lnk.git
synced 2025-09-25 21:18:57 +02:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
84c507828d | ||
|
d02f112200 | ||
|
f96bfb6ce0 | ||
|
7007ec64f2 | ||
|
ec6ad6b0d0 | ||
|
e7f316ea6e | ||
|
09d67f181e | ||
|
3a34e4fb37 | ||
|
fc0b567e9f | ||
|
61a9cc8c88 | ||
|
1e2728fe33 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
|||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: go test -v -race -coverprofile=coverage.out ./test
|
run: go test -v -race -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
go-version: '1.24'
|
go-version: '1.24'
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test ./test
|
run: go test ./...
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
10
Makefile
10
Makefile
@@ -60,19 +60,19 @@ build:
|
|||||||
## test: Run tests
|
## test: Run tests
|
||||||
test:
|
test:
|
||||||
@echo "$(BLUE)Running tests...$(NC)"
|
@echo "$(BLUE)Running tests...$(NC)"
|
||||||
@go test ./test
|
@go test ./...
|
||||||
@echo "$(GREEN)✓ Tests passed$(NC)"
|
@echo "$(GREEN)✓ Tests passed$(NC)"
|
||||||
|
|
||||||
## test-v: Run tests with verbose output
|
## test-v: Run tests with verbose output
|
||||||
test-v:
|
test-v:
|
||||||
@echo "$(BLUE)Running tests (verbose)...$(NC)"
|
@echo "$(BLUE)Running tests (verbose)...$(NC)"
|
||||||
@go test -v ./test
|
@go test -v ./...
|
||||||
|
|
||||||
## test-cover: Run tests with coverage
|
## test-cover: Run tests with coverage
|
||||||
test-cover:
|
test-cover:
|
||||||
@echo "$(BLUE)Running tests with coverage...$(NC)"
|
@echo "$(BLUE)Running tests with coverage...$(NC)"
|
||||||
@go test -v -cover ./test
|
@go test -v -cover ./...
|
||||||
@go test -coverprofile=coverage.out ./test
|
@go test -coverprofile=coverage.out ./
|
||||||
@go tool cover -html=coverage.out -o coverage.html
|
@go tool cover -html=coverage.out -o coverage.html
|
||||||
@echo "$(GREEN)✓ Coverage report generated: coverage.html$(NC)"
|
@echo "$(GREEN)✓ Coverage report generated: coverage.html$(NC)"
|
||||||
|
|
||||||
@@ -183,4 +183,4 @@ goreleaser-snapshot: goreleaser-check
|
|||||||
@echo "$(GREEN)✓ Snapshot release built in dist/$(NC)"
|
@echo "$(GREEN)✓ Snapshot release built in dist/$(NC)"
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
all: check build
|
all: check build
|
||||||
|
@@ -15,15 +15,21 @@ lnk push "setup"
|
|||||||
```bash
|
```bash
|
||||||
# Quick install (recommended)
|
# Quick install (recommended)
|
||||||
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
# Homebrew (macOS/Linux)
|
# Homebrew (macOS/Linux)
|
||||||
brew tap yarlson/lnk
|
brew tap yarlson/lnk
|
||||||
brew install lnk
|
brew install lnk
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
# Manual download
|
# Manual download
|
||||||
wget https://github.com/yarlson/lnk/releases/latest/download/lnk-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64
|
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
|
chmod +x lnk-* && sudo mv lnk-* /usr/local/bin/lnk
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
# From source
|
# From source
|
||||||
git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/
|
git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/
|
||||||
```
|
```
|
||||||
|
@@ -64,11 +64,13 @@ git push origin v1.0.0
|
|||||||
## What GoReleaser Does
|
## What GoReleaser Does
|
||||||
|
|
||||||
1. **Builds binaries** for multiple platforms:
|
1. **Builds binaries** for multiple platforms:
|
||||||
|
|
||||||
- Linux (amd64, arm64)
|
- Linux (amd64, arm64)
|
||||||
- macOS (amd64, arm64)
|
- macOS (amd64, arm64)
|
||||||
- Windows (amd64)
|
- Windows (amd64)
|
||||||
|
|
||||||
2. **Creates archives** with consistent naming:
|
2. **Creates archives** with consistent naming:
|
||||||
|
|
||||||
- `lnk_Linux_x86_64.tar.gz`
|
- `lnk_Linux_x86_64.tar.gz`
|
||||||
- `lnk_Darwin_arm64.tar.gz`
|
- `lnk_Darwin_arm64.tar.gz`
|
||||||
- etc.
|
- etc.
|
||||||
@@ -76,6 +78,7 @@ git push origin v1.0.0
|
|||||||
3. **Generates checksums** for verification
|
3. **Generates checksums** for verification
|
||||||
|
|
||||||
4. **Creates GitHub release** with:
|
4. **Creates GitHub release** with:
|
||||||
|
|
||||||
- Automatic changelog from conventional commits
|
- Automatic changelog from conventional commits
|
||||||
- Installation instructions
|
- Installation instructions
|
||||||
- Download links for all platforms
|
- Download links for all platforms
|
||||||
@@ -112,17 +115,20 @@ ls -la dist/
|
|||||||
After a release is published, users can install lnk using multiple methods:
|
After a release is published, users can install lnk using multiple methods:
|
||||||
|
|
||||||
### 1. Shell Script (Recommended)
|
### 1. Shell Script (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Homebrew (macOS/Linux)
|
### 2. Homebrew (macOS/Linux)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew tap yarlson/lnk
|
brew tap yarlson/lnk
|
||||||
brew install lnk
|
brew install lnk
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Manual Download
|
### 3. Manual Download
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download from GitHub releases
|
# Download from GitHub releases
|
||||||
wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz
|
wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz
|
||||||
@@ -181,4 +187,4 @@ The Homebrew formula is automatically maintained in the [homebrew-lnk](https://g
|
|||||||
1. Check that the GITHUB_TOKEN has access to the homebrew-lnk repository
|
1. Check that the GITHUB_TOKEN has access to the homebrew-lnk repository
|
||||||
2. Verify the repository name and owner in `.goreleaser.yml`
|
2. Verify the repository name and owner in `.goreleaser.yml`
|
||||||
3. Check the release workflow logs for Homebrew-related errors
|
3. Check the release workflow logs for Homebrew-related errors
|
||||||
4. Ensure the homebrew-lnk repository exists and is accessible
|
4. Ensure the homebrew-lnk repository exists and is accessible
|
||||||
|
39
cmd/add.go
39
cmd/add.go
@@ -8,25 +8,26 @@ import (
|
|||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var addCmd = &cobra.Command{
|
func newAddCmd() *cobra.Command {
|
||||||
Use: "add <file>",
|
return &cobra.Command{
|
||||||
Short: "Add a file to lnk management",
|
Use: "add <file>",
|
||||||
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
Short: "✨ Add a file to lnk management",
|
||||||
Args: cobra.ExactArgs(1),
|
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Args: cobra.ExactArgs(1),
|
||||||
filePath := args[0]
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
filePath := args[0]
|
||||||
|
|
||||||
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)
|
||||||
fmt.Printf("Added %s to lnk\n", basename)
|
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||||
return nil
|
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
|
||||||
},
|
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||||
}
|
return nil
|
||||||
|
},
|
||||||
func init() {
|
}
|
||||||
rootCmd.AddCommand(addCmd)
|
|
||||||
}
|
}
|
||||||
|
54
cmd/init.go
54
cmd/init.go
@@ -7,29 +7,39 @@ import (
|
|||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var initCmd = &cobra.Command{
|
func newInitCmd() *cobra.Command {
|
||||||
Use: "init",
|
cmd := &cobra.Command{
|
||||||
Short: "Initialize a new lnk repository",
|
Use: "init",
|
||||||
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
Short: "🎯 Initialize a new lnk repository",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
|
||||||
remote, _ := cmd.Flags().GetString("remote")
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
remote, _ := cmd.Flags().GetString("remote")
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
if err := lnk.InitWithRemote(remote); err != nil {
|
if err := lnk.InitWithRemote(remote); err != nil {
|
||||||
return fmt.Errorf("failed to initialize lnk: %w", err)
|
return fmt.Errorf("failed to initialize lnk: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if remote != "" {
|
if remote != "" {
|
||||||
fmt.Printf("Initialized lnk repository by cloning: %s\n", remote)
|
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||||
} else {
|
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||||
fmt.Println("Initialized lnk repository")
|
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||||
}
|
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||||
|
printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
|
||||||
|
printf(cmd, " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
|
||||||
|
} else {
|
||||||
|
printf(cmd, "🎯 \033[1mInitialized empty lnk repository\033[0m\n")
|
||||||
|
printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
|
||||||
|
printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n")
|
||||||
|
printf(cmd, " • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
|
||||||
|
printf(cmd, " • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
||||||
initCmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
|
return cmd
|
||||||
rootCmd.AddCommand(initCmd)
|
|
||||||
}
|
}
|
||||||
|
53
cmd/pull.go
53
cmd/pull.go
@@ -7,30 +7,37 @@ import (
|
|||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pullCmd = &cobra.Command{
|
func newPullCmd() *cobra.Command {
|
||||||
Use: "pull",
|
return &cobra.Command{
|
||||||
Short: "Pull changes from remote and restore symlinks",
|
Use: "pull",
|
||||||
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
Short: "⬇️ Pull changes from remote and restore symlinks",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
|
||||||
lnk := core.NewLnk()
|
SilenceUsage: true,
|
||||||
restored, err := lnk.Pull()
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
lnk := core.NewLnk()
|
||||||
return fmt.Errorf("failed to pull changes: %w", err)
|
restored, err := lnk.Pull()
|
||||||
}
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to pull changes: %w", err)
|
||||||
if len(restored) > 0 {
|
|
||||||
fmt.Printf("Successfully pulled changes and restored %d symlink(s):\n", len(restored))
|
|
||||||
for _, file := range restored {
|
|
||||||
fmt.Printf(" - %s\n", file)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
fmt.Println("Successfully pulled changes (no symlinks needed restoration)")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
if len(restored) > 0 {
|
||||||
},
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||||
}
|
printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored))
|
||||||
|
if len(restored) > 1 {
|
||||||
|
printf(cmd, "s")
|
||||||
|
}
|
||||||
|
printf(cmd, "\033[0m:\n")
|
||||||
|
for _, file := range restored {
|
||||||
|
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
|
||||||
|
}
|
||||||
|
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||||
|
} else {
|
||||||
|
printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
|
||||||
|
printf(cmd, " ✅ All symlinks already in place\n")
|
||||||
|
printf(cmd, " 🎉 Everything is up to date!\n")
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
return nil
|
||||||
rootCmd.AddCommand(pullCmd)
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
44
cmd/push.go
44
cmd/push.go
@@ -7,27 +7,29 @@ import (
|
|||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pushCmd = &cobra.Command{
|
func newPushCmd() *cobra.Command {
|
||||||
Use: "push [message]",
|
return &cobra.Command{
|
||||||
Short: "Push local changes to remote repository",
|
Use: "push [message]",
|
||||||
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
Short: "🚀 Push local changes to remote repository",
|
||||||
Args: cobra.MaximumNArgs(1),
|
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Args: cobra.MaximumNArgs(1),
|
||||||
message := "lnk: sync configuration files"
|
SilenceUsage: true,
|
||||||
if len(args) > 0 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
message = args[0]
|
message := "lnk: sync configuration files"
|
||||||
}
|
if len(args) > 0 {
|
||||||
|
message = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
lnk := core.NewLnk()
|
||||||
if err := lnk.Push(message); err != nil {
|
if err := lnk.Push(message); err != nil {
|
||||||
return fmt.Errorf("failed to push changes: %w", err)
|
return fmt.Errorf("failed to push changes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Successfully pushed changes to remote")
|
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
||||||
return nil
|
printf(cmd, " 💾 Commit: \033[90m%s\033[0m\n", message)
|
||||||
},
|
printf(cmd, " 📡 Synced to remote\n")
|
||||||
}
|
printf(cmd, " ✨ Your dotfiles are up to date!\n")
|
||||||
|
return nil
|
||||||
func init() {
|
},
|
||||||
rootCmd.AddCommand(pushCmd)
|
}
|
||||||
}
|
}
|
||||||
|
39
cmd/rm.go
39
cmd/rm.go
@@ -8,25 +8,26 @@ import (
|
|||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rmCmd = &cobra.Command{
|
func newRemoveCmd() *cobra.Command {
|
||||||
Use: "rm <file>",
|
return &cobra.Command{
|
||||||
Short: "Remove a file from lnk management",
|
Use: "rm <file>",
|
||||||
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
Short: "🗑️ Remove a file from lnk management",
|
||||||
Args: cobra.ExactArgs(1),
|
Long: "Removes a symlink and restores the original file from the lnk repository.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Args: cobra.ExactArgs(1),
|
||||||
filePath := args[0]
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
filePath := args[0]
|
||||||
|
|
||||||
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)
|
||||||
fmt.Printf("Removed %s from lnk\n", basename)
|
printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
|
||||||
return nil
|
printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
|
||||||
},
|
printf(cmd, " 📄 Original file restored\n")
|
||||||
}
|
return nil
|
||||||
|
},
|
||||||
func init() {
|
}
|
||||||
rootCmd.AddCommand(rmCmd)
|
|
||||||
}
|
}
|
||||||
|
37
cmd/root.go
37
cmd/root.go
@@ -12,20 +12,47 @@ var (
|
|||||||
buildTime = "unknown"
|
buildTime = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
// NewRootCommand creates a new root command (testable)
|
||||||
Use: "lnk",
|
func NewRootCommand() *cobra.Command {
|
||||||
Short: "Dotfiles, linked. No fluff.",
|
rootCmd := &cobra.Command{
|
||||||
Long: "Lnk is a minimalist CLI tool for managing dotfiles using symlinks and Git.",
|
Use: "lnk",
|
||||||
|
Short: "🔗 Dotfiles, linked. No fluff.",
|
||||||
|
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
|
||||||
|
|
||||||
|
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
|
||||||
|
That's it.
|
||||||
|
|
||||||
|
✨ Examples:
|
||||||
|
lnk init # Fresh start
|
||||||
|
lnk init -r <repo-url> # Clone existing dotfiles
|
||||||
|
lnk add ~/.vimrc ~/.bashrc # Start managing files
|
||||||
|
lnk push "setup complete" # Sync to remote
|
||||||
|
lnk pull # Get latest changes
|
||||||
|
|
||||||
|
🎯 Simple, fast, and Git-native.`,
|
||||||
|
SilenceUsage: true,
|
||||||
|
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subcommands
|
||||||
|
rootCmd.AddCommand(newInitCmd())
|
||||||
|
rootCmd.AddCommand(newAddCmd())
|
||||||
|
rootCmd.AddCommand(newRemoveCmd())
|
||||||
|
rootCmd.AddCommand(newStatusCmd())
|
||||||
|
rootCmd.AddCommand(newPushCmd())
|
||||||
|
rootCmd.AddCommand(newPullCmd())
|
||||||
|
|
||||||
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetVersion sets the version information for the CLI
|
// SetVersion sets the version information for the CLI
|
||||||
func SetVersion(v, bt string) {
|
func SetVersion(v, bt string) {
|
||||||
version = v
|
version = v
|
||||||
buildTime = bt
|
buildTime = bt
|
||||||
rootCmd.Version = fmt.Sprintf("%s (built %s)", version, buildTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
|
rootCmd := NewRootCommand()
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
473
cmd/root_test.go
Normal file
473
cmd/root_test.go
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CLITestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tempDir string
|
||||||
|
originalDir string
|
||||||
|
stdout *bytes.Buffer
|
||||||
|
stderr *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) SetupTest() {
|
||||||
|
// Create temp directory and change to it
|
||||||
|
tempDir, err := os.MkdirTemp("", "lnk-cli-test-*")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.tempDir = tempDir
|
||||||
|
|
||||||
|
originalDir, err := os.Getwd()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.originalDir = originalDir
|
||||||
|
|
||||||
|
err = os.Chdir(tempDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Set XDG_CONFIG_HOME to temp directory
|
||||||
|
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
||||||
|
|
||||||
|
// Capture output
|
||||||
|
suite.stdout = &bytes.Buffer{}
|
||||||
|
suite.stderr = &bytes.Buffer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TearDownTest() {
|
||||||
|
err := os.Chdir(suite.originalDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.RemoveAll(suite.tempDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) runCommand(args ...string) error {
|
||||||
|
rootCmd := NewRootCommand()
|
||||||
|
rootCmd.SetOut(suite.stdout)
|
||||||
|
rootCmd.SetErr(suite.stderr)
|
||||||
|
rootCmd.SetArgs(args)
|
||||||
|
return rootCmd.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestInitCommand() {
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Check output
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Initialized empty lnk repository")
|
||||||
|
suite.Contains(output, "Location:")
|
||||||
|
suite.Contains(output, "Next steps:")
|
||||||
|
suite.Contains(output, "lnk add <file>")
|
||||||
|
|
||||||
|
// Verify actual effect
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
suite.DirExists(lnkDir)
|
||||||
|
|
||||||
|
gitDir := filepath.Join(lnkDir, ".git")
|
||||||
|
suite.DirExists(gitDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestInitWithRemote() {
|
||||||
|
err := suite.runCommand("init", "-r", "https://github.com/user/dotfiles.git")
|
||||||
|
// This will fail because we don't have a real remote, but that's expected
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "git clone failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestAddCommand() {
|
||||||
|
// Initialize first
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
|
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test add command
|
||||||
|
err = suite.runCommand("add", testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Check output
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Added .bashrc to lnk")
|
||||||
|
suite.Contains(output, "→")
|
||||||
|
suite.Contains(output, "sync to remote")
|
||||||
|
|
||||||
|
// Verify symlink was created
|
||||||
|
info, err := os.Lstat(testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
// Verify some file exists in repo with .bashrc in the name
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
entries, err := os.ReadDir(lnkDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.Contains(entry.Name(), ".bashrc") && entry.Name() != ".lnk" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suite.True(found, "Repository should contain a file with .bashrc in the name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestRemoveCommand() {
|
||||||
|
// Setup: init and add a file
|
||||||
|
_ = suite.runCommand("init")
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
||||||
|
_ = os.WriteFile(testFile, []byte("set number"), 0644)
|
||||||
|
_ = suite.runCommand("add", testFile)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test remove command
|
||||||
|
err := suite.runCommand("rm", testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Check output
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Removed .vimrc from lnk")
|
||||||
|
suite.Contains(output, "→")
|
||||||
|
suite.Contains(output, "Original file restored")
|
||||||
|
|
||||||
|
// Verify symlink is gone and regular file is restored
|
||||||
|
info, err := os.Lstat(testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
||||||
|
|
||||||
|
// Verify content is preserved
|
||||||
|
content, err := os.ReadFile(testFile)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal("set number", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestStatusCommand() {
|
||||||
|
// Initialize first
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Test status without remote - should fail
|
||||||
|
err = suite.runCommand("status")
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "no remote configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestErrorHandling() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
outContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "add nonexistent file",
|
||||||
|
args: []string{"add", "/nonexistent/file"},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "File does not exist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status without init",
|
||||||
|
args: []string{"status"},
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "Lnk repository not initialized",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "help command",
|
||||||
|
args: []string{"--help"},
|
||||||
|
wantErr: false,
|
||||||
|
outContains: "Lnk - Git-native dotfiles management",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version command",
|
||||||
|
args: []string{"--version"},
|
||||||
|
wantErr: false,
|
||||||
|
outContains: "lnk version",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "init help",
|
||||||
|
args: []string{"init", "--help"},
|
||||||
|
wantErr: false,
|
||||||
|
outContains: "Creates the lnk directory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add help",
|
||||||
|
args: []string{"add", "--help"},
|
||||||
|
wantErr: false,
|
||||||
|
outContains: "Moves a file to the lnk repository",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
suite.Run(tt.name, func() {
|
||||||
|
suite.stdout.Reset()
|
||||||
|
suite.stderr.Reset()
|
||||||
|
|
||||||
|
err := suite.runCommand(tt.args...)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
suite.Error(err, "Expected error for %s", tt.name)
|
||||||
|
if tt.errContains != "" {
|
||||||
|
suite.Contains(err.Error(), tt.errContains, "Wrong error message for %s", tt.name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
suite.NoError(err, "Unexpected error for %s", tt.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.outContains != "" {
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, tt.outContains, "Expected output not found for %s", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestCompleteWorkflow() {
|
||||||
|
// Test realistic user workflow
|
||||||
|
steps := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
setup func()
|
||||||
|
verify func(output string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "initialize repository",
|
||||||
|
args: []string{"init"},
|
||||||
|
verify: func(output string) {
|
||||||
|
suite.Contains(output, "Initialized empty lnk repository")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add config file",
|
||||||
|
args: []string{"add", ".bashrc"},
|
||||||
|
setup: func() {
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
|
_ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||||
|
},
|
||||||
|
verify: func(output string) {
|
||||||
|
suite.Contains(output, "Added .bashrc to lnk")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add another file",
|
||||||
|
args: []string{"add", ".vimrc"},
|
||||||
|
setup: func() {
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
||||||
|
_ = os.WriteFile(testFile, []byte("set number"), 0644)
|
||||||
|
},
|
||||||
|
verify: func(output string) {
|
||||||
|
suite.Contains(output, "Added .vimrc to lnk")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove file",
|
||||||
|
args: []string{"rm", ".vimrc"},
|
||||||
|
verify: func(output string) {
|
||||||
|
suite.Contains(output, "Removed .vimrc from lnk")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, step := range steps {
|
||||||
|
suite.Run(step.name, func() {
|
||||||
|
if step.setup != nil {
|
||||||
|
step.setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
suite.stdout.Reset()
|
||||||
|
suite.stderr.Reset()
|
||||||
|
|
||||||
|
err := suite.runCommand(step.args...)
|
||||||
|
suite.NoError(err, "Step %s failed: %v", step.name, err)
|
||||||
|
|
||||||
|
output := suite.stdout.String()
|
||||||
|
if step.verify != nil {
|
||||||
|
step.verify(output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestRemoveUnmanagedFile() {
|
||||||
|
// Initialize repository
|
||||||
|
_ = suite.runCommand("init")
|
||||||
|
|
||||||
|
// Create a regular file (not managed by lnk)
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".regularfile")
|
||||||
|
_ = os.WriteFile(testFile, []byte("content"), 0644)
|
||||||
|
|
||||||
|
// Try to remove it
|
||||||
|
err := suite.runCommand("rm", testFile)
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "File is not managed by lnk")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestAddDirectory() {
|
||||||
|
// Initialize repository
|
||||||
|
_ = suite.runCommand("init")
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create a directory with files
|
||||||
|
testDir := filepath.Join(suite.tempDir, ".config")
|
||||||
|
_ = os.MkdirAll(testDir, 0755)
|
||||||
|
configFile := filepath.Join(testDir, "app.conf")
|
||||||
|
_ = os.WriteFile(configFile, []byte("setting=value"), 0644)
|
||||||
|
|
||||||
|
// Add the directory
|
||||||
|
err := suite.runCommand("add", testDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Check output
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Added .config to lnk")
|
||||||
|
|
||||||
|
// Verify directory is now a symlink
|
||||||
|
info, err := os.Lstat(testDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
// Verify some directory exists in repo with .config in the name
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
entries, err := os.ReadDir(lnkDir)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.Contains(entry.Name(), ".config") && entry.Name() != ".lnk" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suite.True(found, "Repository should contain a directory with .config in the name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CLITestSuite) TestSameBasenameFilesBug() {
|
||||||
|
// Initialize repository
|
||||||
|
err := suite.runCommand("init")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Create two directories with files having the same basename
|
||||||
|
dirA := filepath.Join(suite.tempDir, "a")
|
||||||
|
dirB := filepath.Join(suite.tempDir, "b")
|
||||||
|
err = os.MkdirAll(dirA, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.MkdirAll(dirB, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create files with same basename but different content
|
||||||
|
fileA := filepath.Join(dirA, "config.json")
|
||||||
|
fileB := filepath.Join(dirB, "config.json")
|
||||||
|
contentA := `{"name": "config_a"}`
|
||||||
|
contentB := `{"name": "config_b"}`
|
||||||
|
|
||||||
|
err = os.WriteFile(fileA, []byte(contentA), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(fileB, []byte(contentB), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add first file
|
||||||
|
err = suite.runCommand("add", fileA)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.stdout.Reset()
|
||||||
|
|
||||||
|
// Verify first file content is preserved
|
||||||
|
content, err := os.ReadFile(fileA)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(contentA, string(content), "First file should preserve its original content")
|
||||||
|
|
||||||
|
// Add second file with same basename - this should work correctly
|
||||||
|
err = suite.runCommand("add", fileB)
|
||||||
|
suite.NoError(err, "Adding second file with same basename should work")
|
||||||
|
|
||||||
|
// CORRECT BEHAVIOR: Both files should preserve their original content
|
||||||
|
contentAfterAddA, err := os.ReadFile(fileA)
|
||||||
|
suite.NoError(err)
|
||||||
|
contentAfterAddB, err := os.ReadFile(fileB)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
suite.Equal(contentA, string(contentAfterAddA), "First file should keep its original content")
|
||||||
|
suite.Equal(contentB, string(contentAfterAddB), "Second file should keep its original content")
|
||||||
|
|
||||||
|
// Both files should be removable independently
|
||||||
|
suite.stdout.Reset()
|
||||||
|
err = suite.runCommand("rm", fileA)
|
||||||
|
suite.NoError(err, "First file should be removable")
|
||||||
|
|
||||||
|
// Verify output shows removal
|
||||||
|
output := suite.stdout.String()
|
||||||
|
suite.Contains(output, "Removed config.json from lnk")
|
||||||
|
|
||||||
|
// Verify first file is restored with correct content
|
||||||
|
restoredContentA, err := os.ReadFile(fileA)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Equal(contentA, string(restoredContentA), "Restored first file should have original content")
|
||||||
|
|
||||||
|
// Second file should still be removable without errors
|
||||||
|
suite.stdout.Reset()
|
||||||
|
err = suite.runCommand("rm", fileB)
|
||||||
|
suite.NoError(err, "Second file should also be removable without errors")
|
||||||
|
|
||||||
|
// Verify second file is restored with correct content
|
||||||
|
restoredContentB, err := os.ReadFile(fileB)
|
||||||
|
suite.NoError(err)
|
||||||
|
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 TestCLISuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(CLITestSuite))
|
||||||
|
}
|
@@ -7,32 +7,87 @@ import (
|
|||||||
"github.com/yarlson/lnk/internal/core"
|
"github.com/yarlson/lnk/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
var statusCmd = &cobra.Command{
|
func newStatusCmd() *cobra.Command {
|
||||||
Use: "status",
|
return &cobra.Command{
|
||||||
Short: "Show repository sync status",
|
Use: "status",
|
||||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
|
Short: "📊 Show repository sync status",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||||
lnk := core.NewLnk()
|
SilenceUsage: true,
|
||||||
status, err := lnk.Status()
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
lnk := core.NewLnk()
|
||||||
return fmt.Errorf("failed to get status: %w", err)
|
status, err := lnk.Status()
|
||||||
}
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if status.Ahead == 0 && status.Behind == 0 {
|
if status.Dirty {
|
||||||
fmt.Println("Repository is up to date with remote")
|
displayDirtyStatus(cmd, status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Ahead == 0 && status.Behind == 0 {
|
||||||
|
displayUpToDateStatus(cmd, status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
displaySyncStatus(cmd, status)
|
||||||
|
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 {
|
} else {
|
||||||
if status.Ahead > 0 {
|
printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
|
||||||
fmt.Printf("Your branch is ahead of '%s' by %d commit(s)\n", status.Remote, status.Ahead)
|
|
||||||
}
|
|
||||||
if status.Behind > 0 {
|
|
||||||
fmt.Printf("Your branch is behind '%s' by %d commit(s)\n", status.Remote, status.Behind)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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 init() {
|
func getCommitText(count int) string {
|
||||||
rootCmd.AddCommand(statusCmd)
|
if count == 1 {
|
||||||
|
return "commit"
|
||||||
|
}
|
||||||
|
return "commits"
|
||||||
}
|
}
|
||||||
|
12
cmd/utils.go
Normal file
12
cmd/utils.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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...)
|
||||||
|
}
|
84
install.sh
84
install.sh
@@ -17,6 +17,9 @@ REPO="yarlson/lnk"
|
|||||||
INSTALL_DIR="/usr/local/bin"
|
INSTALL_DIR="/usr/local/bin"
|
||||||
BINARY_NAME="lnk"
|
BINARY_NAME="lnk"
|
||||||
|
|
||||||
|
# Fallback version if redirect fails
|
||||||
|
FALLBACK_VERSION="v0.0.2"
|
||||||
|
|
||||||
# Detect OS and architecture
|
# Detect OS and architecture
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
local os arch
|
local os arch
|
||||||
@@ -45,11 +48,44 @@ detect_platform() {
|
|||||||
echo "${os}_${arch}"
|
echo "${os}_${arch}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the latest release version
|
# Get latest version by following redirect
|
||||||
get_latest_version() {
|
get_latest_version() {
|
||||||
curl -s "https://api.github.com/repos/${REPO}/releases/latest" | \
|
echo -e "${BLUE}Getting latest release version...${NC}" >&2
|
||||||
grep '"tag_name":' | \
|
|
||||||
sed -E 's/.*"([^"]+)".*/\1/'
|
# Get redirect location from releases/latest
|
||||||
|
local redirect_url
|
||||||
|
redirect_url=$(curl -s -I "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/\r$//' | cut -d' ' -f2-)
|
||||||
|
|
||||||
|
if [ -z "$redirect_url" ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Could not get redirect URL, using fallback version ${FALLBACK_VERSION}${NC}" >&2
|
||||||
|
echo "$FALLBACK_VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract version from redirect URL (format: https://github.com/user/repo/releases/tag/v1.2.3)
|
||||||
|
local version
|
||||||
|
version=$(echo "$redirect_url" | sed -E 's|.*/releases/tag/([^/]*)\s*$|\1|')
|
||||||
|
|
||||||
|
if [ -z "$version" ] || [ "$version" = "$redirect_url" ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Could not parse version from redirect URL: $redirect_url${NC}" >&2
|
||||||
|
echo -e "${YELLOW}Using fallback version ${FALLBACK_VERSION}${NC}" >&2
|
||||||
|
echo "$FALLBACK_VERSION"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get version to install
|
||||||
|
get_version() {
|
||||||
|
# Allow override via environment variable
|
||||||
|
if [ -n "$LNK_VERSION" ]; then
|
||||||
|
echo "$LNK_VERSION"
|
||||||
|
elif [ -n "$1" ]; then
|
||||||
|
echo "$1"
|
||||||
|
else
|
||||||
|
get_latest_version
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Download and install
|
# Download and install
|
||||||
@@ -59,14 +95,9 @@ install_lnk() {
|
|||||||
echo -e "${BLUE}🔗 Installing lnk...${NC}"
|
echo -e "${BLUE}🔗 Installing lnk...${NC}"
|
||||||
|
|
||||||
platform=$(detect_platform)
|
platform=$(detect_platform)
|
||||||
version=$(get_latest_version)
|
version=$(get_version "$1")
|
||||||
|
|
||||||
if [ -z "$version" ]; then
|
echo -e "${BLUE}Version: ${version}${NC}"
|
||||||
echo -e "${RED}Error: Failed to get latest version${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${BLUE}Latest version: ${version}${NC}"
|
|
||||||
echo -e "${BLUE}Platform: ${platform}${NC}"
|
echo -e "${BLUE}Platform: ${platform}${NC}"
|
||||||
|
|
||||||
# Download URL
|
# Download URL
|
||||||
@@ -82,6 +113,16 @@ install_lnk() {
|
|||||||
# Download the binary
|
# Download the binary
|
||||||
if ! curl -sL "$url" -o "$filename"; then
|
if ! curl -sL "$url" -o "$filename"; then
|
||||||
echo -e "${RED}Error: Failed to download ${url}${NC}"
|
echo -e "${RED}Error: Failed to download ${url}${NC}"
|
||||||
|
echo -e "${YELLOW}Please check if the release exists at: https://github.com/${REPO}/releases/tag/${version}${NC}"
|
||||||
|
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we got an HTML error page instead of the binary
|
||||||
|
if file "$filename" 2>/dev/null | grep -q "HTML"; then
|
||||||
|
echo -e "${RED}Error: Downloaded file appears to be an HTML page (404 error)${NC}"
|
||||||
|
echo -e "${YELLOW}The release ${version} might not exist.${NC}"
|
||||||
|
echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -107,20 +148,33 @@ install_lnk() {
|
|||||||
|
|
||||||
echo -e "${GREEN}✅ lnk installed successfully!${NC}"
|
echo -e "${GREEN}✅ lnk installed successfully!${NC}"
|
||||||
echo -e "${GREEN}Run 'lnk --help' to get started.${NC}"
|
echo -e "${GREEN}Run 'lnk --help' to get started.${NC}"
|
||||||
|
|
||||||
|
# Test the installation
|
||||||
|
if command -v lnk >/dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}Installed version: $(lnk --version)${NC}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if running with --help
|
# Check if running with --help
|
||||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||||
echo "Lnk installer script"
|
echo "Lnk installer script"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Usage: curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
|
echo "Usage:"
|
||||||
|
echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
|
||||||
|
echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash -s v0.0.1"
|
||||||
|
echo " LNK_VERSION=v0.0.1 curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash"
|
||||||
echo ""
|
echo ""
|
||||||
echo "This script will:"
|
echo "This script will:"
|
||||||
echo " 1. Detect your OS and architecture"
|
echo " 1. Detect your OS and architecture"
|
||||||
echo " 2. Download the latest lnk release"
|
echo " 2. Auto-detect the latest release by following GitHub redirects"
|
||||||
echo " 3. Install it to /usr/local/bin (requires sudo)"
|
echo " 3. Download and install to /usr/local/bin (requires sudo)"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables:"
|
||||||
|
echo " LNK_VERSION - Specify version to install (e.g., v0.0.1)"
|
||||||
|
echo ""
|
||||||
|
echo "Manual installation: https://github.com/yarlson/lnk/releases"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run the installer
|
# Run the installer
|
||||||
install_lnk
|
install_lnk "$1"
|
@@ -43,6 +43,38 @@ func getRepoPath() string {
|
|||||||
return filepath.Join(xdgConfig, "lnk")
|
return filepath.Join(xdgConfig, "lnk")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateRepoName creates a unique repository filename from a relative path
|
||||||
|
func generateRepoName(relativePath string) string {
|
||||||
|
// Replace slashes and backslashes with underscores to create valid filename
|
||||||
|
repoName := strings.ReplaceAll(relativePath, "/", "_")
|
||||||
|
repoName = strings.ReplaceAll(repoName, "\\", "_")
|
||||||
|
return repoName
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRelativePath converts an absolute path to a relative path from home directory
|
||||||
|
func getRelativePath(absPath string) (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file is under home directory
|
||||||
|
relPath, err := filepath.Rel(homeDir, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get relative path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the relative path starts with "..", the file is outside home directory
|
||||||
|
// In this case, use the absolute path as relative (without the leading slash)
|
||||||
|
if strings.HasPrefix(relPath, "..") {
|
||||||
|
// Use absolute path but remove leading slash and drive letter (for cross-platform)
|
||||||
|
cleanPath := strings.TrimPrefix(absPath, "/")
|
||||||
|
return cleanPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return relPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Init initializes the lnk repository
|
// Init initializes the lnk repository
|
||||||
func (l *Lnk) Init() error {
|
func (l *Lnk) Init() error {
|
||||||
return l.InitWithRemote("")
|
return l.InitWithRemote("")
|
||||||
@@ -68,7 +100,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 %s appears to contain an existing Git repository that is not managed by lnk. Please backup or move the existing repository before initializing lnk", l.repoPath)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,9 +141,26 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
return fmt.Errorf("failed to get absolute path: %w", err)
|
return fmt.Errorf("failed to get absolute path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate destination path in repo
|
// Get relative path for tracking
|
||||||
basename := filepath.Base(absPath)
|
relativePath, err := getRelativePath(absPath)
|
||||||
destPath := filepath.Join(l.repoPath, basename)
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get relative path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique repository name from relative path
|
||||||
|
repoName := generateRepoName(relativePath)
|
||||||
|
destPath := filepath.Join(l.repoPath, repoName)
|
||||||
|
|
||||||
|
// Check if this relative path is already managed
|
||||||
|
managedItems, err := l.getManagedItems()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get managed items: %w", err)
|
||||||
|
}
|
||||||
|
for _, item := range managedItems {
|
||||||
|
if item == relativePath {
|
||||||
|
return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a directory or file
|
// Check if it's a directory or file
|
||||||
info, err := os.Stat(absPath)
|
info, err := os.Stat(absPath)
|
||||||
@@ -141,8 +190,8 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
return fmt.Errorf("failed to create symlink: %w", err)
|
return fmt.Errorf("failed to create symlink: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to .lnk tracking file
|
// Add to .lnk tracking file using relative path
|
||||||
if err := l.addManagedItem(absPath); err != nil {
|
if err := l.addManagedItem(relativePath); err != nil {
|
||||||
// Try to restore the original state if tracking fails
|
// Try to restore the original state if tracking fails
|
||||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
@@ -154,10 +203,10 @@ 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(basename); err != nil {
|
if err := l.git.Add(repoName); 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(absPath) // Ignore error in cleanup
|
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||||
} else {
|
} else {
|
||||||
@@ -169,8 +218,8 @@ 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(".lnk"); 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(absPath) // Ignore error in cleanup
|
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||||
} else {
|
} else {
|
||||||
@@ -180,10 +229,11 @@ func (l *Lnk) Add(filePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commit both changes together
|
// Commit both changes together
|
||||||
|
basename := filepath.Base(relativePath)
|
||||||
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
|
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
|
||||||
// Try to restore the original state if commit fails
|
// Try to restore the original state if commit fails
|
||||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||||
_ = l.removeManagedItem(absPath) // Ignore error in cleanup
|
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||||
} else {
|
} else {
|
||||||
@@ -208,6 +258,29 @@ func (l *Lnk) Remove(filePath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get relative path for tracking
|
||||||
|
relativePath, err := getRelativePath(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get relative path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this relative path is managed
|
||||||
|
managedItems, err := l.getManagedItems()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get managed items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, item := range managedItems {
|
||||||
|
if item == relativePath {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
// Get the target path in the repository
|
// Get the target path in the repository
|
||||||
target, err := os.Readlink(absPath)
|
target, err := os.Readlink(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -219,7 +292,7 @@ func (l *Lnk) Remove(filePath string) error {
|
|||||||
target = filepath.Join(filepath.Dir(absPath), target)
|
target = filepath.Join(filepath.Dir(absPath), target)
|
||||||
}
|
}
|
||||||
|
|
||||||
basename := filepath.Base(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)
|
||||||
@@ -232,13 +305,13 @@ func (l *Lnk) Remove(filePath string) error {
|
|||||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
return fmt.Errorf("failed to remove symlink: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from .lnk tracking file
|
// Remove from .lnk tracking file using relative path
|
||||||
if err := l.removeManagedItem(absPath); err != nil {
|
if err := l.removeManagedItem(relativePath); err != nil {
|
||||||
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)
|
// Remove from Git first (while the item is still in the repository)
|
||||||
if err := l.git.Remove(basename); err != nil {
|
if err := l.git.Remove(repoName); err != nil {
|
||||||
return fmt.Errorf("failed to remove from git: %w", err)
|
return fmt.Errorf("failed to remove from git: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +321,7 @@ func (l *Lnk) Remove(filePath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commit both changes together
|
// Commit both changes together
|
||||||
|
basename := filepath.Base(relativePath)
|
||||||
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
|
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
|
||||||
return fmt.Errorf("failed to commit changes: %w", err)
|
return fmt.Errorf("failed to commit changes: %w", err)
|
||||||
}
|
}
|
||||||
@@ -276,13 +350,14 @@ 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
|
||||||
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 - run 'lnk init' first")
|
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||||
}
|
}
|
||||||
|
|
||||||
gitStatus, err := l.git.GetStatus()
|
gitStatus, err := l.git.GetStatus()
|
||||||
@@ -294,6 +369,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +377,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 - run 'lnk init' first")
|
return fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are any changes
|
// Check if there are any changes
|
||||||
@@ -335,7 +411,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 - run 'lnk init' first")
|
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -356,34 +432,41 @@ func (l *Lnk) Pull() ([]string, error) {
|
|||||||
func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||||
var restored []string
|
var restored []string
|
||||||
|
|
||||||
// Get managed items from .lnk file
|
// Get managed items from .lnk file (now containing relative paths)
|
||||||
managedItems, err := l.getManagedItems()
|
managedItems, err := l.getManagedItems()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get managed items: %w", err)
|
return nil, fmt.Errorf("failed to get managed items: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, itemName := range managedItems {
|
homeDir, err := os.UserHomeDir()
|
||||||
repoItem := filepath.Join(l.repoPath, itemName)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, relativePath := range managedItems {
|
||||||
|
// Generate repository name from relative path
|
||||||
|
repoName := generateRepoName(relativePath)
|
||||||
|
repoItem := filepath.Join(l.repoPath, 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) {
|
||||||
continue // Skip missing items
|
continue // Skip missing items
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine where the symlink should be
|
// Determine where the symlink should be created
|
||||||
// For config files, we'll place them in the user's home directory
|
symlinkPath := filepath.Join(homeDir, relativePath)
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
symlinkPath := filepath.Join(homeDir, itemName)
|
|
||||||
|
|
||||||
// Check if symlink already exists and is correct
|
// Check if symlink already exists and is correct
|
||||||
if l.isValidSymlink(symlinkPath, repoItem) {
|
if l.isValidSymlink(symlinkPath, repoItem) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
symlinkDir := filepath.Dir(symlinkPath)
|
||||||
|
if err := os.MkdirAll(symlinkDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create directory %s: %w", symlinkDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Remove existing file/symlink if it exists
|
// Remove existing file/symlink if it exists
|
||||||
if _, err := os.Lstat(symlinkPath); err == nil {
|
if _, err := os.Lstat(symlinkPath); err == nil {
|
||||||
if err := os.RemoveAll(symlinkPath); err != nil {
|
if err := os.RemoveAll(symlinkPath); err != nil {
|
||||||
@@ -393,10 +476,10 @@ func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
|||||||
|
|
||||||
// Create symlink
|
// Create symlink
|
||||||
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
|
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create symlink for %s: %w", itemName, err)
|
return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
restored = append(restored, itemName)
|
restored = append(restored, relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return restored, nil
|
return restored, nil
|
||||||
@@ -470,25 +553,22 @@ func (l *Lnk) getManagedItems() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addManagedItem adds an item to the .lnk tracking file
|
// addManagedItem adds an item to the .lnk tracking file
|
||||||
func (l *Lnk) addManagedItem(itemPath string) error {
|
func (l *Lnk) addManagedItem(relativePath string) error {
|
||||||
// Get current items
|
// Get current items
|
||||||
items, err := l.getManagedItems()
|
items, err := l.getManagedItems()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get managed items: %w", err)
|
return fmt.Errorf("failed to get managed items: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the basename for storage
|
|
||||||
basename := filepath.Base(itemPath)
|
|
||||||
|
|
||||||
// Check if already exists
|
// Check if already exists
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item == basename {
|
if item == relativePath {
|
||||||
return nil // Already managed
|
return nil // Already managed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new item
|
// Add new item using relative path
|
||||||
items = append(items, basename)
|
items = append(items, relativePath)
|
||||||
|
|
||||||
// Sort for consistent ordering
|
// Sort for consistent ordering
|
||||||
sort.Strings(items)
|
sort.Strings(items)
|
||||||
@@ -497,20 +577,17 @@ func (l *Lnk) addManagedItem(itemPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// removeManagedItem removes an item from the .lnk tracking file
|
// removeManagedItem removes an item from the .lnk tracking file
|
||||||
func (l *Lnk) removeManagedItem(itemPath string) error {
|
func (l *Lnk) removeManagedItem(relativePath string) error {
|
||||||
// Get current items
|
// Get current items
|
||||||
items, err := l.getManagedItems()
|
items, err := l.getManagedItems()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get managed items: %w", err)
|
return fmt.Errorf("failed to get managed items: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the basename for removal
|
// Remove item using relative path
|
||||||
basename := filepath.Base(itemPath)
|
|
||||||
|
|
||||||
// Remove item
|
|
||||||
var newItems []string
|
var newItems []string
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item != basename {
|
if item != relativePath {
|
||||||
newItems = append(newItems, item)
|
newItems = append(newItems, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
521
internal/core/lnk_test.go
Normal file
521
internal/core/lnk_test.go
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CoreTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
tempDir string
|
||||||
|
originalDir string
|
||||||
|
lnk *Lnk
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) SetupTest() {
|
||||||
|
// Create temporary directory for each test
|
||||||
|
tempDir, err := os.MkdirTemp("", "lnk-test-*")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.tempDir = tempDir
|
||||||
|
|
||||||
|
// Change to temp directory
|
||||||
|
originalDir, err := os.Getwd()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.originalDir = originalDir
|
||||||
|
|
||||||
|
err = os.Chdir(tempDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Set XDG_CONFIG_HOME to temp directory
|
||||||
|
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
||||||
|
|
||||||
|
// Initialize Lnk instance
|
||||||
|
suite.lnk = NewLnk()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *CoreTestSuite) TearDownTest() {
|
||||||
|
// Return to original directory
|
||||||
|
err := os.Chdir(suite.originalDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Clean up temp directory
|
||||||
|
err = os.RemoveAll(suite.tempDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test core initialization functionality
|
||||||
|
func (suite *CoreTestSuite) TestCoreInit() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Check that the lnk directory was created
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
suite.DirExists(lnkDir)
|
||||||
|
|
||||||
|
// Check that Git repo was initialized
|
||||||
|
gitDir := filepath.Join(lnkDir, ".git")
|
||||||
|
suite.DirExists(gitDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test core add/remove functionality with files
|
||||||
|
func (suite *CoreTestSuite) TestCoreFileOperations() {
|
||||||
|
// Initialize first
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a test 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)
|
||||||
|
|
||||||
|
// Add the file
|
||||||
|
err = suite.lnk.Add(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify symlink and repo file
|
||||||
|
info, err := os.Lstat(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
// The repository file will have a generated name based on the relative path
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
entries, err := os.ReadDir(lnkDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
var repoFile string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.Contains(entry.Name(), ".bashrc") && entry.Name() != ".lnk" {
|
||||||
|
repoFile = filepath.Join(lnkDir, entry.Name())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suite.NotEmpty(repoFile, "Repository should contain a file with .bashrc in the name")
|
||||||
|
suite.FileExists(repoFile)
|
||||||
|
|
||||||
|
// Verify content is preserved
|
||||||
|
repoContent, err := os.ReadFile(repoFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(content, string(repoContent))
|
||||||
|
|
||||||
|
// Test remove
|
||||||
|
err = suite.lnk.Remove(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify symlink is gone and regular file is restored
|
||||||
|
info, err = os.Lstat(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
||||||
|
|
||||||
|
// Verify content is preserved
|
||||||
|
restoredContent, err := os.ReadFile(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(content, string(restoredContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test core add/remove functionality with directories
|
||||||
|
func (suite *CoreTestSuite) TestCoreDirectoryOperations() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create a directory with files
|
||||||
|
testDir := filepath.Join(suite.tempDir, "testdir")
|
||||||
|
err = os.MkdirAll(testDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
testFile := filepath.Join(testDir, "config.txt")
|
||||||
|
content := "test config"
|
||||||
|
err = os.WriteFile(testFile, []byte(content), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add the directory
|
||||||
|
err = suite.lnk.Add(testDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify directory is now a symlink
|
||||||
|
info, err := os.Lstat(testDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
// Check that some repository directory exists with testdir in the name
|
||||||
|
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||||
|
entries, err := os.ReadDir(lnkDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
var repoDir string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.Contains(entry.Name(), "testdir") && entry.Name() != ".lnk" {
|
||||||
|
repoDir = filepath.Join(lnkDir, entry.Name())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suite.NotEmpty(repoDir, "Repository should contain a directory with testdir in the name")
|
||||||
|
suite.DirExists(repoDir)
|
||||||
|
|
||||||
|
// Remove the directory
|
||||||
|
err = suite.lnk.Remove(testDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify symlink is gone and regular directory is restored
|
||||||
|
info, err = os.Lstat(testDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
||||||
|
suite.True(info.IsDir()) // Is a directory
|
||||||
|
|
||||||
|
// Verify content is preserved
|
||||||
|
restoredContent, err := os.ReadFile(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(content, string(restoredContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test .lnk file tracking functionality
|
||||||
|
func (suite *CoreTestSuite) TestLnkFileTracking() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add multiple items
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||||
|
err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = suite.lnk.Add(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
testDir := filepath.Join(suite.tempDir, ".ssh")
|
||||||
|
err = os.MkdirAll(testDir, 0700)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
configFile := filepath.Join(testDir, "config")
|
||||||
|
err = os.WriteFile(configFile, []byte("Host example.com"), 0600)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = suite.lnk.Add(testDir)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Check .lnk file contains both entries
|
||||||
|
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
||||||
|
suite.FileExists(lnkFile)
|
||||||
|
|
||||||
|
lnkContent, err := os.ReadFile(lnkFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(lnkContent)), "\n")
|
||||||
|
suite.Len(lines, 2)
|
||||||
|
|
||||||
|
// The .lnk file now contains relative paths, not basenames
|
||||||
|
// Check that the content contains references to .bashrc and .ssh
|
||||||
|
content := string(lnkContent)
|
||||||
|
suite.Contains(content, ".bashrc", ".lnk file should contain reference to .bashrc")
|
||||||
|
suite.Contains(content, ".ssh", ".lnk file should contain reference to .ssh")
|
||||||
|
|
||||||
|
// Remove one item and verify tracking is updated
|
||||||
|
err = suite.lnk.Remove(testFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
lnkContent, err = os.ReadFile(lnkFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
lines = strings.Split(strings.TrimSpace(string(lnkContent)), "\n")
|
||||||
|
suite.Len(lines, 1)
|
||||||
|
|
||||||
|
content = string(lnkContent)
|
||||||
|
suite.Contains(content, ".ssh", ".lnk file should still contain reference to .ssh")
|
||||||
|
suite.NotContains(content, ".bashrc", ".lnk file should not contain reference to .bashrc after removal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test XDG_CONFIG_HOME fallback
|
||||||
|
func (suite *CoreTestSuite) TestXDGConfigHomeFallback() {
|
||||||
|
// Test fallback to ~/.config/lnk when XDG_CONFIG_HOME is not set
|
||||||
|
suite.T().Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
|
||||||
|
homeDir := filepath.Join(suite.tempDir, "home")
|
||||||
|
err := os.MkdirAll(homeDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.T().Setenv("HOME", homeDir)
|
||||||
|
|
||||||
|
lnk := NewLnk()
|
||||||
|
err = lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Check that the lnk directory was created under ~/.config/lnk
|
||||||
|
expectedDir := filepath.Join(homeDir, ".config", "lnk")
|
||||||
|
suite.DirExists(expectedDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test symlink restoration (pull functionality)
|
||||||
|
func (suite *CoreTestSuite) TestSymlinkRestoration() {
|
||||||
|
_ = suite.lnk.Init()
|
||||||
|
|
||||||
|
// Create a file in the repo directly (simulating a pull)
|
||||||
|
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
||||||
|
content := "export PATH=$PATH:/usr/local/bin"
|
||||||
|
err := os.WriteFile(repoFile, []byte(content), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create .lnk file to track it
|
||||||
|
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
||||||
|
err = os.WriteFile(lnkFile, []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 := suite.lnk.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 error conditions
|
||||||
|
func (suite *CoreTestSuite) TestErrorConditions() {
|
||||||
|
// Test add nonexistent file
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.lnk.Add("/nonexistent/file")
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "File does not exist")
|
||||||
|
|
||||||
|
// Test remove unmanaged file
|
||||||
|
testFile := filepath.Join(suite.tempDir, ".regularfile")
|
||||||
|
err = os.WriteFile(testFile, []byte("content"), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
err = suite.lnk.Remove(testFile)
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "File is not managed by lnk")
|
||||||
|
|
||||||
|
// Test status without remote
|
||||||
|
_, err = suite.lnk.Status()
|
||||||
|
suite.Error(err)
|
||||||
|
suite.Contains(err.Error(), "no remote configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test git operations
|
||||||
|
func (suite *CoreTestSuite) TestGitOperations() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add a file to create a commit
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Check that Git commit was made
|
||||||
|
commits, err := suite.lnk.GetCommits()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Len(commits, 1)
|
||||||
|
suite.Contains(commits[0], "lnk: added .bashrc")
|
||||||
|
|
||||||
|
// Test add remote
|
||||||
|
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Test status with remote
|
||||||
|
status, err := suite.lnk.Status()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(1, status.Ahead)
|
||||||
|
suite.Equal(0, status.Behind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test edge case: files with same basename from different directories should be handled properly
|
||||||
|
func (suite *CoreTestSuite) TestSameBasenameFilesOverwrite() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create two directories with files having the same basename
|
||||||
|
dirA := filepath.Join(suite.tempDir, "a")
|
||||||
|
dirB := filepath.Join(suite.tempDir, "b")
|
||||||
|
err = os.MkdirAll(dirA, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.MkdirAll(dirB, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create files with same basename but different content
|
||||||
|
fileA := filepath.Join(dirA, "config.json")
|
||||||
|
fileB := filepath.Join(dirB, "config.json")
|
||||||
|
contentA := `{"name": "config_a"}`
|
||||||
|
contentB := `{"name": "config_b"}`
|
||||||
|
|
||||||
|
err = os.WriteFile(fileA, []byte(contentA), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(fileB, []byte(contentB), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add first file
|
||||||
|
err = suite.lnk.Add(fileA)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify first file is managed correctly and preserves content
|
||||||
|
info, err := os.Lstat(fileA)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
symlinkContentA, err := os.ReadFile(fileA)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(contentA, string(symlinkContentA), "First file should preserve its original content")
|
||||||
|
|
||||||
|
// Add second file - this should work without overwriting the first
|
||||||
|
err = suite.lnk.Add(fileB)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Verify second file is managed
|
||||||
|
info, err = os.Lstat(fileB)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||||
|
|
||||||
|
// CORRECT BEHAVIOR: Both files should preserve their original content
|
||||||
|
symlinkContentA, err = os.ReadFile(fileA)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
symlinkContentB, err := os.ReadFile(fileB)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
suite.Equal(contentA, string(symlinkContentA), "First file should keep its original content")
|
||||||
|
suite.Equal(contentB, string(symlinkContentB), "Second file should keep its original content")
|
||||||
|
|
||||||
|
// Both files should be removable independently
|
||||||
|
err = suite.lnk.Remove(fileA)
|
||||||
|
suite.Require().NoError(err, "First file should be removable")
|
||||||
|
|
||||||
|
// First file should be restored with correct content
|
||||||
|
info, err = os.Lstat(fileA)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore
|
||||||
|
|
||||||
|
restoredContentA, err := os.ReadFile(fileA)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(contentA, string(restoredContentA), "Restored file should have original content")
|
||||||
|
|
||||||
|
// Second file should still be manageable and removable
|
||||||
|
err = suite.lnk.Remove(fileB)
|
||||||
|
suite.Require().NoError(err, "Second file should also be removable without errors")
|
||||||
|
|
||||||
|
// Second file should be restored with correct content
|
||||||
|
info, err = os.Lstat(fileB)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore
|
||||||
|
|
||||||
|
restoredContentB, err := os.ReadFile(fileB)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
suite.Equal(contentB, string(restoredContentB), "Second restored file should have original content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test another variant: adding files with same basename should work correctly
|
||||||
|
func (suite *CoreTestSuite) TestSameBasenameSequentialAdd() {
|
||||||
|
err := suite.lnk.Init()
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create subdirectories in different locations
|
||||||
|
configDir := filepath.Join(suite.tempDir, "config")
|
||||||
|
backupDir := filepath.Join(suite.tempDir, "backup")
|
||||||
|
err = os.MkdirAll(configDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.MkdirAll(backupDir, 0755)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Create files with same basename (.bashrc)
|
||||||
|
configBashrc := filepath.Join(configDir, ".bashrc")
|
||||||
|
backupBashrc := filepath.Join(backupDir, ".bashrc")
|
||||||
|
|
||||||
|
originalContent := "export PATH=/usr/local/bin:$PATH"
|
||||||
|
backupContent := "export PATH=/opt/bin:$PATH"
|
||||||
|
|
||||||
|
err = os.WriteFile(configBashrc, []byte(originalContent), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
err = os.WriteFile(backupBashrc, []byte(backupContent), 0644)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add first .bashrc
|
||||||
|
err = suite.lnk.Add(configBashrc)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Add second .bashrc - should work without overwriting the first
|
||||||
|
err = suite.lnk.Add(backupBashrc)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Check .lnk tracking file should track both properly
|
||||||
|
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
||||||
|
lnkContent, err := os.ReadFile(lnkFile)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
// Both entries should be tracked and distinguishable
|
||||||
|
content := string(lnkContent)
|
||||||
|
suite.Contains(content, ".bashrc", "Both .bashrc files should be tracked")
|
||||||
|
|
||||||
|
// Both files should maintain their distinct content
|
||||||
|
content1, err := os.ReadFile(configBashrc)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
content2, err := os.ReadFile(backupBashrc)
|
||||||
|
suite.Require().NoError(err)
|
||||||
|
|
||||||
|
suite.Equal(originalContent, string(content1), "First file should keep original content")
|
||||||
|
suite.Equal(backupContent, string(content2), "Second file should keep its distinct content")
|
||||||
|
|
||||||
|
// Both should be removable independently
|
||||||
|
err = suite.lnk.Remove(configBashrc)
|
||||||
|
suite.Require().NoError(err, "First .bashrc should be removable")
|
||||||
|
|
||||||
|
err = suite.lnk.Remove(backupBashrc)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(CoreTestSuite))
|
||||||
|
}
|
@@ -21,14 +21,14 @@ func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
|
|||||||
info, err := os.Stat(filePath)
|
info, err := os.Stat(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("file does not exist: %s", filePath)
|
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to stat file: %w", err)
|
return fmt.Errorf("❌ Failed to check file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow both regular files and directories
|
// Allow both regular files and directories
|
||||||
if !info.Mode().IsRegular() && !info.IsDir() {
|
if !info.Mode().IsRegular() && !info.IsDir() {
|
||||||
return fmt.Errorf("only regular files and directories are supported: %s", filePath)
|
return fmt.Errorf("❌ Only regular files and directories are supported: \033[31m%s\033[0m", filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -40,14 +40,14 @@ func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error
|
|||||||
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
|
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("file does not exist: %s", filePath)
|
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to stat file: %w", err)
|
return fmt.Errorf("❌ Failed to check file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a symlink
|
// Check if it's a symlink
|
||||||
if info.Mode()&os.ModeSymlink == 0 {
|
if info.Mode()&os.ModeSymlink == 0 {
|
||||||
return fmt.Errorf("file is not managed by lnk: %s", filePath)
|
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m\n 💡 Use \033[1mlnk add\033[0m to manage this file first", filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if symlink points to the repository
|
// Check if symlink points to the repository
|
||||||
@@ -67,7 +67,7 @@ func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error
|
|||||||
|
|
||||||
// Check if target is inside the repository
|
// Check if target is inside the repository
|
||||||
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
|
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
|
||||||
return fmt.Errorf("file is not managed by lnk: %s", filePath)
|
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,718 +0,0 @@
|
|||||||
package test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
"github.com/yarlson/lnk/internal/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LnkIntegrationTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
tempDir string
|
|
||||||
originalDir string
|
|
||||||
lnk *core.Lnk
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) SetupTest() {
|
|
||||||
// Create temporary directory for each test
|
|
||||||
tempDir, err := os.MkdirTemp("", "lnk-test-*")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.tempDir = tempDir
|
|
||||||
|
|
||||||
// Change to temp directory
|
|
||||||
originalDir, err := os.Getwd()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.originalDir = originalDir
|
|
||||||
|
|
||||||
err = os.Chdir(tempDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Set XDG_CONFIG_HOME to temp directory
|
|
||||||
suite.T().Setenv("XDG_CONFIG_HOME", tempDir)
|
|
||||||
|
|
||||||
// Initialize Lnk instance
|
|
||||||
suite.lnk = core.NewLnk()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TearDownTest() {
|
|
||||||
// Return to original directory
|
|
||||||
err := os.Chdir(suite.originalDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Clean up temp directory
|
|
||||||
err = os.RemoveAll(suite.tempDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInit() {
|
|
||||||
// Test that init creates the directory and Git repo
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the lnk directory was created
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
suite.DirExists(lnkDir)
|
|
||||||
|
|
||||||
// Check that Git repo was initialized
|
|
||||||
gitDir := filepath.Join(lnkDir, ".git")
|
|
||||||
suite.DirExists(gitDir)
|
|
||||||
|
|
||||||
// Verify it's a non-bare repo
|
|
||||||
configPath := filepath.Join(gitDir, "config")
|
|
||||||
suite.FileExists(configPath)
|
|
||||||
|
|
||||||
// Verify the default branch is set to 'main'
|
|
||||||
cmd := exec.Command("git", "symbolic-ref", "HEAD")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
output, err := cmd.Output()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal("refs/heads/main", strings.TrimSpace(string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestAddFile() {
|
|
||||||
// Initialize first
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a test 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)
|
|
||||||
|
|
||||||
// Add the file
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the original file is now a symlink
|
|
||||||
info, err := os.Lstat(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
|
||||||
|
|
||||||
// Check that the file exists in the repo
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
|
||||||
suite.FileExists(repoFile)
|
|
||||||
|
|
||||||
// Check that the content is preserved
|
|
||||||
repoContent, err := os.ReadFile(repoFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(repoContent))
|
|
||||||
|
|
||||||
// Check that symlink points to the correct location
|
|
||||||
linkTarget, err := os.Readlink(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
expectedTarget, err := filepath.Rel(filepath.Dir(testFile), repoFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(expectedTarget, linkTarget)
|
|
||||||
|
|
||||||
// Check that Git commit was made
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Len(commits, 1)
|
|
||||||
suite.Contains(commits[0], "lnk: added .bashrc")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestRemoveFile() {
|
|
||||||
// Initialize and add a file first
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
|
||||||
content := "set number"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Now remove the file
|
|
||||||
err = suite.lnk.Remove(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the symlink is gone and regular file is restored
|
|
||||||
info, err := os.Lstat(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
|
||||||
|
|
||||||
// Check that content is preserved
|
|
||||||
restoredContent, err := os.ReadFile(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(restoredContent))
|
|
||||||
|
|
||||||
// Check that file is removed from repo
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".vimrc")
|
|
||||||
suite.NoFileExists(repoFile)
|
|
||||||
|
|
||||||
// Check that Git commit was made
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Len(commits, 2) // add + remove
|
|
||||||
suite.Contains(commits[0], "lnk: removed .vimrc")
|
|
||||||
suite.Contains(commits[1], "lnk: added .vimrc")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestAddNonexistentFile() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add("/nonexistent/file")
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "file does not exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestAddDirectory() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a directory with files
|
|
||||||
testDir := filepath.Join(suite.tempDir, "testdir")
|
|
||||||
err = os.MkdirAll(testDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add files to the directory
|
|
||||||
testFile1 := filepath.Join(testDir, "file1.txt")
|
|
||||||
err = os.WriteFile(testFile1, []byte("content1"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testFile2 := filepath.Join(testDir, "file2.txt")
|
|
||||||
err = os.WriteFile(testFile2, []byte("content2"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add the directory - should now succeed
|
|
||||||
err = suite.lnk.Add(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the directory is now a symlink
|
|
||||||
info, err := os.Lstat(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
|
||||||
|
|
||||||
// Check that the directory exists in the repo
|
|
||||||
repoDir := filepath.Join(suite.tempDir, "lnk", "testdir")
|
|
||||||
suite.DirExists(repoDir)
|
|
||||||
|
|
||||||
// Check that files are preserved
|
|
||||||
repoFile1 := filepath.Join(repoDir, "file1.txt")
|
|
||||||
repoFile2 := filepath.Join(repoDir, "file2.txt")
|
|
||||||
suite.FileExists(repoFile1)
|
|
||||||
suite.FileExists(repoFile2)
|
|
||||||
|
|
||||||
content1, err := os.ReadFile(repoFile1)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal("content1", string(content1))
|
|
||||||
|
|
||||||
content2, err := os.ReadFile(repoFile2)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal("content2", string(content2))
|
|
||||||
|
|
||||||
// Check that .lnk file was created and contains the directory
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
suite.FileExists(lnkFile)
|
|
||||||
|
|
||||||
lnkContent, err := os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Contains(string(lnkContent), "testdir")
|
|
||||||
|
|
||||||
// Check that Git commit was made
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Len(commits, 1)
|
|
||||||
suite.Contains(commits[0], "lnk: added testdir")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestRemoveDirectory() {
|
|
||||||
// Initialize and add a directory first
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testDir := filepath.Join(suite.tempDir, "testdir")
|
|
||||||
err = os.MkdirAll(testDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
testFile := filepath.Join(testDir, "config.txt")
|
|
||||||
content := "test config"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Now remove the directory
|
|
||||||
err = suite.lnk.Remove(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the symlink is gone and regular directory is restored
|
|
||||||
info, err := os.Lstat(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
|
||||||
suite.True(info.IsDir()) // Is a directory
|
|
||||||
|
|
||||||
// Check that content is preserved
|
|
||||||
restoredContent, err := os.ReadFile(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(restoredContent))
|
|
||||||
|
|
||||||
// Check that directory is removed from repo
|
|
||||||
repoDir := filepath.Join(suite.tempDir, "lnk", "testdir")
|
|
||||||
suite.NoDirExists(repoDir)
|
|
||||||
|
|
||||||
// Check that .lnk file no longer contains the directory
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
if suite.FileExists(lnkFile) {
|
|
||||||
lnkContent, err := os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.NotContains(string(lnkContent), "testdir")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that Git commit was made
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Len(commits, 2) // add + remove
|
|
||||||
suite.Contains(commits[0], "lnk: removed testdir")
|
|
||||||
suite.Contains(commits[1], "lnk: added testdir")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestLnkFileTracking() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// 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.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add a directory
|
|
||||||
testDir := filepath.Join(suite.tempDir, ".ssh")
|
|
||||||
err = os.MkdirAll(testDir, 0700)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
configFile := filepath.Join(testDir, "config")
|
|
||||||
err = os.WriteFile(configFile, []byte("Host example.com"), 0600)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check .lnk file contains both entries
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
suite.FileExists(lnkFile)
|
|
||||||
|
|
||||||
lnkContent, err := os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(lnkContent)), "\n")
|
|
||||||
suite.Len(lines, 2)
|
|
||||||
suite.Contains(lines, ".bashrc")
|
|
||||||
suite.Contains(lines, ".ssh")
|
|
||||||
|
|
||||||
// Remove a file and check .lnk is updated
|
|
||||||
err = suite.lnk.Remove(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lnkContent, err = os.ReadFile(lnkFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lines = strings.Split(strings.TrimSpace(string(lnkContent)), "\n")
|
|
||||||
suite.Len(lines, 1)
|
|
||||||
suite.Contains(lines, ".ssh")
|
|
||||||
suite.NotContains(lines, ".bashrc")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestPullWithDirectories() {
|
|
||||||
// Initialize repo
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add remote for pull to work
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a directory and .lnk file in the repo directly to simulate a pull
|
|
||||||
repoDir := filepath.Join(suite.tempDir, "lnk", ".config")
|
|
||||||
err = os.MkdirAll(repoDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
configFile := filepath.Join(repoDir, "app.conf")
|
|
||||||
content := "setting=value"
|
|
||||||
err = os.WriteFile(configFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create .lnk file
|
|
||||||
lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk")
|
|
||||||
err = os.WriteFile(lnkFile, []byte(".config\n"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Get home directory for the test
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
targetDir := filepath.Join(homeDir, ".config")
|
|
||||||
|
|
||||||
// Clean up the test directory after the test
|
|
||||||
defer func() {
|
|
||||||
_ = os.RemoveAll(targetDir)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Create a regular directory in home to simulate conflict scenario
|
|
||||||
err = os.MkdirAll(targetDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = os.WriteFile(filepath.Join(targetDir, "different.conf"), []byte("different"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Pull should restore symlinks and handle conflicts
|
|
||||||
restored, err := suite.lnk.Pull()
|
|
||||||
// In tests, pull will fail because we don't have real remotes, but that's expected
|
|
||||||
// We can still test the symlink restoration part
|
|
||||||
if err != nil {
|
|
||||||
suite.Contains(err.Error(), "git pull failed")
|
|
||||||
// Test symlink restoration directly
|
|
||||||
restored, err = suite.lnk.RestoreSymlinks()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have restored the symlink
|
|
||||||
suite.GreaterOrEqual(len(restored), 1)
|
|
||||||
if len(restored) > 0 {
|
|
||||||
suite.Equal(".config", restored[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that directory is back to being a symlink
|
|
||||||
info, err := os.Lstat(targetDir)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
|
||||||
|
|
||||||
// Check content is preserved from repo
|
|
||||||
repoContent, err := os.ReadFile(configFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(content, string(repoContent))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestRemoveNonSymlink() {
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a regular file (not managed by lnk)
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".regularfile")
|
|
||||||
err = os.WriteFile(testFile, []byte("content"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Remove(testFile)
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "file is not managed by lnk")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestXDGConfigHomeFallback() {
|
|
||||||
// Test fallback to ~/.config/lnk when XDG_CONFIG_HOME is not set
|
|
||||||
suite.T().Setenv("XDG_CONFIG_HOME", "")
|
|
||||||
|
|
||||||
homeDir := filepath.Join(suite.tempDir, "home")
|
|
||||||
err := os.MkdirAll(homeDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.T().Setenv("HOME", homeDir)
|
|
||||||
|
|
||||||
lnk := core.NewLnk()
|
|
||||||
err = lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Check that the lnk directory was created under ~/.config/lnk
|
|
||||||
expectedDir := filepath.Join(homeDir, ".config", "lnk")
|
|
||||||
suite.DirExists(expectedDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitWithRemote() {
|
|
||||||
// Test that init with remote adds the origin remote
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
remoteURL := "https://github.com/user/dotfiles.git"
|
|
||||||
err = suite.lnk.AddRemote("origin", remoteURL)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify the remote was added by checking git config
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
|
|
||||||
output, err := cmd.Output()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(remoteURL, strings.TrimSpace(string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitIdempotent() {
|
|
||||||
// Test that running init multiple times is safe
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
|
|
||||||
// Add a file to the repo to ensure it's not lost
|
|
||||||
testFile := filepath.Join(lnkDir, "test.txt")
|
|
||||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Run init again - should be idempotent
|
|
||||||
err = suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// File should still exist
|
|
||||||
suite.FileExists(testFile)
|
|
||||||
content, err := os.ReadFile(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal("test content", string(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitWithExistingRemote() {
|
|
||||||
// Test init with remote when remote already exists (same URL)
|
|
||||||
remoteURL := "https://github.com/user/dotfiles.git"
|
|
||||||
|
|
||||||
// First init with remote
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = suite.lnk.AddRemote("origin", remoteURL)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Init again with same remote should be idempotent
|
|
||||||
err = suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
err = suite.lnk.AddRemote("origin", remoteURL)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Verify remote is still correct
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
output, err := cmd.Output()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(remoteURL, strings.TrimSpace(string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitWithDifferentRemote() {
|
|
||||||
// Test init with different remote when remote already exists
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add first remote
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/user/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Try to add different remote - should error
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/user/other-repo.git")
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "already exists with different URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestInitWithNonLnkRepo() {
|
|
||||||
// Test init when directory contains a non-lnk Git repository
|
|
||||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
|
||||||
err := os.MkdirAll(lnkDir, 0755)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Create a non-lnk git repo in the lnk directory
|
|
||||||
cmd := exec.Command("git", "init")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add some content to make it look like a real repo
|
|
||||||
testFile := filepath.Join(lnkDir, "important-file.txt")
|
|
||||||
err = os.WriteFile(testFile, []byte("important data"), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Configure git and commit
|
|
||||||
cmd = exec.Command("git", "config", "user.name", "Test User")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
cmd = exec.Command("git", "config", "user.email", "test@example.com")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
cmd = exec.Command("git", "add", "important-file.txt")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
cmd = exec.Command("git", "commit", "-m", "important commit")
|
|
||||||
cmd.Dir = lnkDir
|
|
||||||
err = cmd.Run()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Now try to init lnk - should error to protect existing repo
|
|
||||||
err = suite.lnk.Init()
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "appears to contain an existing Git repository")
|
|
||||||
|
|
||||||
// Verify the original file is still there
|
|
||||||
suite.FileExists(testFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncStatus tests the status command functionality
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncStatus() {
|
|
||||||
// Initialize repo with remote
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add a file to create some local changes
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Get status - should show 1 commit ahead
|
|
||||||
status, err := suite.lnk.Status()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.Equal(1, status.Ahead)
|
|
||||||
suite.Equal(0, status.Behind)
|
|
||||||
suite.Equal("origin/main", status.Remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncPush tests the push command functionality
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncPush() {
|
|
||||||
// Initialize repo
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add remote for push to work
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add a file
|
|
||||||
testFile := filepath.Join(suite.tempDir, ".vimrc")
|
|
||||||
content := "set number"
|
|
||||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add another file for a second commit
|
|
||||||
testFile2 := filepath.Join(suite.tempDir, ".gitconfig")
|
|
||||||
content2 := "[user]\n name = Test User"
|
|
||||||
err = os.WriteFile(testFile2, []byte(content2), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
err = suite.lnk.Add(testFile2)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Modify one of the files to create uncommitted changes
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".vimrc")
|
|
||||||
modifiedContent := "set number\nset relativenumber"
|
|
||||||
err = os.WriteFile(repoFile, []byte(modifiedContent), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Push should stage all changes and create a sync commit
|
|
||||||
message := "Updated configuration files"
|
|
||||||
err = suite.lnk.Push(message)
|
|
||||||
// In tests, push will fail because we don't have real remotes, but that's expected
|
|
||||||
// The important part is that it stages and commits changes
|
|
||||||
if err != nil {
|
|
||||||
suite.Contains(err.Error(), "git push failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that a sync commit was made (even if push failed)
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.GreaterOrEqual(len(commits), 3) // at least 2 add commits + 1 sync commit
|
|
||||||
suite.Contains(commits[0], message) // Latest commit should contain our message
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncPull tests the pull command functionality
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncPull() {
|
|
||||||
// Initialize repo
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add remote for pull to work
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Pull should attempt to pull from remote (will fail in tests but that's expected)
|
|
||||||
_, err = suite.lnk.Pull()
|
|
||||||
// In tests, pull will fail because we don't have real remotes, but that's expected
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "git pull failed")
|
|
||||||
|
|
||||||
// Test RestoreSymlinks functionality separately
|
|
||||||
// Create a file in the repo directly
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
|
||||||
content := "export PATH=$PATH:/usr/local/bin"
|
|
||||||
err = os.WriteFile(repoFile, []byte(content), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Test that RestoreSymlinks can be called (even if it doesn't restore anything in this test setup)
|
|
||||||
restored, err := suite.lnk.RestoreSymlinks()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
// In this test setup, it might not restore anything, and that's okay for Phase 1
|
|
||||||
suite.GreaterOrEqual(len(restored), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncStatusNoRemote tests status when no remote is configured
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncStatusNoRemote() {
|
|
||||||
// Initialize repo without remote
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Status should indicate no remote
|
|
||||||
_, err = suite.lnk.Status()
|
|
||||||
suite.Error(err)
|
|
||||||
suite.Contains(err.Error(), "no remote configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSyncPushWithModifiedFiles tests push when files are modified
|
|
||||||
func (suite *LnkIntegrationTestSuite) TestSyncPushWithModifiedFiles() {
|
|
||||||
// Initialize repo and add a file
|
|
||||||
err := suite.lnk.Init()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Add remote for push to work
|
|
||||||
err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git")
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Modify the file in the repo (simulate editing managed file)
|
|
||||||
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
|
|
||||||
modifiedContent := "export PATH=$PATH:/usr/local/bin\nexport EDITOR=vim"
|
|
||||||
err = os.WriteFile(repoFile, []byte(modifiedContent), 0644)
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
|
|
||||||
// Push should detect and commit the changes
|
|
||||||
message := "Updated bashrc with editor setting"
|
|
||||||
err = suite.lnk.Push(message)
|
|
||||||
// In tests, push will fail because we don't have real remotes, but that's expected
|
|
||||||
if err != nil {
|
|
||||||
suite.Contains(err.Error(), "git push failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that changes were committed (even if push failed)
|
|
||||||
commits, err := suite.lnk.GetCommits()
|
|
||||||
suite.Require().NoError(err)
|
|
||||||
suite.GreaterOrEqual(len(commits), 2) // add commit + sync commit
|
|
||||||
suite.Contains(commits[0], message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLnkIntegrationSuite(t *testing.T) {
|
|
||||||
suite.Run(t, new(LnkIntegrationTestSuite))
|
|
||||||
}
|
|
Reference in New Issue
Block a user