feat(core): refactor to clean architecture and improve error handling

This commit is contained in:
Yar Kravtsov
2025-06-01 08:43:36 +03:00
parent 3e6b426a19
commit c718055f26
28 changed files with 5210 additions and 2176 deletions

View File

@@ -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

View File

@@ -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)

View File

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

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -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"
}

View File

@@ -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
View 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
}

View 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))
}

View File

@@ -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
}

View File

@@ -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
View 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)
}

View 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
View 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
}

View 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))
}

View File

@@ -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
}

View File

@@ -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
View 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
}

View 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
View 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
}

View 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))
}

View 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
}

View 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
View 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
}

File diff suppressed because it is too large Load Diff