refactor(cmd): improve testability and error handling in CLI commands

This commit is contained in:
Yar Kravtsov
2025-05-24 11:28:16 +03:00
parent 61a9cc8c88
commit fc0b567e9f
10 changed files with 837 additions and 899 deletions

View File

@@ -8,28 +8,26 @@ import (
"github.com/yarlson/lnk/internal/core"
)
var addCmd = &cobra.Command{
Use: "add <file>",
Short: "✨ Add a file to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
func newAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add <file>",
Short: "✨ Add a file to lnk management",
Long: "Moves a file to the lnk repository and creates a symlink in its place.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
lnk := core.NewLnk()
if err := lnk.Add(filePath); err != nil {
return fmt.Errorf("failed to add file: %w", err)
}
lnk := core.NewLnk()
if err := lnk.Add(filePath); err != nil {
return fmt.Errorf("failed to add file: %w", err)
}
basename := filepath.Base(filePath)
fmt.Printf("✨ \033[1mAdded %s to lnk\033[0m\n", basename)
fmt.Printf(" 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
fmt.Printf(" 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
return nil
},
}
func init() {
rootCmd.AddCommand(addCmd)
basename := filepath.Base(filePath)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
return nil
},
}
}

View File

@@ -7,39 +7,39 @@ import (
"github.com/yarlson/lnk/internal/core"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote")
func newInitCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "init",
Short: "🎯 Initialize a new lnk repository",
Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
remote, _ := cmd.Flags().GetString("remote")
lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil {
return fmt.Errorf("failed to initialize lnk: %w", err)
}
lnk := core.NewLnk()
if err := lnk.InitWithRemote(remote); err != nil {
return fmt.Errorf("failed to initialize lnk: %w", err)
}
if remote != "" {
fmt.Printf("🎯 \033[1mInitialized lnk repository\033[0m\n")
fmt.Printf(" 📦 Cloned from: \033[36m%s\033[0m\n", remote)
fmt.Printf(" 📁 Location: \033[90m~/.config/lnk\033[0m\n")
fmt.Printf("\n💡 \033[33mNext steps:\033[0m\n")
fmt.Printf(" • Run \033[1mlnk pull\033[0m to restore symlinks\n")
fmt.Printf(" • Use \033[1mlnk add <file>\033[0m to manage new files\n")
} else {
fmt.Printf("🎯 \033[1mInitialized empty lnk repository\033[0m\n")
fmt.Printf(" 📁 Location: \033[90m~/.config/lnk\033[0m\n")
fmt.Printf("\n💡 \033[33mNext steps:\033[0m\n")
fmt.Printf(" • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
fmt.Printf(" • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
}
if remote != "" {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "🎯 \033[1mInitialized lnk repository\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n💡 \033[33mNext steps:\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " • Run \033[1mlnk pull\033[0m to restore symlinks\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " • Use \033[1mlnk add <file>\033[0m to manage new files\n")
} else {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "🎯 \033[1mInitialized empty lnk repository\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 📁 Location: \033[90m~/.config/lnk\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n💡 \033[33mNext steps:\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " • Run \033[1mlnk add <file>\033[0m to start managing dotfiles\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " • Add a remote with: \033[1mgit remote add origin <url>\033[0m\n")
}
return nil
},
}
func init() {
initCmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
rootCmd.AddCommand(initCmd)
return nil
},
}
cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository")
return cmd
}

View File

@@ -7,39 +7,37 @@ import (
"github.com/yarlson/lnk/internal/core"
)
var pullCmd = &cobra.Command{
Use: "pull",
Short: "⬇️ Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
restored, err := lnk.Pull()
if err != nil {
return fmt.Errorf("failed to pull changes: %w", err)
}
if len(restored) > 0 {
fmt.Printf("⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
fmt.Printf(" 🔗 Restored \033[1m%d symlink", len(restored))
if len(restored) > 1 {
fmt.Printf("s")
func newPullCmd() *cobra.Command {
return &cobra.Command{
Use: "pull",
Short: "⬇️ Pull changes from remote and restore symlinks",
Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
restored, err := lnk.Pull()
if err != nil {
return fmt.Errorf("failed to pull changes: %w", err)
}
fmt.Printf("\033[0m:\n")
for _, file := range restored {
fmt.Printf(" ✨ \033[36m%s\033[0m\n", file)
if len(restored) > 0 {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 🔗 Restored \033[1m%d symlink", len(restored))
if len(restored) > 1 {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "s")
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[0m:\n")
for _, file := range restored {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " ✨ \033[36m%s\033[0m\n", file)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n 🎉 Your dotfiles are synced and ready!\n")
} else {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " ✅ All symlinks already in place\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 🎉 Everything is up to date!\n")
}
fmt.Printf("\n 🎉 Your dotfiles are synced and ready!\n")
} else {
fmt.Printf("⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n")
fmt.Printf(" ✅ All symlinks already in place\n")
fmt.Printf(" 🎉 Everything is up to date!\n")
}
return nil
},
}
func init() {
rootCmd.AddCommand(pullCmd)
return nil
},
}
}

View File

@@ -7,31 +7,29 @@ import (
"github.com/yarlson/lnk/internal/core"
)
var pushCmd = &cobra.Command{
Use: "push [message]",
Short: "🚀 Push local changes to remote repository",
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
message := "lnk: sync configuration files"
if len(args) > 0 {
message = args[0]
}
func newPushCmd() *cobra.Command {
return &cobra.Command{
Use: "push [message]",
Short: "🚀 Push local changes to remote repository",
Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.",
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
message := "lnk: sync configuration files"
if len(args) > 0 {
message = args[0]
}
lnk := core.NewLnk()
if err := lnk.Push(message); err != nil {
return fmt.Errorf("failed to push changes: %w", err)
}
lnk := core.NewLnk()
if err := lnk.Push(message); err != nil {
return fmt.Errorf("failed to push changes: %w", err)
}
fmt.Printf("🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
fmt.Printf(" 💾 Commit: \033[90m%s\033[0m\n", message)
fmt.Printf(" 📡 Synced to remote\n")
fmt.Printf(" ✨ Your dotfiles are up to date!\n")
return nil
},
}
func init() {
rootCmd.AddCommand(pushCmd)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 💾 Commit: \033[90m%s\033[0m\n", message)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 📡 Synced to remote\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " ✨ Your dotfiles are up to date!\n")
return nil
},
}
}

View File

@@ -8,28 +8,26 @@ import (
"github.com/yarlson/lnk/internal/core"
)
var rmCmd = &cobra.Command{
Use: "rm <file>",
Short: "🗑️ Remove a file from lnk management",
Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
func newRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "rm <file>",
Short: "🗑️ Remove a file from lnk management",
Long: "Removes a symlink and restores the original file from the lnk repository.",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := args[0]
lnk := core.NewLnk()
if err := lnk.Remove(filePath); err != nil {
return fmt.Errorf("failed to remove file: %w", err)
}
lnk := core.NewLnk()
if err := lnk.Remove(filePath); err != nil {
return fmt.Errorf("failed to remove file: %w", err)
}
basename := filepath.Base(filePath)
fmt.Printf("🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
fmt.Printf(" ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
fmt.Printf(" 📄 Original file restored\n")
return nil
},
}
func init() {
rootCmd.AddCommand(rmCmd)
basename := filepath.Base(filePath)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 📄 Original file restored\n")
return nil
},
}
}

View File

@@ -12,10 +12,12 @@ var (
buildTime = "unknown"
)
var rootCmd = &cobra.Command{
Use: "lnk",
Short: "🔗 Dotfiles, linked. No fluff.",
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
// NewRootCommand creates a new root command (testable)
func NewRootCommand() *cobra.Command {
rootCmd := &cobra.Command{
Use: "lnk",
Short: "🔗 Dotfiles, linked. No fluff.",
Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck.
Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal.
That's it.
@@ -28,17 +30,29 @@ That's it.
lnk pull # Get latest changes
🎯 Simple, fast, and Git-native.`,
SilenceUsage: true,
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
func SetVersion(v, bt string) {
version = v
buildTime = bt
rootCmd.Version = fmt.Sprintf("%s (built %s)", version, buildTime)
}
func Execute() {
rootCmd := NewRootCommand()
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)

View File

@@ -7,52 +7,50 @@ import (
"github.com/yarlson/lnk/internal/core"
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "📊 Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
status, err := lnk.Status()
if err != nil {
return fmt.Errorf("failed to get status: %w", err)
}
func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "📊 Show repository sync status",
Long: "Display how many commits ahead/behind the local repository is relative to the remote.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
lnk := core.NewLnk()
status, err := lnk.Status()
if err != nil {
return fmt.Errorf("failed to get status: %w", err)
}
if status.Ahead == 0 && status.Behind == 0 {
fmt.Printf("✅ \033[1;32mRepository is up to date\033[0m\n")
fmt.Printf(" 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
} else {
fmt.Printf("📊 \033[1mRepository Status\033[0m\n")
fmt.Printf(" 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
fmt.Printf("\n")
if status.Ahead == 0 && status.Behind == 0 {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "✅ \033[1;32mRepository is up to date\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
} else {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "📊 \033[1mRepository Status\033[0m\n")
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n")
if status.Ahead > 0 {
commitText := "commit"
if status.Ahead > 1 {
commitText = "commits"
if status.Ahead > 0 {
commitText := "commit"
if status.Ahead > 1 {
commitText = "commits"
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
}
fmt.Printf(" ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText)
}
if status.Behind > 0 {
commitText := "commit"
if status.Behind > 1 {
commitText = "commits"
if status.Behind > 0 {
commitText := "commit"
if status.Behind > 1 {
commitText = "commits"
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
}
if status.Ahead > 0 && status.Behind == 0 {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n💡 Run \033[1mlnk push\033[0m to sync your changes")
} else if status.Behind > 0 {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n💡 Run \033[1mlnk pull\033[0m to get latest changes")
}
fmt.Printf(" ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText)
}
if status.Ahead > 0 && status.Behind == 0 {
fmt.Printf("\n💡 Run \033[1mlnk push\033[0m to sync your changes")
} else if status.Behind > 0 {
fmt.Printf("\n💡 Run \033[1mlnk pull\033[0m to get latest changes")
}
}
return nil
},
}
func init() {
rootCmd.AddCommand(statusCmd)
return nil
},
}
}

336
test/cli_test.go Normal file
View File

@@ -0,0 +1,336 @@
package test
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/suite"
"github.com/yarlson/lnk/cmd"
)
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 := cmd.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 file exists in repo
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
suite.FileExists(repoFile)
}
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 directory exists in repo
repoDir := filepath.Join(suite.tempDir, "lnk", ".config")
suite.DirExists(repoDir)
}
func TestCLISuite(t *testing.T) {
suite.Run(t, new(CLITestSuite))
}

316
test/core_test.go Normal file
View File

@@ -0,0 +1,316 @@
package test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/yarlson/lnk/internal/core"
)
type CoreTestSuite struct {
suite.Suite
tempDir string
originalDir string
lnk *core.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 = core.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)
repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc")
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)
// Verify directory exists in repo
repoDir := filepath.Join(suite.tempDir, "lnk", "testdir")
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)
suite.Contains(lines, ".bashrc")
suite.Contains(lines, ".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)
suite.Contains(lines, ".ssh")
suite.NotContains(lines, ".bashrc")
}
// 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 := 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)
}
// 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)
}
func TestCoreSuite(t *testing.T) {
suite.Run(t, new(CoreTestSuite))
}

View File

@@ -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(), "contains 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))
}