mirror of
https://github.com/yarlson/lnk.git
synced 2025-08-31 18:01:41 +02:00
feat(core): refactor to clean architecture and improve error handling
This commit is contained in:
26
cmd/add.go
26
cmd/add.go
@@ -1,11 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
|
||||
"github.com/yarlson/lnk/internal/service"
|
||||
)
|
||||
|
||||
func newAddCmd() *cobra.Command {
|
||||
@@ -19,24 +20,27 @@ func newAddCmd() *cobra.Command {
|
||||
filePath := args[0]
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
// Create service instance
|
||||
lnkService, err := service.New()
|
||||
if err != nil {
|
||||
return wrapServiceError("initialize lnk service", err)
|
||||
}
|
||||
|
||||
if err := lnk.Add(filePath); err != nil {
|
||||
return fmt.Errorf("failed to add file: %w", err)
|
||||
// Add file using service layer
|
||||
ctx := context.Background()
|
||||
managedFile, err := lnkService.AddFile(ctx, filePath, host)
|
||||
if err != nil {
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
// Display success message
|
||||
basename := filepath.Base(filePath)
|
||||
if host != "" {
|
||||
printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", managedFile.OriginalPath, host, managedFile.RelativePath)
|
||||
} else {
|
||||
printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath)
|
||||
printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", managedFile.OriginalPath, managedFile.RelativePath)
|
||||
}
|
||||
printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n")
|
||||
return nil
|
||||
|
19
cmd/init.go
19
cmd/init.go
@@ -1,10 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
|
||||
"github.com/yarlson/lnk/internal/service"
|
||||
)
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
@@ -16,11 +17,19 @@ func newInitCmd() *cobra.Command {
|
||||
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)
|
||||
// Create service instance
|
||||
lnkService, err := service.New()
|
||||
if err != nil {
|
||||
return wrapServiceError("initialize lnk service", err)
|
||||
}
|
||||
|
||||
// Initialize repository using service layer
|
||||
ctx := context.Background()
|
||||
if err := lnkService.InitializeRepository(ctx, remote); err != nil {
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
// Display success message
|
||||
if remote != "" {
|
||||
printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n")
|
||||
printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote)
|
||||
|
102
cmd/list.go
102
cmd/list.go
@@ -1,13 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
|
||||
"github.com/yarlson/lnk/internal/service"
|
||||
)
|
||||
|
||||
func newListCmd() *cobra.Command {
|
||||
@@ -41,26 +42,31 @@ func newListCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func listCommonConfig(cmd *cobra.Command) error {
|
||||
lnk := core.NewLnk()
|
||||
managedItems, err := lnk.List()
|
||||
ctx := context.Background()
|
||||
lnkService, err := service.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list managed items: %w", err)
|
||||
return wrapServiceError("initialize lnk service", err)
|
||||
}
|
||||
|
||||
if len(managedItems) == 0 {
|
||||
managedFiles, err := lnkService.ListManagedFiles(ctx, "")
|
||||
if err != nil {
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
if len(managedFiles) == 0 {
|
||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n")
|
||||
printf(cmd, " 💡 Use \033[1mlnk add <file>\033[0m to start managing files\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedFiles))
|
||||
if len(managedFiles) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n\n")
|
||||
|
||||
for _, item := range managedItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
for _, file := range managedFiles {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
@@ -68,26 +74,31 @@ func listCommonConfig(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
func listHostConfig(cmd *cobra.Command, host string) error {
|
||||
lnk := core.NewLnkWithHost(host)
|
||||
managedItems, err := lnk.List()
|
||||
ctx := context.Background()
|
||||
lnkService, err := service.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list managed items for host %s: %w", host, err)
|
||||
return wrapServiceError("initialize lnk service", err)
|
||||
}
|
||||
|
||||
if len(managedItems) == 0 {
|
||||
managedFiles, err := lnkService.ListManagedFiles(ctx, host)
|
||||
if err != nil {
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
if len(managedFiles) == 0 {
|
||||
printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host)
|
||||
printf(cmd, " 💡 Use \033[1mlnk add --host %s <file>\033[0m to start managing files\n", host)
|
||||
return nil
|
||||
}
|
||||
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems))
|
||||
if len(managedItems) > 1 {
|
||||
printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedFiles))
|
||||
if len(managedFiles) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n\n")
|
||||
|
||||
for _, item := range managedItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
for _, file := range managedFiles {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
||||
}
|
||||
|
||||
printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n")
|
||||
@@ -95,56 +106,60 @@ func listHostConfig(cmd *cobra.Command, host string) error {
|
||||
}
|
||||
|
||||
func listAllConfigs(cmd *cobra.Command) error {
|
||||
ctx := context.Background()
|
||||
lnkService, err := service.New()
|
||||
if err != nil {
|
||||
return wrapServiceError("initialize lnk service", err)
|
||||
}
|
||||
|
||||
// List common configuration
|
||||
printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n")
|
||||
|
||||
lnk := core.NewLnk()
|
||||
commonItems, err := lnk.List()
|
||||
commonFiles, err := lnkService.ListManagedFiles(ctx, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list common managed items: %w", err)
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems))
|
||||
if len(commonItems) > 1 {
|
||||
printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonFiles))
|
||||
if len(commonFiles) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
|
||||
if len(commonItems) == 0 {
|
||||
if len(commonFiles) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
} else {
|
||||
for _, item := range commonItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
for _, file := range commonFiles {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Find all host-specific configurations
|
||||
hosts, err := findHostConfigs()
|
||||
hosts, err := findHostConfigs(lnkService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find host configurations: %w", err)
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host)
|
||||
|
||||
hostLnk := core.NewLnkWithHost(host)
|
||||
hostItems, err := hostLnk.List()
|
||||
hostFiles, err := lnkService.ListManagedFiles(ctx, host)
|
||||
if err != nil {
|
||||
printf(cmd, " \033[31m(error: %v)\033[0m\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
printf(cmd, " (\033[36m%d item", len(hostItems))
|
||||
if len(hostItems) > 1 {
|
||||
printf(cmd, " (\033[36m%d item", len(hostFiles))
|
||||
if len(hostFiles) > 1 {
|
||||
printf(cmd, "s")
|
||||
}
|
||||
printf(cmd, "\033[0m):\n")
|
||||
|
||||
if len(hostItems) == 0 {
|
||||
if len(hostFiles) == 0 {
|
||||
printf(cmd, " \033[90m(no files)\033[0m\n")
|
||||
} else {
|
||||
for _, item := range hostItems {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", item)
|
||||
for _, file := range hostFiles {
|
||||
printf(cmd, " 🔗 \033[36m%s\033[0m\n", file.RelativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,8 +168,8 @@ func listAllConfigs(cmd *cobra.Command) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func findHostConfigs() ([]string, error) {
|
||||
repoPath := getRepoPath()
|
||||
func findHostConfigs(service *service.Service) ([]string, error) {
|
||||
repoPath := service.GetRepoPath()
|
||||
|
||||
// Check if repo exists
|
||||
if _, err := os.Stat(repoPath); os.IsNotExist(err) {
|
||||
@@ -178,16 +193,3 @@ func findHostConfigs() ([]string, error) {
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func getRepoPath() string {
|
||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdgConfig == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
xdgConfig = "."
|
||||
} else {
|
||||
xdgConfig = filepath.Join(homeDir, ".config")
|
||||
}
|
||||
}
|
||||
return filepath.Join(xdgConfig, "lnk")
|
||||
}
|
||||
|
22
cmd/pull.go
22
cmd/pull.go
@@ -1,10 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
|
||||
"github.com/yarlson/lnk/internal/service"
|
||||
)
|
||||
|
||||
func newPullCmd() *cobra.Command {
|
||||
@@ -16,16 +17,17 @@ func newPullCmd() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
// Create service instance
|
||||
lnkService, err := service.New()
|
||||
if err != nil {
|
||||
return wrapServiceError("initialize lnk service", err)
|
||||
}
|
||||
|
||||
restored, err := lnk.Pull()
|
||||
// Pull changes using the service
|
||||
ctx := context.Background()
|
||||
restored, err := lnkService.PullChanges(ctx, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull changes: %w", err)
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
if len(restored) > 0 {
|
||||
@@ -40,7 +42,7 @@ func newPullCmd() *cobra.Command {
|
||||
}
|
||||
printf(cmd, "\033[0m:\n")
|
||||
for _, file := range restored {
|
||||
printf(cmd, " ✨ \033[36m%s\033[0m\n", file)
|
||||
printf(cmd, " ✨ \033[36m%s\033[0m\n", file.RelativePath)
|
||||
}
|
||||
printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n")
|
||||
} else {
|
||||
|
18
cmd/push.go
18
cmd/push.go
@@ -1,10 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
|
||||
"github.com/yarlson/lnk/internal/service"
|
||||
)
|
||||
|
||||
func newPushCmd() *cobra.Command {
|
||||
@@ -20,9 +21,16 @@ func newPushCmd() *cobra.Command {
|
||||
message = args[0]
|
||||
}
|
||||
|
||||
lnk := core.NewLnk()
|
||||
if err := lnk.Push(message); err != nil {
|
||||
return fmt.Errorf("failed to push changes: %w", err)
|
||||
// Create service instance
|
||||
lnkService, err := service.New()
|
||||
if err != nil {
|
||||
return wrapServiceError("initialize lnk service", err)
|
||||
}
|
||||
|
||||
// Push changes using the service
|
||||
ctx := context.Background()
|
||||
if err := lnkService.PushChanges(ctx, message); err != nil {
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n")
|
||||
|
20
cmd/rm.go
20
cmd/rm.go
@@ -1,11 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
|
||||
"github.com/yarlson/lnk/internal/service"
|
||||
)
|
||||
|
||||
func newRemoveCmd() *cobra.Command {
|
||||
@@ -19,15 +20,16 @@ func newRemoveCmd() *cobra.Command {
|
||||
filePath := args[0]
|
||||
host, _ := cmd.Flags().GetString("host")
|
||||
|
||||
var lnk *core.Lnk
|
||||
if host != "" {
|
||||
lnk = core.NewLnkWithHost(host)
|
||||
} else {
|
||||
lnk = core.NewLnk()
|
||||
// Create service instance
|
||||
lnkService, err := service.New()
|
||||
if err != nil {
|
||||
return wrapServiceError("initialize lnk service", err)
|
||||
}
|
||||
|
||||
if err := lnk.Remove(filePath); err != nil {
|
||||
return fmt.Errorf("failed to remove file: %w", err)
|
||||
// Remove the file using the service
|
||||
ctx := context.Background()
|
||||
if err := lnkService.RemoveFile(ctx, filePath, host); err != nil {
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
basename := filepath.Base(filePath)
|
||||
|
@@ -32,8 +32,9 @@ Supports both common configurations and host-specific setups.
|
||||
lnk push "setup complete" # Sync to remote
|
||||
|
||||
🎯 Simple, fast, Git-native, and multi-host ready.`,
|
||||
SilenceUsage: true,
|
||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: fmt.Sprintf("%s (built %s)", version, buildTime),
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
@@ -57,7 +58,9 @@ func SetVersion(v, bt string) {
|
||||
func Execute() {
|
||||
rootCmd := NewRootCommand()
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
// Format the error nicely for the user
|
||||
formattedErr := formatError(err)
|
||||
fmt.Fprintln(os.Stderr, formattedErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
@@ -416,7 +416,7 @@ func (suite *CLITestSuite) TestRemoveUnmanagedFile() {
|
||||
// Try to remove it
|
||||
err := suite.runCommand("rm", testFile)
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "File is not managed by lnk")
|
||||
suite.Contains(err.Error(), "not a symlink")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestAddDirectory() {
|
||||
@@ -462,6 +462,54 @@ func (suite *CLITestSuite) TestAddDirectory() {
|
||||
suite.Equal(".ssh\n", string(lnkContent))
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestRemoveDirectory() {
|
||||
// Initialize repository
|
||||
_ = suite.runCommand("init")
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Create a directory with files
|
||||
testDir := filepath.Join(suite.tempDir, ".config", "aerospace")
|
||||
_ = os.MkdirAll(testDir, 0755)
|
||||
configFile := filepath.Join(testDir, "aerospace.toml")
|
||||
_ = os.WriteFile(configFile, []byte("# Aerospace config"), 0644)
|
||||
|
||||
// Add the directory
|
||||
err := suite.runCommand("add", testDir)
|
||||
suite.NoError(err)
|
||||
suite.stdout.Reset()
|
||||
|
||||
// Verify directory is now a symlink
|
||||
info, err := os.Lstat(testDir)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
|
||||
// Remove the directory
|
||||
err = suite.runCommand("rm", testDir)
|
||||
suite.NoError(err, "Should be able to remove directory without error")
|
||||
|
||||
// Check output
|
||||
output := suite.stdout.String()
|
||||
suite.Contains(output, "Removed aerospace from lnk")
|
||||
suite.Contains(output, "Original file restored")
|
||||
|
||||
// Verify directory is no longer a symlink
|
||||
info, err = os.Lstat(testDir)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink
|
||||
|
||||
// Verify directory content is preserved
|
||||
content, err := os.ReadFile(configFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal("# Aerospace config", string(content))
|
||||
|
||||
// Verify directory is removed from tracking
|
||||
lnkDir := filepath.Join(suite.tempDir, ".config", "lnk")
|
||||
lnkFile := filepath.Join(lnkDir, ".lnk")
|
||||
lnkContent, err := os.ReadFile(lnkFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal("", string(lnkContent), ".lnk file should be empty after removing directory")
|
||||
}
|
||||
|
||||
func (suite *CLITestSuite) TestSameBasenameFilesBug() {
|
||||
// Initialize repository
|
||||
err := suite.runCommand("init")
|
||||
@@ -737,7 +785,7 @@ func (suite *CLITestSuite) TestMultihostErrorHandling() {
|
||||
|
||||
err = suite.runCommand("rm", "--host", "nonexistent", testFile)
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "File is not managed by lnk")
|
||||
suite.Contains(err.Error(), "not a symlink")
|
||||
|
||||
// Try to list non-existent host config
|
||||
err = suite.runCommand("list", "--host", "nonexistent")
|
||||
|
@@ -1,10 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/yarlson/lnk/internal/core"
|
||||
|
||||
"github.com/yarlson/lnk/internal/models"
|
||||
"github.com/yarlson/lnk/internal/service"
|
||||
)
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
@@ -14,10 +16,15 @@ func newStatusCmd() *cobra.Command {
|
||||
Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
lnk := core.NewLnk()
|
||||
status, err := lnk.Status()
|
||||
lnkService, err := service.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get status: %w", err)
|
||||
return wrapServiceError("initialize lnk service", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
status, err := lnkService.GetStatus(ctx)
|
||||
if err != nil {
|
||||
return formatError(err)
|
||||
}
|
||||
|
||||
if status.Dirty {
|
||||
@@ -36,9 +43,9 @@ func newStatusCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
func displayDirtyStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
||||
printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n")
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status))
|
||||
|
||||
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")
|
||||
@@ -50,14 +57,14 @@ func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
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) {
|
||||
func displayUpToDateStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
||||
printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n")
|
||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote)
|
||||
printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", getRemoteDisplay(status))
|
||||
}
|
||||
|
||||
func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
func displaySyncStatus(cmd *cobra.Command, status *models.SyncStatus) {
|
||||
printf(cmd, "📊 \033[1mRepository Status\033[0m\n")
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote)
|
||||
printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status))
|
||||
printf(cmd, "\n")
|
||||
|
||||
displayAheadBehindInfo(cmd, status, false)
|
||||
@@ -69,7 +76,7 @@ func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) {
|
||||
func displayAheadBehindInfo(cmd *cobra.Command, status *models.SyncStatus, isDirty bool) {
|
||||
if status.Ahead > 0 {
|
||||
commitText := getCommitText(status.Ahead)
|
||||
if isDirty {
|
||||
@@ -91,3 +98,13 @@ func getCommitText(count int) string {
|
||||
}
|
||||
return "commits"
|
||||
}
|
||||
|
||||
func getRemoteDisplay(status *models.SyncStatus) string {
|
||||
if status.HasRemote && status.RemoteBranch != "" {
|
||||
return status.RemoteBranch
|
||||
}
|
||||
if status.HasRemote && status.RemoteURL != "" {
|
||||
return status.RemoteURL
|
||||
}
|
||||
return "no remote configured"
|
||||
}
|
||||
|
163
cmd/utils.go
163
cmd/utils.go
@@ -1,12 +1,175 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/yarlson/lnk/internal/errors"
|
||||
)
|
||||
|
||||
// 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...)
|
||||
}
|
||||
|
||||
// formatError provides user-friendly error formatting while preserving specific error messages for tests
|
||||
func formatError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle typed LnkError first
|
||||
var lnkErr *errors.LnkError
|
||||
if stderrors.As(err, &lnkErr) {
|
||||
return formatLnkError(lnkErr)
|
||||
}
|
||||
|
||||
// Handle other error patterns with improved messages
|
||||
errMsg := err.Error()
|
||||
|
||||
// Git-related errors
|
||||
if strings.Contains(errMsg, "git") {
|
||||
if strings.Contains(errMsg, "no remote configured") {
|
||||
return fmt.Errorf("🚫 no remote configured\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
|
||||
}
|
||||
if strings.Contains(errMsg, "authentication") || strings.Contains(errMsg, "permission denied") {
|
||||
return fmt.Errorf("🔐 \033[31mGit authentication failed\033[0m\n 💡 Check your SSH keys or credentials: \033[36mhttps://docs.github.com/en/authentication\033[0m")
|
||||
}
|
||||
if strings.Contains(errMsg, "not found") && strings.Contains(errMsg, "remote") {
|
||||
return fmt.Errorf("🌐 \033[31mRemote repository not found\033[0m\n 💡 Verify the repository URL is correct and you have access")
|
||||
}
|
||||
}
|
||||
|
||||
// Service initialization errors
|
||||
if strings.Contains(errMsg, "failed to initialize lnk service") {
|
||||
return fmt.Errorf("⚠️ \033[31mFailed to initialize lnk\033[0m\n 💡 This is likely a system configuration issue. Please check permissions and try again.")
|
||||
}
|
||||
|
||||
// Return original error for unhandled cases to maintain test compatibility
|
||||
return err
|
||||
}
|
||||
|
||||
// formatLnkError formats typed LnkError instances with user-friendly messages
|
||||
func formatLnkError(lnkErr *errors.LnkError) error {
|
||||
switch lnkErr.Code {
|
||||
case errors.ErrorCodeFileNotFound:
|
||||
// Preserve "File does not exist" for test compatibility but add consistent colors
|
||||
if path, ok := lnkErr.Context["path"].(string); ok {
|
||||
return fmt.Errorf("❌ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", path)
|
||||
}
|
||||
return fmt.Errorf("❌ \033[31mFile does not exist\033[0m\n 💡 Check the file path and try again")
|
||||
|
||||
case errors.ErrorCodeRepoNotInitialized:
|
||||
// Preserve "Lnk repository not initialized" for test compatibility but add consistent colors
|
||||
return fmt.Errorf("📦 \033[31mLnk repository not initialized\033[0m\n 💡 Run \033[1mlnk init\033[0m to get started")
|
||||
|
||||
case errors.ErrorCodeNotSymlink:
|
||||
// Preserve "not a symlink" for test compatibility but add consistent colors
|
||||
return fmt.Errorf("🔗 \033[31mnot a symlink\033[0m\n 💡 Only files managed by lnk can be removed. Use \033[1mlnk list\033[0m to see managed files")
|
||||
|
||||
case errors.ErrorCodeFileAlreadyManaged:
|
||||
if path, ok := lnkErr.Context["path"].(string); ok {
|
||||
return fmt.Errorf("✨ \033[33mFile is already managed by lnk:\033[0m \033[36m%s\033[0m\n 💡 Use \033[1mlnk list\033[0m to see all managed files", path)
|
||||
}
|
||||
return fmt.Errorf("✨ \033[33mFile is already managed by lnk\033[0m\n 💡 Use \033[1mlnk list\033[0m to see all managed files")
|
||||
|
||||
case errors.ErrorCodeNoRemoteConfigured:
|
||||
// Preserve "no remote configured" for test compatibility but add consistent colors
|
||||
return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
|
||||
|
||||
case errors.ErrorCodePermissionDenied:
|
||||
if path, ok := lnkErr.Context["path"].(string); ok {
|
||||
return fmt.Errorf("🔒 \033[31mPermission denied:\033[0m \033[36m%s\033[0m\n 💡 Check file permissions or run with appropriate privileges", path)
|
||||
}
|
||||
return fmt.Errorf("🔒 \033[31mPermission denied\033[0m\n 💡 Check file permissions or run with appropriate privileges")
|
||||
|
||||
case errors.ErrorCodeGitOperation:
|
||||
// Check if this is a "no remote configured" case by examining the underlying error first
|
||||
if lnkErr.Cause != nil && strings.Contains(lnkErr.Cause.Error(), "no remote configured") {
|
||||
return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n 💡 Add a remote first: \033[1mgit remote add origin <url>\033[0m in \033[36m~/.config/lnk\033[0m")
|
||||
}
|
||||
|
||||
operation := lnkErr.Context["operation"]
|
||||
if op, ok := operation.(string); ok {
|
||||
switch op {
|
||||
case "get_status", "status":
|
||||
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||
case "push_to_remote", "push":
|
||||
return fmt.Errorf("🚀 \033[31mGit operation failed\033[0m\n 💡 Check your internet connection and Git credentials\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||
case "pull_from_remote", "pull":
|
||||
return fmt.Errorf("⬇️ \033[31mGit operation failed\033[0m\n 💡 Check your internet connection and resolve any conflicts\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||
case "clone_repository", "clone":
|
||||
return fmt.Errorf("📥 \033[31mGit operation failed\033[0m\n 💡 Check the repository URL and your access permissions\n 💡 Ensure you have the correct SSH keys or credentials")
|
||||
case "commit_changes", "commit":
|
||||
return fmt.Errorf("💾 \033[31mGit operation failed\033[0m\n 💡 Check if you have Git user.name and user.email configured\n 💡 Run \033[1mgit config --global user.name \"Your Name\"\033[0m")
|
||||
default:
|
||||
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("🔧 \033[31mGit operation failed\033[0m\n 💡 Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details")
|
||||
|
||||
case errors.ErrorCodeFileSystemOperation:
|
||||
operation := lnkErr.Context["operation"]
|
||||
path := lnkErr.Context["path"]
|
||||
|
||||
// Determine user-friendly message based on operation and underlying cause
|
||||
if op, ok := operation.(string); ok {
|
||||
switch op {
|
||||
case "stat_symlink", "check_file_exists":
|
||||
// Use consistent "File does not exist" messaging
|
||||
if pathStr, pathOk := path.(string); pathOk {
|
||||
return fmt.Errorf("❌ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", pathStr)
|
||||
}
|
||||
return fmt.Errorf("❌ \033[31mFile does not exist\033[0m\n 💡 Check the file path and try again")
|
||||
case "move_file":
|
||||
return fmt.Errorf("📁 \033[31mFile operation failed\033[0m\n 💡 Check file permissions and available disk space")
|
||||
case "create_symlink":
|
||||
return fmt.Errorf("🔗 \033[31mFile operation failed\033[0m\n 💡 Check directory permissions and ensure target file exists")
|
||||
case "remove_symlink", "remove_file":
|
||||
return fmt.Errorf("🗑️ \033[31mFile operation failed\033[0m\n 💡 Check file permissions and ensure file exists")
|
||||
case "read_symlink":
|
||||
return fmt.Errorf("🔗 \033[31mFile operation failed\033[0m\n 💡 The symlink may be broken or you don't have permission to read it")
|
||||
case "resolve_path", "get_relative_path":
|
||||
if pathStr, pathOk := path.(string); pathOk {
|
||||
return fmt.Errorf("📂 \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", pathStr)
|
||||
}
|
||||
return fmt.Errorf("📂 \033[31mInvalid file path\033[0m\n 💡 Check the file path and try again")
|
||||
case "create_dest_dir", "create_repo_dir":
|
||||
return fmt.Errorf("📁 \033[31mFile operation failed\033[0m\n 💡 Check permissions and available disk space")
|
||||
default:
|
||||
// Don't expose cryptic operation names - give generic but helpful message
|
||||
return fmt.Errorf("💽 \033[31mFile operation failed\033[0m\n 💡 Check file permissions, paths, and available disk space")
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("💽 \033[31mFile operation failed\033[0m\n 💡 Check file permissions and available disk space")
|
||||
|
||||
case errors.ErrorCodeInvalidPath:
|
||||
if path, ok := lnkErr.Context["path"].(string); ok {
|
||||
return fmt.Errorf("📂 \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n 💡 Check the file path and try again", path)
|
||||
}
|
||||
return fmt.Errorf("📂 \033[31mInvalid file path\033[0m\n 💡 Check the file path and try again")
|
||||
|
||||
default:
|
||||
// For unknown LnkError types, preserve original message but add context
|
||||
return fmt.Errorf("⚠️ \033[31m%s\033[0m", lnkErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// wrapServiceError wraps service errors with consistent messaging while preserving specific errors for tests
|
||||
func wrapServiceError(operation string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For typed errors, format them nicely
|
||||
var lnkErr *errors.LnkError
|
||||
if stderrors.As(err, &lnkErr) {
|
||||
return formatLnkError(lnkErr)
|
||||
}
|
||||
|
||||
// For other errors, provide operation context but preserve original message for tests
|
||||
return fmt.Errorf("failed to %s: %w", operation, err)
|
||||
}
|
||||
|
232
internal/config/config.go
Normal file
232
internal/config/config.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yarlson/lnk/internal/errors"
|
||||
"github.com/yarlson/lnk/internal/fs"
|
||||
"github.com/yarlson/lnk/internal/models"
|
||||
"github.com/yarlson/lnk/internal/pathresolver"
|
||||
)
|
||||
|
||||
// Config implements the service.ConfigManager interface
|
||||
type Config struct {
|
||||
fileManager *fs.FileManager
|
||||
pathResolver *pathresolver.Resolver
|
||||
}
|
||||
|
||||
// New creates a new ConfigManager instance
|
||||
func New(fileManager *fs.FileManager, pathResolver *pathresolver.Resolver) *Config {
|
||||
return &Config{
|
||||
fileManager: fileManager,
|
||||
pathResolver: pathResolver,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadHostConfig loads the configuration for a specific host
|
||||
func (cm *Config) LoadHostConfig(ctx context.Context, repoPath, host string) (*models.HostConfig, error) {
|
||||
managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.HostConfig{
|
||||
Name: host,
|
||||
ManagedFiles: managedFiles,
|
||||
LastUpdate: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveHostConfig saves the configuration for a specific host
|
||||
func (cm *Config) SaveHostConfig(ctx context.Context, repoPath string, config *models.HostConfig) error {
|
||||
// Convert managed files to relative paths for storage
|
||||
var relativePaths []string
|
||||
for _, file := range config.ManagedFiles {
|
||||
relativePaths = append(relativePaths, file.RelativePath)
|
||||
}
|
||||
|
||||
// Sort for consistent ordering
|
||||
sort.Strings(relativePaths)
|
||||
|
||||
return cm.writeManagedItems(ctx, repoPath, config.Name, relativePaths)
|
||||
}
|
||||
|
||||
// AddManagedFileToHost adds a managed file to a host's configuration
|
||||
func (cm *Config) AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error {
|
||||
// Get current managed files
|
||||
managedFiles, err := cm.getManagedItems(ctx, repoPath, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
for _, item := range managedFiles {
|
||||
if item == file.RelativePath {
|
||||
return nil // Already managed
|
||||
}
|
||||
}
|
||||
|
||||
// Add new item
|
||||
managedFiles = append(managedFiles, file.RelativePath)
|
||||
|
||||
// Sort for consistent ordering
|
||||
sort.Strings(managedFiles)
|
||||
|
||||
return cm.writeManagedItems(ctx, repoPath, host, managedFiles)
|
||||
}
|
||||
|
||||
// RemoveManagedFileFromHost removes a managed file from a host's configuration
|
||||
func (cm *Config) RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error {
|
||||
// Get current managed files
|
||||
managedFiles, err := cm.getManagedItems(ctx, repoPath, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove item
|
||||
var newManagedFiles []string
|
||||
for _, item := range managedFiles {
|
||||
if item != relativePath {
|
||||
newManagedFiles = append(newManagedFiles, item)
|
||||
}
|
||||
}
|
||||
|
||||
return cm.writeManagedItems(ctx, repoPath, host, newManagedFiles)
|
||||
}
|
||||
|
||||
// ListManagedFiles returns all files managed by a specific host
|
||||
func (cm *Config) ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) {
|
||||
relativePaths, err := cm.getManagedItems(ctx, repoPath, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var managedFiles []models.ManagedFile
|
||||
for _, relativePath := range relativePaths {
|
||||
// Get file storage path
|
||||
fileStoragePath, err := cm.pathResolver.GetFileStoragePathInRepo(repoPath, host, relativePath)
|
||||
if err != nil {
|
||||
return nil, errors.NewConfigNotFoundError(host).
|
||||
WithContext("relative_path", relativePath)
|
||||
}
|
||||
|
||||
// Get original path (where symlink should be)
|
||||
originalPath, err := cm.pathResolver.GetAbsolutePathInHome(relativePath)
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidPathError(relativePath, "cannot convert to absolute path")
|
||||
}
|
||||
|
||||
// Check if file exists and get info
|
||||
var isDirectory bool
|
||||
var mode os.FileMode
|
||||
if exists, err := cm.fileManager.Exists(ctx, fileStoragePath); err == nil && exists {
|
||||
if info, err := cm.fileManager.Stat(ctx, fileStoragePath); err == nil {
|
||||
isDirectory = info.IsDir()
|
||||
mode = info.Mode()
|
||||
}
|
||||
}
|
||||
|
||||
managedFile := models.ManagedFile{
|
||||
OriginalPath: originalPath,
|
||||
RepoPath: fileStoragePath,
|
||||
RelativePath: relativePath,
|
||||
Host: host,
|
||||
IsDirectory: isDirectory,
|
||||
Mode: mode,
|
||||
}
|
||||
|
||||
managedFiles = append(managedFiles, managedFile)
|
||||
}
|
||||
|
||||
return managedFiles, nil
|
||||
}
|
||||
|
||||
// GetManagedFile retrieves a specific managed file by relative path
|
||||
func (cm *Config) GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) {
|
||||
managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, file := range managedFiles {
|
||||
if file.RelativePath == relativePath {
|
||||
return &file, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.NewFileNotFoundError(relativePath)
|
||||
}
|
||||
|
||||
// ConfigExists checks if a configuration file exists for the host
|
||||
func (cm *Config) ConfigExists(ctx context.Context, repoPath, host string) (bool, error) {
|
||||
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return cm.fileManager.Exists(ctx, trackingFilePath)
|
||||
}
|
||||
|
||||
// getManagedItems returns the list of managed files and directories from .lnk file
|
||||
// This is the core method that reads the plain text format
|
||||
func (cm *Config) getManagedItems(ctx context.Context, repoPath, host string) ([]string, error) {
|
||||
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
|
||||
if err != nil {
|
||||
return nil, errors.NewConfigNotFoundError(host).
|
||||
WithContext("repo_path", repoPath)
|
||||
}
|
||||
|
||||
// If .lnk file doesn't exist, return empty list
|
||||
exists, err := cm.fileManager.Exists(ctx, trackingFilePath)
|
||||
if err != nil {
|
||||
return nil, errors.NewFileSystemOperationError("check_exists", trackingFilePath, err)
|
||||
}
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
content, err := cm.fileManager.ReadFile(ctx, trackingFilePath)
|
||||
if err != nil {
|
||||
return nil, errors.NewFileSystemOperationError("read", trackingFilePath, err)
|
||||
}
|
||||
|
||||
if len(content) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||
var items []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
items = append(items, line)
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// writeManagedItems writes the list of managed items to .lnk file
|
||||
// This maintains the plain text line-by-line format for compatibility
|
||||
func (cm *Config) writeManagedItems(ctx context.Context, repoPath, host string, items []string) error {
|
||||
trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host)
|
||||
if err != nil {
|
||||
return errors.NewConfigNotFoundError(host).
|
||||
WithContext("repo_path", repoPath)
|
||||
}
|
||||
|
||||
content := strings.Join(items, "\n")
|
||||
if len(items) > 0 {
|
||||
content += "\n"
|
||||
}
|
||||
|
||||
if err := cm.fileManager.WriteFile(ctx, trackingFilePath, []byte(content), 0644); err != nil {
|
||||
return errors.NewFileSystemOperationError("write", trackingFilePath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
278
internal/config/config_test.go
Normal file
278
internal/config/config_test.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/yarlson/lnk/internal/errors"
|
||||
"github.com/yarlson/lnk/internal/fs"
|
||||
"github.com/yarlson/lnk/internal/models"
|
||||
"github.com/yarlson/lnk/internal/pathresolver"
|
||||
)
|
||||
|
||||
type ConfigTestSuite struct {
|
||||
suite.Suite
|
||||
tempDir string
|
||||
configManager *Config
|
||||
fileManager *fs.FileManager
|
||||
pathResolver *pathresolver.Resolver
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) SetupTest() {
|
||||
// Create temp directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "lnk_config_test_*")
|
||||
suite.Require().NoError(err)
|
||||
suite.tempDir = tempDir
|
||||
|
||||
// Create file manager and path resolver
|
||||
suite.fileManager = fs.New()
|
||||
suite.pathResolver = pathresolver.New()
|
||||
|
||||
// Create config manager
|
||||
suite.configManager = New(suite.fileManager, suite.pathResolver)
|
||||
|
||||
// Create context
|
||||
suite.ctx = context.Background()
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TearDownTest() {
|
||||
err := os.RemoveAll(suite.tempDir)
|
||||
suite.Require().NoError(err)
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TestAddAndListManagedFiles() {
|
||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||
host := "testhost"
|
||||
|
||||
// Create a managed file
|
||||
managedFile := models.ManagedFile{
|
||||
RelativePath: ".vimrc",
|
||||
Host: host,
|
||||
IsDirectory: false,
|
||||
}
|
||||
|
||||
// Add managed file
|
||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// List managed files
|
||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Len(files, 1)
|
||||
suite.Equal(".vimrc", files[0].RelativePath)
|
||||
suite.Equal(host, files[0].Host)
|
||||
|
||||
// Verify tracking file was created
|
||||
trackingPath, err := suite.pathResolver.GetTrackingFilePath(repoPath, host)
|
||||
suite.NoError(err)
|
||||
|
||||
exists, err := suite.fileManager.Exists(suite.ctx, trackingPath)
|
||||
suite.NoError(err)
|
||||
suite.True(exists)
|
||||
|
||||
// Read tracking file content
|
||||
content, err := suite.fileManager.ReadFile(suite.ctx, trackingPath)
|
||||
suite.NoError(err)
|
||||
|
||||
expectedContent := ".vimrc\n"
|
||||
suite.Equal(expectedContent, string(content))
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TestAddDuplicateFile() {
|
||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||
host := "testhost"
|
||||
|
||||
managedFile := models.ManagedFile{
|
||||
RelativePath: ".bashrc",
|
||||
Host: host,
|
||||
}
|
||||
|
||||
// Add file twice
|
||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||
suite.NoError(err)
|
||||
|
||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// Should still have only one file
|
||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||
suite.NoError(err)
|
||||
suite.Len(files, 1)
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TestRemoveManagedFile() {
|
||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||
host := "testhost"
|
||||
|
||||
// Add two managed files
|
||||
file1 := models.ManagedFile{RelativePath: ".vimrc", Host: host}
|
||||
file2 := models.ManagedFile{RelativePath: ".bashrc", Host: host}
|
||||
|
||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file1)
|
||||
suite.NoError(err)
|
||||
|
||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file2)
|
||||
suite.NoError(err)
|
||||
|
||||
// Remove one file
|
||||
err = suite.configManager.RemoveManagedFileFromHost(suite.ctx, repoPath, host, ".vimrc")
|
||||
suite.NoError(err)
|
||||
|
||||
// Should have only one file left
|
||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Len(files, 1)
|
||||
suite.Equal(".bashrc", files[0].RelativePath)
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TestLoadAndSaveHostConfig() {
|
||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||
host := "workstation"
|
||||
|
||||
// Create host config with managed files
|
||||
config := &models.HostConfig{
|
||||
Name: host,
|
||||
ManagedFiles: []models.ManagedFile{
|
||||
{RelativePath: ".vimrc", Host: host},
|
||||
{RelativePath: ".bashrc", Host: host},
|
||||
},
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
|
||||
// Save config
|
||||
err := suite.configManager.SaveHostConfig(suite.ctx, repoPath, config)
|
||||
suite.NoError(err)
|
||||
|
||||
// Load config
|
||||
loadedConfig, err := suite.configManager.LoadHostConfig(suite.ctx, repoPath, host)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal(host, loadedConfig.Name)
|
||||
suite.Len(loadedConfig.ManagedFiles, 2)
|
||||
|
||||
// Check files are sorted
|
||||
suite.Equal(".bashrc", loadedConfig.ManagedFiles[0].RelativePath)
|
||||
suite.Equal(".vimrc", loadedConfig.ManagedFiles[1].RelativePath)
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TestGetManagedFile() {
|
||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||
host := "testhost"
|
||||
|
||||
managedFile := models.ManagedFile{
|
||||
RelativePath: ".gitconfig",
|
||||
Host: host,
|
||||
}
|
||||
|
||||
// Add managed file
|
||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// Get specific managed file
|
||||
file, err := suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".gitconfig")
|
||||
suite.NoError(err)
|
||||
suite.Equal(".gitconfig", file.RelativePath)
|
||||
|
||||
// Try to get non-existent file
|
||||
_, err = suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".nonexistent")
|
||||
suite.Error(err)
|
||||
suite.True(errors.NewFileNotFoundError("").Is(err))
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TestConfigExists() {
|
||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||
host := "testhost"
|
||||
|
||||
// Initially should not exist
|
||||
exists, err := suite.configManager.ConfigExists(suite.ctx, repoPath, host)
|
||||
suite.NoError(err)
|
||||
suite.False(exists)
|
||||
|
||||
// Add a managed file
|
||||
managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host}
|
||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// Now should exist
|
||||
exists, err = suite.configManager.ConfigExists(suite.ctx, repoPath, host)
|
||||
suite.NoError(err)
|
||||
suite.True(exists)
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TestEmptyConfig() {
|
||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||
host := "emptyhost"
|
||||
|
||||
// List files from non-existent config
|
||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||
suite.NoError(err)
|
||||
suite.Len(files, 0)
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TestCommonAndHostConfigs() {
|
||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||
|
||||
// Add file to common config (empty host)
|
||||
commonFile := models.ManagedFile{RelativePath: ".bashrc", Host: ""}
|
||||
err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "", commonFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// Add file to host-specific config
|
||||
hostFile := models.ManagedFile{RelativePath: ".vimrc", Host: "workstation"}
|
||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "workstation", hostFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// List common files
|
||||
commonFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "")
|
||||
suite.NoError(err)
|
||||
suite.Len(commonFiles, 1)
|
||||
suite.Equal(".bashrc", commonFiles[0].RelativePath)
|
||||
|
||||
// List host files
|
||||
hostFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "workstation")
|
||||
suite.NoError(err)
|
||||
suite.Len(hostFiles, 1)
|
||||
suite.Equal(".vimrc", hostFiles[0].RelativePath)
|
||||
}
|
||||
|
||||
func (suite *ConfigTestSuite) TestFileWithMetadata() {
|
||||
repoPath := filepath.Join(suite.tempDir, "repo")
|
||||
host := "testhost"
|
||||
|
||||
// Create actual file in repository storage area
|
||||
hostStoragePath := filepath.Join(repoPath, host+".lnk")
|
||||
testFilePath := filepath.Join(hostStoragePath, ".vimrc")
|
||||
|
||||
err := suite.fileManager.WriteFile(suite.ctx, testFilePath, []byte("set number"), 0644)
|
||||
suite.NoError(err)
|
||||
|
||||
// Add managed file
|
||||
managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host}
|
||||
err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// List files should include metadata
|
||||
files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host)
|
||||
suite.NoError(err)
|
||||
suite.Len(files, 1)
|
||||
|
||||
file := files[0]
|
||||
suite.False(file.IsDirectory)
|
||||
suite.NotZero(file.Mode)
|
||||
|
||||
// Expected paths
|
||||
expectedRepoPath := testFilePath
|
||||
suite.Equal(expectedRepoPath, file.RepoPath)
|
||||
}
|
||||
|
||||
func TestConfigSuite(t *testing.T) {
|
||||
suite.Run(t, new(ConfigTestSuite))
|
||||
}
|
@@ -1,674 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/yarlson/lnk/internal/fs"
|
||||
"github.com/yarlson/lnk/internal/git"
|
||||
)
|
||||
|
||||
// Lnk represents the main application logic
|
||||
type Lnk struct {
|
||||
repoPath string
|
||||
host string // Host-specific configuration
|
||||
git *git.Git
|
||||
fs *fs.FileSystem
|
||||
}
|
||||
|
||||
// NewLnk creates a new Lnk instance for common configuration
|
||||
func NewLnk() *Lnk {
|
||||
repoPath := getRepoPath()
|
||||
return &Lnk{
|
||||
repoPath: repoPath,
|
||||
host: "", // Empty host means common configuration
|
||||
git: git.New(repoPath),
|
||||
fs: fs.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewLnkWithHost creates a new Lnk instance for host-specific configuration
|
||||
func NewLnkWithHost(host string) *Lnk {
|
||||
repoPath := getRepoPath()
|
||||
return &Lnk{
|
||||
repoPath: repoPath,
|
||||
host: host,
|
||||
git: git.New(repoPath),
|
||||
fs: fs.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentHostname returns the current system hostname
|
||||
func GetCurrentHostname() (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||
}
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
// getRepoPath returns the path to the lnk repository directory
|
||||
func getRepoPath() string {
|
||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdgConfig == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
// Fallback to current directory if we can't get home
|
||||
xdgConfig = "."
|
||||
} else {
|
||||
xdgConfig = filepath.Join(homeDir, ".config")
|
||||
}
|
||||
}
|
||||
return filepath.Join(xdgConfig, "lnk")
|
||||
}
|
||||
|
||||
// getHostStoragePath returns the storage path for host-specific or common files
|
||||
func (l *Lnk) getHostStoragePath() string {
|
||||
if l.host == "" {
|
||||
// Common configuration - store in root of repo
|
||||
return l.repoPath
|
||||
}
|
||||
// Host-specific configuration - store in host subdirectory
|
||||
return filepath.Join(l.repoPath, l.host+".lnk")
|
||||
}
|
||||
|
||||
// getLnkFileName returns the appropriate .lnk tracking file name
|
||||
func (l *Lnk) getLnkFileName() string {
|
||||
if l.host == "" {
|
||||
return ".lnk"
|
||||
}
|
||||
return ".lnk." + l.host
|
||||
}
|
||||
|
||||
// getRelativePath converts an absolute path to a relative path from home directory
|
||||
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
|
||||
func (l *Lnk) Init() error {
|
||||
return l.InitWithRemote("")
|
||||
}
|
||||
|
||||
// InitWithRemote initializes the lnk repository, optionally cloning from a remote
|
||||
func (l *Lnk) InitWithRemote(remoteURL string) error {
|
||||
if remoteURL != "" {
|
||||
// Clone from remote
|
||||
return l.Clone(remoteURL)
|
||||
}
|
||||
|
||||
// Create the repository directory
|
||||
if err := os.MkdirAll(l.repoPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create lnk directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if there's already a Git repository
|
||||
if l.git.IsGitRepository() {
|
||||
// Repository exists, check if it's a lnk repository
|
||||
if l.git.IsLnkRepository() {
|
||||
// It's a lnk repository, init is idempotent - do nothing
|
||||
return nil
|
||||
} else {
|
||||
// It's not a lnk repository, error to prevent data loss
|
||||
return fmt.Errorf("❌ Directory \033[31m%s\033[0m contains an existing Git repository\n 💡 Please backup or move the existing repository before initializing lnk", l.repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
// No existing repository, initialize Git repository
|
||||
if err := l.git.Init(); err != nil {
|
||||
return fmt.Errorf("failed to initialize git repository: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone clones a repository from the given URL
|
||||
func (l *Lnk) Clone(url string) error {
|
||||
if err := l.git.Clone(url); err != nil {
|
||||
return fmt.Errorf("failed to clone repository: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRemote adds a remote to the repository
|
||||
func (l *Lnk) AddRemote(name, url string) error {
|
||||
if err := l.git.AddRemote(name, url); err != nil {
|
||||
return fmt.Errorf("failed to add remote %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add moves a file or directory to the repository and creates a symlink
|
||||
func (l *Lnk) Add(filePath string) error {
|
||||
// Validate the file or directory
|
||||
if err := l.fs.ValidateFileForAdd(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get absolute path
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
// Get relative path for tracking
|
||||
relativePath, err := getRelativePath(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
// Generate repository path from relative path
|
||||
storagePath := l.getHostStoragePath()
|
||||
destPath := filepath.Join(storagePath, relativePath)
|
||||
|
||||
// Ensure destination directory exists (including parent directories for host-specific files)
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if this relative path is already managed
|
||||
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
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat path: %w", err)
|
||||
}
|
||||
|
||||
// Move to repository (handles both files and directories)
|
||||
if info.IsDir() {
|
||||
if err := l.fs.MoveDirectory(absPath, destPath); err != nil {
|
||||
return fmt.Errorf("failed to move directory to repository: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := l.fs.MoveFile(absPath, destPath); err != nil {
|
||||
return fmt.Errorf("failed to move file to repository: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
if err := l.fs.CreateSymlink(destPath, absPath); err != nil {
|
||||
// Try to restore the original if symlink creation fails
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
}
|
||||
return fmt.Errorf("failed to create symlink: %w", err)
|
||||
}
|
||||
|
||||
// Add to .lnk tracking file using relative path
|
||||
if err := l.addManagedItem(relativePath); err != nil {
|
||||
// Try to restore the original state if tracking fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
}
|
||||
return fmt.Errorf("failed to update tracking file: %w", err)
|
||||
}
|
||||
|
||||
// Add both the item and .lnk file to git in a single commit
|
||||
// For host-specific files, we need to add the relative path from repo root
|
||||
gitPath := relativePath
|
||||
if l.host != "" {
|
||||
gitPath = filepath.Join(l.host+".lnk", relativePath)
|
||||
}
|
||||
if err := l.git.Add(gitPath); err != nil {
|
||||
// Try to restore the original state if git add fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
}
|
||||
return fmt.Errorf("failed to add item to git: %w", err)
|
||||
}
|
||||
|
||||
// Add .lnk file to the same commit
|
||||
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||
// Try to restore the original state if git add fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
}
|
||||
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
||||
}
|
||||
|
||||
// Commit both changes together
|
||||
basename := filepath.Base(relativePath)
|
||||
if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil {
|
||||
// Try to restore the original state if commit fails
|
||||
_ = os.Remove(absPath) // Ignore error in cleanup
|
||||
_ = l.removeManagedItem(relativePath) // Ignore error in cleanup
|
||||
if info.IsDir() {
|
||||
_ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup
|
||||
} else {
|
||||
_ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup
|
||||
}
|
||||
return fmt.Errorf("failed to commit changes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes a symlink and restores the original file or directory
|
||||
func (l *Lnk) Remove(filePath string) error {
|
||||
// Get absolute path
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
|
||||
// Validate that this is a symlink managed by lnk
|
||||
if err := l.fs.ValidateSymlinkForRemove(absPath, l.repoPath); err != nil {
|
||||
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
|
||||
target, err := os.Readlink(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read symlink: %w", err)
|
||||
}
|
||||
|
||||
// Convert relative path to absolute if needed
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(absPath), target)
|
||||
}
|
||||
|
||||
// Check if target is a directory or file
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat target: %w", err)
|
||||
}
|
||||
|
||||
// Remove the symlink
|
||||
if err := os.Remove(absPath); err != nil {
|
||||
return fmt.Errorf("failed to remove symlink: %w", err)
|
||||
}
|
||||
|
||||
// Remove from .lnk tracking file using relative path
|
||||
if err := l.removeManagedItem(relativePath); err != nil {
|
||||
return fmt.Errorf("failed to update tracking file: %w", err)
|
||||
}
|
||||
|
||||
// Generate the correct git path for removal
|
||||
gitPath := relativePath
|
||||
if l.host != "" {
|
||||
gitPath = filepath.Join(l.host+".lnk", relativePath)
|
||||
}
|
||||
if err := l.git.Remove(gitPath); err != nil {
|
||||
return fmt.Errorf("failed to remove from git: %w", err)
|
||||
}
|
||||
|
||||
// Add .lnk file to the same commit
|
||||
if err := l.git.Add(l.getLnkFileName()); err != nil {
|
||||
return fmt.Errorf("failed to add .lnk file to git: %w", err)
|
||||
}
|
||||
|
||||
// Commit both changes together
|
||||
basename := filepath.Base(relativePath)
|
||||
if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil {
|
||||
return fmt.Errorf("failed to commit changes: %w", err)
|
||||
}
|
||||
|
||||
// Move back from repository (handles both files and directories)
|
||||
if info.IsDir() {
|
||||
if err := l.fs.MoveDirectory(target, absPath); err != nil {
|
||||
return fmt.Errorf("failed to restore directory: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := l.fs.MoveFile(target, absPath); err != nil {
|
||||
return fmt.Errorf("failed to restore file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommits returns the list of commits for testing purposes
|
||||
func (l *Lnk) GetCommits() ([]string, error) {
|
||||
return l.git.GetCommits()
|
||||
}
|
||||
|
||||
// StatusInfo contains repository sync status information
|
||||
type StatusInfo struct {
|
||||
Ahead int
|
||||
Behind int
|
||||
Remote string
|
||||
Dirty bool
|
||||
}
|
||||
|
||||
// Status returns the repository sync status
|
||||
func (l *Lnk) Status() (*StatusInfo, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
}
|
||||
|
||||
gitStatus, err := l.git.GetStatus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repository status: %w", err)
|
||||
}
|
||||
|
||||
return &StatusInfo{
|
||||
Ahead: gitStatus.Ahead,
|
||||
Behind: gitStatus.Behind,
|
||||
Remote: gitStatus.Remote,
|
||||
Dirty: gitStatus.Dirty,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Push stages all changes and creates a sync commit, then pushes to remote
|
||||
func (l *Lnk) Push(message string) error {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
}
|
||||
|
||||
// Check if there are any changes
|
||||
hasChanges, err := l.git.HasChanges()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for changes: %w", err)
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
// Stage all changes
|
||||
if err := l.git.AddAll(); err != nil {
|
||||
return fmt.Errorf("failed to stage changes: %w", err)
|
||||
}
|
||||
|
||||
// Create a sync commit
|
||||
if err := l.git.Commit(message); err != nil {
|
||||
return fmt.Errorf("failed to commit changes: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Push to remote (this will be a no-op in tests since we don't have real remotes)
|
||||
// In real usage, this would push to the actual remote repository
|
||||
if err := l.git.Push(); err != nil {
|
||||
return fmt.Errorf("failed to push to remote: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pull fetches changes from remote and restores symlinks as needed
|
||||
func (l *Lnk) Pull() ([]string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
}
|
||||
|
||||
// Pull changes from remote (this will be a no-op in tests since we don't have real remotes)
|
||||
if err := l.git.Pull(); err != nil {
|
||||
return nil, fmt.Errorf("failed to pull from remote: %w", err)
|
||||
}
|
||||
|
||||
// Find all managed files in the repository and restore symlinks
|
||||
restored, err := l.RestoreSymlinks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to restore symlinks: %w", err)
|
||||
}
|
||||
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// List returns the list of files and directories currently managed by lnk
|
||||
func (l *Lnk) List() ([]string, error) {
|
||||
// Check if repository is initialized
|
||||
if !l.git.IsGitRepository() {
|
||||
return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first")
|
||||
}
|
||||
|
||||
// Get managed items from .lnk file
|
||||
managedItems, err := l.getManagedItems()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get managed items: %w", err)
|
||||
}
|
||||
|
||||
return managedItems, nil
|
||||
}
|
||||
|
||||
// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks
|
||||
func (l *Lnk) RestoreSymlinks() ([]string, error) {
|
||||
var restored []string
|
||||
|
||||
// Get managed items from .lnk file (now containing relative paths)
|
||||
managedItems, err := l.getManagedItems()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get managed items: %w", err)
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
for _, relativePath := range managedItems {
|
||||
// Generate repository name from relative path
|
||||
storagePath := l.getHostStoragePath()
|
||||
repoItem := filepath.Join(storagePath, relativePath)
|
||||
|
||||
// Check if item exists in repository
|
||||
if _, err := os.Stat(repoItem); os.IsNotExist(err) {
|
||||
continue // Skip missing items
|
||||
}
|
||||
|
||||
// Determine where the symlink should be created
|
||||
symlinkPath := filepath.Join(homeDir, relativePath)
|
||||
|
||||
// Check if symlink already exists and is correct
|
||||
if l.isValidSymlink(symlinkPath, repoItem) {
|
||||
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
|
||||
if _, err := os.Lstat(symlinkPath); err == nil {
|
||||
if err := os.RemoveAll(symlinkPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to remove existing item %s: %w", symlinkPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err)
|
||||
}
|
||||
|
||||
restored = append(restored, relativePath)
|
||||
}
|
||||
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// isValidSymlink checks if the given path is a symlink pointing to the expected target
|
||||
func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool {
|
||||
info, err := os.Lstat(symlinkPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a symlink
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it points to the correct target
|
||||
target, err := os.Readlink(symlinkPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert relative path to absolute if needed
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(symlinkPath), target)
|
||||
}
|
||||
|
||||
// Clean both paths for comparison
|
||||
targetAbs, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expectedAbs, err := filepath.Abs(expectedTarget)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return targetAbs == expectedAbs
|
||||
}
|
||||
|
||||
// getManagedItems returns the list of managed files and directories from .lnk file
|
||||
func (l *Lnk) getManagedItems() ([]string, error) {
|
||||
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
|
||||
|
||||
// If .lnk file doesn't exist, return empty list
|
||||
if _, err := os.Stat(lnkFile); os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(lnkFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read .lnk file: %w", err)
|
||||
}
|
||||
|
||||
if len(content) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||
var items []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
items = append(items, line)
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// addManagedItem adds an item to the .lnk tracking file
|
||||
func (l *Lnk) addManagedItem(relativePath string) error {
|
||||
// Get current items
|
||||
items, err := l.getManagedItems()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get managed items: %w", err)
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
for _, item := range items {
|
||||
if item == relativePath {
|
||||
return nil // Already managed
|
||||
}
|
||||
}
|
||||
|
||||
// Add new item using relative path
|
||||
items = append(items, relativePath)
|
||||
|
||||
// Sort for consistent ordering
|
||||
sort.Strings(items)
|
||||
|
||||
return l.writeManagedItems(items)
|
||||
}
|
||||
|
||||
// removeManagedItem removes an item from the .lnk tracking file
|
||||
func (l *Lnk) removeManagedItem(relativePath string) error {
|
||||
// Get current items
|
||||
items, err := l.getManagedItems()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get managed items: %w", err)
|
||||
}
|
||||
|
||||
// Remove item using relative path
|
||||
var newItems []string
|
||||
for _, item := range items {
|
||||
if item != relativePath {
|
||||
newItems = append(newItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
return l.writeManagedItems(newItems)
|
||||
}
|
||||
|
||||
// writeManagedItems writes the list of managed items to .lnk file
|
||||
func (l *Lnk) writeManagedItems(items []string) error {
|
||||
lnkFile := filepath.Join(l.repoPath, l.getLnkFileName())
|
||||
|
||||
content := strings.Join(items, "\n")
|
||||
if len(items) > 0 {
|
||||
content += "\n"
|
||||
}
|
||||
|
||||
err := os.WriteFile(lnkFile, []byte(content), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write .lnk file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@@ -1,754 +0,0 @@
|
||||
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 preserve the directory structure
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// Find the .bashrc file in the repository (it should be at the relative path)
|
||||
repoFile := filepath.Join(lnkDir, suite.tempDir, ".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)
|
||||
|
||||
// Check that the repository directory preserves the structure
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// The directory should be at the relative path
|
||||
repoDir := filepath.Join(lnkDir, suite.tempDir, "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)
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Test list functionality
|
||||
func (suite *CoreTestSuite) TestListManagedItems() {
|
||||
// Test list without init - should fail
|
||||
_, err := suite.lnk.List()
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "Lnk repository not initialized")
|
||||
|
||||
// Initialize repository
|
||||
err = suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test list with no managed files
|
||||
items, err := suite.lnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Empty(items)
|
||||
|
||||
// Add a file
|
||||
testFile := filepath.Join(suite.tempDir, ".bashrc")
|
||||
content := "export PATH=$PATH:/usr/local/bin"
|
||||
err = os.WriteFile(testFile, []byte(content), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.lnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test list with one managed file
|
||||
items, err = suite.lnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(items, 1)
|
||||
suite.Contains(items[0], ".bashrc")
|
||||
|
||||
// Add a directory
|
||||
testDir := filepath.Join(suite.tempDir, ".config")
|
||||
err = os.MkdirAll(testDir, 0755)
|
||||
suite.Require().NoError(err)
|
||||
configFile := filepath.Join(testDir, "app.conf")
|
||||
err = os.WriteFile(configFile, []byte("setting=value"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.lnk.Add(testDir)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test list with multiple managed items
|
||||
items, err = suite.lnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(items, 2)
|
||||
|
||||
// Check that both items are present
|
||||
found := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
if strings.Contains(item, ".bashrc") {
|
||||
found[".bashrc"] = true
|
||||
}
|
||||
if strings.Contains(item, ".config") {
|
||||
found[".config"] = true
|
||||
}
|
||||
}
|
||||
suite.True(found[".bashrc"], "Should contain .bashrc")
|
||||
suite.True(found[".config"], "Should contain .config")
|
||||
|
||||
// Remove one item and verify list is updated
|
||||
err = suite.lnk.Remove(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
items, err = suite.lnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(items, 1)
|
||||
suite.Contains(items[0], ".config")
|
||||
}
|
||||
|
||||
// Test multihost functionality
|
||||
func (suite *CoreTestSuite) TestMultihostFileOperations() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create test files for different hosts
|
||||
testFile1 := filepath.Join(suite.tempDir, ".bashrc")
|
||||
content1 := "export PATH=$PATH:/usr/local/bin"
|
||||
err = os.WriteFile(testFile1, []byte(content1), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
testFile2 := filepath.Join(suite.tempDir, ".vimrc")
|
||||
content2 := "set number"
|
||||
err = os.WriteFile(testFile2, []byte(content2), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file to common configuration
|
||||
commonLnk := NewLnk()
|
||||
err = commonLnk.Add(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add file to host-specific configuration
|
||||
hostLnk := NewLnkWithHost("workstation")
|
||||
err = hostLnk.Add(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify both files are symlinks
|
||||
info1, err := os.Lstat(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink)
|
||||
|
||||
info2, err := os.Lstat(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink)
|
||||
|
||||
// Verify common configuration tracking
|
||||
commonItems, err := commonLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(commonItems, 1)
|
||||
suite.Contains(commonItems[0], ".bashrc")
|
||||
|
||||
// Verify host-specific configuration tracking
|
||||
hostItems, err := hostLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(hostItems, 1)
|
||||
suite.Contains(hostItems[0], ".vimrc")
|
||||
|
||||
// Verify files are stored in correct locations
|
||||
lnkDir := filepath.Join(suite.tempDir, "lnk")
|
||||
|
||||
// Common file should be in root
|
||||
commonFile := filepath.Join(lnkDir, ".lnk")
|
||||
suite.FileExists(commonFile)
|
||||
|
||||
// Host-specific file should be in host subdirectory
|
||||
hostDir := filepath.Join(lnkDir, "workstation.lnk")
|
||||
suite.DirExists(hostDir)
|
||||
|
||||
hostTrackingFile := filepath.Join(lnkDir, ".lnk.workstation")
|
||||
suite.FileExists(hostTrackingFile)
|
||||
|
||||
// Test removal
|
||||
err = commonLnk.Remove(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = hostLnk.Remove(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify files are restored
|
||||
info1, err = os.Lstat(testFile1)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink)
|
||||
|
||||
info2, err = os.Lstat(testFile2)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink)
|
||||
}
|
||||
|
||||
// Test hostname detection
|
||||
func (suite *CoreTestSuite) TestHostnameDetection() {
|
||||
hostname, err := GetCurrentHostname()
|
||||
suite.NoError(err)
|
||||
suite.NotEmpty(hostname)
|
||||
}
|
||||
|
||||
// Test host-specific symlink restoration
|
||||
func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create files directly in host-specific storage (simulating a pull)
|
||||
hostLnk := NewLnkWithHost("testhost")
|
||||
|
||||
// Ensure host storage directory exists
|
||||
hostStoragePath := hostLnk.getHostStoragePath()
|
||||
err = os.MkdirAll(hostStoragePath, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a file in host storage
|
||||
repoFile := filepath.Join(hostStoragePath, ".bashrc")
|
||||
content := "export HOST=testhost"
|
||||
err = os.WriteFile(repoFile, []byte(content), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create host tracking file
|
||||
trackingFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost")
|
||||
err = os.WriteFile(trackingFile, []byte(".bashrc\n"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Get home directory for the test
|
||||
homeDir, err := os.UserHomeDir()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
targetFile := filepath.Join(homeDir, ".bashrc")
|
||||
|
||||
// Clean up the test file after the test
|
||||
defer func() {
|
||||
_ = os.Remove(targetFile)
|
||||
}()
|
||||
|
||||
// Test symlink restoration
|
||||
restored, err := hostLnk.RestoreSymlinks()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Should have restored the symlink
|
||||
suite.Len(restored, 1)
|
||||
suite.Equal(".bashrc", restored[0])
|
||||
|
||||
// Check that file is now a symlink
|
||||
info, err := os.Lstat(targetFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink)
|
||||
}
|
||||
|
||||
// Test that common and host-specific configurations don't interfere
|
||||
func (suite *CoreTestSuite) TestMultihostIsolation() {
|
||||
err := suite.lnk.Init()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create same file for common and host-specific
|
||||
testFile := filepath.Join(suite.tempDir, ".gitconfig")
|
||||
commonContent := "[user]\n\tname = Common User"
|
||||
err = os.WriteFile(testFile, []byte(commonContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add to common
|
||||
commonLnk := NewLnk()
|
||||
err = commonLnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Remove and recreate with different content
|
||||
err = commonLnk.Remove(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
hostContent := "[user]\n\tname = Work User"
|
||||
err = os.WriteFile(testFile, []byte(hostContent), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Add to host-specific
|
||||
hostLnk := NewLnkWithHost("work")
|
||||
err = hostLnk.Add(testFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Verify tracking files are separate
|
||||
commonItems, err := commonLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(commonItems, 0) // Should be empty after removal
|
||||
|
||||
hostItems, err := hostLnk.List()
|
||||
suite.Require().NoError(err)
|
||||
suite.Len(hostItems, 1)
|
||||
suite.Contains(hostItems[0], ".gitconfig")
|
||||
|
||||
// Verify content is correct
|
||||
symlinkContent, err := os.ReadFile(testFile)
|
||||
suite.Require().NoError(err)
|
||||
suite.Equal(hostContent, string(symlinkContent))
|
||||
}
|
||||
|
||||
func TestCoreSuite(t *testing.T) {
|
||||
suite.Run(t, new(CoreTestSuite))
|
||||
}
|
241
internal/errors/errors.go
Normal file
241
internal/errors/errors.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Standard error variables
|
||||
var (
|
||||
// ErrFileNotFound indicates a file or directory was not found
|
||||
ErrFileNotFound = errors.New("file not found")
|
||||
|
||||
// ErrFileAlreadyManaged indicates a file is already being managed by lnk
|
||||
ErrFileAlreadyManaged = errors.New("file already managed")
|
||||
|
||||
// ErrNotSymlink indicates the file is not a symbolic link
|
||||
ErrNotSymlink = errors.New("not a symlink")
|
||||
|
||||
// ErrRepoNotInitialized indicates the lnk repository has not been initialized
|
||||
ErrRepoNotInitialized = errors.New("repository not initialized")
|
||||
|
||||
// ErrNoRemoteConfigured indicates no Git remote has been configured
|
||||
ErrNoRemoteConfigured = errors.New("no remote configured")
|
||||
|
||||
// ErrOperationAborted indicates an operation was aborted by the user
|
||||
ErrOperationAborted = errors.New("operation aborted")
|
||||
|
||||
// ErrConfigNotFound indicates a configuration file was not found
|
||||
ErrConfigNotFound = errors.New("configuration not found")
|
||||
|
||||
// ErrInvalidPath indicates an invalid file path was provided
|
||||
ErrInvalidPath = errors.New("invalid path")
|
||||
|
||||
// ErrPermissionDenied indicates insufficient permissions for the operation
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
|
||||
// ErrGitOperation indicates a Git operation failed
|
||||
ErrGitOperation = errors.New("git operation failed")
|
||||
|
||||
// ErrFileSystemOperation indicates a file system operation failed
|
||||
ErrFileSystemOperation = errors.New("file system operation failed")
|
||||
)
|
||||
|
||||
// ErrorCode represents different types of errors that can occur
|
||||
type ErrorCode int
|
||||
|
||||
const (
|
||||
// ErrorCodeUnknown represents an unknown error
|
||||
ErrorCodeUnknown ErrorCode = iota
|
||||
|
||||
// ErrorCodeFileNotFound represents file not found errors
|
||||
ErrorCodeFileNotFound
|
||||
|
||||
// ErrorCodeFileAlreadyManaged represents file already managed errors
|
||||
ErrorCodeFileAlreadyManaged
|
||||
|
||||
// ErrorCodeNotSymlink represents not a symlink errors
|
||||
ErrorCodeNotSymlink
|
||||
|
||||
// ErrorCodeRepoNotInitialized represents repository not initialized errors
|
||||
ErrorCodeRepoNotInitialized
|
||||
|
||||
// ErrorCodeNoRemoteConfigured represents no remote configured errors
|
||||
ErrorCodeNoRemoteConfigured
|
||||
|
||||
// ErrorCodeOperationAborted represents operation aborted errors
|
||||
ErrorCodeOperationAborted
|
||||
|
||||
// ErrorCodeConfigNotFound represents configuration not found errors
|
||||
ErrorCodeConfigNotFound
|
||||
|
||||
// ErrorCodeInvalidPath represents invalid path errors
|
||||
ErrorCodeInvalidPath
|
||||
|
||||
// ErrorCodePermissionDenied represents permission denied errors
|
||||
ErrorCodePermissionDenied
|
||||
|
||||
// ErrorCodeGitOperation represents Git operation errors
|
||||
ErrorCodeGitOperation
|
||||
|
||||
// ErrorCodeFileSystemOperation represents file system operation errors
|
||||
ErrorCodeFileSystemOperation
|
||||
)
|
||||
|
||||
// String returns a string representation of the error code
|
||||
func (e ErrorCode) String() string {
|
||||
switch e {
|
||||
case ErrorCodeFileNotFound:
|
||||
return "FILE_NOT_FOUND"
|
||||
case ErrorCodeFileAlreadyManaged:
|
||||
return "FILE_ALREADY_MANAGED"
|
||||
case ErrorCodeNotSymlink:
|
||||
return "NOT_SYMLINK"
|
||||
case ErrorCodeRepoNotInitialized:
|
||||
return "REPO_NOT_INITIALIZED"
|
||||
case ErrorCodeNoRemoteConfigured:
|
||||
return "NO_REMOTE_CONFIGURED"
|
||||
case ErrorCodeOperationAborted:
|
||||
return "OPERATION_ABORTED"
|
||||
case ErrorCodeConfigNotFound:
|
||||
return "CONFIG_NOT_FOUND"
|
||||
case ErrorCodeInvalidPath:
|
||||
return "INVALID_PATH"
|
||||
case ErrorCodePermissionDenied:
|
||||
return "PERMISSION_DENIED"
|
||||
case ErrorCodeGitOperation:
|
||||
return "GIT_OPERATION"
|
||||
case ErrorCodeFileSystemOperation:
|
||||
return "FILE_SYSTEM_OPERATION"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
// LnkError represents a structured error with additional context
|
||||
type LnkError struct {
|
||||
// Code represents the type of error
|
||||
Code ErrorCode
|
||||
|
||||
// Message is the human-readable error message
|
||||
Message string
|
||||
|
||||
// Cause is the underlying error that caused this error
|
||||
Cause error
|
||||
|
||||
// Context provides additional context about when/where the error occurred
|
||||
Context map[string]interface{}
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *LnkError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying cause error for Go 1.13+ error handling
|
||||
func (e *LnkError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Is implements error comparison for Go 1.13+ error handling
|
||||
func (e *LnkError) Is(target error) bool {
|
||||
if lnkErr, ok := target.(*LnkError); ok {
|
||||
return e.Code == lnkErr.Code
|
||||
}
|
||||
return errors.Is(e.Cause, target)
|
||||
}
|
||||
|
||||
// WithContext adds context information to the error
|
||||
func (e *LnkError) WithContext(key string, value interface{}) *LnkError {
|
||||
if e.Context == nil {
|
||||
e.Context = make(map[string]interface{})
|
||||
}
|
||||
e.Context[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// NewLnkError creates a new LnkError with the given code and message
|
||||
func NewLnkError(code ErrorCode, message string) *LnkError {
|
||||
return &LnkError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Context: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// WrapError wraps an existing error with LnkError context
|
||||
func WrapError(code ErrorCode, message string, cause error) *LnkError {
|
||||
return &LnkError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Cause: cause,
|
||||
Context: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for creating common errors
|
||||
|
||||
// NewFileNotFoundError creates a file not found error
|
||||
func NewFileNotFoundError(path string) *LnkError {
|
||||
return NewLnkError(ErrorCodeFileNotFound, fmt.Sprintf("❌ File does not exist: \033[31m%s\033[0m", path)).
|
||||
WithContext("path", path)
|
||||
}
|
||||
|
||||
// NewFileAlreadyManagedError creates a file already managed error
|
||||
func NewFileAlreadyManagedError(path string) *LnkError {
|
||||
return NewLnkError(ErrorCodeFileAlreadyManaged, fmt.Sprintf("file already managed: %s", path)).
|
||||
WithContext("path", path)
|
||||
}
|
||||
|
||||
// NewNotSymlinkError creates a not symlink error
|
||||
func NewNotSymlinkError(path string) *LnkError {
|
||||
return NewLnkError(ErrorCodeNotSymlink, fmt.Sprintf("not a symlink: %s", path)).
|
||||
WithContext("path", path)
|
||||
}
|
||||
|
||||
// NewRepoNotInitializedError creates a repository not initialized error
|
||||
func NewRepoNotInitializedError(repoPath string) *LnkError {
|
||||
return NewLnkError(ErrorCodeRepoNotInitialized, "Lnk repository not initialized").
|
||||
WithContext("repo_path", repoPath)
|
||||
}
|
||||
|
||||
// NewNoRemoteConfiguredError creates a no remote configured error
|
||||
func NewNoRemoteConfiguredError() *LnkError {
|
||||
return NewLnkError(ErrorCodeNoRemoteConfigured, "no git remote configured")
|
||||
}
|
||||
|
||||
// NewConfigNotFoundError creates a configuration not found error
|
||||
func NewConfigNotFoundError(host string) *LnkError {
|
||||
return NewLnkError(ErrorCodeConfigNotFound, fmt.Sprintf("configuration not found for host: %s", host)).
|
||||
WithContext("host", host)
|
||||
}
|
||||
|
||||
// NewInvalidPathError creates an invalid path error
|
||||
func NewInvalidPathError(path string, reason string) *LnkError {
|
||||
return NewLnkError(ErrorCodeInvalidPath, fmt.Sprintf("invalid path %s: %s", path, reason)).
|
||||
WithContext("path", path).
|
||||
WithContext("reason", reason)
|
||||
}
|
||||
|
||||
// NewPermissionDeniedError creates a permission denied error
|
||||
func NewPermissionDeniedError(operation, path string) *LnkError {
|
||||
return NewLnkError(ErrorCodePermissionDenied, fmt.Sprintf("permission denied for %s: %s", operation, path)).
|
||||
WithContext("operation", operation).
|
||||
WithContext("path", path)
|
||||
}
|
||||
|
||||
// NewGitOperationError creates a Git operation error
|
||||
func NewGitOperationError(operation string, cause error) *LnkError {
|
||||
return WrapError(ErrorCodeGitOperation, fmt.Sprintf("git %s failed", operation), cause).
|
||||
WithContext("operation", operation)
|
||||
}
|
||||
|
||||
// NewFileSystemOperationError creates a file system operation error
|
||||
func NewFileSystemOperationError(operation, path string, cause error) *LnkError {
|
||||
return WrapError(ErrorCodeFileSystemOperation, fmt.Sprintf("file system %s failed for %s", operation, path), cause).
|
||||
WithContext("operation", operation).
|
||||
WithContext("path", path)
|
||||
}
|
126
internal/errors/errors_test.go
Normal file
126
internal/errors/errors_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type ErrorsTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (suite *ErrorsTestSuite) TestErrorCodeString() {
|
||||
tests := []struct {
|
||||
code ErrorCode
|
||||
expected string
|
||||
}{
|
||||
{ErrorCodeFileNotFound, "FILE_NOT_FOUND"},
|
||||
{ErrorCodeFileAlreadyManaged, "FILE_ALREADY_MANAGED"},
|
||||
{ErrorCodeNotSymlink, "NOT_SYMLINK"},
|
||||
{ErrorCodeRepoNotInitialized, "REPO_NOT_INITIALIZED"},
|
||||
{ErrorCodeNoRemoteConfigured, "NO_REMOTE_CONFIGURED"},
|
||||
{ErrorCodeOperationAborted, "OPERATION_ABORTED"},
|
||||
{ErrorCodeConfigNotFound, "CONFIG_NOT_FOUND"},
|
||||
{ErrorCodeInvalidPath, "INVALID_PATH"},
|
||||
{ErrorCodePermissionDenied, "PERMISSION_DENIED"},
|
||||
{ErrorCodeGitOperation, "GIT_OPERATION"},
|
||||
{ErrorCodeFileSystemOperation, "FILE_SYSTEM_OPERATION"},
|
||||
{ErrorCodeUnknown, "UNKNOWN"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.expected, func() {
|
||||
result := tt.code.String()
|
||||
suite.Equal(tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ErrorsTestSuite) TestLnkErrorError() {
|
||||
suite.Run("without_cause", func() {
|
||||
err := NewLnkError(ErrorCodeFileNotFound, "test file not found")
|
||||
expected := "test file not found"
|
||||
suite.Equal(expected, err.Error())
|
||||
})
|
||||
|
||||
suite.Run("with_cause", func() {
|
||||
cause := errors.New("underlying error")
|
||||
err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause)
|
||||
expected := "file operation failed: underlying error"
|
||||
suite.Equal(expected, err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *ErrorsTestSuite) TestLnkErrorUnwrap() {
|
||||
cause := errors.New("underlying error")
|
||||
err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause)
|
||||
|
||||
unwrapped := err.Unwrap()
|
||||
suite.Equal(cause, unwrapped)
|
||||
}
|
||||
|
||||
func (suite *ErrorsTestSuite) TestLnkErrorIs() {
|
||||
err1 := NewLnkError(ErrorCodeFileNotFound, "file not found")
|
||||
err2 := NewLnkError(ErrorCodeFileNotFound, "another file not found")
|
||||
err3 := NewLnkError(ErrorCodeFileAlreadyManaged, "file already managed")
|
||||
|
||||
// Same error code should match
|
||||
suite.True(errors.Is(err1, err2), "expected errors with same code to match")
|
||||
|
||||
// Different error codes should not match
|
||||
suite.False(errors.Is(err1, err3), "expected errors with different codes to not match")
|
||||
|
||||
// Test with wrapped errors
|
||||
cause := errors.New("io error")
|
||||
wrappedErr := WrapError(ErrorCodeFileSystemOperation, "wrapped", cause)
|
||||
suite.True(errors.Is(wrappedErr, cause), "expected wrapped error to match its cause")
|
||||
}
|
||||
|
||||
func (suite *ErrorsTestSuite) TestLnkErrorWithContext() {
|
||||
err := NewLnkError(ErrorCodeFileNotFound, "file not found")
|
||||
err = err.WithContext("path", "/test/file.txt")
|
||||
err = err.WithContext("operation", "read")
|
||||
|
||||
suite.Equal("/test/file.txt", err.Context["path"])
|
||||
suite.Equal("read", err.Context["operation"])
|
||||
}
|
||||
|
||||
func (suite *ErrorsTestSuite) TestNewFileNotFoundError() {
|
||||
path := "/test/file.txt"
|
||||
err := NewFileNotFoundError(path)
|
||||
|
||||
suite.Equal(ErrorCodeFileNotFound, err.Code)
|
||||
suite.Equal(path, err.Context["path"])
|
||||
}
|
||||
|
||||
func (suite *ErrorsTestSuite) TestNewFileAlreadyManagedError() {
|
||||
path := "/test/file.txt"
|
||||
err := NewFileAlreadyManagedError(path)
|
||||
|
||||
suite.Equal(ErrorCodeFileAlreadyManaged, err.Code)
|
||||
suite.Equal(path, err.Context["path"])
|
||||
}
|
||||
|
||||
func (suite *ErrorsTestSuite) TestNewRepoNotInitializedError() {
|
||||
repoPath := "/test/repo"
|
||||
err := NewRepoNotInitializedError(repoPath)
|
||||
|
||||
suite.Equal(ErrorCodeRepoNotInitialized, err.Code)
|
||||
suite.Equal(repoPath, err.Context["repo_path"])
|
||||
}
|
||||
|
||||
func (suite *ErrorsTestSuite) TestNewGitOperationError() {
|
||||
operation := "push"
|
||||
cause := errors.New("network error")
|
||||
err := NewGitOperationError(operation, cause)
|
||||
|
||||
suite.Equal(ErrorCodeGitOperation, err.Code)
|
||||
suite.Equal(cause, err.Cause)
|
||||
suite.Equal(operation, err.Context["operation"])
|
||||
}
|
||||
|
||||
func TestErrorsSuite(t *testing.T) {
|
||||
suite.Run(t, new(ErrorsTestSuite))
|
||||
}
|
254
internal/fs/filemanager.go
Normal file
254
internal/fs/filemanager.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yarlson/lnk/internal/errors"
|
||||
)
|
||||
|
||||
// FileManager implements the models.FileManager interface
|
||||
type FileManager struct{}
|
||||
|
||||
// New creates a new FileManager instance
|
||||
func New() *FileManager {
|
||||
return &FileManager{}
|
||||
}
|
||||
|
||||
// Exists checks if a file or directory exists
|
||||
func (fm *FileManager) Exists(ctx context.Context, path string) (bool, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
_, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.NewFileSystemOperationError("stat", path, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// IsDirectory checks if the path points to a directory
|
||||
func (fm *FileManager) IsDirectory(ctx context.Context, path string) (bool, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, errors.NewFileNotFoundError(path)
|
||||
}
|
||||
return false, errors.NewFileSystemOperationError("stat", path, err)
|
||||
}
|
||||
return info.IsDir(), nil
|
||||
}
|
||||
|
||||
// Move moves a file or directory from src to dst
|
||||
func (fm *FileManager) Move(ctx context.Context, src, dst string) error {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Ensure destination directory exists
|
||||
dstDir := filepath.Dir(dst)
|
||||
if err := fm.MkdirAll(ctx, dstDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
// Check for context cancellation before move
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Move the file or directory
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return errors.NewFileSystemOperationError("move", src, err).
|
||||
WithContext("destination", dst)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSymlink creates a symlink pointing from linkPath to target
|
||||
func (fm *FileManager) CreateSymlink(ctx context.Context, target, linkPath string) error {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Calculate relative path from linkPath to target
|
||||
linkDir := filepath.Dir(linkPath)
|
||||
relTarget, err := filepath.Rel(linkDir, target)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("calculate_relative_path", linkPath, err).
|
||||
WithContext("target", target)
|
||||
}
|
||||
|
||||
// Create the symlink
|
||||
if err := os.Symlink(relTarget, linkPath); err != nil {
|
||||
return errors.NewFileSystemOperationError("create_symlink", linkPath, err).
|
||||
WithContext("target", relTarget)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes a file or directory
|
||||
func (fm *FileManager) Remove(ctx context.Context, path string) error {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return errors.NewFileSystemOperationError("remove", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadFile reads the contents of a file
|
||||
func (fm *FileManager) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, errors.NewFileNotFoundError(path)
|
||||
}
|
||||
return nil, errors.NewFileSystemOperationError("read", path, err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// WriteFile writes data to a file with the given permissions
|
||||
func (fm *FileManager) WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode) error {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := fm.MkdirAll(ctx, dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory: %w", err)
|
||||
}
|
||||
|
||||
// Check for context cancellation before write
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, perm); err != nil {
|
||||
return errors.NewFileSystemOperationError("write", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MkdirAll creates a directory and all necessary parent directories
|
||||
func (fm *FileManager) MkdirAll(ctx context.Context, path string, perm os.FileMode) error {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path, perm); err != nil {
|
||||
return errors.NewFileSystemOperationError("mkdir", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Readlink returns the target of a symbolic link
|
||||
func (fm *FileManager) Readlink(ctx context.Context, path string) (string, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
target, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", errors.NewFileNotFoundError(path)
|
||||
}
|
||||
return "", errors.NewFileSystemOperationError("readlink", path, err)
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// Lstat returns file info without following symbolic links
|
||||
func (fm *FileManager) Lstat(ctx context.Context, path string) (os.FileInfo, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, errors.NewFileNotFoundError(path)
|
||||
}
|
||||
return nil, errors.NewFileSystemOperationError("lstat", path, err)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Stat returns file info, following symbolic links
|
||||
func (fm *FileManager) Stat(ctx context.Context, path string) (os.FileInfo, error) {
|
||||
// Check for context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, errors.NewFileNotFoundError(path)
|
||||
}
|
||||
return nil, errors.NewFileSystemOperationError("stat", path, err)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
261
internal/fs/filemanager_test.go
Normal file
261
internal/fs/filemanager_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/yarlson/lnk/internal/errors"
|
||||
)
|
||||
|
||||
type FileManagerTestSuite struct {
|
||||
suite.Suite
|
||||
tempDir string
|
||||
fileManager *FileManager
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) SetupTest() {
|
||||
// Create temp directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "lnk_test_*")
|
||||
suite.Require().NoError(err)
|
||||
suite.tempDir = tempDir
|
||||
|
||||
// Create file manager
|
||||
suite.fileManager = New()
|
||||
|
||||
// Create context
|
||||
suite.ctx = context.Background()
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TearDownTest() {
|
||||
err := os.RemoveAll(suite.tempDir)
|
||||
suite.Require().NoError(err)
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestExists() {
|
||||
// Test existing file
|
||||
testFile := filepath.Join(suite.tempDir, "test.txt")
|
||||
err := os.WriteFile(testFile, []byte("test"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
exists, err := suite.fileManager.Exists(suite.ctx, testFile)
|
||||
suite.NoError(err)
|
||||
suite.True(exists)
|
||||
|
||||
// Test non-existing file
|
||||
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||
exists, err = suite.fileManager.Exists(suite.ctx, nonExistentFile)
|
||||
suite.NoError(err)
|
||||
suite.False(exists)
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestExistsWithCancellation() {
|
||||
// Create cancelled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := suite.fileManager.Exists(ctx, "/any/path")
|
||||
suite.Equal(context.Canceled, err)
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestIsDirectory() {
|
||||
// Test directory
|
||||
isDir, err := suite.fileManager.IsDirectory(suite.ctx, suite.tempDir)
|
||||
suite.NoError(err)
|
||||
suite.True(isDir)
|
||||
|
||||
// Test file
|
||||
testFile := filepath.Join(suite.tempDir, "test.txt")
|
||||
err = os.WriteFile(testFile, []byte("test"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
isDir, err = suite.fileManager.IsDirectory(suite.ctx, testFile)
|
||||
suite.NoError(err)
|
||||
suite.False(isDir)
|
||||
|
||||
// Test non-existing file
|
||||
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||
_, err = suite.fileManager.IsDirectory(suite.ctx, nonExistentFile)
|
||||
suite.Error(err)
|
||||
|
||||
// Check that it's a models error
|
||||
suite.True(errors.NewFileNotFoundError("").Is(err))
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestMove() {
|
||||
// Create test file
|
||||
srcFile := filepath.Join(suite.tempDir, "source.txt")
|
||||
testContent := []byte("test content")
|
||||
err := os.WriteFile(srcFile, testContent, 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test moving file
|
||||
dstFile := filepath.Join(suite.tempDir, "subdir", "destination.txt")
|
||||
err = suite.fileManager.Move(suite.ctx, srcFile, dstFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// Verify source doesn't exist
|
||||
_, err = os.Stat(srcFile)
|
||||
suite.True(os.IsNotExist(err))
|
||||
|
||||
// Verify destination exists with correct content
|
||||
content, err := os.ReadFile(dstFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal(string(testContent), string(content))
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestCreateSymlink() {
|
||||
// Create target file
|
||||
targetFile := filepath.Join(suite.tempDir, "target.txt")
|
||||
err := os.WriteFile(targetFile, []byte("test"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create symlink
|
||||
linkFile := filepath.Join(suite.tempDir, "link.txt")
|
||||
err = suite.fileManager.CreateSymlink(suite.ctx, targetFile, linkFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// Verify symlink exists and points to target
|
||||
info, err := os.Lstat(linkFile)
|
||||
suite.NoError(err)
|
||||
suite.NotZero(info.Mode() & os.ModeSymlink)
|
||||
|
||||
// Verify symlink target
|
||||
target, err := os.Readlink(linkFile)
|
||||
suite.NoError(err)
|
||||
|
||||
expectedTarget := "target.txt" // Should be relative
|
||||
suite.Equal(expectedTarget, target)
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestReadWriteFile() {
|
||||
// Test writing file
|
||||
testFile := filepath.Join(suite.tempDir, "subdir", "test.txt")
|
||||
testContent := []byte("test content")
|
||||
err := suite.fileManager.WriteFile(suite.ctx, testFile, testContent, 0644)
|
||||
suite.NoError(err)
|
||||
|
||||
// Test reading file
|
||||
content, err := suite.fileManager.ReadFile(suite.ctx, testFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal(string(testContent), string(content))
|
||||
|
||||
// Test reading non-existent file
|
||||
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||
_, err = suite.fileManager.ReadFile(suite.ctx, nonExistentFile)
|
||||
suite.Error(err)
|
||||
suite.True(errors.NewFileNotFoundError("").Is(err))
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestRemove() {
|
||||
// Create test file
|
||||
testFile := filepath.Join(suite.tempDir, "test.txt")
|
||||
err := os.WriteFile(testFile, []byte("test"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Remove file
|
||||
err = suite.fileManager.Remove(suite.ctx, testFile)
|
||||
suite.NoError(err)
|
||||
|
||||
// Verify file doesn't exist
|
||||
_, err = os.Stat(testFile)
|
||||
suite.True(os.IsNotExist(err))
|
||||
|
||||
// Test removing non-existent file (should not error)
|
||||
err = suite.fileManager.Remove(suite.ctx, testFile)
|
||||
suite.NoError(err)
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestMkdirAll() {
|
||||
// Create nested directory
|
||||
nestedDir := filepath.Join(suite.tempDir, "a", "b", "c")
|
||||
err := suite.fileManager.MkdirAll(suite.ctx, nestedDir, 0755)
|
||||
suite.NoError(err)
|
||||
|
||||
// Verify directory exists
|
||||
info, err := os.Stat(nestedDir)
|
||||
suite.NoError(err)
|
||||
suite.True(info.IsDir())
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestReadlink() {
|
||||
// Create target file
|
||||
targetFile := filepath.Join(suite.tempDir, "target.txt")
|
||||
err := os.WriteFile(targetFile, []byte("test"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create symlink
|
||||
linkFile := filepath.Join(suite.tempDir, "link.txt")
|
||||
err = os.Symlink("target.txt", linkFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test reading symlink
|
||||
target, err := suite.fileManager.Readlink(suite.ctx, linkFile)
|
||||
suite.NoError(err)
|
||||
suite.Equal("target.txt", target)
|
||||
|
||||
// Test reading non-symlink
|
||||
_, err = suite.fileManager.Readlink(suite.ctx, targetFile)
|
||||
suite.Error(err)
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestStatAndLstat() {
|
||||
// Create target file
|
||||
targetFile := filepath.Join(suite.tempDir, "target.txt")
|
||||
err := os.WriteFile(targetFile, []byte("test"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create symlink
|
||||
linkFile := filepath.Join(suite.tempDir, "link.txt")
|
||||
err = os.Symlink("target.txt", linkFile)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test Stat on regular file
|
||||
info, err := suite.fileManager.Stat(suite.ctx, targetFile)
|
||||
suite.NoError(err)
|
||||
suite.False(info.IsDir())
|
||||
|
||||
// Test Stat on symlink (should follow link)
|
||||
info, err = suite.fileManager.Stat(suite.ctx, linkFile)
|
||||
suite.NoError(err)
|
||||
suite.False(info.IsDir())
|
||||
|
||||
// Test Lstat on symlink (should not follow link)
|
||||
info, err = suite.fileManager.Lstat(suite.ctx, linkFile)
|
||||
suite.NoError(err)
|
||||
suite.NotZero(info.Mode() & os.ModeSymlink)
|
||||
|
||||
// Test on non-existent file
|
||||
nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt")
|
||||
_, err = suite.fileManager.Stat(suite.ctx, nonExistentFile)
|
||||
suite.Error(err)
|
||||
suite.True(errors.NewFileNotFoundError("").Is(err))
|
||||
}
|
||||
|
||||
func (suite *FileManagerTestSuite) TestContextCancellation() {
|
||||
// Test with timeout context
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
// Allow time for context to expire
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
// Test various operations with cancelled context
|
||||
_, err := suite.fileManager.Exists(ctx, "/any/path")
|
||||
suite.Equal(context.DeadlineExceeded, err)
|
||||
|
||||
_, err = suite.fileManager.IsDirectory(ctx, "/any/path")
|
||||
suite.Equal(context.DeadlineExceeded, err)
|
||||
|
||||
err = suite.fileManager.Move(ctx, "/src", "/dst")
|
||||
suite.Equal(context.DeadlineExceeded, err)
|
||||
}
|
||||
|
||||
func TestFileManagerSuite(t *testing.T) {
|
||||
suite.Run(t, new(FileManagerTestSuite))
|
||||
}
|
@@ -1,133 +0,0 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileSystem handles file system operations
|
||||
type FileSystem struct{}
|
||||
|
||||
// New creates a new FileSystem instance
|
||||
func New() *FileSystem {
|
||||
return &FileSystem{}
|
||||
}
|
||||
|
||||
// ValidateFileForAdd validates that a file or directory can be added to lnk
|
||||
func (fs *FileSystem) ValidateFileForAdd(filePath string) error {
|
||||
// Check if file exists
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
|
||||
}
|
||||
return fmt.Errorf("❌ Failed to check file: %w", err)
|
||||
}
|
||||
|
||||
// Allow both regular files and directories
|
||||
if !info.Mode().IsRegular() && !info.IsDir() {
|
||||
return fmt.Errorf("❌ Only regular files and directories are supported: \033[31m%s\033[0m", filePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSymlinkForRemove validates that a symlink can be removed from lnk
|
||||
func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error {
|
||||
// Check if file exists
|
||||
info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("❌ File does not exist: \033[31m%s\033[0m", filePath)
|
||||
}
|
||||
return fmt.Errorf("❌ Failed to check file: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's a symlink
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
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
|
||||
target, err := os.Readlink(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read symlink: %w", err)
|
||||
}
|
||||
|
||||
// Convert relative path to absolute if needed
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(filePath), target)
|
||||
}
|
||||
|
||||
// Clean the path to resolve any .. or . components
|
||||
target = filepath.Clean(target)
|
||||
repoPath = filepath.Clean(repoPath)
|
||||
|
||||
// Check if target is inside the repository
|
||||
if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath {
|
||||
return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", filePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveFile moves a file from source to destination
|
||||
func (fs *FileSystem) MoveFile(src, dst string) error {
|
||||
// Ensure destination directory exists
|
||||
dstDir := filepath.Dir(dst)
|
||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
// Move the file
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("failed to move file from %s to %s: %w", src, dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSymlink creates a relative symlink from target to linkPath
|
||||
func (fs *FileSystem) CreateSymlink(target, linkPath string) error {
|
||||
// Calculate relative path from linkPath to target
|
||||
linkDir := filepath.Dir(linkPath)
|
||||
relTarget, err := filepath.Rel(linkDir, target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to calculate relative path: %w", err)
|
||||
}
|
||||
|
||||
// Create the symlink
|
||||
if err := os.Symlink(relTarget, linkPath); err != nil {
|
||||
return fmt.Errorf("failed to create symlink: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveDirectory moves a directory from source to destination recursively
|
||||
func (fs *FileSystem) MoveDirectory(src, dst string) error {
|
||||
// Check if source is a directory
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat source: %w", err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("source is not a directory: %s", src)
|
||||
}
|
||||
|
||||
// Ensure destination parent directory exists
|
||||
dstParent := filepath.Dir(dst)
|
||||
if err := os.MkdirAll(dstParent, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination parent directory: %w", err)
|
||||
}
|
||||
|
||||
// Use os.Rename which works for directories
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
return fmt.Errorf("failed to move directory from %s to %s: %w", src, dst, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@@ -1,508 +0,0 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Git handles Git operations
|
||||
type Git struct {
|
||||
repoPath string
|
||||
}
|
||||
|
||||
// New creates a new Git instance
|
||||
func New(repoPath string) *Git {
|
||||
return &Git{
|
||||
repoPath: repoPath,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes a new Git repository
|
||||
func (g *Git) Init() error {
|
||||
// Try using git init -b main first (Git 2.28+)
|
||||
cmd := exec.Command("git", "init", "-b", "main")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
_, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Fallback to regular init + branch rename for older Git versions
|
||||
cmd = exec.Command("git", "init")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
// Set the default branch to main
|
||||
cmd = exec.Command("git", "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to set default branch to main: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRemote adds a remote to the repository
|
||||
func (g *Git) AddRemote(name, url string) error {
|
||||
// Check if remote already exists
|
||||
existingURL, err := g.getRemoteURL(name)
|
||||
if err == nil {
|
||||
// Remote exists, check if URL matches
|
||||
if existingURL == url {
|
||||
// Same URL, idempotent - do nothing
|
||||
return nil
|
||||
}
|
||||
// Different URL, error
|
||||
return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url)
|
||||
}
|
||||
|
||||
// Remote doesn't exist, add it
|
||||
cmd := exec.Command("git", "remote", "add", name, url)
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRemoteURL returns the URL for a remote, or error if not found
|
||||
func (g *Git) getRemoteURL(name string) (string, error) {
|
||||
cmd := exec.Command("git", "remote", "get-url", name)
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
// IsGitRepository checks if the directory contains a Git repository
|
||||
func (g *Git) IsGitRepository() bool {
|
||||
gitDir := filepath.Join(g.repoPath, ".git")
|
||||
_, err := os.Stat(gitDir)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsLnkRepository checks if the repository appears to be managed by lnk
|
||||
func (g *Git) IsLnkRepository() bool {
|
||||
if !g.IsGitRepository() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this looks like a lnk repository
|
||||
// We consider it a lnk repo if:
|
||||
// 1. It has no commits (fresh repo), OR
|
||||
// 2. All commits start with "lnk:" pattern
|
||||
|
||||
commits, err := g.GetCommits()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If no commits, it's a fresh repo - could be lnk
|
||||
if len(commits) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// If all commits start with "lnk:", it's definitely ours
|
||||
// If ANY commit doesn't start with "lnk:", it's probably not ours
|
||||
for _, commit := range commits {
|
||||
if !strings.HasPrefix(commit, "lnk:") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// AddAndCommit stages a file and commits it
|
||||
func (g *Git) AddAndCommit(filename, message string) error {
|
||||
// Stage the file
|
||||
if err := g.Add(filename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the changes
|
||||
if err := g.Commit(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAndCommit removes a file from Git and commits the change
|
||||
func (g *Git) RemoveAndCommit(filename, message string) error {
|
||||
// Remove the file from Git
|
||||
if err := g.Remove(filename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the changes
|
||||
if err := g.Commit(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add stages a file
|
||||
func (g *Git) Add(filename string) error {
|
||||
cmd := exec.Command("git", "add", filename)
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes a file from Git tracking
|
||||
func (g *Git) Remove(filename string) error {
|
||||
// Check if it's a directory that needs -r flag
|
||||
fullPath := filepath.Join(g.repoPath, filename)
|
||||
info, err := os.Stat(fullPath)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if err == nil && info.IsDir() {
|
||||
// Use -r and --cached flags for directories (only remove from git, not filesystem)
|
||||
cmd = exec.Command("git", "rm", "-r", "--cached", filename)
|
||||
} else {
|
||||
// Regular file (only remove from git, not filesystem)
|
||||
cmd = exec.Command("git", "rm", "--cached", filename)
|
||||
}
|
||||
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit creates a commit with the given message
|
||||
func (g *Git) Commit(message string) error {
|
||||
// Configure git user if not already configured
|
||||
if err := g.ensureGitConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "commit", "-m", message)
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureGitConfig ensures that git user.name and user.email are configured
|
||||
func (g *Git) ensureGitConfig() error {
|
||||
// Check if user.name is configured
|
||||
cmd := exec.Command("git", "config", "user.name")
|
||||
cmd.Dir = g.repoPath
|
||||
if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
||||
// Set a default user.name
|
||||
cmd = exec.Command("git", "config", "user.name", "Lnk User")
|
||||
cmd.Dir = g.repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to set git user.name: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user.email is configured
|
||||
cmd = exec.Command("git", "config", "user.email")
|
||||
cmd.Dir = g.repoPath
|
||||
if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 {
|
||||
// Set a default user.email
|
||||
cmd = exec.Command("git", "config", "user.email", "lnk@localhost")
|
||||
cmd.Dir = g.repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to set git user.email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommits returns the list of commit messages for testing purposes
|
||||
func (g *Git) GetCommits() ([]string, error) {
|
||||
// Check if .git directory exists
|
||||
gitDir := filepath.Join(g.repoPath, ".git")
|
||||
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "log", "--oneline", "--format=%s")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// If there are no commits yet, return empty slice
|
||||
outputStr := string(output)
|
||||
if strings.Contains(outputStr, "does not have any commits yet") {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("git log failed: %w", err)
|
||||
}
|
||||
|
||||
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(commits) == 1 && commits[0] == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
// GetRemoteInfo returns information about the default remote
|
||||
func (g *Git) GetRemoteInfo() (string, error) {
|
||||
// First try to get origin remote
|
||||
url, err := g.getRemoteURL("origin")
|
||||
if err != nil {
|
||||
// If origin doesn't exist, try to get any remote
|
||||
cmd := exec.Command("git", "remote")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list remotes: %w", err)
|
||||
}
|
||||
|
||||
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(remotes) == 0 || remotes[0] == "" {
|
||||
return "", fmt.Errorf("no remote configured")
|
||||
}
|
||||
|
||||
// Use the first remote
|
||||
url, err = g.getRemoteURL(remotes[0])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get remote URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// StatusInfo contains repository status information
|
||||
type StatusInfo struct {
|
||||
Ahead int
|
||||
Behind int
|
||||
Remote string
|
||||
Dirty bool
|
||||
}
|
||||
|
||||
// GetStatus returns the repository status relative to remote
|
||||
func (g *Git) GetStatus() (*StatusInfo, error) {
|
||||
// Check if we have a remote
|
||||
_, err := g.GetRemoteInfo()
|
||||
if err != nil {
|
||||
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
|
||||
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No upstream branch set, assume origin/main
|
||||
remoteBranch := "origin/main"
|
||||
return &StatusInfo{
|
||||
Ahead: g.getAheadCount(remoteBranch),
|
||||
Behind: 0, // Can't be behind if no upstream
|
||||
Remote: remoteBranch,
|
||||
Dirty: dirty,
|
||||
}, nil
|
||||
}
|
||||
|
||||
remoteBranch := strings.TrimSpace(string(output))
|
||||
|
||||
return &StatusInfo{
|
||||
Ahead: g.getAheadCount(remoteBranch),
|
||||
Behind: g.getBehindCount(remoteBranch),
|
||||
Remote: remoteBranch,
|
||||
Dirty: dirty,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getAheadCount returns how many commits ahead of remote
|
||||
func (g *Git) getAheadCount(remoteBranch string) int {
|
||||
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch))
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// If remote branch doesn't exist, count all local commits
|
||||
cmd = exec.Command("git", "rev-list", "--count", "HEAD")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
count := strings.TrimSpace(string(output))
|
||||
if count == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert to int
|
||||
var ahead int
|
||||
if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return ahead
|
||||
}
|
||||
|
||||
// getBehindCount returns how many commits behind remote
|
||||
func (g *Git) getBehindCount(remoteBranch string) int {
|
||||
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch))
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := strings.TrimSpace(string(output))
|
||||
if count == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert to int
|
||||
var behind int
|
||||
if _, err := fmt.Sscanf(count, "%d", &behind); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return behind
|
||||
}
|
||||
|
||||
// HasChanges checks if there are uncommitted changes
|
||||
func (g *Git) HasChanges() (bool, error) {
|
||||
cmd := exec.Command("git", "status", "--porcelain")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status failed: %w", err)
|
||||
}
|
||||
|
||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||
}
|
||||
|
||||
// AddAll stages all changes in the repository
|
||||
func (g *Git) AddAll() error {
|
||||
cmd := exec.Command("git", "add", "-A")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Push pushes changes to remote
|
||||
func (g *Git) Push() error {
|
||||
// First ensure we have a remote configured
|
||||
_, err := g.GetRemoteInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot push: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "push", "-u", "origin", "main")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pull pulls changes from remote
|
||||
func (g *Git) Pull() error {
|
||||
// First ensure we have a remote configured
|
||||
_, err := g.GetRemoteInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot pull: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "pull", "origin", "main")
|
||||
cmd.Dir = g.repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone clones a repository from the given URL
|
||||
func (g *Git) Clone(url string) error {
|
||||
// Remove the directory if it exists to ensure clean clone
|
||||
if err := os.RemoveAll(g.repoPath); err != nil {
|
||||
return fmt.Errorf("failed to remove existing directory: %w", err)
|
||||
}
|
||||
|
||||
// Create parent directory
|
||||
parentDir := filepath.Dir(g.repoPath)
|
||||
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory: %w", err)
|
||||
}
|
||||
|
||||
// Clone the repository
|
||||
cmd := exec.Command("git", "clone", url, g.repoPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
// Set up upstream tracking for main branch
|
||||
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/main", "main")
|
||||
cmd.Dir = g.repoPath
|
||||
_, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// If main doesn't exist, try master
|
||||
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/master", "master")
|
||||
cmd.Dir = g.repoPath
|
||||
_, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// If that also fails, try to set upstream for current branch
|
||||
cmd = exec.Command("git", "branch", "--set-upstream-to=origin/HEAD")
|
||||
cmd.Dir = g.repoPath
|
||||
_, _ = cmd.CombinedOutput() // Ignore error as this is best effort
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
547
internal/git/gitmanager.go
Normal file
547
internal/git/gitmanager.go
Normal file
@@ -0,0 +1,547 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yarlson/lnk/internal/errors"
|
||||
"github.com/yarlson/lnk/internal/models"
|
||||
)
|
||||
|
||||
// GitManager implements the models.GitManager interface
|
||||
type GitManager struct{}
|
||||
|
||||
// New creates a new GitManager instance
|
||||
func New() *GitManager {
|
||||
return &GitManager{}
|
||||
}
|
||||
|
||||
// Init initializes a new Git repository at repoPath
|
||||
func (g *GitManager) Init(ctx context.Context, repoPath string) error {
|
||||
// Try using git init -b main first (Git 2.28+)
|
||||
cmd := exec.CommandContext(ctx, "git", "init", "-b", "main")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
_, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Fallback to regular init + branch rename for older Git versions
|
||||
cmd = exec.CommandContext(ctx, "git", "init")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("init", fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output)))
|
||||
}
|
||||
|
||||
// Set the default branch to main
|
||||
cmd = exec.CommandContext(ctx, "git", "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errors.NewGitOperationError("init", fmt.Errorf("failed to set default branch to main: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone clones a repository from url to repoPath
|
||||
func (g *GitManager) Clone(ctx context.Context, repoPath, url string) error {
|
||||
// Remove the directory if it exists to ensure clean clone
|
||||
if err := os.RemoveAll(repoPath); err != nil {
|
||||
return errors.NewFileSystemOperationError("remove_existing_dir", repoPath,
|
||||
fmt.Errorf("failed to remove existing directory: %w", err))
|
||||
}
|
||||
|
||||
// Create parent directory
|
||||
parentDir := filepath.Dir(repoPath)
|
||||
if err := os.MkdirAll(parentDir, 0755); err != nil {
|
||||
return errors.NewFileSystemOperationError("create_parent_dir", parentDir,
|
||||
fmt.Errorf("failed to create parent directory: %w", err))
|
||||
}
|
||||
|
||||
// Clone the repository
|
||||
cmd := exec.CommandContext(ctx, "git", "clone", url, repoPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("clone", fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output)))
|
||||
}
|
||||
|
||||
// Set up upstream tracking for main branch
|
||||
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/main", "main")
|
||||
cmd.Dir = repoPath
|
||||
_, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// If main doesn't exist, try master
|
||||
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/master", "master")
|
||||
cmd.Dir = repoPath
|
||||
_, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// If that also fails, try to set upstream for current branch
|
||||
cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/HEAD")
|
||||
cmd.Dir = repoPath
|
||||
_, _ = cmd.CombinedOutput() // Ignore error as this is best effort
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add stages files for commit
|
||||
func (g *GitManager) Add(ctx context.Context, repoPath string, files ...string) error {
|
||||
args := append([]string{"add"}, files...)
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("add", fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes files from Git tracking
|
||||
func (g *GitManager) Remove(ctx context.Context, repoPath string, files ...string) error {
|
||||
for _, filename := range files {
|
||||
// Check if it's a directory in the repository by checking the actual repo path
|
||||
fullPath := filepath.Join(repoPath, filename)
|
||||
info, err := os.Stat(fullPath)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
useRecursive := false
|
||||
if err == nil && info.IsDir() {
|
||||
useRecursive = true
|
||||
}
|
||||
|
||||
if useRecursive {
|
||||
// Use -r and --cached flags for directories (only remove from git, not fs)
|
||||
cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename)
|
||||
} else {
|
||||
// Regular file (only remove from git, not fs)
|
||||
cmd = exec.CommandContext(ctx, "git", "rm", "--cached", filename)
|
||||
}
|
||||
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// If we tried without -r and got a "recursively without -r" error, try with -r
|
||||
if !useRecursive && strings.Contains(string(output), "recursively without -r") {
|
||||
cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename)
|
||||
cmd.Dir = repoPath
|
||||
output, err = cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("remove", fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit creates a commit with the given message
|
||||
func (g *GitManager) Commit(ctx context.Context, repoPath, message string) error {
|
||||
// Configure git user if not already configured
|
||||
if err := g.ensureGitConfig(ctx, repoPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", "commit", "-m", message)
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("commit", fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Push pushes changes to the remote repository
|
||||
func (g *GitManager) Push(ctx context.Context, repoPath string) error {
|
||||
// First ensure we have a remote configured
|
||||
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("push", fmt.Errorf("cannot push: %w", err))
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", "push", "-u", "origin", "main")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("push", fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pull pulls changes from the remote repository
|
||||
func (g *GitManager) Pull(ctx context.Context, repoPath string) error {
|
||||
// First ensure we have a remote configured
|
||||
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("pull", fmt.Errorf("cannot pull: %w", err))
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "git", "pull", "origin", "main")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("pull", fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the current Git status
|
||||
func (g *GitManager) Status(ctx context.Context, repoPath string) (*models.SyncStatus, error) {
|
||||
// First check if we have a remote configured - this should match old behavior
|
||||
_, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
||||
if err != nil {
|
||||
// If origin doesn't exist, check if we have any remotes at all
|
||||
cmd := exec.CommandContext(ctx, "git", "remote")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, errors.NewGitOperationError("list_remotes", err)
|
||||
}
|
||||
|
||||
remotes := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(remotes) == 0 || remotes[0] == "" {
|
||||
return nil, errors.NewGitOperationError("status", fmt.Errorf("no remote configured"))
|
||||
}
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
currentBranch, err := g.getCurrentBranch(ctx, repoPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewGitOperationError("get_current_branch", err)
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
dirty, err := g.HasChanges(ctx, repoPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewGitOperationError("check_changes", err)
|
||||
}
|
||||
|
||||
// Get the remote URL
|
||||
remoteURL, err := g.GetRemoteURL(ctx, repoPath, "origin")
|
||||
hasRemote := err == nil
|
||||
|
||||
// Initialize status with basic information
|
||||
status := &models.SyncStatus{
|
||||
CurrentBranch: currentBranch,
|
||||
Dirty: dirty,
|
||||
HasRemote: hasRemote,
|
||||
RemoteURL: remoteURL,
|
||||
}
|
||||
|
||||
// If no remote, we can't determine ahead/behind counts
|
||||
if !hasRemote {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// Get the remote tracking branch
|
||||
remoteBranch, err := g.getRemoteTrackingBranch(ctx, repoPath)
|
||||
if err != nil {
|
||||
// No upstream branch set, assume origin/main
|
||||
remoteBranch = "origin/main"
|
||||
}
|
||||
status.RemoteBranch = remoteBranch
|
||||
|
||||
// Get ahead/behind counts
|
||||
status.Ahead = g.getAheadCount(ctx, repoPath, remoteBranch)
|
||||
status.Behind = g.getBehindCount(ctx, repoPath, remoteBranch)
|
||||
|
||||
// Get last commit hash
|
||||
lastCommitHash, err := g.getLastCommitHash(ctx, repoPath)
|
||||
if err == nil {
|
||||
status.LastCommitHash = lastCommitHash
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// IsRepository checks if the path is a Git repository
|
||||
func (g *GitManager) IsRepository(ctx context.Context, repoPath string) (bool, error) {
|
||||
gitDir := filepath.Join(repoPath, ".git")
|
||||
_, err := os.Stat(gitDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.NewFileSystemOperationError("check_git_dir", gitDir, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// HasChanges checks if there are uncommitted changes
|
||||
func (g *GitManager) HasChanges(ctx context.Context, repoPath string) (bool, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, errors.NewGitOperationError("status", fmt.Errorf("git status failed: %w", err))
|
||||
}
|
||||
|
||||
return len(strings.TrimSpace(string(output))) > 0, nil
|
||||
}
|
||||
|
||||
// AddRemote adds a remote to the repository
|
||||
func (g *GitManager) AddRemote(ctx context.Context, repoPath, name, url string) error {
|
||||
// Check if remote already exists
|
||||
existingURL, err := g.GetRemoteURL(ctx, repoPath, name)
|
||||
if err == nil {
|
||||
// Remote exists, check if URL matches
|
||||
if existingURL == url {
|
||||
// Same URL, idempotent - do nothing
|
||||
return nil
|
||||
}
|
||||
// Different URL, error
|
||||
return errors.NewGitOperationError("add_remote",
|
||||
fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url))
|
||||
}
|
||||
|
||||
// Remote doesn't exist, add it
|
||||
cmd := exec.CommandContext(ctx, "git", "remote", "add", name, url)
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("add_remote", fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRemoteURL returns the URL of a remote
|
||||
func (g *GitManager) GetRemoteURL(ctx context.Context, repoPath, name string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", name)
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", errors.NewGitOperationError("get_remote_url", fmt.Errorf("failed to get remote URL for %s: %w", name, err))
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
// IsLnkRepository checks if the repository appears to be managed by lnk
|
||||
func (g *GitManager) IsLnkRepository(ctx context.Context, repoPath string) (bool, error) {
|
||||
isRepo, err := g.IsRepository(ctx, repoPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !isRepo {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check if this looks like a lnk repository
|
||||
// We consider it a lnk repo if:
|
||||
// 1. It has no commits (fresh repo), OR
|
||||
// 2. All commits start with "lnk:" pattern
|
||||
|
||||
commits, err := g.getCommits(ctx, repoPath)
|
||||
if err != nil {
|
||||
return false, errors.NewGitOperationError("get_commits", err)
|
||||
}
|
||||
|
||||
// If no commits, it's a fresh repo - could be lnk
|
||||
if len(commits) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If all commits start with "lnk:", it's definitely ours
|
||||
// If ANY commit doesn't start with "lnk:", it's probably not ours
|
||||
for _, commit := range commits {
|
||||
if !strings.HasPrefix(commit, "lnk:") {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
// ensureGitConfig configures git user if not already configured
|
||||
func (g *GitManager) ensureGitConfig(ctx context.Context, repoPath string) error {
|
||||
// Check if user.name is configured
|
||||
cmd := exec.CommandContext(ctx, "git", "config", "user.name")
|
||||
cmd.Dir = repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Set default user.name
|
||||
cmd = exec.CommandContext(ctx, "git", "config", "user.name", "lnk")
|
||||
cmd.Dir = repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errors.NewGitOperationError("config_user_name", fmt.Errorf("failed to set git user.name: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user.email is configured
|
||||
cmd = exec.CommandContext(ctx, "git", "config", "user.email")
|
||||
cmd.Dir = repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Set default user.email
|
||||
cmd = exec.CommandContext(ctx, "git", "config", "user.email", "lnk@local")
|
||||
cmd.Dir = repoPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errors.NewGitOperationError("config_user_email", fmt.Errorf("failed to set git user.email: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentBranch returns the current branch name
|
||||
func (g *GitManager) getCurrentBranch(ctx context.Context, repoPath string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// For empty repositories, HEAD might not exist yet, default to main
|
||||
errStr := string(output)
|
||||
|
||||
if strings.Contains(errStr, "fatal: ambiguous argument 'HEAD'") ||
|
||||
strings.Contains(errStr, "unknown revision") ||
|
||||
strings.Contains(errStr, "not a valid ref") ||
|
||||
strings.Contains(errStr, "bad revision") {
|
||||
return "main", nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to get current branch: %w", err)
|
||||
}
|
||||
|
||||
branch := strings.TrimSpace(string(output))
|
||||
// If the branch is HEAD (detached state), try to get the default branch
|
||||
if branch == "HEAD" {
|
||||
return "main", nil
|
||||
}
|
||||
|
||||
return branch, nil
|
||||
}
|
||||
|
||||
// getRemoteTrackingBranch returns the remote tracking branch
|
||||
func (g *GitManager) getRemoteTrackingBranch(ctx context.Context, repoPath string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no upstream branch set: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
// getAheadCount returns how many commits ahead of remote
|
||||
func (g *GitManager) getAheadCount(ctx context.Context, repoPath, remoteBranch string) int {
|
||||
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch))
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// If remote branch doesn't exist, count all local commits
|
||||
cmd = exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
count := strings.TrimSpace(string(output))
|
||||
if count == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert to int
|
||||
var ahead int
|
||||
if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return ahead
|
||||
}
|
||||
|
||||
// getBehindCount returns how many commits behind remote
|
||||
func (g *GitManager) getBehindCount(ctx context.Context, repoPath, remoteBranch string) int {
|
||||
cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch))
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := strings.TrimSpace(string(output))
|
||||
if count == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert to int
|
||||
var behind int
|
||||
if _, err := fmt.Sscanf(count, "%d", &behind); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return behind
|
||||
}
|
||||
|
||||
// getLastCommitHash returns the hash of the last commit
|
||||
func (g *GitManager) getLastCommitHash(ctx context.Context, repoPath string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get last commit hash: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
// getCommits returns commit messages
|
||||
func (g *GitManager) getCommits(ctx context.Context, repoPath string) ([]string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "log", "--pretty=format:%s")
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// If there are no commits, git log will fail
|
||||
// Use CombinedOutput to get both stdout and stderr to check the error message
|
||||
cmd = exec.CommandContext(ctx, "git", "log", "--pretty=format:%s")
|
||||
cmd.Dir = repoPath
|
||||
combinedOutput, _ := cmd.CombinedOutput()
|
||||
errStr := string(combinedOutput)
|
||||
|
||||
if strings.Contains(errStr, "does not have any commits yet") ||
|
||||
strings.Contains(errStr, "bad default revision") ||
|
||||
strings.Contains(errStr, "unknown revision") ||
|
||||
strings.Contains(errStr, "ambiguous argument") {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get commits: %w", err)
|
||||
}
|
||||
|
||||
outputStr := strings.TrimSpace(string(output))
|
||||
if outputStr == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
commitMessages := strings.Split(outputStr, "\n")
|
||||
return commitMessages, nil
|
||||
}
|
307
internal/git/gitmanager_test.go
Normal file
307
internal/git/gitmanager_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type GitManagerTestSuite struct {
|
||||
suite.Suite
|
||||
tempDir string
|
||||
gitManager *GitManager
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (suite *GitManagerTestSuite) SetupTest() {
|
||||
// Create temp directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "lnk_git_test_*")
|
||||
suite.Require().NoError(err)
|
||||
suite.tempDir = tempDir
|
||||
|
||||
// Create git manager
|
||||
suite.gitManager = New()
|
||||
|
||||
// Create context
|
||||
suite.ctx = context.Background()
|
||||
}
|
||||
|
||||
func (suite *GitManagerTestSuite) TearDownTest() {
|
||||
err := os.RemoveAll(suite.tempDir)
|
||||
suite.Require().NoError(err)
|
||||
}
|
||||
|
||||
// Helper function to check if file exists
|
||||
func (suite *GitManagerTestSuite) fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (suite *GitManagerTestSuite) TestInit() {
|
||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||
|
||||
// Create the directory
|
||||
err := os.MkdirAll(repoPath, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test init
|
||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||
suite.NoError(err)
|
||||
|
||||
// Verify repository was created
|
||||
isRepo, err := suite.gitManager.IsRepository(suite.ctx, repoPath)
|
||||
suite.NoError(err)
|
||||
suite.True(isRepo)
|
||||
}
|
||||
|
||||
func (suite *GitManagerTestSuite) TestAddCommit() {
|
||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||
|
||||
// Create and initialize repository
|
||||
err := os.MkdirAll(repoPath, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create a test file
|
||||
testFile := filepath.Join(repoPath, "test.txt")
|
||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test adding file
|
||||
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
|
||||
suite.NoError(err)
|
||||
|
||||
// Test commit
|
||||
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit")
|
||||
suite.NoError(err)
|
||||
|
||||
// Verify no uncommitted changes
|
||||
hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath)
|
||||
suite.NoError(err)
|
||||
suite.False(hasChanges)
|
||||
}
|
||||
|
||||
func (suite *GitManagerTestSuite) TestStatus() {
|
||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||
|
||||
// Create and initialize repository
|
||||
err := os.MkdirAll(repoPath, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test status on empty repository should fail with no remote configured
|
||||
_, err = suite.gitManager.Status(suite.ctx, repoPath)
|
||||
suite.Error(err)
|
||||
suite.Contains(err.Error(), "no remote configured")
|
||||
|
||||
// Add a remote to make status work
|
||||
testURL := "https://github.com/test/repo.git"
|
||||
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test status with remote configured but no commits
|
||||
status, err := suite.gitManager.Status(suite.ctx, repoPath)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal("main", status.CurrentBranch)
|
||||
suite.False(status.Dirty)
|
||||
suite.True(status.HasRemote)
|
||||
|
||||
// Create and commit a file
|
||||
testFile := filepath.Join(repoPath, "test.txt")
|
||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test dirty status
|
||||
status, err = suite.gitManager.Status(suite.ctx, repoPath)
|
||||
suite.NoError(err)
|
||||
suite.True(status.Dirty)
|
||||
|
||||
// Add and commit
|
||||
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test clean status
|
||||
status, err = suite.gitManager.Status(suite.ctx, repoPath)
|
||||
suite.NoError(err)
|
||||
suite.False(status.Dirty)
|
||||
suite.NotEmpty(status.LastCommitHash)
|
||||
}
|
||||
|
||||
func (suite *GitManagerTestSuite) TestRemoteOperations() {
|
||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||
|
||||
// Create and initialize repository
|
||||
err := os.MkdirAll(repoPath, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test adding remote
|
||||
testURL := "https://github.com/test/repo.git"
|
||||
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
|
||||
suite.NoError(err)
|
||||
|
||||
// Test getting remote URL
|
||||
remoteURL, err := suite.gitManager.GetRemoteURL(suite.ctx, repoPath, "origin")
|
||||
suite.NoError(err)
|
||||
suite.Equal(testURL, remoteURL)
|
||||
|
||||
// Test idempotent add (same URL)
|
||||
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL)
|
||||
suite.NoError(err)
|
||||
|
||||
// Test adding remote with different URL should fail
|
||||
err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", "https://github.com/different/repo.git")
|
||||
suite.Error(err)
|
||||
}
|
||||
|
||||
func (suite *GitManagerTestSuite) TestIsLnkRepository() {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(string) error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "not_a_repository",
|
||||
setup: func(path string) error {
|
||||
return os.MkdirAll(path, 0755)
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty_git_repository",
|
||||
setup: func(path string) error {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return suite.gitManager.Init(suite.ctx, path)
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "repository_with_lnk_commits",
|
||||
setup: func(path string) error {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := suite.gitManager.Init(suite.ctx, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create and commit a file with lnk prefix
|
||||
testFile := filepath.Join(path, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil {
|
||||
return err
|
||||
}
|
||||
return suite.gitManager.Commit(suite.ctx, path, "lnk: add test file")
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "repository_with_non-lnk_commits",
|
||||
setup: func(path string) error {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := suite.gitManager.Init(suite.ctx, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create and commit a file without lnk prefix
|
||||
testFile := filepath.Join(path, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil {
|
||||
return err
|
||||
}
|
||||
return suite.gitManager.Commit(suite.ctx, path, "regular commit")
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
repoPath := filepath.Join(suite.tempDir, tt.name)
|
||||
err := tt.setup(repoPath)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
isLnk, err := suite.gitManager.IsLnkRepository(suite.ctx, repoPath)
|
||||
suite.NoError(err)
|
||||
suite.Equal(tt.expected, isLnk)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *GitManagerTestSuite) TestContextCancellation() {
|
||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||
|
||||
err := os.MkdirAll(repoPath, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test context cancellation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
// This should fail due to context timeout
|
||||
err = suite.gitManager.Init(ctx, repoPath)
|
||||
suite.Error(err)
|
||||
|
||||
// Verify the error is context-related
|
||||
suite.NotNil(ctx.Err())
|
||||
}
|
||||
|
||||
func (suite *GitManagerTestSuite) TestRemove() {
|
||||
repoPath := filepath.Join(suite.tempDir, "test-repo")
|
||||
|
||||
// Create and initialize repository
|
||||
err := os.MkdirAll(repoPath, 0755)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.gitManager.Init(suite.ctx, repoPath)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Create and add files
|
||||
testFile := filepath.Join(repoPath, "test.txt")
|
||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: add test file")
|
||||
suite.Require().NoError(err)
|
||||
|
||||
// Test removing file
|
||||
err = suite.gitManager.Remove(suite.ctx, repoPath, "test.txt")
|
||||
suite.NoError(err)
|
||||
|
||||
// Verify file is removed from git but still exists on fs
|
||||
suite.True(suite.fileExists(testFile))
|
||||
|
||||
// Verify repository has changes
|
||||
hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath)
|
||||
suite.NoError(err)
|
||||
suite.True(hasChanges)
|
||||
}
|
||||
|
||||
func TestGitManagerSuite(t *testing.T) {
|
||||
suite.Run(t, new(GitManagerTestSuite))
|
||||
}
|
108
internal/models/models.go
Normal file
108
internal/models/models.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ManagedFile represents a file or directory managed by lnk
|
||||
type ManagedFile struct {
|
||||
// ID for potential future database use
|
||||
ID string `json:"id,omitempty"`
|
||||
|
||||
// OriginalPath is the original absolute path where the file was located
|
||||
OriginalPath string `json:"original_path"`
|
||||
|
||||
// RepoPath is the path within the lnk repository
|
||||
RepoPath string `json:"repo_path"`
|
||||
|
||||
// RelativePath is the path relative to the home directory (or absolute for files outside home)
|
||||
RelativePath string `json:"relative_path"`
|
||||
|
||||
// Host is the hostname where this file is managed
|
||||
Host string `json:"host"`
|
||||
|
||||
// IsDirectory indicates whether this is a directory
|
||||
IsDirectory bool `json:"is_directory"`
|
||||
|
||||
// SymlinkTarget is the current symlink target (if the original location is now a symlink)
|
||||
SymlinkTarget string `json:"symlink_target,omitempty"`
|
||||
|
||||
// AddedAt is when the file was first added to lnk
|
||||
AddedAt time.Time `json:"added_at,omitempty"`
|
||||
|
||||
// UpdatedAt is when the file was last updated
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
|
||||
// Mode stores the file permissions
|
||||
Mode os.FileMode `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
// RepositoryConfig represents the lnk repository settings
|
||||
type RepositoryConfig struct {
|
||||
// Path is the absolute path to the lnk repository
|
||||
Path string `json:"path"`
|
||||
|
||||
// DefaultRemote is the default Git remote for sync operations
|
||||
DefaultRemote string `json:"default_remote,omitempty"`
|
||||
|
||||
// Created is when the repository was created
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
|
||||
// LastSync is when the repository was last synced
|
||||
LastSync time.Time `json:"last_sync,omitempty"`
|
||||
}
|
||||
|
||||
// HostConfig represents configuration specific to a host
|
||||
type HostConfig struct {
|
||||
// Name is the hostname
|
||||
Name string `json:"name"`
|
||||
|
||||
// ManagedFiles is the list of files managed on this host
|
||||
ManagedFiles []ManagedFile `json:"managed_files"`
|
||||
|
||||
// LastUpdate is when this host configuration was last updated
|
||||
LastUpdate time.Time `json:"last_update,omitempty"`
|
||||
}
|
||||
|
||||
// SyncStatus represents Git repository sync status
|
||||
type SyncStatus struct {
|
||||
// Ahead is the number of commits ahead of remote
|
||||
Ahead int `json:"ahead"`
|
||||
|
||||
// Behind is the number of commits behind remote
|
||||
Behind int `json:"behind"`
|
||||
|
||||
// CurrentBranch is the currently checked out branch
|
||||
CurrentBranch string `json:"current_branch"`
|
||||
|
||||
// RemoteBranch is the remote tracking branch
|
||||
RemoteBranch string `json:"remote_branch"`
|
||||
|
||||
// RemoteURL is the URL of the remote repository
|
||||
RemoteURL string `json:"remote_url"`
|
||||
|
||||
// Dirty indicates if there are uncommitted changes
|
||||
Dirty bool `json:"dirty"`
|
||||
|
||||
// LastCommitHash is the hash of the last commit
|
||||
LastCommitHash string `json:"last_commit_hash"`
|
||||
|
||||
// HasRemote indicates if a remote is configured
|
||||
HasRemote bool `json:"has_remote"`
|
||||
}
|
||||
|
||||
// IsClean returns true if the repository is clean (no uncommitted changes)
|
||||
func (s *SyncStatus) IsClean() bool {
|
||||
return !s.Dirty
|
||||
}
|
||||
|
||||
// IsSynced returns true if the repository is in sync with remote (ahead=0, behind=0)
|
||||
func (s *SyncStatus) IsSynced() bool {
|
||||
return s.Ahead == 0 && s.Behind == 0
|
||||
}
|
||||
|
||||
// NeedsSync returns true if the repository needs to be synced with remote
|
||||
func (s *SyncStatus) NeedsSync() bool {
|
||||
return s.Ahead > 0 || s.Behind > 0
|
||||
}
|
185
internal/models/models_test.go
Normal file
185
internal/models/models_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type ModelsTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (suite *ModelsTestSuite) TestManagedFile() {
|
||||
now := time.Now()
|
||||
|
||||
file := ManagedFile{
|
||||
ID: "test-id",
|
||||
OriginalPath: "/home/user/.vimrc",
|
||||
RepoPath: "/home/user/.config/lnk/.vimrc",
|
||||
RelativePath: ".vimrc",
|
||||
Host: "workstation",
|
||||
IsDirectory: false,
|
||||
AddedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
suite.Equal("test-id", file.ID)
|
||||
suite.Equal("/home/user/.vimrc", file.OriginalPath)
|
||||
suite.Equal("workstation", file.Host)
|
||||
}
|
||||
|
||||
func (suite *ModelsTestSuite) TestRepositoryConfig() {
|
||||
now := time.Now()
|
||||
|
||||
config := RepositoryConfig{
|
||||
Path: "/home/user/.config/lnk",
|
||||
DefaultRemote: "origin",
|
||||
Created: now,
|
||||
LastSync: now,
|
||||
}
|
||||
|
||||
suite.Equal("/home/user/.config/lnk", config.Path)
|
||||
suite.Equal("origin", config.DefaultRemote)
|
||||
}
|
||||
|
||||
func (suite *ModelsTestSuite) TestHostConfig() {
|
||||
now := time.Now()
|
||||
|
||||
managedFile := ManagedFile{
|
||||
RelativePath: ".vimrc",
|
||||
Host: "workstation",
|
||||
}
|
||||
|
||||
config := HostConfig{
|
||||
Name: "workstation",
|
||||
ManagedFiles: []ManagedFile{managedFile},
|
||||
LastUpdate: now,
|
||||
}
|
||||
|
||||
suite.Equal("workstation", config.Name)
|
||||
suite.Len(config.ManagedFiles, 1)
|
||||
suite.Equal(".vimrc", config.ManagedFiles[0].RelativePath)
|
||||
}
|
||||
|
||||
func (suite *ModelsTestSuite) TestSyncStatusIsClean() {
|
||||
tests := []struct {
|
||||
name string
|
||||
dirty bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "clean_repository",
|
||||
dirty: false,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "dirty_repository",
|
||||
dirty: true,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
status := SyncStatus{Dirty: tt.dirty}
|
||||
result := status.IsClean()
|
||||
suite.Equal(tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ModelsTestSuite) TestSyncStatusIsSynced() {
|
||||
tests := []struct {
|
||||
name string
|
||||
ahead int
|
||||
behind int
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "fully_synced",
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ahead_of_remote",
|
||||
ahead: 2,
|
||||
behind: 0,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "behind_remote",
|
||||
ahead: 0,
|
||||
behind: 3,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "diverged",
|
||||
ahead: 1,
|
||||
behind: 2,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
status := SyncStatus{
|
||||
Ahead: tt.ahead,
|
||||
Behind: tt.behind,
|
||||
}
|
||||
result := status.IsSynced()
|
||||
suite.Equal(tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ModelsTestSuite) TestSyncStatusNeedsSync() {
|
||||
tests := []struct {
|
||||
name string
|
||||
ahead int
|
||||
behind int
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "fully_synced",
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "ahead_of_remote",
|
||||
ahead: 2,
|
||||
behind: 0,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "behind_remote",
|
||||
ahead: 0,
|
||||
behind: 3,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "diverged",
|
||||
ahead: 1,
|
||||
behind: 2,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
status := SyncStatus{
|
||||
Ahead: tt.ahead,
|
||||
Behind: tt.behind,
|
||||
}
|
||||
result := status.NeedsSync()
|
||||
suite.Equal(tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsSuite(t *testing.T) {
|
||||
suite.Run(t, new(ModelsTestSuite))
|
||||
}
|
153
internal/pathresolver/resolver.go
Normal file
153
internal/pathresolver/resolver.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package pathresolver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Resolver implements the models.PathResolver interface
|
||||
type Resolver struct{}
|
||||
|
||||
// New creates a new PathResolver instance
|
||||
func New() *Resolver {
|
||||
return &Resolver{}
|
||||
}
|
||||
|
||||
// GetRepoStoragePath returns the base path where lnk repositories are stored
|
||||
// This is based on XDG Base Directory specification
|
||||
func (r *Resolver) GetRepoStoragePath() (string, error) {
|
||||
xdgConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdgConfig == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
xdgConfig = filepath.Join(homeDir, ".config")
|
||||
}
|
||||
return filepath.Join(xdgConfig, "lnk"), nil
|
||||
}
|
||||
|
||||
// GetFileStoragePathInRepo returns the path where a file should be stored in the repository
|
||||
func (r *Resolver) GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error) {
|
||||
hostPath, err := r.GetHostStoragePath(repoPath, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(hostPath, relativePath), nil
|
||||
}
|
||||
|
||||
// GetTrackingFilePath returns the path to the tracking file for a host
|
||||
func (r *Resolver) GetTrackingFilePath(repoPath, host string) (string, error) {
|
||||
var fileName string
|
||||
if host == "" {
|
||||
// Common configuration
|
||||
fileName = ".lnk"
|
||||
} else {
|
||||
// Host-specific configuration
|
||||
fileName = ".lnk." + host
|
||||
}
|
||||
return filepath.Join(repoPath, fileName), nil
|
||||
}
|
||||
|
||||
// GetHomePath returns the user's home directory path
|
||||
func (r *Resolver) GetHomePath() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
return homeDir, nil
|
||||
}
|
||||
|
||||
// GetRelativePathFromHome converts an absolute path to relative from home directory
|
||||
// This is migrated from the original getRelativePath function
|
||||
func (r *Resolver) GetRelativePathFromHome(absPath string) (string, error) {
|
||||
homeDir, err := r.GetHomePath()
|
||||
if err != nil {
|
||||
return "", 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
|
||||
}
|
||||
|
||||
// GetAbsolutePathInHome converts a relative path to absolute within home directory
|
||||
func (r *Resolver) GetAbsolutePathInHome(relPath string) (string, error) {
|
||||
homeDir, err := r.GetHomePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If the relative path looks like an absolute path (starts with / or drive letter),
|
||||
// it's probably a file outside home directory
|
||||
if filepath.IsAbs(relPath) {
|
||||
return relPath, nil
|
||||
}
|
||||
|
||||
// If it starts with a drive letter on Windows or looks like an absolute path,
|
||||
// treat it as absolute
|
||||
if len(relPath) > 0 && !strings.HasPrefix(relPath, ".") {
|
||||
// Check if it looks like an absolute path stored without leading slash
|
||||
// This handles paths like "etc/hosts" which should become "/etc/hosts"
|
||||
if strings.HasPrefix(relPath, "etc/") ||
|
||||
strings.HasPrefix(relPath, "usr/") ||
|
||||
strings.HasPrefix(relPath, "var/") ||
|
||||
strings.HasPrefix(relPath, "opt/") ||
|
||||
strings.HasPrefix(relPath, "tmp/") {
|
||||
// Reconstruct the absolute path
|
||||
return "/" + relPath, nil
|
||||
}
|
||||
// Windows drive patterns like "C:" or contains drive separator
|
||||
if strings.Contains(relPath, ":") {
|
||||
return relPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(homeDir, relPath), nil
|
||||
}
|
||||
|
||||
// GetHostStoragePath returns the directory where files for a host are stored
|
||||
// This is migrated from the original getHostStoragePath method
|
||||
func (r *Resolver) GetHostStoragePath(repoPath, host string) (string, error) {
|
||||
if host == "" {
|
||||
// Common configuration - store in root of repo
|
||||
return repoPath, nil
|
||||
}
|
||||
// Host-specific configuration - store in host subdirectory
|
||||
return filepath.Join(repoPath, host+".lnk"), nil
|
||||
}
|
||||
|
||||
// IsUnderHome checks if a path is under the home directory
|
||||
func (r *Resolver) IsUnderHome(path string) (bool, error) {
|
||||
homeDir, err := r.GetHomePath()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Clean both paths to handle relative components like .. and .
|
||||
cleanPath := filepath.Clean(path)
|
||||
cleanHome := filepath.Clean(homeDir)
|
||||
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(cleanHome, cleanPath)
|
||||
if err != nil {
|
||||
return false, nil // If we can't get relative path, assume not under home
|
||||
}
|
||||
|
||||
// If relative path starts with "..", it's outside home directory
|
||||
return !strings.HasPrefix(relPath, ".."), nil
|
||||
}
|
250
internal/pathresolver/resolver_test.go
Normal file
250
internal/pathresolver/resolver_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package pathresolver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type ResolverTestSuite struct {
|
||||
suite.Suite
|
||||
resolver *Resolver
|
||||
}
|
||||
|
||||
func (suite *ResolverTestSuite) SetupTest() {
|
||||
suite.resolver = New()
|
||||
}
|
||||
|
||||
func (suite *ResolverTestSuite) TestGetRepoStoragePath() {
|
||||
// Test with XDG_CONFIG_HOME set
|
||||
originalXDG := os.Getenv("XDG_CONFIG_HOME")
|
||||
defer os.Setenv("XDG_CONFIG_HOME", originalXDG)
|
||||
|
||||
suite.Run("with_XDG_CONFIG_HOME_set", func() {
|
||||
testXDG := "/test/config"
|
||||
os.Setenv("XDG_CONFIG_HOME", testXDG)
|
||||
|
||||
path, err := suite.resolver.GetRepoStoragePath()
|
||||
suite.NoError(err)
|
||||
|
||||
expected := filepath.Join(testXDG, "lnk")
|
||||
suite.Equal(expected, path)
|
||||
})
|
||||
|
||||
suite.Run("without_XDG_CONFIG_HOME", func() {
|
||||
os.Unsetenv("XDG_CONFIG_HOME")
|
||||
|
||||
path, err := suite.resolver.GetRepoStoragePath()
|
||||
suite.NoError(err)
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(homeDir, ".config", "lnk")
|
||||
suite.Equal(expected, path)
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *ResolverTestSuite) TestGetTrackingFilePath() {
|
||||
repoPath := "/test/repo"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "common_config",
|
||||
host: "",
|
||||
expected: filepath.Join(repoPath, ".lnk"),
|
||||
},
|
||||
{
|
||||
name: "host-specific_config",
|
||||
host: "myhost",
|
||||
expected: filepath.Join(repoPath, ".lnk.myhost"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
path, err := suite.resolver.GetTrackingFilePath(repoPath, tt.host)
|
||||
suite.NoError(err)
|
||||
suite.Equal(tt.expected, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ResolverTestSuite) TestGetHostStoragePath() {
|
||||
repoPath := "/test/repo"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "common_config",
|
||||
host: "",
|
||||
expected: repoPath,
|
||||
},
|
||||
{
|
||||
name: "host-specific_config",
|
||||
host: "myhost",
|
||||
expected: filepath.Join(repoPath, "myhost.lnk"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
path, err := suite.resolver.GetHostStoragePath(repoPath, tt.host)
|
||||
suite.NoError(err)
|
||||
suite.Equal(tt.expected, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ResolverTestSuite) TestGetRelativePathFromHome() {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
absPath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "file_in_home",
|
||||
absPath: filepath.Join(homeDir, "Documents", "test.txt"),
|
||||
expected: filepath.Join("Documents", "test.txt"),
|
||||
},
|
||||
{
|
||||
name: "file_outside_home",
|
||||
absPath: "/etc/hosts",
|
||||
expected: "etc/hosts",
|
||||
},
|
||||
{
|
||||
name: "home_directory_itself",
|
||||
absPath: homeDir,
|
||||
expected: ".",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
result, err := suite.resolver.GetRelativePathFromHome(tt.absPath)
|
||||
suite.NoError(err)
|
||||
suite.Equal(tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ResolverTestSuite) TestGetAbsolutePathInHome() {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
relPath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "relative_path_in_home",
|
||||
relPath: filepath.Join("Documents", "test.txt"),
|
||||
expected: filepath.Join(homeDir, "Documents", "test.txt"),
|
||||
},
|
||||
{
|
||||
name: "already_absolute_path",
|
||||
relPath: "/etc/hosts",
|
||||
expected: "/etc/hosts",
|
||||
},
|
||||
{
|
||||
name: "absolute-like_path_without_leading_slash",
|
||||
relPath: "etc/hosts",
|
||||
expected: "/etc/hosts",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
result, err := suite.resolver.GetAbsolutePathInHome(tt.relPath)
|
||||
suite.NoError(err)
|
||||
suite.Equal(tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ResolverTestSuite) TestIsUnderHome() {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
suite.Require().NoError(err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "file_in_home",
|
||||
path: filepath.Join(homeDir, "Documents", "test.txt"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "file_outside_home",
|
||||
path: "/etc/hosts",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "home_directory_itself",
|
||||
path: homeDir,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "parent_of_home",
|
||||
path: filepath.Dir(homeDir),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
result, err := suite.resolver.IsUnderHome(tt.path)
|
||||
suite.NoError(err)
|
||||
suite.Equal(tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ResolverTestSuite) TestGetFileStoragePathInRepo() {
|
||||
repoPath := "/test/repo"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
relativePath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "common_config_file",
|
||||
host: "",
|
||||
relativePath: "Documents/test.txt",
|
||||
expected: filepath.Join(repoPath, "Documents", "test.txt"),
|
||||
},
|
||||
{
|
||||
name: "host-specific_file",
|
||||
host: "myhost",
|
||||
relativePath: "Documents/test.txt",
|
||||
expected: filepath.Join(repoPath, "myhost.lnk", "Documents", "test.txt"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
suite.Run(tt.name, func() {
|
||||
result, err := suite.resolver.GetFileStoragePathInRepo(repoPath, tt.host, tt.relativePath)
|
||||
suite.NoError(err)
|
||||
suite.Equal(tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverSuite(t *testing.T) {
|
||||
suite.Run(t, new(ResolverTestSuite))
|
||||
}
|
823
internal/service/service.go
Normal file
823
internal/service/service.go
Normal file
@@ -0,0 +1,823 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yarlson/lnk/internal/config"
|
||||
"github.com/yarlson/lnk/internal/errors"
|
||||
"github.com/yarlson/lnk/internal/fs"
|
||||
"github.com/yarlson/lnk/internal/git"
|
||||
"github.com/yarlson/lnk/internal/models"
|
||||
"github.com/yarlson/lnk/internal/pathresolver"
|
||||
)
|
||||
|
||||
// FileManager handles file system operations
|
||||
type FileManager interface {
|
||||
Exists(ctx context.Context, path string) (bool, error)
|
||||
Move(ctx context.Context, src, dst string) error
|
||||
CreateSymlink(ctx context.Context, target, linkPath string) error
|
||||
Remove(ctx context.Context, path string) error
|
||||
MkdirAll(ctx context.Context, path string, perm os.FileMode) error
|
||||
Readlink(ctx context.Context, path string) (string, error)
|
||||
Lstat(ctx context.Context, path string) (os.FileInfo, error)
|
||||
Stat(ctx context.Context, path string) (os.FileInfo, error)
|
||||
}
|
||||
|
||||
// ConfigManager handles configuration persistence (reading and writing .lnk files)
|
||||
type ConfigManager interface {
|
||||
AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error
|
||||
RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error
|
||||
ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error)
|
||||
GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error)
|
||||
}
|
||||
|
||||
// GitManager handles Git operations
|
||||
type GitManager interface {
|
||||
Init(ctx context.Context, repoPath string) error
|
||||
Clone(ctx context.Context, repoPath, url string) error
|
||||
Add(ctx context.Context, repoPath string, files ...string) error
|
||||
Remove(ctx context.Context, repoPath string, files ...string) error
|
||||
Commit(ctx context.Context, repoPath, message string) error
|
||||
Push(ctx context.Context, repoPath string) error
|
||||
Pull(ctx context.Context, repoPath string) error
|
||||
Status(ctx context.Context, repoPath string) (*models.SyncStatus, error)
|
||||
IsRepository(ctx context.Context, repoPath string) (bool, error)
|
||||
HasChanges(ctx context.Context, repoPath string) (bool, error)
|
||||
IsLnkRepository(ctx context.Context, repoPath string) (bool, error)
|
||||
}
|
||||
|
||||
// PathResolver handles path resolution and manipulation
|
||||
type PathResolver interface {
|
||||
GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error)
|
||||
GetTrackingFilePath(repoPath, host string) (string, error)
|
||||
GetHomePath() (string, error)
|
||||
GetRelativePathFromHome(absPath string) (string, error)
|
||||
GetAbsolutePathInHome(relPath string) (string, error)
|
||||
}
|
||||
|
||||
// Service encapsulates the business logic for lnk operations
|
||||
type Service struct {
|
||||
fileManager FileManager
|
||||
gitManager GitManager // May be nil for some operations
|
||||
configManager ConfigManager
|
||||
pathResolver PathResolver
|
||||
repoPath string
|
||||
}
|
||||
|
||||
// New creates a new Service instance with default dependencies
|
||||
func New() (*Service, error) {
|
||||
// Initialize adapters
|
||||
fileManager := fs.New()
|
||||
gitManager := git.New()
|
||||
pathResolver := pathresolver.New()
|
||||
configManager := config.New(fileManager, pathResolver)
|
||||
|
||||
// Get repository path
|
||||
repoPath, err := pathResolver.GetRepoStoragePath()
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidPathError("", "failed to determine repository storage path").
|
||||
WithContext("error", err.Error())
|
||||
}
|
||||
|
||||
return &Service{
|
||||
fileManager: fileManager,
|
||||
gitManager: gitManager,
|
||||
configManager: configManager,
|
||||
pathResolver: pathResolver,
|
||||
repoPath: repoPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewLnkServiceWithDeps creates a new Service instance with provided dependencies (for testing)
|
||||
func NewLnkServiceWithDeps(
|
||||
fileManager FileManager,
|
||||
gitManager GitManager,
|
||||
configManager ConfigManager,
|
||||
pathResolver PathResolver,
|
||||
repoPath string,
|
||||
) *Service {
|
||||
return &Service{
|
||||
fileManager: fileManager,
|
||||
gitManager: gitManager,
|
||||
configManager: configManager,
|
||||
pathResolver: pathResolver,
|
||||
repoPath: repoPath,
|
||||
}
|
||||
}
|
||||
|
||||
// ListManagedFiles returns the list of files managed by lnk for a specific host
|
||||
// If host is empty, returns common configuration files
|
||||
func (s *Service) ListManagedFiles(ctx context.Context, host string) ([]models.ManagedFile, error) {
|
||||
// Check if the repository exists
|
||||
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||
}
|
||||
|
||||
// Use the config manager to list managed files
|
||||
managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host)
|
||||
if err != nil {
|
||||
return nil, err // ConfigManager already returns properly typed errors
|
||||
}
|
||||
|
||||
return managedFiles, nil
|
||||
}
|
||||
|
||||
// GetStatus returns the Git repository status
|
||||
// Returns an error if the repository is not initialized or GitManager is not available
|
||||
func (s *Service) GetStatus(ctx context.Context) (*models.SyncStatus, error) {
|
||||
// Check if GitManager is available
|
||||
if s.gitManager == nil {
|
||||
return nil, errors.NewGitOperationError("get_status",
|
||||
fmt.Errorf("git manager not available"))
|
||||
}
|
||||
|
||||
// Check if the repository exists
|
||||
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||
}
|
||||
|
||||
// Check if it's a Git repository
|
||||
isRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewGitOperationError("check_git_repo", err)
|
||||
}
|
||||
|
||||
if !isRepo {
|
||||
return nil, errors.NewRepoNotInitializedError(s.repoPath).
|
||||
WithContext("reason", "directory exists but is not a git repository")
|
||||
}
|
||||
|
||||
// Get Git status
|
||||
status, err := s.gitManager.Status(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return nil, err // GitManager already returns properly typed errors
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// GetRepoPath returns the repository path
|
||||
func (s *Service) GetRepoPath() string {
|
||||
return s.repoPath
|
||||
}
|
||||
|
||||
// IsRepositoryInitialized checks if the lnk repository has been initialized
|
||||
func (s *Service) IsRepositoryInitialized(ctx context.Context) (bool, error) {
|
||||
// Check if repository directory exists
|
||||
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return false, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check if it's a Git repository (if GitManager is available)
|
||||
if s.gitManager != nil {
|
||||
isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return false, errors.NewGitOperationError("check_git_repo", err)
|
||||
}
|
||||
return isGitRepo, nil
|
||||
}
|
||||
|
||||
// If no GitManager, just check if the directory exists
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// InitializeRepository initializes a new lnk repository, optionally cloning from a remote URL
|
||||
func (s *Service) InitializeRepository(ctx context.Context, remoteURL string) error {
|
||||
// Check if GitManager is available
|
||||
if s.gitManager == nil {
|
||||
return errors.NewGitOperationError("initialize_repository",
|
||||
fmt.Errorf("git manager not available"))
|
||||
}
|
||||
|
||||
if remoteURL != "" {
|
||||
// Clone from remote
|
||||
return s.cloneRepository(ctx, remoteURL)
|
||||
}
|
||||
|
||||
// Initialize empty repository
|
||||
return s.initEmptyRepository(ctx)
|
||||
}
|
||||
|
||||
// cloneRepository clones a repository from the given URL
|
||||
func (s *Service) cloneRepository(ctx context.Context, remoteURL string) error {
|
||||
// Clone using GitManager
|
||||
if err := s.gitManager.Clone(ctx, s.repoPath, remoteURL); err != nil {
|
||||
return errors.NewGitOperationError("clone_repository", err).
|
||||
WithContext("remote_url", remoteURL).
|
||||
WithContext("repo_path", s.repoPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initEmptyRepository initializes an empty Git repository
|
||||
func (s *Service) initEmptyRepository(ctx context.Context) error {
|
||||
// Check if repository directory already exists
|
||||
exists, err := s.fileManager.Exists(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
// Check if it's already a Git repository
|
||||
isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("check_git_repo", err)
|
||||
}
|
||||
|
||||
if isGitRepo {
|
||||
// Check if it's a lnk repository
|
||||
isLnkRepo, err := s.gitManager.IsLnkRepository(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("check_lnk_repo", err)
|
||||
}
|
||||
|
||||
if isLnkRepo {
|
||||
// It's already a lnk repository, init is idempotent
|
||||
return nil
|
||||
} else {
|
||||
// It's not a lnk repository, error to prevent data loss
|
||||
return errors.NewRepoNotInitializedError(s.repoPath).
|
||||
WithContext("reason", "directory contains an existing non-lnk Git repository")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the repository directory if it doesn't exist
|
||||
if !exists {
|
||||
if err := s.fileManager.MkdirAll(ctx, s.repoPath, 0755); err != nil {
|
||||
return errors.NewFileSystemOperationError("create_repo_dir", s.repoPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Git repository
|
||||
if err := s.gitManager.Init(ctx, s.repoPath); err != nil {
|
||||
// Clean up directory if we created it
|
||||
if !exists {
|
||||
_ = s.fileManager.Remove(ctx, s.repoPath) // Ignore cleanup errors
|
||||
}
|
||||
return errors.NewGitOperationError("init_git_repo", err).
|
||||
WithContext("repo_path", s.repoPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddFile adds a file or directory to lnk management for the specified host
|
||||
// This involves moving the file to the repository, creating a symlink, updating tracking, and committing to Git
|
||||
func (s *Service) AddFile(ctx context.Context, filePath, host string) (*models.ManagedFile, error) {
|
||||
// Check if GitManager is available
|
||||
if s.gitManager == nil {
|
||||
return nil, errors.NewGitOperationError("add_file",
|
||||
fmt.Errorf("git manager not available"))
|
||||
}
|
||||
|
||||
// Get absolute path
|
||||
absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath)
|
||||
if err != nil {
|
||||
// If it fails, try as-is (might be already absolute)
|
||||
var pathErr error
|
||||
absPath, pathErr = filepath.Abs(filePath)
|
||||
if pathErr != nil {
|
||||
return nil, errors.NewFileSystemOperationError("resolve_path", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that the file exists and is accessible (check this FIRST like the old implementation)
|
||||
exists, err := s.fileManager.Exists(ctx, absPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewFileSystemOperationError("check_file_exists", absPath, err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, errors.NewFileNotFoundError(absPath)
|
||||
}
|
||||
|
||||
// Check if repository is initialized (after file existence check)
|
||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !initialized {
|
||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||
}
|
||||
|
||||
// Get file information to determine if it's a directory
|
||||
fileInfo, err := s.fileManager.Stat(ctx, absPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewFileSystemOperationError("stat_file", absPath, err)
|
||||
}
|
||||
|
||||
// Get relative path for tracking
|
||||
relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewFileSystemOperationError("get_relative_path", absPath, err)
|
||||
}
|
||||
|
||||
// Check if file is already managed
|
||||
existingFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath)
|
||||
if err == nil && existingFile != nil {
|
||||
return nil, errors.NewFileAlreadyManagedError(relativePath)
|
||||
}
|
||||
|
||||
// Create managed file model
|
||||
managedFile := models.ManagedFile{
|
||||
OriginalPath: absPath,
|
||||
RelativePath: relativePath,
|
||||
Host: host,
|
||||
IsDirectory: fileInfo.IsDir(),
|
||||
}
|
||||
|
||||
// Get storage path in repository
|
||||
storagePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, relativePath)
|
||||
if err != nil {
|
||||
return nil, errors.NewFileSystemOperationError("get_storage_path", relativePath, err)
|
||||
}
|
||||
|
||||
managedFile.RepoPath = storagePath
|
||||
|
||||
// Execute the file addition with rollback support
|
||||
if err := s.executeFileAddition(ctx, &managedFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &managedFile, nil
|
||||
}
|
||||
|
||||
// executeFileAddition performs the actual file addition with rollback logic
|
||||
func (s *Service) executeFileAddition(ctx context.Context, file *models.ManagedFile) error {
|
||||
var rollbackActions []func() error
|
||||
|
||||
// Helper function to add rollback action
|
||||
addRollback := func(action func() error) {
|
||||
rollbackActions = append([]func() error{action}, rollbackActions...)
|
||||
}
|
||||
|
||||
// Execute rollback if any step fails
|
||||
defer func() {
|
||||
if len(rollbackActions) > 0 {
|
||||
for _, action := range rollbackActions {
|
||||
_ = action() // Ignore rollback errors
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Step 1: Create destination directory
|
||||
destDir := filepath.Dir(file.RepoPath)
|
||||
if err := s.fileManager.MkdirAll(ctx, destDir, 0755); err != nil {
|
||||
return errors.NewFileSystemOperationError("create_dest_dir", destDir, err)
|
||||
}
|
||||
|
||||
// Step 2: Move file to repository
|
||||
if err := s.fileManager.Move(ctx, file.OriginalPath, file.RepoPath); err != nil {
|
||||
return errors.NewFileSystemOperationError("move_file", file.OriginalPath, err)
|
||||
}
|
||||
|
||||
// Add rollback for move operation
|
||||
addRollback(func() error {
|
||||
return s.fileManager.Move(context.Background(), file.RepoPath, file.OriginalPath)
|
||||
})
|
||||
|
||||
// Step 3: Create symlink
|
||||
if err := s.fileManager.CreateSymlink(ctx, file.RepoPath, file.OriginalPath); err != nil {
|
||||
return errors.NewFileSystemOperationError("create_symlink", file.OriginalPath, err)
|
||||
}
|
||||
|
||||
// Add rollback for symlink creation
|
||||
addRollback(func() error {
|
||||
return s.fileManager.Remove(context.Background(), file.OriginalPath)
|
||||
})
|
||||
|
||||
// Step 4: Add to config tracking
|
||||
if err := s.configManager.AddManagedFileToHost(ctx, s.repoPath, file.Host, *file); err != nil {
|
||||
return err // ConfigManager returns properly typed errors
|
||||
}
|
||||
|
||||
// Add rollback for config update
|
||||
addRollback(func() error {
|
||||
return s.configManager.RemoveManagedFileFromHost(context.Background(),
|
||||
s.repoPath, file.Host, file.RelativePath)
|
||||
})
|
||||
|
||||
// Step 5: Add file to Git
|
||||
gitPath := file.RelativePath
|
||||
if file.Host != "" {
|
||||
gitPath = filepath.Join(file.Host+".lnk", file.RelativePath)
|
||||
}
|
||||
|
||||
if err := s.gitManager.Add(ctx, s.repoPath, gitPath); err != nil {
|
||||
return errors.NewGitOperationError("add_file_to_git", err)
|
||||
}
|
||||
|
||||
// Step 6: Add config file to Git
|
||||
trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, file.Host)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("get_tracking_file", "", err)
|
||||
}
|
||||
|
||||
// Get relative path of tracking file from repo root
|
||||
trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err)
|
||||
}
|
||||
|
||||
if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil {
|
||||
return errors.NewGitOperationError("add_tracking_file_to_git", err)
|
||||
}
|
||||
|
||||
// Step 7: Commit changes
|
||||
basename := filepath.Base(file.RelativePath)
|
||||
commitMessage := fmt.Sprintf("lnk: added %s", basename)
|
||||
if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil {
|
||||
return errors.NewGitOperationError("commit_changes", err)
|
||||
}
|
||||
|
||||
// If we reach here, everything succeeded - clear rollback actions
|
||||
rollbackActions = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFile removes a file or directory from lnk management for the specified host
|
||||
// This involves removing the symlink, restoring the original file, updating tracking, and committing to Git
|
||||
func (s *Service) RemoveFile(ctx context.Context, filePath, host string) error {
|
||||
// Check if GitManager is available
|
||||
if s.gitManager == nil {
|
||||
return errors.NewGitOperationError("remove_file",
|
||||
fmt.Errorf("git manager not available"))
|
||||
}
|
||||
|
||||
// Check if repository is initialized
|
||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !initialized {
|
||||
return errors.NewRepoNotInitializedError(s.repoPath)
|
||||
}
|
||||
|
||||
// Get absolute path
|
||||
absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath)
|
||||
if err != nil {
|
||||
// If it fails, try as-is (might be already absolute)
|
||||
var pathErr error
|
||||
absPath, pathErr = filepath.Abs(filePath)
|
||||
if pathErr != nil {
|
||||
return errors.NewFileSystemOperationError("resolve_path", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that this is a symlink
|
||||
linkInfo, err := s.fileManager.Lstat(ctx, absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errors.NewFileNotFoundError(absPath)
|
||||
}
|
||||
return errors.NewFileSystemOperationError("stat_symlink", absPath, err)
|
||||
}
|
||||
|
||||
if linkInfo.Mode()&os.ModeSymlink == 0 {
|
||||
return errors.NewNotSymlinkError(absPath)
|
||||
}
|
||||
|
||||
// Get symlink target
|
||||
target, err := s.fileManager.Readlink(ctx, absPath)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("read_symlink", absPath, err)
|
||||
}
|
||||
|
||||
// Convert relative symlink target to absolute path
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(absPath), target)
|
||||
}
|
||||
|
||||
// Validate that the target exists in our repository
|
||||
targetAbs, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("resolve_target", target, err)
|
||||
}
|
||||
|
||||
repoPathAbs, err := filepath.Abs(s.repoPath)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("resolve_repo_path", s.repoPath, err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(targetAbs, repoPathAbs) {
|
||||
return errors.NewInvalidPathError(targetAbs, "symlink target is not in lnk repository")
|
||||
}
|
||||
|
||||
// Get relative path for tracking
|
||||
relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("get_relative_path", absPath, err)
|
||||
}
|
||||
|
||||
// Check if this file is actually managed
|
||||
managedFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath)
|
||||
if err != nil || managedFile == nil {
|
||||
return errors.NewLnkError(errors.ErrorCodeFileNotFound, fmt.Sprintf("file is not managed by lnk: %s", relativePath))
|
||||
}
|
||||
|
||||
// Get target file info to determine if it's a directory
|
||||
targetInfo, err := s.fileManager.Stat(ctx, targetAbs)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("stat_target", targetAbs, err)
|
||||
}
|
||||
|
||||
// Execute the file removal with rollback support
|
||||
return s.executeFileRemoval(ctx, absPath, targetAbs, relativePath, host, targetInfo.IsDir())
|
||||
}
|
||||
|
||||
// executeFileRemoval performs the actual file removal with rollback logic
|
||||
func (s *Service) executeFileRemoval(ctx context.Context, symlinkPath, targetPath, relativePath, host string, isDirectory bool) error {
|
||||
var rollbackActions []func() error
|
||||
|
||||
// Helper function to add rollback action
|
||||
addRollback := func(action func() error) {
|
||||
rollbackActions = append([]func() error{action}, rollbackActions...)
|
||||
}
|
||||
|
||||
// Execute rollback if any step fails
|
||||
defer func() {
|
||||
if len(rollbackActions) > 0 {
|
||||
for _, action := range rollbackActions {
|
||||
_ = action() // Ignore rollback errors
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Step 1: Remove the symlink
|
||||
if err := s.fileManager.Remove(ctx, symlinkPath); err != nil {
|
||||
return errors.NewFileSystemOperationError("remove_symlink", symlinkPath, err)
|
||||
}
|
||||
|
||||
// Add rollback for symlink removal
|
||||
addRollback(func() error {
|
||||
return s.fileManager.CreateSymlink(context.Background(), targetPath, symlinkPath)
|
||||
})
|
||||
|
||||
// Step 2: Move file back from repository to original location
|
||||
if err := s.fileManager.Move(ctx, targetPath, symlinkPath); err != nil {
|
||||
return errors.NewFileSystemOperationError("restore_file", targetPath, err)
|
||||
}
|
||||
|
||||
// Add rollback for file restoration
|
||||
addRollback(func() error {
|
||||
return s.fileManager.Move(context.Background(), symlinkPath, targetPath)
|
||||
})
|
||||
|
||||
// Step 3: Remove from config tracking
|
||||
if err := s.configManager.RemoveManagedFileFromHost(ctx, s.repoPath, host, relativePath); err != nil {
|
||||
return err // ConfigManager returns properly typed errors
|
||||
}
|
||||
|
||||
// Add rollback for config update
|
||||
managedFile := models.ManagedFile{
|
||||
OriginalPath: symlinkPath,
|
||||
RelativePath: relativePath,
|
||||
RepoPath: targetPath,
|
||||
Host: host,
|
||||
IsDirectory: isDirectory,
|
||||
}
|
||||
addRollback(func() error {
|
||||
return s.configManager.AddManagedFileToHost(context.Background(), s.repoPath, host, managedFile)
|
||||
})
|
||||
|
||||
// Step 4: Remove file from Git
|
||||
gitPath := relativePath
|
||||
if host != "" {
|
||||
gitPath = filepath.Join(host+".lnk", relativePath)
|
||||
}
|
||||
|
||||
if err := s.gitManager.Remove(ctx, s.repoPath, gitPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 5: Add config file to Git (to commit the tracking change)
|
||||
trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, host)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("get_tracking_file", "", err)
|
||||
}
|
||||
|
||||
// Get relative path of tracking file from repo root
|
||||
trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile)
|
||||
if err != nil {
|
||||
return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err)
|
||||
}
|
||||
|
||||
if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil {
|
||||
return errors.NewGitOperationError("add_tracking_file_to_git", err)
|
||||
}
|
||||
|
||||
// Step 6: Commit changes
|
||||
basename := filepath.Base(relativePath)
|
||||
commitMessage := fmt.Sprintf("lnk: removed %s", basename)
|
||||
if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil {
|
||||
return errors.NewGitOperationError("commit_changes", err)
|
||||
}
|
||||
|
||||
// If we reach here, everything succeeded - clear rollback actions
|
||||
rollbackActions = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// PushChanges stages all changes and pushes to remote repository
|
||||
func (s *Service) PushChanges(ctx context.Context, message string) error {
|
||||
// Check if GitManager is available
|
||||
if s.gitManager == nil {
|
||||
return errors.NewGitOperationError("push_changes",
|
||||
fmt.Errorf("git manager not available"))
|
||||
}
|
||||
|
||||
// Check if repository is initialized
|
||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !initialized {
|
||||
return errors.NewRepoNotInitializedError(s.repoPath)
|
||||
}
|
||||
|
||||
// Check if there are any changes to commit
|
||||
hasChanges, err := s.gitManager.HasChanges(ctx, s.repoPath)
|
||||
if err != nil {
|
||||
return errors.NewGitOperationError("check_changes", err)
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
// Add all changes (equivalent to git add .)
|
||||
if err := s.gitManager.Add(ctx, s.repoPath, "."); err != nil {
|
||||
return errors.NewGitOperationError("stage_changes", err)
|
||||
}
|
||||
|
||||
// Create a sync commit
|
||||
if err := s.gitManager.Commit(ctx, s.repoPath, message); err != nil {
|
||||
return errors.NewGitOperationError("commit_changes", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Push to remote
|
||||
if err := s.gitManager.Push(ctx, s.repoPath); err != nil {
|
||||
return errors.NewGitOperationError("push_to_remote", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PullChanges pulls changes from remote and restores symlinks for the specified host
|
||||
func (s *Service) PullChanges(ctx context.Context, host string) ([]models.ManagedFile, error) {
|
||||
// Check if GitManager is available
|
||||
if s.gitManager == nil {
|
||||
return nil, errors.NewGitOperationError("pull_changes",
|
||||
fmt.Errorf("git manager not available"))
|
||||
}
|
||||
|
||||
// Check if repository is initialized
|
||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !initialized {
|
||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||
}
|
||||
|
||||
// Pull changes from remote
|
||||
if err := s.gitManager.Pull(ctx, s.repoPath); err != nil {
|
||||
return nil, errors.NewGitOperationError("pull_from_remote", err)
|
||||
}
|
||||
|
||||
// Restore symlinks for the specified host
|
||||
restored, err := s.RestoreSymlinksForHost(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// RestoreSymlinksForHost restores symlinks for all managed files for the specified host
|
||||
func (s *Service) RestoreSymlinksForHost(ctx context.Context, host string) ([]models.ManagedFile, error) {
|
||||
// Check if repository is initialized
|
||||
initialized, err := s.IsRepositoryInitialized(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !initialized {
|
||||
return nil, errors.NewRepoNotInitializedError(s.repoPath)
|
||||
}
|
||||
|
||||
// Get list of managed files for this host
|
||||
managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var restored []models.ManagedFile
|
||||
homeDir, err := s.pathResolver.GetHomePath()
|
||||
if err != nil {
|
||||
return nil, errors.NewFileSystemOperationError("get_home_dir", "", err)
|
||||
}
|
||||
|
||||
for _, managedFile := range managedFiles {
|
||||
// Determine symlink path (where the symlink should be created)
|
||||
symlinkPath := filepath.Join(homeDir, managedFile.RelativePath)
|
||||
|
||||
// Determine repository file path (what the symlink should point to)
|
||||
repoFilePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, managedFile.RelativePath)
|
||||
if err != nil {
|
||||
continue // Skip files with path resolution issues
|
||||
}
|
||||
|
||||
// Check if repository file exists
|
||||
repoExists, err := s.fileManager.Exists(ctx, repoFilePath)
|
||||
if err != nil || !repoExists {
|
||||
continue // Skip missing files
|
||||
}
|
||||
|
||||
// Check if symlink already exists and is correct
|
||||
if s.isValidSymlink(ctx, symlinkPath, repoFilePath) {
|
||||
continue // Skip files that are already correctly symlinked
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
symlinkDir := filepath.Dir(symlinkPath)
|
||||
if err := s.fileManager.MkdirAll(ctx, symlinkDir, 0755); err != nil {
|
||||
continue // Skip files with directory creation issues
|
||||
}
|
||||
|
||||
// Remove existing file/symlink if it exists
|
||||
exists, err := s.fileManager.Exists(ctx, symlinkPath)
|
||||
if err == nil && exists {
|
||||
if err := s.fileManager.Remove(ctx, symlinkPath); err != nil {
|
||||
continue // Skip files that can't be removed
|
||||
}
|
||||
}
|
||||
|
||||
// Create symlink
|
||||
if err := s.fileManager.CreateSymlink(ctx, repoFilePath, symlinkPath); err != nil {
|
||||
continue // Skip files with symlink creation issues
|
||||
}
|
||||
|
||||
// Update the managed file with current paths
|
||||
restoredFile := managedFile
|
||||
restoredFile.OriginalPath = symlinkPath
|
||||
restoredFile.RepoPath = repoFilePath
|
||||
restored = append(restored, restoredFile)
|
||||
}
|
||||
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// isValidSymlink checks if the given path is a symlink pointing to the expected target
|
||||
func (s *Service) isValidSymlink(ctx context.Context, symlinkPath, expectedTarget string) bool {
|
||||
info, err := s.fileManager.Lstat(ctx, symlinkPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a symlink
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it points to the correct target
|
||||
target, err := s.fileManager.Readlink(ctx, symlinkPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert relative path to absolute if needed
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(symlinkPath), target)
|
||||
}
|
||||
|
||||
// Clean both paths for comparison
|
||||
targetAbs, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expectedAbs, err := filepath.Abs(expectedTarget)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return targetAbs == expectedAbs
|
||||
}
|
1080
internal/service/service_test.go
Normal file
1080
internal/service/service_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user