├── main.go ├── go.mod ├── .golangci.yml ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ ├── validate.yml │ └── ci.yml ├── .gitignore ├── LICENSE ├── cmd ├── push.go ├── bootstrap.go ├── pull.go ├── rm.go ├── root.go ├── status.go ├── init.go ├── output.go ├── list.go ├── add.go ├── output_test.go └── root_test.go ├── internal ├── lnkerr │ └── error.go ├── fs │ └── filesystem.go ├── git │ └── git.go └── core │ └── lnk.go ├── go.sum ├── .goreleaser.yml ├── install.sh ├── Makefile └── README.md /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/yarlson/lnk/cmd" 4 | 5 | // These variables are set by GoReleaser via ldflags 6 | var ( 7 | version = "dev" 8 | buildTime = "unknown" 9 | ) 10 | 11 | func main() { 12 | cmd.SetVersion(version, buildTime) 13 | cmd.Execute() 14 | } 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yarlson/lnk 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/spf13/cobra v1.10.2 7 | github.com/stretchr/testify v1.11.1 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | github.com/spf13/pflag v1.0.10 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | enable: 5 | - errcheck 6 | - govet 7 | - ineffassign 8 | - staticcheck 9 | - unused 10 | - misspell 11 | settings: 12 | staticcheck: 13 | checks: 14 | - all 15 | - -ST1005 # Allow capitalized error strings and punctuation 16 | 17 | issues: 18 | max-issues-per-linter: 0 19 | max-same-issues: 0 20 | 21 | run: 22 | timeout: 5m 23 | tests: true 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/optimizing-pr-creation-version-updates#setting-up-a-cooldown-period-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: github-actions 6 | directory: / 7 | schedule: 8 | interval: weekly 9 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#groups-- 10 | groups: 11 | actions: 12 | # Combine all images of last week 13 | patterns: ["*"] 14 | - package-ecosystem: gomod 15 | directory: / 16 | schedule: 17 | interval: weekly 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | lnk 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool 13 | *.out 14 | *.html 15 | 16 | # Go workspace file 17 | go.work 18 | go.work.sum 19 | 20 | # Build artifacts 21 | dist/ 22 | bin/ 23 | 24 | # IDE and editor files 25 | .vscode/ 26 | .idea/ 27 | *.swp 28 | *.swo 29 | *~ 30 | 31 | # OS generated files 32 | .DS_Store 33 | .DS_Store? 34 | ._* 35 | .Spotlight-V100 36 | .Trashes 37 | ehthumbs.db 38 | Thumbs.db 39 | desktop.ini 40 | 41 | # Temporary files 42 | *.tmp 43 | *.log 44 | 45 | # GoReleaser artifacts 46 | goreleaser/ 47 | *.md 48 | !/README.md 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v6 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v6 22 | with: 23 | go-version: "1.24" 24 | 25 | - name: Run tests 26 | run: go test ./... 27 | 28 | - name: Run GoReleaser 29 | uses: goreleaser/goreleaser-action@v6 30 | with: 31 | distribution: goreleaser 32 | version: latest 33 | args: release --clean 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | permissions: 3 | contents: read 4 | 5 | on: 6 | pull_request: 7 | branches: [main] 8 | paths: 9 | - ".goreleaser.yml" 10 | - "main.go" 11 | - "cmd/**" 12 | - "internal/**" 13 | - "go.mod" 14 | - "go.sum" 15 | 16 | jobs: 17 | goreleaser: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v6 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v6 27 | with: 28 | go-version: "1.24" 29 | 30 | - name: Check GoReleaser config 31 | uses: goreleaser/goreleaser-action@v6 32 | with: 33 | distribution: goreleaser 34 | version: latest 35 | args: check 36 | 37 | - name: Test GoReleaser build 38 | uses: goreleaser/goreleaser-action@v6 39 | with: 40 | distribution: goreleaser 41 | version: latest 42 | args: build --snapshot --clean 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Yar Kravtsov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /cmd/push.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/yarlson/lnk/internal/core" 7 | ) 8 | 9 | func newPushCmd() *cobra.Command { 10 | return &cobra.Command{ 11 | Use: "push [message]", 12 | Short: "🚀 Push local changes to remote repository", 13 | Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.", 14 | Args: cobra.MaximumNArgs(1), 15 | SilenceUsage: true, 16 | SilenceErrors: true, 17 | RunE: func(cmd *cobra.Command, args []string) error { 18 | message := "lnk: sync configuration files" 19 | if len(args) > 0 { 20 | message = args[0] 21 | } 22 | 23 | lnk := core.NewLnk() 24 | w := GetWriter(cmd) 25 | 26 | if err := lnk.Push(message); err != nil { 27 | return err 28 | } 29 | 30 | w.Writeln(Rocket("Successfully pushed changes")). 31 | WriteString(" "). 32 | Write(Message{Text: "Commit: ", Emoji: "💾"}). 33 | Writeln(Colored(message, ColorGray)). 34 | WriteString(" "). 35 | Writeln(Message{Text: "Synced to remote", Emoji: "📡"}). 36 | WriteString(" "). 37 | Writeln(Sparkles("Your dotfiles are up to date!")) 38 | 39 | return w.Err() 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/lnkerr/error.go: -------------------------------------------------------------------------------- 1 | // Package lnkerr provides a single error wrapper type for the lnk application. 2 | package lnkerr 3 | 4 | // Error wraps a sentinel error with optional context for display. 5 | // This is the only custom error type in the codebase. 6 | type Error struct { 7 | Err error // Underlying sentinel error 8 | Path string // Optional path for display 9 | Suggestion string // Optional suggestion for user 10 | } 11 | 12 | func (e *Error) Error() string { 13 | msg := e.Err.Error() 14 | if e.Path != "" { 15 | msg += ": " + e.Path 16 | } 17 | if e.Suggestion != "" { 18 | msg += " (" + e.Suggestion + ")" 19 | } 20 | return msg 21 | } 22 | 23 | func (e *Error) Unwrap() error { 24 | return e.Err 25 | } 26 | 27 | // Wrap creates an Error with just the sentinel. 28 | func Wrap(err error) *Error { 29 | return &Error{Err: err} 30 | } 31 | 32 | // WithPath creates an Error with path context. 33 | func WithPath(err error, path string) *Error { 34 | return &Error{Err: err, Path: path} 35 | } 36 | 37 | // WithSuggestion creates an Error with a suggestion. 38 | func WithSuggestion(err error, suggestion string) *Error { 39 | return &Error{Err: err, Suggestion: suggestion} 40 | } 41 | 42 | // WithPathAndSuggestion creates an Error with both. 43 | func WithPathAndSuggestion(err error, path, suggestion string) *Error { 44 | return &Error{Err: err, Path: path, Suggestion: suggestion} 45 | } 46 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 5 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 9 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 10 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 11 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 12 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 13 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 14 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 15 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 16 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 20 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 21 | -------------------------------------------------------------------------------- /cmd/bootstrap.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/yarlson/lnk/internal/core" 9 | ) 10 | 11 | func newBootstrapCmd() *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "bootstrap", 14 | Short: "🚀 Run the bootstrap script to set up your environment", 15 | Long: "Executes the bootstrap script from your dotfiles repository to install dependencies and configure your system.", 16 | SilenceUsage: true, 17 | SilenceErrors: true, 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | lnk := core.NewLnk() 20 | w := GetWriter(cmd) 21 | 22 | scriptPath, err := lnk.FindBootstrapScript() 23 | if err != nil { 24 | return err 25 | } 26 | 27 | if scriptPath == "" { 28 | w.Writeln(Info("No bootstrap script found")). 29 | WriteString(" "). 30 | Write(Message{Text: "Create a ", Emoji: "📝"}). 31 | Write(Bold("bootstrap.sh")). 32 | WritelnString(" file in your dotfiles repository:"). 33 | WriteString(" "). 34 | Writeln(Colored("#!/bin/bash", ColorGray)). 35 | WriteString(" "). 36 | Writeln(Colored("echo \"Setting up environment...\"", ColorGray)). 37 | WriteString(" "). 38 | Writeln(Colored("# Your setup commands here", ColorGray)) 39 | return w.Err() 40 | } 41 | 42 | w.Writeln(Rocket("Running bootstrap script")). 43 | WriteString(" "). 44 | Write(Message{Text: "Script: ", Emoji: "📄"}). 45 | Writeln(Colored(scriptPath, ColorCyan)). 46 | WritelnString("") 47 | 48 | if err := w.Err(); err != nil { 49 | return err 50 | } 51 | 52 | if err := lnk.RunBootstrapScript(scriptPath, os.Stdout, os.Stderr, os.Stdin); err != nil { 53 | return err 54 | } 55 | 56 | w.WritelnString(""). 57 | Writeln(Success("Bootstrap completed successfully!")). 58 | WriteString(" "). 59 | Writeln(Message{Text: "Your environment is ready to use", Emoji: "🎉"}) 60 | 61 | return w.Err() 62 | }, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | 12 | env: 13 | GO_VERSION: "1.24" 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v6 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v6 24 | with: 25 | go-version: ${{ env.GO_VERSION }} 26 | 27 | - name: Cache Go modules 28 | uses: actions/cache@v4 29 | with: 30 | path: ~/go/pkg/mod 31 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 32 | restore-keys: | 33 | ${{ runner.os }}-go- 34 | 35 | - name: Download dependencies 36 | run: go mod download 37 | 38 | - name: Format check 39 | run: | 40 | gofmt -l . 41 | test -z "$(gofmt -l .)" 42 | 43 | - name: Vet 44 | run: go vet ./... 45 | 46 | - name: Test 47 | run: go test -v -race -coverprofile=coverage.out ./... 48 | 49 | - name: Upload coverage to Codecov 50 | uses: codecov/codecov-action@v5 51 | with: 52 | files: ./coverage.out 53 | 54 | lint: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v6 59 | 60 | - name: Set up Go 61 | uses: actions/setup-go@v6 62 | with: 63 | go-version: ${{ env.GO_VERSION }} 64 | 65 | - name: golangci-lint 66 | uses: golangci/golangci-lint-action@v9 67 | with: 68 | version: latest 69 | 70 | build: 71 | runs-on: ubuntu-latest 72 | needs: [test, lint] 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v6 76 | 77 | - name: Set up Go 78 | uses: actions/setup-go@v6 79 | with: 80 | go-version: ${{ env.GO_VERSION }} 81 | 82 | - name: Build 83 | run: go build -v ./... 84 | 85 | - name: Test GoReleaser 86 | uses: goreleaser/goreleaser-action@v6 87 | with: 88 | distribution: goreleaser 89 | version: latest 90 | args: build --snapshot --clean 91 | -------------------------------------------------------------------------------- /cmd/pull.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/yarlson/lnk/internal/core" 9 | ) 10 | 11 | func newPullCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "pull", 14 | Short: "⬇️ Pull changes from remote and restore symlinks", 15 | Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.", 16 | SilenceUsage: true, 17 | SilenceErrors: true, 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | host, _ := cmd.Flags().GetString("host") 20 | lnk := core.NewLnk(core.WithHost(host)) 21 | w := GetWriter(cmd) 22 | 23 | restored, err := lnk.Pull() 24 | if err != nil { 25 | return err 26 | } 27 | 28 | if len(restored) > 0 { 29 | var successMsg string 30 | if host != "" { 31 | successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host) 32 | } else { 33 | successMsg = "Successfully pulled changes" 34 | } 35 | 36 | symlinkText := fmt.Sprintf("Restored %d symlink", len(restored)) 37 | if len(restored) > 1 { 38 | symlinkText += "s" 39 | } 40 | symlinkText += ":" 41 | 42 | w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}). 43 | WriteString(" "). 44 | Writeln(Link(symlinkText)) 45 | 46 | for _, file := range restored { 47 | w.WriteString(" "). 48 | Writeln(Sparkles(file)) 49 | } 50 | 51 | w.WritelnString(""). 52 | WriteString(" "). 53 | Writeln(Message{Text: "Your dotfiles are synced and ready!", Emoji: "🎉"}) 54 | } else { 55 | var successMsg string 56 | if host != "" { 57 | successMsg = fmt.Sprintf("Successfully pulled changes (host: %s)", host) 58 | } else { 59 | successMsg = "Successfully pulled changes" 60 | } 61 | 62 | w.Writeln(Message{Text: successMsg, Emoji: "⬇️", Color: ColorBrightGreen, Bold: true}). 63 | WriteString(" "). 64 | Writeln(Success("All symlinks already in place")). 65 | WriteString(" "). 66 | Writeln(Message{Text: "Everything is up to date!", Emoji: "🎉"}) 67 | } 68 | 69 | return w.Err() 70 | }, 71 | } 72 | 73 | cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)") 74 | return cmd 75 | } 76 | -------------------------------------------------------------------------------- /cmd/rm.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/yarlson/lnk/internal/core" 10 | ) 11 | 12 | func newRemoveCmd() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "rm ", 15 | Short: "🗑️ Remove a file from lnk management", 16 | Long: `Removes a symlink and restores the original file from the lnk repository. 17 | 18 | Use --force to remove a file from tracking even if the symlink no longer exists 19 | (e.g., if you accidentally deleted the symlink without using lnk rm).`, 20 | Args: cobra.ExactArgs(1), 21 | SilenceUsage: true, 22 | SilenceErrors: true, 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | filePath := args[0] 25 | host, _ := cmd.Flags().GetString("host") 26 | force, _ := cmd.Flags().GetBool("force") 27 | lnk := core.NewLnk(core.WithHost(host)) 28 | w := GetWriter(cmd) 29 | 30 | if force { 31 | if err := lnk.RemoveForce(filePath); err != nil { 32 | return err 33 | } 34 | 35 | basename := filepath.Base(filePath) 36 | if host != "" { 37 | w.Writeln(Message{Text: fmt.Sprintf("Force removed %s from lnk (host: %s)", basename, host), Emoji: "🗑️", Bold: true}) 38 | } else { 39 | w.Writeln(Message{Text: fmt.Sprintf("Force removed %s from lnk", basename), Emoji: "🗑️", Bold: true}) 40 | } 41 | w.WriteString(" "). 42 | Writeln(Message{Text: "File removed from tracking", Emoji: "📋"}) 43 | 44 | return w.Err() 45 | } 46 | 47 | if err := lnk.Remove(filePath); err != nil { 48 | return err 49 | } 50 | 51 | basename := filepath.Base(filePath) 52 | if host != "" { 53 | w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk (host: %s)", basename, host), Emoji: "🗑️", Bold: true}). 54 | WriteString(" "). 55 | Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, basename), Emoji: "↩️"}). 56 | WriteString(" → "). 57 | Writeln(Colored(filePath, ColorCyan)) 58 | } else { 59 | w.Writeln(Message{Text: fmt.Sprintf("Removed %s from lnk", basename), Emoji: "🗑️", Bold: true}). 60 | WriteString(" "). 61 | Write(Message{Text: fmt.Sprintf("~/.config/lnk/%s", basename), Emoji: "↩️"}). 62 | WriteString(" → "). 63 | Writeln(Colored(filePath, ColorCyan)) 64 | } 65 | 66 | w.WriteString(" "). 67 | Writeln(Message{Text: "Original file restored", Emoji: "📄"}) 68 | 69 | return w.Err() 70 | }, 71 | } 72 | 73 | cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)") 74 | cmd.Flags().BoolP("force", "f", false, "Force removal from tracking even if symlink is missing") 75 | return cmd 76 | } 77 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # GoReleaser configuration for lnk 2 | version: 2 3 | 4 | project_name: lnk 5 | 6 | before: 7 | hooks: 8 | # You may remove this if you don't use go modules. 9 | - go mod tidy 10 | # you may remove this if you don't need go generate 11 | - go generate ./... 12 | 13 | builds: 14 | - env: 15 | - CGO_ENABLED=0 16 | goos: 17 | - linux 18 | - windows 19 | - darwin 20 | goarch: 21 | - amd64 22 | - arm64 23 | # Optional: exclude specific combinations 24 | ignore: 25 | - goos: windows 26 | goarch: arm64 27 | ldflags: 28 | - -s -w 29 | - -X main.version={{.Version}} 30 | - -X main.buildTime={{.Date}} 31 | main: ./main.go 32 | binary: lnk 33 | 34 | archives: 35 | - id: default 36 | # this name template makes the OS and Arch compatible with the results of uname. 37 | name_template: >- 38 | {{ .ProjectName }}_ 39 | {{- title .Os }}_ 40 | {{- if eq .Arch "amd64" }}x86_64 41 | {{- else if eq .Arch "386" }}i386 42 | {{- else }}{{ .Arch }}{{ end }} 43 | {{- if .Arm }}v{{ .Arm }}{{ end }} 44 | files: 45 | - README.md 46 | - LICENSE 47 | builds_info: 48 | group: root 49 | owner: root 50 | 51 | checksum: 52 | name_template: "checksums.txt" 53 | 54 | snapshot: 55 | version_template: "{{ incpatch .Version }}-next" 56 | 57 | changelog: 58 | sort: asc 59 | use: github 60 | filters: 61 | exclude: 62 | - "^docs:" 63 | - "^test:" 64 | - "^ci:" 65 | - "^chore:" 66 | - "^style:" 67 | - "^refactor:" 68 | groups: 69 | - title: Features 70 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 71 | order: 0 72 | - title: "Bug fixes" 73 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' 74 | order: 1 75 | - title: Others 76 | order: 999 77 | 78 | # GitHub release configuration 79 | release: 80 | github: 81 | owner: yarlson 82 | name: lnk 83 | draft: false 84 | prerelease: auto 85 | mode: replace 86 | header: | 87 | ## Lnk {{.Tag}} 88 | 89 | Git-native dotfiles management that doesn't suck. 90 | 91 | ### Installation 92 | 93 | ```bash 94 | # Quick install 95 | curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash 96 | 97 | # Homebrew 98 | brew tap yarlson/lnk 99 | brew install lnk 100 | 101 | # Manual download 102 | # Download the appropriate archive for your platform from the assets below 103 | # Extract and move to your PATH: 104 | # tar -xzf lnk_*.tar.gz && sudo mv lnk /usr/local/bin/ 105 | ``` 106 | 107 | footer: | 108 | --- 109 | **Full Changelog**: https://github.com/yarlson/lnk/compare/{{.PreviousTag}}...{{.Tag}} 110 | 111 | # Homebrew tap 112 | brews: 113 | - repository: 114 | owner: yarlson 115 | name: homebrew-lnk 116 | branch: main 117 | token: "{{ .Env.GITHUB_TOKEN }}" 118 | homepage: "https://github.com/yarlson/lnk" 119 | description: "Git-native dotfiles management that doesn't suck" 120 | license: "MIT" 121 | skip_upload: auto 122 | commit_author: 123 | name: goreleaserbot 124 | email: bot@goreleaser.com 125 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 126 | test: | 127 | system "#{bin}/lnk --version" 128 | install: | 129 | bin.install "lnk" 130 | 131 | # Docker images (optional) 132 | # dockers: 133 | # - image_templates: 134 | # - "yarlson/lnk:latest" 135 | # - "yarlson/lnk:{{ .Tag }}" 136 | # - "yarlson/lnk:v{{ .Major }}" 137 | # dockerfile: Dockerfile 138 | # build_flag_templates: 139 | # - "--label=org.opencontainers.image.created={{.Date}}" 140 | # - "--label=org.opencontainers.image.title={{.ProjectName}}" 141 | # - "--label=org.opencontainers.image.revision={{.FullCommit}}" 142 | # - "--label=org.opencontainers.image.version={{.Version}}" 143 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Package cmd implements the CLI commands for lnk. 2 | package cmd 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/yarlson/lnk/internal/lnkerr" 12 | ) 13 | 14 | var ( 15 | version = "dev" 16 | buildTime = "unknown" 17 | ) 18 | 19 | // NewRootCommand creates a new root command (testable) 20 | func NewRootCommand() *cobra.Command { 21 | var ( 22 | colors string 23 | emoji bool 24 | noEmoji bool 25 | ) 26 | 27 | rootCmd := &cobra.Command{ 28 | Use: "lnk", 29 | Short: "🔗 Dotfiles, linked. No fluff.", 30 | Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck. 31 | 32 | Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal. 33 | Supports both common configurations, host-specific setups, and bulk operations for multiple files. 34 | 35 | ✨ Examples: 36 | lnk init # Fresh start 37 | lnk init -r # Clone existing dotfiles (runs bootstrap automatically) 38 | lnk add ~/.vimrc ~/.bashrc # Start managing common files 39 | lnk add --recursive ~/.config/nvim # Add directory contents individually 40 | lnk add --dry-run ~/.gitconfig # Preview changes without applying 41 | lnk add --host work ~/.ssh/config # Manage host-specific files 42 | lnk list --all # Show all configurations 43 | lnk pull --host work # Pull host-specific changes 44 | lnk push "setup complete" # Sync to remote 45 | lnk bootstrap # Run bootstrap script manually 46 | 47 | 🚀 Bootstrap Support: 48 | Automatically runs bootstrap.sh when cloning a repository. 49 | Use --no-bootstrap to disable. 50 | 51 | 🎯 Simple, fast, Git-native, and multi-host ready.`, 52 | SilenceUsage: true, 53 | SilenceErrors: true, 54 | Version: fmt.Sprintf("%s (built %s)", version, buildTime), 55 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 56 | // Handle emoji flag logic 57 | emojiEnabled := emoji 58 | if noEmoji { 59 | emojiEnabled = false 60 | } 61 | err := SetGlobalConfig(colors, emojiEnabled) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | return nil 67 | }, 68 | } 69 | 70 | // Add global flags for output formatting 71 | rootCmd.PersistentFlags().StringVar(&colors, "colors", "auto", "when to use colors (auto, always, never)") 72 | rootCmd.PersistentFlags().BoolVar(&emoji, "emoji", true, "enable emoji in output") 73 | rootCmd.PersistentFlags().BoolVar(&noEmoji, "no-emoji", false, "disable emoji in output") 74 | 75 | // Mark emoji flags as mutually exclusive 76 | rootCmd.MarkFlagsMutuallyExclusive("emoji", "no-emoji") 77 | 78 | // Add subcommands 79 | rootCmd.AddCommand(newInitCmd()) 80 | rootCmd.AddCommand(newAddCmd()) 81 | rootCmd.AddCommand(newRemoveCmd()) 82 | rootCmd.AddCommand(newListCmd()) 83 | rootCmd.AddCommand(newStatusCmd()) 84 | rootCmd.AddCommand(newPushCmd()) 85 | rootCmd.AddCommand(newPullCmd()) 86 | rootCmd.AddCommand(newBootstrapCmd()) 87 | 88 | return rootCmd 89 | } 90 | 91 | // SetVersion sets the version information for the CLI 92 | func SetVersion(v, bt string) { 93 | version = v 94 | buildTime = bt 95 | } 96 | 97 | func Execute() { 98 | rootCmd := NewRootCommand() 99 | if err := rootCmd.Execute(); err != nil { 100 | DisplayError(err) 101 | os.Exit(1) 102 | } 103 | } 104 | 105 | // DisplayError formats and displays an error with appropriate styling 106 | func DisplayError(err error) { 107 | w := GetErrorWriter() 108 | 109 | var lnkErr *lnkerr.Error 110 | if errors.As(err, &lnkErr) { 111 | w.Write(Error(lnkErr.Err.Error())) 112 | if lnkErr.Path != "" { 113 | w.WritelnString(""). 114 | WriteString(" "). 115 | Write(Colored(lnkErr.Path, ColorRed)) 116 | } 117 | if lnkErr.Suggestion != "" { 118 | w.WritelnString(""). 119 | WriteString(" "). 120 | Write(Info(lnkErr.Suggestion)) 121 | } 122 | w.WritelnString("") 123 | return 124 | } 125 | 126 | w.Writeln(Error(err.Error())) 127 | } 128 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/yarlson/lnk/internal/core" 9 | ) 10 | 11 | func newStatusCmd() *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "status", 14 | Short: "📊 Show repository sync status", 15 | Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.", 16 | SilenceUsage: true, 17 | SilenceErrors: true, 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | lnk := core.NewLnk() 20 | status, err := lnk.Status() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | if status.Dirty { 26 | displayDirtyStatus(cmd, status) 27 | return nil 28 | } 29 | 30 | if status.Ahead == 0 && status.Behind == 0 { 31 | displayUpToDateStatus(cmd, status) 32 | return nil 33 | } 34 | 35 | displaySyncStatus(cmd, status) 36 | return nil 37 | }, 38 | } 39 | } 40 | 41 | func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) { 42 | w := GetWriter(cmd) 43 | 44 | w.Writeln(Warning("Repository has uncommitted changes")). 45 | WriteString(" "). 46 | Write(Message{Text: "Remote: ", Emoji: "📡"}). 47 | Writeln(Colored(status.Remote, ColorCyan)) 48 | 49 | if status.Ahead == 0 && status.Behind == 0 { 50 | w.WritelnString(""). 51 | Write(Info("Run ")). 52 | Write(Bold("git add && git commit")). 53 | WriteString(" in "). 54 | Write(Colored("~/.config/lnk", ColorCyan)). 55 | WriteString(" or "). 56 | Write(Bold("lnk push")). 57 | WritelnString(" to commit changes") 58 | return 59 | } 60 | 61 | w.WritelnString("") 62 | displayAheadBehindInfo(cmd, status, true) 63 | w.WritelnString(""). 64 | Write(Info("Run ")). 65 | Write(Bold("git add && git commit")). 66 | WriteString(" in "). 67 | Write(Colored("~/.config/lnk", ColorCyan)). 68 | WriteString(" or "). 69 | Write(Bold("lnk push")). 70 | WritelnString(" to commit changes") 71 | } 72 | 73 | func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) { 74 | w := GetWriter(cmd) 75 | 76 | w.Writeln(Success("Repository is up to date")). 77 | WriteString(" "). 78 | Write(Message{Text: "Synced with ", Emoji: "📡"}). 79 | Writeln(Colored(status.Remote, ColorCyan)) 80 | } 81 | 82 | func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) { 83 | w := GetWriter(cmd) 84 | 85 | w.Writeln(Message{Text: "Repository Status", Emoji: "📊", Bold: true}). 86 | WriteString(" "). 87 | Write(Message{Text: "Remote: ", Emoji: "📡"}). 88 | Writeln(Colored(status.Remote, ColorCyan)). 89 | WritelnString("") 90 | 91 | displayAheadBehindInfo(cmd, status, false) 92 | 93 | if status.Ahead > 0 && status.Behind == 0 { 94 | w.WritelnString(""). 95 | Write(Info("Run ")). 96 | Write(Bold("lnk push")). 97 | WritelnString(" to sync your changes") 98 | } else if status.Behind > 0 { 99 | w.WritelnString(""). 100 | Write(Info("Run ")). 101 | Write(Bold("lnk pull")). 102 | WritelnString(" to get latest changes") 103 | } 104 | } 105 | 106 | func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) { 107 | w := GetWriter(cmd) 108 | 109 | if status.Ahead > 0 { 110 | commitText := getCommitText(status.Ahead) 111 | if isDirty { 112 | w.WriteString(" "). 113 | Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}). 114 | WritelnString(" (excluding uncommitted changes)") 115 | } else { 116 | w.WriteString(" "). 117 | Write(Message{Text: fmt.Sprintf("%d %s ahead", status.Ahead, commitText), Emoji: "⬆️", Color: ColorBrightYellow, Bold: true}). 118 | WritelnString(" - ready to push") 119 | } 120 | } 121 | 122 | if status.Behind > 0 { 123 | commitText := getCommitText(status.Behind) 124 | w.WriteString(" "). 125 | Write(Message{Text: fmt.Sprintf("%d %s behind", status.Behind, commitText), Emoji: "⬇️", Color: ColorBrightRed, Bold: true}). 126 | WriteString(" - run "). 127 | Write(Bold("lnk pull")). 128 | WritelnString("") 129 | } 130 | } 131 | 132 | func getCommitText(count int) string { 133 | if count == 1 { 134 | return "commit" 135 | } 136 | return "commits" 137 | } 138 | -------------------------------------------------------------------------------- /internal/fs/filesystem.go: -------------------------------------------------------------------------------- 1 | // Package fs provides file system operations for lnk. 2 | package fs 3 | 4 | import ( 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/yarlson/lnk/internal/lnkerr" 11 | ) 12 | 13 | // Sentinel errors for file system operations. 14 | var ( 15 | ErrFileNotExists = errors.New("File or directory not found") 16 | ErrFileCheck = errors.New("Unable to access file. Please check file permissions and try again.") 17 | ErrUnsupportedType = errors.New("Cannot manage this type of file") 18 | ErrNotManaged = errors.New("File is not managed by lnk") 19 | ErrSymlinkRead = errors.New("Unable to read symlink. The file may be corrupted or have invalid permissions.") 20 | ErrDirCreate = errors.New("Failed to create directory. Please check permissions and available disk space.") 21 | ErrRelativePath = errors.New("Unable to create symlink due to path configuration issues. Please check file locations.") 22 | ) 23 | 24 | // FileSystem handles file system operations 25 | type FileSystem struct{} 26 | 27 | // New creates a new FileSystem instance 28 | func New() *FileSystem { 29 | return &FileSystem{} 30 | } 31 | 32 | // ValidateFileForAdd validates that a file or directory can be added to lnk 33 | func (fs *FileSystem) ValidateFileForAdd(filePath string) error { 34 | // Check if file exists and get its info 35 | info, err := os.Stat(filePath) 36 | if err != nil { 37 | if os.IsNotExist(err) { 38 | return lnkerr.WithPath(ErrFileNotExists, filePath) 39 | } 40 | 41 | return lnkerr.WithPath(ErrFileCheck, filePath) 42 | } 43 | 44 | // Allow both regular files and directories 45 | if !info.Mode().IsRegular() && !info.IsDir() { 46 | return lnkerr.WithPathAndSuggestion(ErrUnsupportedType, filePath, "lnk can only manage regular files and directories") 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // ValidateSymlinkForRemove validates that a symlink can be removed from lnk 53 | func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error { 54 | // Check if file exists and is a symlink 55 | info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks 56 | if err != nil { 57 | if os.IsNotExist(err) { 58 | return lnkerr.WithPath(ErrFileNotExists, filePath) 59 | } 60 | 61 | return lnkerr.WithPath(ErrFileCheck, filePath) 62 | } 63 | 64 | if info.Mode()&os.ModeSymlink == 0 { 65 | return lnkerr.WithPathAndSuggestion(ErrNotManaged, filePath, "use 'lnk add' to manage this file first") 66 | } 67 | 68 | // Get symlink target and resolve to absolute path 69 | target, err := os.Readlink(filePath) 70 | if err != nil { 71 | return lnkerr.WithPath(ErrSymlinkRead, filePath) 72 | } 73 | 74 | if !filepath.IsAbs(target) { 75 | target = filepath.Join(filepath.Dir(filePath), target) 76 | } 77 | 78 | // Clean paths and check if target is inside the repository 79 | target = filepath.Clean(target) 80 | repoPath = filepath.Clean(repoPath) 81 | 82 | if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath { 83 | return lnkerr.WithPathAndSuggestion(ErrNotManaged, filePath, "use 'lnk add' to manage this file first") 84 | } 85 | 86 | return nil 87 | } 88 | 89 | // Move moves a file or directory from source to destination based on the file info 90 | func (fs *FileSystem) Move(src, dst string, info os.FileInfo) error { 91 | if info.IsDir() { 92 | return fs.MoveDirectory(src, dst) 93 | } 94 | return fs.MoveFile(src, dst) 95 | } 96 | 97 | // MoveFile moves a file from source to destination 98 | func (fs *FileSystem) MoveFile(src, dst string) error { 99 | // Ensure destination directory exists 100 | if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { 101 | return lnkerr.WithPath(ErrDirCreate, filepath.Dir(dst)) 102 | } 103 | 104 | // Move the file 105 | return os.Rename(src, dst) 106 | } 107 | 108 | // CreateSymlink creates a relative symlink from target to linkPath 109 | func (fs *FileSystem) CreateSymlink(target, linkPath string) error { 110 | // Calculate relative path from linkPath to target 111 | relTarget, err := filepath.Rel(filepath.Dir(linkPath), target) 112 | if err != nil { 113 | return lnkerr.Wrap(ErrRelativePath) 114 | } 115 | 116 | // Create the symlink 117 | return os.Symlink(relTarget, linkPath) 118 | } 119 | 120 | // MoveDirectory moves a directory from source to destination recursively 121 | func (fs *FileSystem) MoveDirectory(src, dst string) error { 122 | // Ensure destination parent directory exists 123 | if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { 124 | return lnkerr.WithPath(ErrDirCreate, filepath.Dir(dst)) 125 | } 126 | 127 | // Move the directory 128 | return os.Rename(src, dst) 129 | } 130 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/yarlson/lnk/internal/core" 9 | ) 10 | 11 | func newInitCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "init", 14 | Short: "🎯 Initialize a new lnk repository", 15 | Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.", 16 | SilenceUsage: true, 17 | SilenceErrors: true, 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | remote, _ := cmd.Flags().GetString("remote") 20 | noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap") 21 | force, _ := cmd.Flags().GetBool("force") 22 | 23 | lnk := core.NewLnk() 24 | w := GetWriter(cmd) 25 | 26 | // Show warning when force is used and there are managed files to overwrite 27 | if force && remote != "" && lnk.HasUserContent() { 28 | w.Writeln(Warning("Using --force flag: This will overwrite existing managed files")). 29 | WriteString(" "). 30 | Writeln(Info("Only use this if you understand the risks")). 31 | WritelnString("") 32 | if err := w.Err(); err != nil { 33 | return err 34 | } 35 | } 36 | 37 | if err := lnk.InitWithRemoteForce(remote, force); err != nil { 38 | return err 39 | } 40 | 41 | if remote != "" { 42 | w.Writeln(Target("Initialized lnk repository")). 43 | WriteString(" "). 44 | Write(Message{Text: "Cloned from: ", Emoji: "📦"}). 45 | Writeln(Colored(remote, ColorCyan)). 46 | WriteString(" "). 47 | Write(Message{Text: "Location: ", Emoji: "📁"}). 48 | Writeln(Colored("~/.config/lnk", ColorGray)) 49 | 50 | if err := w.Err(); err != nil { 51 | return err 52 | } 53 | 54 | // Try to run bootstrap script if not disabled 55 | if !noBootstrap { 56 | w.WritelnString(""). 57 | Writeln(Message{Text: "Looking for bootstrap script...", Emoji: "🔍", Bold: true}) 58 | 59 | if err := w.Err(); err != nil { 60 | return err 61 | } 62 | 63 | scriptPath, err := lnk.FindBootstrapScript() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if scriptPath != "" { 69 | w.WriteString(" "). 70 | Write(Success("Found bootstrap script: ")). 71 | Writeln(Colored(scriptPath, ColorCyan)). 72 | WritelnString(""). 73 | Writeln(Rocket("Running bootstrap script...")). 74 | WritelnString("") 75 | 76 | if err := w.Err(); err != nil { 77 | return err 78 | } 79 | 80 | if err := lnk.RunBootstrapScript(scriptPath, os.Stdout, os.Stderr, os.Stdin); err != nil { 81 | w.WritelnString(""). 82 | Writeln(Warning("Bootstrap script failed, but repository was initialized successfully")). 83 | WriteString(" "). 84 | Write(Info("You can run it manually with: ")). 85 | Writeln(Bold("lnk bootstrap")). 86 | WriteString(" "). 87 | Write(Message{Text: "Error: ", Emoji: "🔧"}). 88 | Writeln(Plain(err.Error())) 89 | } else { 90 | w.WritelnString(""). 91 | Writeln(Success("Bootstrap completed successfully!")) 92 | } 93 | 94 | if err := w.Err(); err != nil { 95 | return err 96 | } 97 | } else { 98 | w.WriteString(" "). 99 | Writeln(Info("No bootstrap script found")) 100 | if err := w.Err(); err != nil { 101 | return err 102 | } 103 | } 104 | } 105 | 106 | w.WritelnString(""). 107 | Writeln(Info("Next steps:")). 108 | WriteString(" • Run "). 109 | Write(Bold("lnk pull")). 110 | Writeln(Plain(" to restore symlinks")). 111 | WriteString(" • Use "). 112 | Write(Bold("lnk add ")). 113 | Writeln(Plain(" to manage new files")) 114 | 115 | return w.Err() 116 | } else { 117 | w.Writeln(Target("Initialized empty lnk repository")). 118 | WriteString(" "). 119 | Write(Message{Text: "Location: ", Emoji: "📁"}). 120 | Writeln(Colored("~/.config/lnk", ColorGray)). 121 | WritelnString(""). 122 | Writeln(Info("Next steps:")). 123 | WriteString(" • Run "). 124 | Write(Bold("lnk add ")). 125 | Writeln(Plain(" to start managing dotfiles")). 126 | WriteString(" • Add a remote with: "). 127 | Writeln(Bold("git remote add origin ")) 128 | 129 | return w.Err() 130 | } 131 | }, 132 | } 133 | 134 | cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository") 135 | cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning") 136 | cmd.Flags().Bool("force", false, "Force initialization even if directory contains managed files (WARNING: This will overwrite existing content)") 137 | return cmd 138 | } 139 | -------------------------------------------------------------------------------- /cmd/output.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // OutputConfig controls formatting behavior 12 | type OutputConfig struct { 13 | Colors bool 14 | Emoji bool 15 | } 16 | 17 | // Writer provides formatted output with configurable styling 18 | type Writer struct { 19 | out io.Writer 20 | config OutputConfig 21 | err error // first error encountered 22 | } 23 | 24 | // NewWriter creates a new Writer with the given configuration 25 | func NewWriter(out io.Writer, config OutputConfig) *Writer { 26 | return &Writer{ 27 | out: out, 28 | config: config, 29 | } 30 | } 31 | 32 | // Message represents a structured message with optional formatting 33 | type Message struct { 34 | Text string 35 | Color string 36 | Emoji string 37 | Bold bool 38 | } 39 | 40 | // Write outputs a message according to the writer's configuration 41 | func (w *Writer) Write(msg Message) *Writer { 42 | if w.err != nil { 43 | return w 44 | } 45 | 46 | var output string 47 | 48 | // Add emoji if enabled 49 | if w.config.Emoji && msg.Emoji != "" { 50 | output = msg.Emoji + " " 51 | } 52 | 53 | // Add color/bold if enabled 54 | if w.config.Colors { 55 | if msg.Bold { 56 | output += "\033[1m" 57 | } 58 | if msg.Color != "" { 59 | output += msg.Color 60 | } 61 | } 62 | 63 | output += msg.Text 64 | 65 | // Close formatting if enabled 66 | if w.config.Colors && (msg.Bold || msg.Color != "") { 67 | output += "\033[0m" 68 | } 69 | 70 | _, w.err = fmt.Fprint(w.out, output) 71 | return w 72 | } 73 | 74 | // Printf is like Write but with format string 75 | func (w *Writer) Printf(msg Message, args ...any) *Writer { 76 | newMsg := msg 77 | newMsg.Text = fmt.Sprintf(msg.Text, args...) 78 | return w.Write(newMsg) 79 | } 80 | 81 | // Writeln writes a message followed by a newline 82 | func (w *Writer) Writeln(msg Message) *Writer { 83 | return w.Write(msg).WriteString("\n") 84 | } 85 | 86 | // WriteString outputs plain text (no formatting) 87 | func (w *Writer) WriteString(text string) *Writer { 88 | if w.err != nil { 89 | return w 90 | } 91 | _, w.err = fmt.Fprint(w.out, text) 92 | return w 93 | } 94 | 95 | // WritelnString outputs plain text followed by a newline 96 | func (w *Writer) WritelnString(text string) *Writer { 97 | if w.err != nil { 98 | return w 99 | } 100 | 101 | _, w.err = fmt.Fprintln(w.out, text) 102 | return w 103 | } 104 | 105 | // ANSI color codes 106 | const ( 107 | ColorRed = "\033[31m" 108 | ColorYellow = "\033[33m" 109 | ColorCyan = "\033[36m" 110 | ColorGray = "\033[90m" 111 | ColorBrightGreen = "\033[1;32m" 112 | ColorBrightYellow = "\033[1;33m" 113 | ColorBrightRed = "\033[1;31m" 114 | ) 115 | 116 | // Predefined message constructors for common patterns 117 | 118 | func Success(text string) Message { 119 | return Message{Text: text, Color: ColorBrightGreen, Emoji: "✅", Bold: true} 120 | } 121 | 122 | func Error(text string) Message { 123 | return Message{Text: text, Emoji: "❌"} 124 | } 125 | 126 | func Warning(text string) Message { 127 | return Message{Text: text, Color: ColorBrightYellow, Emoji: "⚠️", Bold: true} 128 | } 129 | 130 | func Info(text string) Message { 131 | return Message{Text: text, Color: ColorYellow, Emoji: "💡"} 132 | } 133 | 134 | func Target(text string) Message { 135 | return Message{Text: text, Emoji: "🎯", Bold: true} 136 | } 137 | 138 | func Rocket(text string) Message { 139 | return Message{Text: text, Emoji: "🚀", Bold: true} 140 | } 141 | 142 | func Sparkles(text string) Message { 143 | return Message{Text: text, Emoji: "✨", Bold: true} 144 | } 145 | 146 | func Link(text string) Message { 147 | return Message{Text: text, Color: ColorCyan, Emoji: "🔗"} 148 | } 149 | 150 | func Plain(text string) Message { 151 | return Message{Text: text} 152 | } 153 | 154 | func Bold(text string) Message { 155 | return Message{Text: text, Bold: true} 156 | } 157 | 158 | func Colored(text, color string) Message { 159 | return Message{Text: text, Color: color} 160 | } 161 | 162 | // Global output configuration 163 | var ( 164 | globalConfig = OutputConfig{ 165 | Colors: true, // auto-detect on first use 166 | Emoji: true, 167 | } 168 | autoDetected bool 169 | ) 170 | 171 | // SetGlobalConfig updates the global output configuration 172 | func SetGlobalConfig(colors string, emoji bool) error { 173 | switch colors { 174 | case "auto": 175 | globalConfig.Colors = isTerminal() 176 | case "always": 177 | globalConfig.Colors = true 178 | case "never": 179 | globalConfig.Colors = false 180 | default: 181 | return fmt.Errorf("invalid color mode: %s (valid: auto, always, never)", colors) 182 | } 183 | 184 | // Check NO_COLOR environment variable (explicit flag takes precedence) 185 | if os.Getenv("NO_COLOR") != "" && colors == "auto" { 186 | globalConfig.Colors = false 187 | } 188 | 189 | globalConfig.Emoji = emoji 190 | autoDetected = true 191 | return nil 192 | } 193 | 194 | // isTerminal checks if stdout is a terminal 195 | func isTerminal() bool { 196 | fileInfo, err := os.Stdout.Stat() 197 | if err != nil { 198 | return false 199 | } 200 | return (fileInfo.Mode() & os.ModeCharDevice) != 0 201 | } 202 | 203 | // autoDetectConfig performs one-time auto-detection if not explicitly configured 204 | func autoDetectConfig() { 205 | if !autoDetected { 206 | if os.Getenv("NO_COLOR") != "" { 207 | globalConfig.Colors = false 208 | } else { 209 | globalConfig.Colors = isTerminal() 210 | } 211 | autoDetected = true 212 | } 213 | } 214 | 215 | // GetWriter returns a writer for the given cobra command 216 | func GetWriter(cmd *cobra.Command) *Writer { 217 | autoDetectConfig() 218 | return NewWriter(cmd.OutOrStdout(), globalConfig) 219 | } 220 | 221 | // GetErrorWriter returns a writer for stderr 222 | func GetErrorWriter() *Writer { 223 | autoDetectConfig() 224 | return NewWriter(os.Stderr, globalConfig) 225 | } 226 | 227 | // Err returns the first error encountered during writing 228 | func (w *Writer) Err() error { 229 | return w.err 230 | } 231 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Lnk installer script 4 | # Downloads and installs the latest release of lnk 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | # GitHub repository 16 | REPO="yarlson/lnk" 17 | INSTALL_DIR="/usr/local/bin" 18 | BINARY_NAME="lnk" 19 | 20 | # Fallback version if redirect fails 21 | FALLBACK_VERSION="v0.3.0" 22 | 23 | # Detect OS and architecture 24 | detect_platform() { 25 | local os arch 26 | 27 | # Detect OS 28 | case "$(uname -s)" in 29 | Linux) os="Linux" ;; 30 | Darwin) os="Darwin" ;; 31 | MINGW*|MSYS*|CYGWIN*) os="Windows" ;; 32 | *) 33 | echo -e "${RED}Error: Unsupported operating system $(uname -s)${NC}" 34 | exit 1 35 | ;; 36 | esac 37 | 38 | # Detect architecture 39 | case "$(uname -m)" in 40 | x86_64|amd64) arch="x86_64" ;; 41 | arm64|aarch64) arch="arm64" ;; 42 | *) 43 | echo -e "${RED}Error: Unsupported architecture $(uname -m)${NC}" 44 | exit 1 45 | ;; 46 | esac 47 | 48 | echo "${os}_${arch}" 49 | } 50 | 51 | # Get latest version by following redirect 52 | get_latest_version() { 53 | echo -e "${BLUE}Getting latest release version...${NC}" >&2 54 | 55 | # Get redirect location from releases/latest 56 | local redirect_url 57 | redirect_url=$(curl -s -I "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed 's/\r$//' | cut -d' ' -f2-) 58 | 59 | if [ -z "$redirect_url" ]; then 60 | echo -e "${YELLOW}⚠ Could not get redirect URL, using fallback version ${FALLBACK_VERSION}${NC}" >&2 61 | echo "$FALLBACK_VERSION" 62 | return 0 63 | fi 64 | 65 | # Extract version from redirect URL (format: https://github.com/user/repo/releases/tag/v1.2.3) 66 | local version 67 | version=$(echo "$redirect_url" | sed -E 's|.*/releases/tag/([^/]*)\s*$|\1|') 68 | 69 | if [ -z "$version" ] || [ "$version" = "$redirect_url" ]; then 70 | echo -e "${YELLOW}⚠ Could not parse version from redirect URL: $redirect_url${NC}" >&2 71 | echo -e "${YELLOW}Using fallback version ${FALLBACK_VERSION}${NC}" >&2 72 | echo "$FALLBACK_VERSION" 73 | return 0 74 | fi 75 | 76 | echo "$version" 77 | } 78 | 79 | # Get version to install 80 | get_version() { 81 | # Allow override via environment variable 82 | if [ -n "$LNK_VERSION" ]; then 83 | echo "$LNK_VERSION" 84 | elif [ -n "$1" ]; then 85 | echo "$1" 86 | else 87 | get_latest_version 88 | fi 89 | } 90 | 91 | # Download and install 92 | install_lnk() { 93 | local platform version 94 | 95 | echo -e "${BLUE}🔗 Installing lnk...${NC}" 96 | 97 | platform=$(detect_platform) 98 | version=$(get_version "$1") 99 | 100 | echo -e "${BLUE}Version: ${version}${NC}" 101 | echo -e "${BLUE}Platform: ${platform}${NC}" 102 | 103 | # Download URL 104 | local filename="lnk_${platform}.tar.gz" 105 | local url="https://github.com/${REPO}/releases/download/${version}/${filename}" 106 | 107 | echo -e "${BLUE}Downloading ${url}...${NC}" 108 | 109 | # Create temporary directory 110 | local tmp_dir=$(mktemp -d) 111 | cd "$tmp_dir" 112 | 113 | # Download the binary 114 | if ! curl -sL "$url" -o "$filename"; then 115 | echo -e "${RED}Error: Failed to download ${url}${NC}" 116 | echo -e "${YELLOW}Please check if the release exists at: https://github.com/${REPO}/releases/tag/${version}${NC}" 117 | echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}" 118 | exit 1 119 | fi 120 | 121 | # Check if we got an HTML error page instead of the binary 122 | if file "$filename" 2>/dev/null | grep -q "HTML"; then 123 | echo -e "${RED}Error: Downloaded file appears to be an HTML page (404 error)${NC}" 124 | echo -e "${YELLOW}The release ${version} might not exist.${NC}" 125 | echo -e "${YELLOW}Available releases: https://github.com/${REPO}/releases${NC}" 126 | exit 1 127 | fi 128 | 129 | # Extract the binary 130 | if ! tar -xzf "$filename"; then 131 | echo -e "${RED}Error: Failed to extract ${filename}${NC}" 132 | exit 1 133 | fi 134 | 135 | # Make binary executable 136 | chmod +x "$BINARY_NAME" 137 | 138 | # Install to system directory 139 | echo -e "${YELLOW}Installing to ${INSTALL_DIR} (requires sudo)...${NC}" 140 | if ! sudo mv "$BINARY_NAME" "$INSTALL_DIR/"; then 141 | echo -e "${RED}Error: Failed to install binary${NC}" 142 | exit 1 143 | fi 144 | 145 | # Cleanup 146 | cd - > /dev/null 147 | rm -rf "$tmp_dir" 148 | 149 | echo -e "${GREEN}✅ lnk installed successfully!${NC}" 150 | echo -e "${GREEN}Run 'lnk --help' to get started.${NC}" 151 | 152 | # Test the installation 153 | if command -v lnk >/dev/null 2>&1; then 154 | echo -e "${GREEN}Installed version: $(lnk --version)${NC}" 155 | fi 156 | } 157 | 158 | # Check if running with --help 159 | if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then 160 | echo "Lnk installer script" 161 | echo "" 162 | echo "Usage:" 163 | echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash" 164 | echo " curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash -s v0.0.1" 165 | echo " LNK_VERSION=v0.0.1 curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash" 166 | echo "" 167 | echo "This script will:" 168 | echo " 1. Detect your OS and architecture" 169 | echo " 2. Auto-detect the latest release by following GitHub redirects" 170 | echo " 3. Download and install to /usr/local/bin (requires sudo)" 171 | echo "" 172 | echo "Environment variables:" 173 | echo " LNK_VERSION - Specify version to install (e.g., v0.0.1)" 174 | echo "" 175 | echo "Manual installation: https://github.com/yarlson/lnk/releases" 176 | exit 0 177 | fi 178 | 179 | # Run the installer 180 | install_lnk "$1" 181 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/yarlson/lnk/internal/core" 11 | ) 12 | 13 | func newListCmd() *cobra.Command { 14 | cmd := &cobra.Command{ 15 | Use: "list", 16 | Short: "📋 List files managed by lnk", 17 | Long: "Display all files and directories currently managed by lnk.", 18 | SilenceUsage: true, 19 | SilenceErrors: true, 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | host, _ := cmd.Flags().GetString("host") 22 | all, _ := cmd.Flags().GetBool("all") 23 | 24 | if host != "" { 25 | // Show specific host configuration 26 | return listHostConfig(cmd, host) 27 | } 28 | 29 | if all { 30 | // Show all configurations (common + all hosts) 31 | return listAllConfigs(cmd) 32 | } 33 | 34 | // Default: show common configuration 35 | return listCommonConfig(cmd) 36 | }, 37 | } 38 | 39 | cmd.Flags().StringP("host", "H", "", "List files for specific host") 40 | cmd.Flags().BoolP("all", "a", false, "List files for all hosts and common configuration") 41 | return cmd 42 | } 43 | 44 | func listCommonConfig(cmd *cobra.Command) error { 45 | lnk := core.NewLnk() 46 | w := GetWriter(cmd) 47 | 48 | managedItems, err := lnk.List() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if len(managedItems) == 0 { 54 | w.Writeln(Message{Text: "No files currently managed by lnk (common)", Emoji: "📋", Bold: true}). 55 | WriteString(" "). 56 | Write(Info("Use ")). 57 | Write(Bold("lnk add ")). 58 | WritelnString(" to start managing files") 59 | return w.Err() 60 | } 61 | 62 | countText := fmt.Sprintf("Files managed by lnk (common) (%d item", len(managedItems)) 63 | if len(managedItems) > 1 { 64 | countText += "s" 65 | } 66 | countText += "):" 67 | 68 | w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}). 69 | WritelnString("") 70 | 71 | for _, item := range managedItems { 72 | w.WriteString(" "). 73 | Writeln(Link(item)) 74 | } 75 | 76 | w.WritelnString(""). 77 | Write(Info("Use ")). 78 | Write(Bold("lnk status")). 79 | WritelnString(" to check sync status") 80 | return w.Err() 81 | } 82 | 83 | func listHostConfig(cmd *cobra.Command, host string) error { 84 | lnk := core.NewLnk(core.WithHost(host)) 85 | w := GetWriter(cmd) 86 | 87 | managedItems, err := lnk.List() 88 | if err != nil { 89 | return err 90 | } 91 | 92 | if len(managedItems) == 0 { 93 | w.Writeln(Message{Text: fmt.Sprintf("No files currently managed by lnk (host: %s)", host), Emoji: "📋", Bold: true}). 94 | WriteString(" "). 95 | Write(Info("Use ")). 96 | Write(Bold(fmt.Sprintf("lnk add --host %s ", host))). 97 | WritelnString(" to start managing files") 98 | return w.Err() 99 | } 100 | 101 | countText := fmt.Sprintf("Files managed by lnk (host: %s) (%d item", host, len(managedItems)) 102 | if len(managedItems) > 1 { 103 | countText += "s" 104 | } 105 | countText += "):" 106 | 107 | w.Writeln(Message{Text: countText, Emoji: "📋", Bold: true}). 108 | WritelnString("") 109 | 110 | for _, item := range managedItems { 111 | w.WriteString(" "). 112 | Writeln(Link(item)) 113 | } 114 | 115 | w.WritelnString(""). 116 | Write(Info("Use ")). 117 | Write(Bold("lnk status")). 118 | WritelnString(" to check sync status") 119 | return w.Err() 120 | } 121 | 122 | func listAllConfigs(cmd *cobra.Command) error { 123 | w := GetWriter(cmd) 124 | 125 | // List common configuration 126 | w.Writeln(Message{Text: "All configurations managed by lnk", Emoji: "📋", Bold: true}). 127 | WritelnString("") 128 | 129 | lnk := core.NewLnk() 130 | commonItems, err := lnk.List() 131 | if err != nil { 132 | return err 133 | } 134 | 135 | countText := fmt.Sprintf("Common configuration (%d item", len(commonItems)) 136 | if len(commonItems) > 1 { 137 | countText += "s" 138 | } 139 | countText += "):" 140 | 141 | w.Writeln(Message{Text: countText, Emoji: "🌐", Bold: true}) 142 | 143 | if len(commonItems) == 0 { 144 | w.WriteString(" "). 145 | Writeln(Colored("(no files)", ColorGray)) 146 | } else { 147 | for _, item := range commonItems { 148 | w.WriteString(" "). 149 | Writeln(Link(item)) 150 | } 151 | } 152 | 153 | // Find all host-specific configurations 154 | hosts, err := findHostConfigs() 155 | if err != nil { 156 | return err 157 | } 158 | 159 | for _, host := range hosts { 160 | w.WritelnString(""). 161 | Write(Message{Text: fmt.Sprintf("Host: %s", host), Emoji: "🖥️", Bold: true}) 162 | 163 | hostLnk := core.NewLnk(core.WithHost(host)) 164 | hostItems, err := hostLnk.List() 165 | if err != nil { 166 | w.WriteString(" "). 167 | Writeln(Colored(fmt.Sprintf("(error: %v)", err), ColorRed)) 168 | continue 169 | } 170 | 171 | countText := fmt.Sprintf(" (%d item", len(hostItems)) 172 | if len(hostItems) > 1 { 173 | countText += "s" 174 | } 175 | countText += "):" 176 | 177 | w.WriteString(countText). 178 | WritelnString("") 179 | 180 | if len(hostItems) == 0 { 181 | w.WriteString(" "). 182 | Writeln(Colored("(no files)", ColorGray)) 183 | } else { 184 | for _, item := range hostItems { 185 | w.WriteString(" "). 186 | Writeln(Link(item)) 187 | } 188 | } 189 | } 190 | 191 | w.WritelnString(""). 192 | Write(Info("Use ")). 193 | Write(Bold("lnk list --host ")). 194 | WritelnString(" to see specific host configuration") 195 | return w.Err() 196 | } 197 | 198 | func findHostConfigs() ([]string, error) { 199 | repoPath := core.GetRepoPath() 200 | 201 | // Check if repo exists 202 | if _, err := os.Stat(repoPath); os.IsNotExist(err) { 203 | return []string{}, nil 204 | } 205 | 206 | entries, err := os.ReadDir(repoPath) 207 | if err != nil { 208 | return nil, err 209 | } 210 | 211 | var hosts []string 212 | for _, entry := range entries { 213 | name := entry.Name() 214 | // Look for .lnk. files 215 | if strings.HasPrefix(name, ".lnk.") && name != ".lnk" { 216 | host := strings.TrimPrefix(name, ".lnk.") 217 | hosts = append(hosts, host) 218 | } 219 | } 220 | 221 | return hosts, nil 222 | } 223 | -------------------------------------------------------------------------------- /cmd/add.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/yarlson/lnk/internal/core" 10 | ) 11 | 12 | func newAddCmd() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "add ...", 15 | Short: "✨ Add files to lnk management", 16 | Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files. 17 | 18 | Examples: 19 | lnk add ~/.bashrc ~/.vimrc # Add multiple files at once 20 | lnk add --recursive ~/.config/nvim # Add directory contents individually 21 | lnk add --dry-run ~/.gitconfig # Preview what would be added 22 | lnk add --host work ~/.ssh/config # Add host-specific configuration 23 | 24 | The --recursive flag processes directory contents individually instead of treating 25 | the directory as a single unit. This is useful for configuration directories where 26 | you want each file managed separately. 27 | 28 | The --dry-run flag shows you exactly what files would be added without making any 29 | changes to your system - perfect for verification before bulk operations.`, 30 | Args: cobra.MinimumNArgs(1), 31 | SilenceUsage: true, 32 | SilenceErrors: true, 33 | RunE: func(cmd *cobra.Command, args []string) error { 34 | host, _ := cmd.Flags().GetString("host") 35 | recursive, _ := cmd.Flags().GetBool("recursive") 36 | dryRun, _ := cmd.Flags().GetBool("dry-run") 37 | lnk := core.NewLnk(core.WithHost(host)) 38 | w := GetWriter(cmd) 39 | 40 | // Handle dry-run mode 41 | if dryRun { 42 | files, err := lnk.PreviewAdd(args, recursive) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | // Display preview output 48 | if recursive { 49 | w.Writeln(Message{Text: fmt.Sprintf("Would add %d files recursively:", len(files)), Emoji: "🔍", Bold: true}) 50 | } else { 51 | w.Writeln(Message{Text: fmt.Sprintf("Would add %d files:", len(files)), Emoji: "🔍", Bold: true}) 52 | } 53 | 54 | // List files that would be added 55 | for _, file := range files { 56 | basename := filepath.Base(file) 57 | w.WriteString(" "). 58 | Writeln(Message{Text: basename, Emoji: "📄"}) 59 | } 60 | 61 | w.WritelnString(""). 62 | Writeln(Info("To proceed: run without --dry-run flag")) 63 | 64 | return w.Err() 65 | } 66 | 67 | // Handle recursive mode 68 | if recursive { 69 | // Get preview to count files first for better output 70 | previewFiles, err := lnk.PreviewAdd(args, recursive) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | // Create progress callback for CLI display 76 | progressCallback := func(current, total int, currentFile string) { 77 | w.WriteString(fmt.Sprintf("\r⏳ Processing %d/%d: %s", current, total, currentFile)) 78 | } 79 | 80 | if err := lnk.AddRecursiveWithProgress(args, progressCallback); err != nil { 81 | return err 82 | } 83 | 84 | // Clear progress line and show completion 85 | w.WriteString("\r") 86 | 87 | // Store processed file count for display 88 | args = previewFiles // Replace args with actual files for display 89 | } else { 90 | // Use appropriate method based on number of files 91 | if len(args) == 1 { 92 | // Single file - use existing Add method for backward compatibility 93 | if err := lnk.Add(args[0]); err != nil { 94 | return err 95 | } 96 | } else { 97 | // Multiple files - use AddMultiple for atomic operation 98 | if err := lnk.AddMultiple(args); err != nil { 99 | return err 100 | } 101 | } 102 | } 103 | 104 | // Display results 105 | if recursive { 106 | // Recursive mode - show enhanced message with count 107 | if host != "" { 108 | w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk (host: %s)", len(args), host))) 109 | } else { 110 | w.Writeln(Sparkles(fmt.Sprintf("Added %d files recursively to lnk", len(args)))) 111 | } 112 | 113 | // Show some of the files that were added (limit to first few for readability) 114 | filesToShow := len(args) 115 | if filesToShow > 5 { 116 | filesToShow = 5 117 | } 118 | 119 | for i := 0; i < filesToShow; i++ { 120 | basename := filepath.Base(args[i]) 121 | if host != "" { 122 | w.WriteString(" "). 123 | Write(Link(basename)). 124 | WriteString(" → "). 125 | Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan)) 126 | } else { 127 | w.WriteString(" "). 128 | Write(Link(basename)). 129 | WriteString(" → "). 130 | Writeln(Colored("~/.config/lnk/...", ColorCyan)) 131 | } 132 | } 133 | 134 | if len(args) > 5 { 135 | w.WriteString(" "). 136 | Writeln(Colored(fmt.Sprintf("... and %d more files", len(args)-5), ColorGray)) 137 | } 138 | } else if len(args) == 1 { 139 | // Single file - maintain existing output format for backward compatibility 140 | filePath := args[0] 141 | basename := filepath.Base(filePath) 142 | if host != "" { 143 | w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk (host: %s)", basename, host))) 144 | w.WriteString(" "). 145 | Write(Link(filePath)). 146 | WriteString(" → "). 147 | Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/%s", host, filePath), ColorCyan)) 148 | } else { 149 | w.Writeln(Sparkles(fmt.Sprintf("Added %s to lnk", basename))) 150 | w.WriteString(" "). 151 | Write(Link(filePath)). 152 | WriteString(" → "). 153 | Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s", filePath), ColorCyan)) 154 | } 155 | } else { 156 | // Multiple files - show summary 157 | if host != "" { 158 | w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk (host: %s)", len(args), host))) 159 | } else { 160 | w.Writeln(Sparkles(fmt.Sprintf("Added %d items to lnk", len(args)))) 161 | } 162 | 163 | // List each added file 164 | for _, filePath := range args { 165 | basename := filepath.Base(filePath) 166 | if host != "" { 167 | w.WriteString(" "). 168 | Write(Link(basename)). 169 | WriteString(" → "). 170 | Writeln(Colored(fmt.Sprintf("~/.config/lnk/%s.lnk/...", host), ColorCyan)) 171 | } else { 172 | w.WriteString(" "). 173 | Write(Link(basename)). 174 | WriteString(" → "). 175 | Writeln(Colored("~/.config/lnk/...", ColorCyan)) 176 | } 177 | } 178 | } 179 | 180 | w.WriteString(" "). 181 | Write(Message{Text: "Use ", Emoji: "📝"}). 182 | Write(Bold("lnk push")). 183 | WritelnString(" to sync to remote") 184 | 185 | return w.Err() 186 | }, 187 | } 188 | 189 | cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)") 190 | cmd.Flags().BoolP("recursive", "r", false, "Add directory contents individually instead of the directory as a whole") 191 | cmd.Flags().BoolP("dry-run", "n", false, "Show what would be added without making changes") 192 | return cmd 193 | } 194 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Variables 2 | BINARY_NAME=lnk 3 | VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 4 | BUILD_TIME=$(shell date +%FT%T%z) 5 | LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME)" 6 | 7 | # Go related variables 8 | GOBASE=$(shell pwd) 9 | GOBIN=$(GOBASE)/bin 10 | GOFILES=$(wildcard *.go) 11 | 12 | # Colors for pretty output 13 | RED=\033[0;31m 14 | GREEN=\033[0;32m 15 | YELLOW=\033[0;33m 16 | BLUE=\033[0;34m 17 | NC=\033[0m # No Color 18 | 19 | .PHONY: help build test clean install uninstall fmt lint vet tidy run dev cross-compile release goreleaser-check goreleaser-snapshot 20 | 21 | ## help: Show this help message 22 | help: 23 | @echo "$(BLUE)Lnk CLI - Available targets:$(NC)" 24 | @echo "" 25 | @echo "$(GREEN)Development:$(NC)" 26 | @echo " build Build the binary" 27 | @echo " test Run tests" 28 | @echo " test-v Run tests with verbose output" 29 | @echo " test-cover Run tests with coverage" 30 | @echo " run Run the application" 31 | @echo " dev Development mode with file watching" 32 | @echo "" 33 | @echo "$(GREEN)Code Quality:$(NC)" 34 | @echo " fmt Format Go code" 35 | @echo " lint Run golangci-lint" 36 | @echo " vet Run go vet" 37 | @echo " tidy Tidy Go modules" 38 | @echo " check Run all quality checks (fmt, vet, lint, test)" 39 | @echo "" 40 | @echo "$(GREEN)Installation:$(NC)" 41 | @echo " install Install binary to /usr/local/bin" 42 | @echo " uninstall Remove binary from /usr/local/bin" 43 | @echo "" 44 | @echo "$(GREEN)Release:$(NC)" 45 | @echo " cross-compile Build for multiple platforms (legacy)" 46 | @echo " release Create release builds (legacy)" 47 | @echo " goreleaser-check Validate .goreleaser.yml config" 48 | @echo " goreleaser-snapshot Build snapshot release with GoReleaser" 49 | @echo "" 50 | @echo "$(GREEN)Utilities:$(NC)" 51 | @echo " clean Clean build artifacts" 52 | @echo " deps Install development dependencies" 53 | 54 | ## build: Build the binary 55 | build: 56 | @echo "$(BLUE)Building $(BINARY_NAME)...$(NC)" 57 | @go build $(LDFLAGS) -o $(BINARY_NAME) . 58 | @echo "$(GREEN)✓ Build complete: $(BINARY_NAME)$(NC)" 59 | 60 | ## test: Run tests 61 | test: 62 | @echo "$(BLUE)Running tests...$(NC)" 63 | @go test ./... 64 | @echo "$(GREEN)✓ Tests passed$(NC)" 65 | 66 | ## test-v: Run tests with verbose output 67 | test-v: 68 | @echo "$(BLUE)Running tests (verbose)...$(NC)" 69 | @go test -v ./... 70 | 71 | ## test-cover: Run tests with coverage 72 | test-cover: 73 | @echo "$(BLUE)Running tests with coverage...$(NC)" 74 | @go test -v -cover ./... 75 | @go test -coverprofile=coverage.out ./ 76 | @go tool cover -html=coverage.out -o coverage.html 77 | @echo "$(GREEN)✓ Coverage report generated: coverage.html$(NC)" 78 | 79 | ## run: Run the application 80 | run: build 81 | @echo "$(BLUE)Running $(BINARY_NAME)...$(NC)" 82 | @./$(BINARY_NAME) 83 | 84 | ## dev: Development mode with file watching (requires entr) 85 | dev: 86 | @echo "$(YELLOW)Development mode - watching for changes...$(NC)" 87 | @echo "$(YELLOW)Install 'entr' if not available: brew install entr$(NC)" 88 | @find . -name "*.go" | entr -r make run 89 | 90 | ## fmt: Format Go code 91 | fmt: 92 | @echo "$(BLUE)Formatting code...$(NC)" 93 | @go fmt ./... 94 | @echo "$(GREEN)✓ Code formatted$(NC)" 95 | 96 | ## lint: Run golangci-lint 97 | lint: 98 | @echo "$(BLUE)Running linter...$(NC)" 99 | @if command -v golangci-lint >/dev/null 2>&1; then \ 100 | golangci-lint run; \ 101 | echo "$(GREEN)✓ Linting complete$(NC)"; \ 102 | else \ 103 | echo "$(YELLOW)⚠ golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest$(NC)"; \ 104 | fi 105 | 106 | ## vet: Run go vet 107 | vet: 108 | @echo "$(BLUE)Running go vet...$(NC)" 109 | @go vet ./... 110 | @echo "$(GREEN)✓ Vet check passed$(NC)" 111 | 112 | ## tidy: Tidy Go modules 113 | tidy: 114 | @echo "$(BLUE)Tidying modules...$(NC)" 115 | @go mod tidy 116 | @echo "$(GREEN)✓ Modules tidied$(NC)" 117 | 118 | ## check: Run all quality checks 119 | check: fmt vet lint test 120 | @echo "$(GREEN)✓ All quality checks passed$(NC)" 121 | 122 | ## install: Install binary to /usr/local/bin 123 | install: build 124 | @echo "$(BLUE)Installing $(BINARY_NAME) to /usr/local/bin...$(NC)" 125 | @sudo cp $(BINARY_NAME) /usr/local/bin/ 126 | @echo "$(GREEN)✓ $(BINARY_NAME) installed$(NC)" 127 | 128 | ## uninstall: Remove binary from /usr/local/bin 129 | uninstall: 130 | @echo "$(BLUE)Uninstalling $(BINARY_NAME)...$(NC)" 131 | @sudo rm -f /usr/local/bin/$(BINARY_NAME) 132 | @echo "$(GREEN)✓ $(BINARY_NAME) uninstalled$(NC)" 133 | 134 | ## cross-compile: Build for multiple platforms 135 | cross-compile: clean 136 | @echo "$(BLUE)Cross-compiling for multiple platforms...$(NC)" 137 | @mkdir -p dist 138 | @GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/$(BINARY_NAME)-linux-amd64 . 139 | @GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o dist/$(BINARY_NAME)-linux-arm64 . 140 | @GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o dist/$(BINARY_NAME)-darwin-amd64 . 141 | @GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o dist/$(BINARY_NAME)-darwin-arm64 . 142 | @GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o dist/$(BINARY_NAME)-windows-amd64.exe . 143 | @echo "$(GREEN)✓ Cross-compilation complete. Binaries in dist/$(NC)" 144 | 145 | ## release: Create release builds with checksums 146 | release: cross-compile 147 | @echo "$(BLUE)Creating release artifacts...$(NC)" 148 | @cd dist && sha256sum * > checksums.txt 149 | @echo "$(GREEN)✓ Release artifacts created in dist/$(NC)" 150 | 151 | ## clean: Clean build artifacts 152 | clean: 153 | @echo "$(BLUE)Cleaning...$(NC)" 154 | @rm -f $(BINARY_NAME) 155 | @rm -rf dist/ 156 | @rm -f coverage.out coverage.html 157 | @echo "$(GREEN)✓ Clean complete$(NC)" 158 | 159 | ## deps: Install development dependencies 160 | deps: 161 | @echo "$(BLUE)Installing development dependencies...$(NC)" 162 | @go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 163 | @if ! command -v goreleaser >/dev/null 2>&1; then \ 164 | echo "$(BLUE)Installing GoReleaser...$(NC)"; \ 165 | go install github.com/goreleaser/goreleaser@latest; \ 166 | fi 167 | @echo "$(GREEN)✓ Dependencies installed$(NC)" 168 | 169 | ## goreleaser-check: Validate GoReleaser configuration 170 | goreleaser-check: 171 | @echo "$(BLUE)Validating GoReleaser configuration...$(NC)" 172 | @if command -v goreleaser >/dev/null 2>&1; then \ 173 | goreleaser check; \ 174 | echo "$(GREEN)✓ GoReleaser configuration is valid$(NC)"; \ 175 | else \ 176 | echo "$(YELLOW)⚠ GoReleaser not found. Install with: make deps$(NC)"; \ 177 | fi 178 | 179 | ## goreleaser-snapshot: Build snapshot release with GoReleaser 180 | goreleaser-snapshot: goreleaser-check 181 | @echo "$(BLUE)Building snapshot release with GoReleaser...$(NC)" 182 | @goreleaser build --snapshot --clean 183 | @echo "$(GREEN)✓ Snapshot release built in dist/$(NC)" 184 | 185 | # Default target 186 | all: check build 187 | -------------------------------------------------------------------------------- /cmd/output_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/yarlson/lnk/internal/lnkerr" 11 | ) 12 | 13 | func TestOutputConfig(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | colors string 17 | emoji bool 18 | expectError bool 19 | expectedColors bool 20 | expectedEmoji bool 21 | }{ 22 | { 23 | name: "auto mode", 24 | colors: "auto", 25 | emoji: true, 26 | expectError: false, 27 | expectedColors: false, // TTY detection will return false in tests 28 | expectedEmoji: true, 29 | }, 30 | { 31 | name: "always mode", 32 | colors: "always", 33 | emoji: false, 34 | expectError: false, 35 | expectedColors: true, 36 | expectedEmoji: false, 37 | }, 38 | { 39 | name: "never mode", 40 | colors: "never", 41 | emoji: true, 42 | expectError: false, 43 | expectedColors: false, 44 | expectedEmoji: true, 45 | }, 46 | { 47 | name: "invalid mode", 48 | colors: "invalid", 49 | emoji: true, 50 | expectError: true, 51 | }, 52 | } 53 | 54 | for _, tt := range tests { 55 | t.Run(tt.name, func(t *testing.T) { 56 | // Clear NO_COLOR for consistent testing 57 | _ = os.Unsetenv("NO_COLOR") 58 | 59 | err := SetGlobalConfig(tt.colors, tt.emoji) 60 | 61 | if tt.expectError && err == nil { 62 | t.Errorf("expected error but got none") 63 | } 64 | if !tt.expectError && err != nil { 65 | t.Errorf("unexpected error: %v", err) 66 | } 67 | 68 | if !tt.expectError { 69 | if globalConfig.Colors != tt.expectedColors { 70 | t.Errorf("expected colors %v, got %v", tt.expectedColors, globalConfig.Colors) 71 | } 72 | if globalConfig.Emoji != tt.expectedEmoji { 73 | t.Errorf("expected emoji %v, got %v", tt.expectedEmoji, globalConfig.Emoji) 74 | } 75 | } 76 | }) 77 | } 78 | } 79 | 80 | func TestNOCOLOREnvironmentVariable(t *testing.T) { 81 | // Test NO_COLOR environment variable with auto mode 82 | _ = os.Setenv("NO_COLOR", "1") 83 | defer func() { _ = os.Unsetenv("NO_COLOR") }() 84 | 85 | err := SetGlobalConfig("auto", true) 86 | if err != nil { 87 | t.Errorf("unexpected error: %v", err) 88 | } 89 | 90 | if globalConfig.Colors != false { 91 | t.Errorf("expected colors disabled when NO_COLOR is set, got %v", globalConfig.Colors) 92 | } 93 | } 94 | 95 | func TestWriterOutput(t *testing.T) { 96 | tests := []struct { 97 | name string 98 | config OutputConfig 99 | message Message 100 | expectedOutput string 101 | }{ 102 | { 103 | name: "full formatting", 104 | config: OutputConfig{Colors: true, Emoji: true}, 105 | message: Message{ 106 | Text: "test message", 107 | Color: ColorRed, 108 | Emoji: "✅", 109 | Bold: true, 110 | }, 111 | expectedOutput: "✅ \033[1m\033[31mtest message\033[0m", 112 | }, 113 | { 114 | name: "colors only", 115 | config: OutputConfig{Colors: true, Emoji: false}, 116 | message: Message{ 117 | Text: "test message", 118 | Color: ColorRed, 119 | Emoji: "✅", 120 | Bold: true, 121 | }, 122 | expectedOutput: "\033[1m\033[31mtest message\033[0m", 123 | }, 124 | { 125 | name: "emoji only", 126 | config: OutputConfig{Colors: false, Emoji: true}, 127 | message: Message{ 128 | Text: "test message", 129 | Color: ColorRed, 130 | Emoji: "✅", 131 | Bold: true, 132 | }, 133 | expectedOutput: "✅ test message", 134 | }, 135 | { 136 | name: "no formatting", 137 | config: OutputConfig{Colors: false, Emoji: false}, 138 | message: Message{ 139 | Text: "test message", 140 | Color: ColorRed, 141 | Emoji: "✅", 142 | Bold: true, 143 | }, 144 | expectedOutput: "test message", 145 | }, 146 | { 147 | name: "plain message", 148 | config: OutputConfig{Colors: true, Emoji: true}, 149 | message: Plain("plain text"), 150 | expectedOutput: "plain text", 151 | }, 152 | } 153 | 154 | for _, tt := range tests { 155 | t.Run(tt.name, func(t *testing.T) { 156 | var buf bytes.Buffer 157 | writer := NewWriter(&buf, tt.config) 158 | 159 | writer.Write(tt.message) 160 | if err := writer.Err(); err != nil { 161 | t.Errorf("unexpected error: %v", err) 162 | } 163 | 164 | if buf.String() != tt.expectedOutput { 165 | t.Errorf("expected %q, got %q", tt.expectedOutput, buf.String()) 166 | } 167 | }) 168 | } 169 | } 170 | 171 | func TestPredefinedMessages(t *testing.T) { 172 | tests := []struct { 173 | name string 174 | creator func(string) Message 175 | text string 176 | }{ 177 | {"Success", Success, "operation succeeded"}, 178 | {"Error", Error, "something failed"}, 179 | {"Warning", Warning, "be careful"}, 180 | {"Info", Info, "useful information"}, 181 | {"Target", Target, "target reached"}, 182 | {"Rocket", Rocket, "launching"}, 183 | {"Sparkles", Sparkles, "amazing"}, 184 | {"Link", Link, "connected"}, 185 | {"Plain", Plain, "no formatting"}, 186 | {"Bold", Bold, "emphasis"}, 187 | } 188 | 189 | var buf bytes.Buffer 190 | writer := NewWriter(&buf, OutputConfig{Colors: true, Emoji: true}) 191 | 192 | for _, tt := range tests { 193 | t.Run(tt.name, func(t *testing.T) { 194 | buf.Reset() 195 | msg := tt.creator(tt.text) 196 | 197 | writer.Write(msg) 198 | if err := writer.Err(); err != nil { 199 | t.Errorf("unexpected error: %v", err) 200 | } 201 | 202 | output := buf.String() 203 | if !strings.Contains(output, tt.text) { 204 | t.Errorf("output should contain text %q, got %q", tt.text, output) 205 | } 206 | }) 207 | } 208 | } 209 | 210 | func TestStructuredErrors(t *testing.T) { 211 | tests := []struct { 212 | name string 213 | err *lnkerr.Error 214 | config OutputConfig 215 | contains []string 216 | notContains []string 217 | }{ 218 | { 219 | name: "structured error with full formatting", 220 | err: &lnkerr.Error{ 221 | Err: errors.New("something went wrong"), 222 | Path: "/some/path", 223 | Suggestion: "try this instead", 224 | }, 225 | config: OutputConfig{Colors: true, Emoji: true}, 226 | contains: []string{"❌", "something went wrong", "/some/path", "💡", "try this instead"}, 227 | }, 228 | { 229 | name: "structured error without emojis", 230 | err: &lnkerr.Error{ 231 | Err: errors.New("something went wrong"), 232 | Path: "/some/path", 233 | Suggestion: "try this instead", 234 | }, 235 | config: OutputConfig{Colors: true, Emoji: false}, 236 | contains: []string{"something went wrong", "/some/path", "try this instead"}, 237 | notContains: []string{"❌", "💡"}, 238 | }, 239 | } 240 | 241 | for _, tt := range tests { 242 | t.Run(tt.name, func(t *testing.T) { 243 | var buf bytes.Buffer 244 | w := NewWriter(&buf, tt.config) 245 | 246 | _ = w.Write(Error(tt.err.Err.Error())) 247 | if tt.err.Path != "" { 248 | _ = w.WriteString("\n ") 249 | _ = w.Write(Colored(tt.err.Path, ColorRed)) 250 | } 251 | if tt.err.Suggestion != "" { 252 | _ = w.WriteString("\n ") 253 | _ = w.Write(Info(tt.err.Suggestion)) 254 | } 255 | 256 | output := buf.String() 257 | for _, expected := range tt.contains { 258 | if !strings.Contains(output, expected) { 259 | t.Errorf("output should contain %q, got %q", expected, output) 260 | } 261 | } 262 | for _, notExpected := range tt.notContains { 263 | if strings.Contains(output, notExpected) { 264 | t.Errorf("output should not contain %q, got %q", notExpected, output) 265 | } 266 | } 267 | }) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lnk 2 | 3 | **Git-native dotfiles management that doesn't suck.** 4 | 5 | Lnk makes managing your dotfiles straightforward, no tedious setups, no complex configurations. Just tell Lnk what files you want tracked, and it'll automatically move them into a tidy Git repository under `~/.config/lnk`. It then creates clean, portable symlinks back to their original locations. Easy. 6 | 7 | Why bother with Lnk instead of plain old Git or other dotfile managers? Unlike traditional methods, Lnk automates the boring parts: safely relocating files, handling host-specific setups, bulk operations for multiple files, recursive directory processing, and even running your custom bootstrap scripts automatically. This means fewer manual steps and less chance of accidentally overwriting something important. 8 | 9 | With Lnk, your dotfiles setup stays organized and effortlessly portable, letting you spend more time doing real work, not wrestling with configuration files. 10 | 11 | ```bash 12 | lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically 13 | lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Multiple files at once 14 | lnk add --recursive ~/.config/nvim # Process directory contents 15 | lnk add --dry-run ~/.tmux.conf # Preview changes first 16 | lnk add --host work ~/.ssh/config # Host-specific config 17 | lnk push "setup" 18 | ``` 19 | 20 | ## Install 21 | 22 | ```bash 23 | # Quick install (recommended) 24 | curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash 25 | ``` 26 | 27 | ```bash 28 | # Homebrew (macOS/Linux) 29 | brew install lnk 30 | ``` 31 | 32 | ```bash 33 | # Manual download 34 | wget https://github.com/yarlson/lnk/releases/latest/download/lnk-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64 35 | chmod +x lnk-* && sudo mv lnk-* /usr/local/bin/lnk 36 | ``` 37 | 38 | ```bash 39 | # From source 40 | git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/ 41 | ``` 42 | 43 | ## Usage 44 | 45 | ### Setup 46 | 47 | ```bash 48 | # Fresh start 49 | lnk init 50 | 51 | # With existing repo (runs bootstrap automatically) 52 | lnk init -r git@github.com:user/dotfiles.git 53 | 54 | # Skip automatic bootstrap 55 | lnk init -r git@github.com:user/dotfiles.git --no-bootstrap 56 | 57 | # Force initialization (WARNING: overwrites existing managed files) 58 | lnk init -r git@github.com:user/dotfiles.git --force 59 | 60 | # Run bootstrap script manually 61 | lnk bootstrap 62 | ``` 63 | 64 | ### Daily workflow 65 | 66 | ```bash 67 | # Add multiple files at once (common config) 68 | lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf 69 | 70 | # Add directory contents individually 71 | lnk add --recursive ~/.config/nvim ~/.config/zsh 72 | 73 | # Preview changes before applying 74 | lnk add --dry-run ~/.config/git/config 75 | lnk add --dry-run --recursive ~/.config/kitty 76 | 77 | # Add host-specific files (supports bulk operations) 78 | lnk add --host laptop ~/.ssh/config ~/.aws/credentials 79 | lnk add --host work ~/.gitconfig ~/.ssh/config 80 | 81 | # List managed files 82 | lnk list # Common config only 83 | lnk list --host laptop # Laptop-specific config 84 | lnk list --all # All configurations 85 | 86 | # Check status 87 | lnk status 88 | 89 | # Sync changes 90 | lnk push "updated vim config" 91 | lnk pull # Pull common config 92 | lnk pull --host laptop # Pull laptop-specific config 93 | ``` 94 | 95 | ## How it works 96 | 97 | ``` 98 | Common files: 99 | Before: ~/.vimrc (file) 100 | After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink) 101 | 102 | Host-specific files: 103 | Before: ~/.ssh/config (file) 104 | After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink) 105 | ``` 106 | 107 | Your files live in `~/.config/lnk` (a Git repo). Common files go in the root, host-specific files go in `.lnk/` subdirectories. Lnk creates symlinks back to original locations. Edit files normally, use Git normally. 108 | 109 | ## Safety Features 110 | 111 | Lnk includes built-in safety checks to prevent accidental data loss: 112 | 113 | ### Data Loss Prevention 114 | 115 | ```bash 116 | # This will be blocked if you already have managed files 117 | lnk init -r git@github.com:user/dotfiles.git 118 | # ❌ Directory ~/.config/lnk already contains managed files 119 | # 💡 Use 'lnk pull' to update from remote instead of 'lnk init -r' 120 | 121 | # Use pull instead to safely update 122 | lnk pull 123 | 124 | # Or force if you understand the risks (shows warning only when needed) 125 | lnk init -r git@github.com:user/dotfiles.git --force 126 | # ⚠️ Using --force flag: This will overwrite existing managed files 127 | # 💡 Only use this if you understand the risks 128 | ``` 129 | 130 | ### Smart Warnings 131 | 132 | - **Contextual alerts**: Warnings only appear when there are actually managed files to overwrite 133 | - **Clear guidance**: Error messages suggest the correct command to use 134 | - **Force override**: Advanced users can bypass safety checks when needed 135 | 136 | ### Recovering from Accidental Deletion 137 | 138 | If you accidentally delete a managed file without using `lnk rm`: 139 | 140 | ```bash 141 | # File was deleted outside of lnk 142 | rm ~/.bashrc # Oops! Should have used 'lnk rm' 143 | 144 | # lnk rm won't work because symlink is gone 145 | lnk rm ~/.bashrc 146 | # ❌ File or directory not found: ~/.bashrc 147 | 148 | # Use --force to clean up the orphaned tracking entry 149 | lnk rm --force ~/.bashrc 150 | # ✅ Force removed .bashrc from lnk 151 | ``` 152 | 153 | ## Bootstrap Support 154 | 155 | Lnk automatically runs bootstrap scripts when cloning dotfiles repositories, making it easy to set up your development environment. Just add a `bootstrap.sh` file to your dotfiles repo. 156 | 157 | ### Examples 158 | 159 | **Simple bootstrap script:** 160 | 161 | ```bash 162 | #!/bin/bash 163 | # bootstrap.sh 164 | echo "Setting up development environment..." 165 | 166 | # Install Homebrew (macOS) 167 | if ! command -v brew &> /dev/null; then 168 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 169 | fi 170 | 171 | # Install packages 172 | brew install git vim tmux 173 | 174 | echo "✅ Setup complete!" 175 | ``` 176 | 177 | **Usage:** 178 | 179 | ```bash 180 | # Automatic bootstrap on clone 181 | lnk init -r git@github.com:you/dotfiles.git 182 | # → Clones repo and runs bootstrap script automatically 183 | 184 | # Skip bootstrap if needed 185 | lnk init -r git@github.com:you/dotfiles.git --no-bootstrap 186 | 187 | # Run bootstrap manually later 188 | lnk bootstrap 189 | ``` 190 | 191 | ## Multihost Support 192 | 193 | Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine). 194 | 195 | ### File Organization 196 | 197 | ``` 198 | ~/.config/lnk/ 199 | ├── .lnk # Tracks common files 200 | ├── .lnk.laptop # Tracks laptop-specific files 201 | ├── .lnk.work # Tracks work-specific files 202 | ├── .vimrc # Common file 203 | ├── .gitconfig # Common file 204 | ├── laptop.lnk/ # Laptop-specific storage 205 | │ ├── .ssh/ 206 | │ │ └── config 207 | │ └── .tmux.conf 208 | └── work.lnk/ # Work-specific storage 209 | ├── .ssh/ 210 | │ └── config 211 | └── .gitconfig 212 | ``` 213 | 214 | ### Usage Patterns 215 | 216 | ```bash 217 | # Common config (shared everywhere) - supports multiple files 218 | lnk add ~/.vimrc ~/.bashrc ~/.gitconfig ~/.tmux.conf 219 | 220 | # Process directory contents individually 221 | lnk add --recursive ~/.config/nvim ~/.config/zsh 222 | 223 | # Preview operations before making changes 224 | lnk add --dry-run ~/.config/alacritty/alacritty.yml 225 | lnk add --dry-run --recursive ~/.config/i3 226 | 227 | # Host-specific config (unique per machine) - supports bulk operations 228 | lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials 229 | lnk add --host work ~/.gitconfig ~/.npmrc 230 | 231 | # List configurations 232 | lnk list # Common only 233 | lnk list --host work # Work host only 234 | lnk list --all # Everything 235 | 236 | # Pull configurations 237 | lnk pull # Common config 238 | lnk pull --host work # Work-specific config 239 | ``` 240 | 241 | ## Why not just Git? 242 | 243 | You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts: 244 | 245 | - Moving files safely (with atomic operations) 246 | - Creating relative symlinks 247 | - Handling conflicts and rollback 248 | - Tracking what's managed 249 | - Processing multiple files efficiently 250 | - Recursive directory traversal 251 | - Preview mode for safety 252 | 253 | ## Examples 254 | 255 | ### First time setup 256 | 257 | ```bash 258 | # Clone dotfiles and run bootstrap automatically 259 | lnk init -r git@github.com:you/dotfiles.git 260 | # → Downloads dependencies, installs packages, configures environment 261 | 262 | # Add common config (shared across all machines) - multiple files at once 263 | lnk add ~/.bashrc ~/.vimrc ~/.gitconfig ~/.tmux.conf 264 | 265 | # Add configuration directories individually 266 | lnk add --recursive ~/.config/nvim ~/.config/zsh 267 | 268 | # Preview before adding sensitive files 269 | lnk add --dry-run ~/.ssh/id_rsa.pub 270 | lnk add ~/.ssh/id_rsa.pub # Add after verification 271 | 272 | # Add host-specific config (supports bulk operations) 273 | lnk add --host $(hostname) ~/.ssh/config ~/.aws/credentials 274 | 275 | lnk push "initial setup" 276 | ``` 277 | 278 | ### On a new machine 279 | 280 | ```bash 281 | # Bootstrap runs automatically 282 | lnk init -r git@github.com:you/dotfiles.git 283 | # → Sets up environment, installs dependencies 284 | 285 | # Pull common config 286 | lnk pull 287 | 288 | # Pull host-specific config (if it exists) 289 | lnk pull --host $(hostname) 290 | 291 | # Or run bootstrap manually if needed 292 | lnk bootstrap 293 | ``` 294 | 295 | ### Daily edits 296 | 297 | ```bash 298 | vim ~/.vimrc # edit normally 299 | lnk list # see common config 300 | lnk list --host $(hostname) # see host-specific config 301 | lnk list --all # see everything 302 | lnk status # check what changed 303 | lnk push "new plugins" # commit & push 304 | ``` 305 | 306 | ### Multi-machine workflow 307 | 308 | ```bash 309 | # On your laptop - use bulk operations for efficiency 310 | lnk add --host laptop ~/.ssh/config ~/.aws/credentials ~/.npmrc 311 | lnk add ~/.vimrc ~/.bashrc ~/.gitconfig # Common config (multiple files) 312 | lnk push "laptop configuration" 313 | 314 | # On your work machine 315 | lnk pull # Get common config 316 | lnk add --host work ~/.gitconfig ~/.ssh/config 317 | lnk add --recursive ~/.config/work-tools # Work-specific tools 318 | lnk push "work configuration" 319 | 320 | # Back on laptop 321 | lnk pull # Get updates (work config won't affect laptop) 322 | ``` 323 | 324 | ## Commands 325 | 326 | - `lnk init [-r remote] [--no-bootstrap] [--force]` - Create repo (runs bootstrap automatically) 327 | - `lnk add [--host HOST] [--recursive] [--dry-run] ...` - Move files to repo, create symlinks 328 | - `lnk rm [--host HOST] [--force] ` - Move files back, remove symlinks 329 | - `lnk list [--host HOST] [--all]` - List files managed by lnk 330 | - `lnk status` - Git status + sync info 331 | - `lnk push [msg]` - Stage all, commit, push 332 | - `lnk pull [--host HOST]` - Pull + restore missing symlinks 333 | - `lnk bootstrap` - Run bootstrap script manually 334 | 335 | ### Command Options 336 | 337 | - `--host HOST` - Manage files for specific host (default: common configuration) 338 | - `--recursive, -r` - Add directory contents individually instead of the directory as a whole 339 | - `--dry-run, -n` - Show what would be added without making changes 340 | - `--all` - Show all configurations (common + all hosts) when listing 341 | - `-r, --remote URL` - Clone from remote URL when initializing 342 | - `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning 343 | - `--force` - Force initialization even if directory contains managed files (WARNING: overwrites existing content) 344 | - `--force, -f` (rm) - Remove from tracking even if symlink is missing (useful if you accidentally deleted a managed file) 345 | 346 | ### Output Formatting 347 | 348 | Lnk provides flexible output formatting options to suit different environments and preferences: 349 | 350 | #### Color Output 351 | 352 | Control when ANSI colors are used in output: 353 | 354 | ```bash 355 | # Default: auto-detect based on TTY 356 | lnk init 357 | 358 | # Force colors regardless of environment 359 | lnk init --colors=always 360 | 361 | # Disable colors completely 362 | lnk init --colors=never 363 | 364 | # Environment variable support 365 | NO_COLOR=1 lnk init # Disables colors (acts like --colors=never) 366 | ``` 367 | 368 | **Color modes:** 369 | 370 | - `auto` (default): Use colors only when stdout is a TTY 371 | - `always`: Force color output regardless of TTY 372 | - `never`: Disable color output regardless of TTY 373 | 374 | The `NO_COLOR` environment variable acts like `--colors=never` when set, but explicit `--colors` flags take precedence. 375 | 376 | #### Emoji Output 377 | 378 | Control emoji usage in output messages: 379 | 380 | ```bash 381 | # Default: emojis enabled 382 | lnk init 383 | 384 | # Disable emojis 385 | lnk init --no-emoji 386 | 387 | # Explicitly enable emojis 388 | lnk init --emoji 389 | ``` 390 | 391 | **Emoji flags:** 392 | 393 | - `--emoji` (default: true): Enable emoji in output 394 | - `--no-emoji`: Disable emoji in output 395 | 396 | The `--emoji` and `--no-emoji` flags are mutually exclusive. 397 | 398 | #### Examples 399 | 400 | ```bash 401 | # Clean output for scripts/pipes 402 | lnk init --colors=never --no-emoji 403 | 404 | # Force colorful output in non-TTY environments 405 | lnk init --colors=always 406 | 407 | # Disable colors but keep emojis 408 | lnk init --colors=never 409 | 410 | # Disable emojis but keep colors 411 | lnk init --no-emoji 412 | ``` 413 | 414 | ### Add Command Examples 415 | 416 | ```bash 417 | # Multiple files at once 418 | lnk add ~/.bashrc ~/.vimrc ~/.gitconfig 419 | 420 | # Recursive directory processing 421 | lnk add --recursive ~/.config/nvim 422 | 423 | # Preview changes first 424 | lnk add --dry-run ~/.ssh/config 425 | lnk add --dry-run --recursive ~/.config/kitty 426 | 427 | # Host-specific bulk operations 428 | lnk add --host work ~/.gitconfig ~/.ssh/config ~/.npmrc 429 | ``` 430 | 431 | ## Technical bits 432 | 433 | - **Single binary** (~8MB, no deps) 434 | - **Relative symlinks** (portable) 435 | - **XDG compliant** (`~/.config/lnk`) 436 | - **Multihost support** (common + host-specific configs) 437 | - **Bootstrap support** (automatic environment setup) 438 | - **Bulk operations** (multiple files, atomic transactions) 439 | - **Recursive processing** (directory contents individually) 440 | - **Preview mode** (dry-run for safety) 441 | - **Data loss prevention** (safety checks with contextual warnings) 442 | - **Git-native** (standard Git repo, no special formats) 443 | 444 | ## Alternatives 445 | 446 | | Tool | Complexity | Why choose it | 447 | | ------- | ---------- | ----------------------------------------------------------------------------------------- | 448 | | **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap, bulk ops, dry-run, safety checks | 449 | | chezmoi | High | Templates, encryption, cross-platform | 450 | | yadm | Medium | Git power user, encryption | 451 | | dotbot | Low | YAML config, basic features | 452 | | stow | Low | Perl, symlink only | 453 | 454 | ## Contributing 455 | 456 | ```bash 457 | git clone https://github.com/yarlson/lnk.git 458 | cd lnk 459 | make deps # Install golangci-lint 460 | make check # Runs fmt, vet, lint, test 461 | ``` 462 | 463 | **What we use:** 464 | 465 | - **Runtime deps**: Only `cobra` (CLI framework) 466 | - **Test deps**: `testify` for assertions 467 | - **Build pipeline**: Standard Makefile with quality checks 468 | 469 | **Before submitting:** 470 | 471 | ```bash 472 | make check # Runs all quality checks + tests 473 | ``` 474 | 475 | **Adding features:** 476 | 477 | - Put integration tests in `test/integration_test.go` 478 | - Use conventional commits: `feat:`, `fix:`, `docs:` 479 | 480 | ## License 481 | 482 | [MIT](LICENSE) 483 | -------------------------------------------------------------------------------- /internal/git/git.go: -------------------------------------------------------------------------------- 1 | // Package git provides Git operations for lnk. 2 | package git 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/yarlson/lnk/internal/lnkerr" 15 | ) 16 | 17 | // Sentinel errors for git operations. 18 | var ( 19 | ErrGitInit = errors.New("Failed to initialize git repository. Please ensure git is installed and try again.") 20 | ErrBranchSetup = errors.New("Failed to set up the default branch. Please check your git installation.") 21 | ErrRemoteExists = errors.New("Remote is already configured with a different repository") 22 | ErrGitCommand = errors.New("Git operation failed. Please check your repository state and try again.") 23 | ErrNoRemote = errors.New("No remote repository is configured. Please add a remote repository first.") 24 | ErrRemoteNotFound = errors.New("Remote repository is not configured") 25 | ErrGitConfig = errors.New("Failed to configure git settings. Please check your git installation.") 26 | ErrPush = errors.New("Failed to push changes to remote repository. Please check your network connection and repository permissions.") 27 | ErrPull = errors.New("Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts.") 28 | ErrGitTimeout = errors.New("git operation timed out") 29 | ErrDirRemove = errors.New("Failed to prepare directory for operation. Please check directory permissions.") 30 | ErrDirCreate = errors.New("Failed to create directory. Please check permissions and available disk space.") 31 | ErrUncommitted = errors.New("Failed to check repository status. Please verify your git repository is valid.") 32 | ) 33 | 34 | const ( 35 | // shortTimeout for fast local operations (status, add, commit, etc.) 36 | shortTimeout = 30 * time.Second 37 | 38 | // longTimeout for network operations and large transfers (clone, push, pull) 39 | longTimeout = 5 * time.Minute 40 | ) 41 | 42 | // Git handles Git operations 43 | type Git struct { 44 | repoPath string 45 | } 46 | 47 | // New creates a new Git instance 48 | func New(repoPath string) *Git { 49 | return &Git{ 50 | repoPath: repoPath, 51 | } 52 | } 53 | 54 | // execGitCommand creates a git command with timeout context 55 | func (g *Git) execGitCommand(timeout time.Duration, args ...string) *exec.Cmd { 56 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 57 | // Note: cancel is not deferred here because the command takes ownership 58 | // of the context. The context will be automatically cleaned up when the 59 | // command completes or the timeout expires. 60 | _ = cancel // Prevent unused variable error 61 | 62 | cmd := exec.CommandContext(ctx, "git", args...) 63 | cmd.Dir = g.repoPath 64 | return cmd 65 | } 66 | 67 | // Init initializes a new Git repository 68 | func (g *Git) Init() error { 69 | // Try using git init -b main first (Git 2.28+) 70 | cmd := g.execGitCommand(shortTimeout, "init", "-b", "main") 71 | 72 | _, err := cmd.CombinedOutput() 73 | if err != nil { 74 | // Fallback to regular init + branch rename for older Git versions 75 | cmd = g.execGitCommand(shortTimeout, "init") 76 | 77 | _, err := cmd.CombinedOutput() 78 | if err != nil { 79 | if errors.Is(err, context.DeadlineExceeded) { 80 | return lnkerr.WithSuggestion(ErrGitTimeout, "check system resources and try again") 81 | } 82 | return lnkerr.WithSuggestion(ErrGitInit, "ensure git is installed and try again") 83 | } 84 | 85 | // Set the default branch to main 86 | cmd = g.execGitCommand(shortTimeout, "symbolic-ref", "HEAD", "refs/heads/main") 87 | 88 | if err := cmd.Run(); err != nil { 89 | if errors.Is(err, context.DeadlineExceeded) { 90 | return lnkerr.WithSuggestion(ErrGitTimeout, "check system resources and try again") 91 | } 92 | return lnkerr.WithSuggestion(ErrBranchSetup, "check your git installation") 93 | } 94 | } 95 | 96 | return nil 97 | } 98 | 99 | // AddRemote adds a remote to the repository 100 | func (g *Git) AddRemote(name, url string) error { 101 | // Check if remote already exists 102 | existingURL, err := g.getRemoteURL(name) 103 | if err == nil { 104 | // Remote exists, check if URL matches 105 | if existingURL == url { 106 | // Same URL, idempotent - do nothing 107 | return nil 108 | } 109 | // Different URL, error 110 | return lnkerr.WithPathAndSuggestion(ErrRemoteExists, name, "existing: "+existingURL+", new: "+url) 111 | } 112 | 113 | // Remote doesn't exist, add it 114 | cmd := g.execGitCommand(shortTimeout, "remote", "add", name, url) 115 | 116 | _, err = cmd.CombinedOutput() 117 | if err != nil { 118 | if errors.Is(err, context.DeadlineExceeded) { 119 | return lnkerr.WithSuggestion(ErrGitTimeout, "check system resources and try again") 120 | } 121 | return lnkerr.WithSuggestion(ErrGitCommand, "check the repository URL and try again") 122 | } 123 | 124 | return nil 125 | } 126 | 127 | // getRemoteURL returns the URL for a remote, or error if not found 128 | func (g *Git) getRemoteURL(name string) (string, error) { 129 | cmd := g.execGitCommand(shortTimeout, "remote", "get-url", name) 130 | 131 | output, err := cmd.Output() 132 | if err != nil { 133 | if errors.Is(err, context.DeadlineExceeded) { 134 | return "", lnkerr.Wrap(ErrGitTimeout) 135 | } 136 | return "", err 137 | } 138 | 139 | return strings.TrimSpace(string(output)), nil 140 | } 141 | 142 | // IsGitRepository checks if the directory contains a Git repository 143 | func (g *Git) IsGitRepository() bool { 144 | gitDir := filepath.Join(g.repoPath, ".git") 145 | _, err := os.Stat(gitDir) 146 | return err == nil 147 | } 148 | 149 | // IsLnkRepository checks if the repository appears to be managed by lnk 150 | func (g *Git) IsLnkRepository() bool { 151 | if !g.IsGitRepository() { 152 | return false 153 | } 154 | 155 | // Check if this looks like a lnk repository 156 | // We consider it a lnk repo if: 157 | // 1. It has no commits (fresh repo), OR 158 | // 2. All commits start with "lnk:" pattern 159 | 160 | commits, err := g.GetCommits() 161 | if err != nil { 162 | return false 163 | } 164 | 165 | // If no commits, it's a fresh repo - could be lnk 166 | if len(commits) == 0 { 167 | return true 168 | } 169 | 170 | // If all commits start with "lnk:", it's definitely ours 171 | // If ANY commit doesn't start with "lnk:", it's probably not ours 172 | for _, commit := range commits { 173 | if !strings.HasPrefix(commit, "lnk:") { 174 | return false 175 | } 176 | } 177 | 178 | return true 179 | } 180 | 181 | // AddAndCommit stages a file and commits it 182 | func (g *Git) AddAndCommit(filename, message string) error { 183 | // Stage the file 184 | if err := g.Add(filename); err != nil { 185 | return err 186 | } 187 | 188 | // Commit the changes 189 | if err := g.Commit(message); err != nil { 190 | return err 191 | } 192 | 193 | return nil 194 | } 195 | 196 | // RemoveAndCommit removes a file from Git and commits the change 197 | func (g *Git) RemoveAndCommit(filename, message string) error { 198 | // Remove the file from Git 199 | if err := g.Remove(filename); err != nil { 200 | return err 201 | } 202 | 203 | // Commit the changes 204 | if err := g.Commit(message); err != nil { 205 | return err 206 | } 207 | 208 | return nil 209 | } 210 | 211 | // Add stages a file 212 | func (g *Git) Add(filename string) error { 213 | cmd := g.execGitCommand(shortTimeout, "add", filename) 214 | 215 | _, err := cmd.CombinedOutput() 216 | if err != nil { 217 | if errors.Is(err, context.DeadlineExceeded) { 218 | return lnkerr.Wrap(ErrGitTimeout) 219 | } 220 | return lnkerr.WithSuggestion(ErrGitCommand, "check file permissions and try again") 221 | } 222 | 223 | return nil 224 | } 225 | 226 | // Remove removes a file from Git tracking 227 | func (g *Git) Remove(filename string) error { 228 | // Check if it's a directory that needs -r flag 229 | fullPath := filepath.Join(g.repoPath, filename) 230 | info, err := os.Stat(fullPath) 231 | 232 | var cmd *exec.Cmd 233 | if err == nil && info.IsDir() { 234 | // Use -r and --cached flags for directories (only remove from git, not filesystem) 235 | cmd = g.execGitCommand(shortTimeout, "rm", "-r", "--cached", filename) 236 | } else { 237 | // Regular file (only remove from git, not filesystem) 238 | cmd = g.execGitCommand(shortTimeout, "rm", "--cached", filename) 239 | } 240 | 241 | _, err = cmd.CombinedOutput() 242 | if err != nil { 243 | if errors.Is(err, context.DeadlineExceeded) { 244 | return lnkerr.Wrap(ErrGitTimeout) 245 | } 246 | return lnkerr.WithSuggestion(ErrGitCommand, "check if the file exists and try again") 247 | } 248 | 249 | return nil 250 | } 251 | 252 | // Commit creates a commit with the given message 253 | func (g *Git) Commit(message string) error { 254 | // Configure git user if not already configured 255 | if err := g.ensureGitConfig(); err != nil { 256 | return err 257 | } 258 | 259 | cmd := g.execGitCommand(shortTimeout, "commit", "-m", message) 260 | 261 | _, err := cmd.CombinedOutput() 262 | if err != nil { 263 | if errors.Is(err, context.DeadlineExceeded) { 264 | return lnkerr.Wrap(ErrGitTimeout) 265 | } 266 | return lnkerr.WithSuggestion(ErrGitCommand, "ensure you have staged changes and try again") 267 | } 268 | 269 | return nil 270 | } 271 | 272 | // ensureGitConfig ensures that git user.name and user.email are configured 273 | func (g *Git) ensureGitConfig() error { 274 | // Check if user.name is configured 275 | cmd := g.execGitCommand(shortTimeout, "config", "user.name") 276 | if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 { 277 | if err != nil && errors.Is(err, context.DeadlineExceeded) { 278 | return lnkerr.Wrap(ErrGitTimeout) 279 | } 280 | // Set a default user.name 281 | cmd = g.execGitCommand(shortTimeout, "config", "user.name", "Lnk User") 282 | if err := cmd.Run(); err != nil { 283 | if errors.Is(err, context.DeadlineExceeded) { 284 | return lnkerr.Wrap(ErrGitTimeout) 285 | } 286 | return lnkerr.WithSuggestion(ErrGitConfig, "check your git installation") 287 | } 288 | } 289 | 290 | // Check if user.email is configured 291 | cmd = g.execGitCommand(shortTimeout, "config", "user.email") 292 | if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 { 293 | if err != nil && errors.Is(err, context.DeadlineExceeded) { 294 | return lnkerr.Wrap(ErrGitTimeout) 295 | } 296 | // Set a default user.email 297 | cmd = g.execGitCommand(shortTimeout, "config", "user.email", "lnk@localhost") 298 | if err := cmd.Run(); err != nil { 299 | if errors.Is(err, context.DeadlineExceeded) { 300 | return lnkerr.Wrap(ErrGitTimeout) 301 | } 302 | return lnkerr.WithSuggestion(ErrGitConfig, "check your git installation") 303 | } 304 | } 305 | 306 | return nil 307 | } 308 | 309 | // GetCommits returns the list of commit messages for testing purposes 310 | func (g *Git) GetCommits() ([]string, error) { 311 | // Check if .git directory exists 312 | gitDir := filepath.Join(g.repoPath, ".git") 313 | if _, err := os.Stat(gitDir); os.IsNotExist(err) { 314 | return []string{}, nil 315 | } 316 | 317 | cmd := g.execGitCommand(shortTimeout, "log", "--oneline", "--format=%s") 318 | 319 | output, err := cmd.CombinedOutput() 320 | if err != nil { 321 | if errors.Is(err, context.DeadlineExceeded) { 322 | return nil, lnkerr.Wrap(ErrGitTimeout) 323 | } 324 | // If there are no commits yet, return empty slice 325 | outputStr := string(output) 326 | if strings.Contains(outputStr, "does not have any commits yet") { 327 | return []string{}, nil 328 | } 329 | return nil, lnkerr.Wrap(ErrGitCommand) 330 | } 331 | 332 | commits := strings.Split(strings.TrimSpace(string(output)), "\n") 333 | if len(commits) == 1 && commits[0] == "" { 334 | return []string{}, nil 335 | } 336 | 337 | return commits, nil 338 | } 339 | 340 | // GetRemoteInfo returns information about the default remote 341 | func (g *Git) GetRemoteInfo() (string, error) { 342 | // First try to get origin remote 343 | url, err := g.getRemoteURL("origin") 344 | if err != nil { 345 | // If origin doesn't exist, try to get any remote 346 | cmd := g.execGitCommand(shortTimeout, "remote") 347 | 348 | output, err := cmd.Output() 349 | if err != nil { 350 | if errors.Is(err, context.DeadlineExceeded) { 351 | return "", lnkerr.Wrap(ErrGitTimeout) 352 | } 353 | return "", lnkerr.Wrap(ErrGitCommand) 354 | } 355 | 356 | remotes := strings.Split(strings.TrimSpace(string(output)), "\n") 357 | if len(remotes) == 0 || remotes[0] == "" { 358 | return "", lnkerr.WithSuggestion(ErrNoRemote, "add a remote repository first") 359 | } 360 | 361 | // Use the first remote 362 | url, err = g.getRemoteURL(remotes[0]) 363 | if err != nil { 364 | return "", lnkerr.WithPath(ErrRemoteNotFound, remotes[0]) 365 | } 366 | } 367 | 368 | return url, nil 369 | } 370 | 371 | // StatusInfo contains repository status information 372 | type StatusInfo struct { 373 | Ahead int 374 | Behind int 375 | Remote string 376 | Dirty bool 377 | } 378 | 379 | // GetStatus returns the repository status relative to remote 380 | func (g *Git) GetStatus() (*StatusInfo, error) { 381 | // Check if we have a remote 382 | _, err := g.GetRemoteInfo() 383 | if err != nil { 384 | return nil, err 385 | } 386 | 387 | // Check for uncommitted changes 388 | dirty, err := g.HasChanges() 389 | if err != nil { 390 | return nil, lnkerr.WithSuggestion(ErrUncommitted, "verify your git repository is valid") 391 | } 392 | 393 | // Get the remote tracking branch 394 | cmd := g.execGitCommand(shortTimeout, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") 395 | 396 | output, err := cmd.Output() 397 | if err != nil { 398 | if errors.Is(err, context.DeadlineExceeded) { 399 | return nil, lnkerr.Wrap(ErrGitTimeout) 400 | } 401 | // No upstream branch set, assume origin/main 402 | remoteBranch := "origin/main" 403 | return &StatusInfo{ 404 | Ahead: g.getAheadCount(remoteBranch), 405 | Behind: 0, // Can't be behind if no upstream 406 | Remote: remoteBranch, 407 | Dirty: dirty, 408 | }, nil 409 | } 410 | 411 | remoteBranch := strings.TrimSpace(string(output)) 412 | 413 | return &StatusInfo{ 414 | Ahead: g.getAheadCount(remoteBranch), 415 | Behind: g.getBehindCount(remoteBranch), 416 | Remote: remoteBranch, 417 | Dirty: dirty, 418 | }, nil 419 | } 420 | 421 | // getAheadCount returns how many commits ahead of remote 422 | func (g *Git) getAheadCount(remoteBranch string) int { 423 | cmd := g.execGitCommand(shortTimeout, "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch)) 424 | 425 | output, err := cmd.Output() 426 | if err != nil { 427 | // If remote branch doesn't exist, count all local commits 428 | cmd = g.execGitCommand(shortTimeout, "rev-list", "--count", "HEAD") 429 | 430 | output, err = cmd.Output() 431 | if err != nil { 432 | return 0 433 | } 434 | } 435 | 436 | count := strings.TrimSpace(string(output)) 437 | if count == "" { 438 | return 0 439 | } 440 | 441 | // Convert to int 442 | var ahead int 443 | if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil { 444 | return 0 445 | } 446 | 447 | return ahead 448 | } 449 | 450 | // getBehindCount returns how many commits behind remote 451 | func (g *Git) getBehindCount(remoteBranch string) int { 452 | cmd := g.execGitCommand(shortTimeout, "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch)) 453 | 454 | output, err := cmd.Output() 455 | if err != nil { 456 | return 0 457 | } 458 | 459 | count := strings.TrimSpace(string(output)) 460 | if count == "" { 461 | return 0 462 | } 463 | 464 | // Convert to int 465 | var behind int 466 | if _, err := fmt.Sscanf(count, "%d", &behind); err != nil { 467 | return 0 468 | } 469 | 470 | return behind 471 | } 472 | 473 | // HasChanges checks if there are uncommitted changes 474 | func (g *Git) HasChanges() (bool, error) { 475 | cmd := g.execGitCommand(shortTimeout, "status", "--porcelain") 476 | 477 | output, err := cmd.Output() 478 | if err != nil { 479 | if errors.Is(err, context.DeadlineExceeded) { 480 | return false, lnkerr.Wrap(ErrGitTimeout) 481 | } 482 | return false, lnkerr.Wrap(ErrGitCommand) 483 | } 484 | 485 | return len(strings.TrimSpace(string(output))) > 0, nil 486 | } 487 | 488 | // AddAll stages all changes in the repository 489 | func (g *Git) AddAll() error { 490 | cmd := g.execGitCommand(shortTimeout, "add", "-A") 491 | 492 | _, err := cmd.CombinedOutput() 493 | if err != nil { 494 | if errors.Is(err, context.DeadlineExceeded) { 495 | return lnkerr.Wrap(ErrGitTimeout) 496 | } 497 | return lnkerr.WithSuggestion(ErrGitCommand, "check file permissions and try again") 498 | } 499 | 500 | return nil 501 | } 502 | 503 | // Push pushes changes to remote 504 | func (g *Git) Push() error { 505 | // First ensure we have a remote configured 506 | _, err := g.GetRemoteInfo() 507 | if err != nil { 508 | return lnkerr.WithSuggestion(ErrPush, err.Error()) 509 | } 510 | 511 | cmd := g.execGitCommand(longTimeout, "push", "-u", "origin") 512 | 513 | _, err = cmd.CombinedOutput() 514 | if err != nil { 515 | if errors.Is(err, context.DeadlineExceeded) { 516 | return lnkerr.Wrap(ErrGitTimeout) 517 | } 518 | return lnkerr.WithSuggestion(ErrPush, "check your network connection and repository permissions") 519 | } 520 | 521 | return nil 522 | } 523 | 524 | // Pull pulls changes from remote 525 | func (g *Git) Pull() error { 526 | // First ensure we have a remote configured 527 | _, err := g.GetRemoteInfo() 528 | if err != nil { 529 | return lnkerr.WithSuggestion(ErrPull, err.Error()) 530 | } 531 | 532 | cmd := g.execGitCommand(longTimeout, "pull", "origin") 533 | 534 | _, err = cmd.CombinedOutput() 535 | if err != nil { 536 | if errors.Is(err, context.DeadlineExceeded) { 537 | return lnkerr.Wrap(ErrGitTimeout) 538 | } 539 | return lnkerr.WithSuggestion(ErrPull, "check your network connection and resolve any conflicts") 540 | } 541 | 542 | return nil 543 | } 544 | 545 | // Clone clones a repository from the given URL 546 | func (g *Git) Clone(url string) error { 547 | // Remove the directory if it exists to ensure clean clone 548 | if err := os.RemoveAll(g.repoPath); err != nil { 549 | return lnkerr.WithPath(ErrDirRemove, g.repoPath) 550 | } 551 | 552 | // Create parent directory 553 | parentDir := filepath.Dir(g.repoPath) 554 | if err := os.MkdirAll(parentDir, 0755); err != nil { 555 | return lnkerr.WithPath(ErrDirCreate, parentDir) 556 | } 557 | 558 | // Clone the repository 559 | // Note: Can't use execGitCommand here because it sets cmd.Dir to g.repoPath, 560 | // which doesn't exist yet. Clone needs to run from parent directory. 561 | ctx, cancel := context.WithTimeout(context.Background(), longTimeout) 562 | defer cancel() 563 | 564 | cmd := exec.CommandContext(ctx, "git", "clone", url, g.repoPath) 565 | _, err := cmd.CombinedOutput() 566 | if err != nil { 567 | if errors.Is(err, context.DeadlineExceeded) { 568 | return lnkerr.Wrap(ErrGitTimeout) 569 | } 570 | return lnkerr.WithSuggestion(ErrGitCommand, "check the repository URL and your network connection") 571 | } 572 | 573 | // Set up upstream tracking for main branch 574 | cmd = g.execGitCommand(shortTimeout, "branch", "--set-upstream-to=origin/main", "main") 575 | _, err = cmd.CombinedOutput() 576 | if err != nil { 577 | if errors.Is(err, context.DeadlineExceeded) { 578 | return lnkerr.Wrap(ErrGitTimeout) 579 | } 580 | // If main doesn't exist, try master 581 | cmd = g.execGitCommand(shortTimeout, "branch", "--set-upstream-to=origin/master", "master") 582 | _, err = cmd.CombinedOutput() 583 | if err != nil { 584 | if errors.Is(err, context.DeadlineExceeded) { 585 | return lnkerr.Wrap(ErrGitTimeout) 586 | } 587 | // If that also fails, try to set upstream for current branch 588 | cmd = g.execGitCommand(shortTimeout, "branch", "--set-upstream-to=origin/HEAD") 589 | _, _ = cmd.CombinedOutput() // Ignore error as this is best effort 590 | } 591 | } 592 | 593 | return nil 594 | } 595 | -------------------------------------------------------------------------------- /internal/core/lnk.go: -------------------------------------------------------------------------------- 1 | // Package core implements the business logic for lnk. 2 | package core 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "sort" 12 | "strings" 13 | 14 | "github.com/yarlson/lnk/internal/fs" 15 | "github.com/yarlson/lnk/internal/git" 16 | "github.com/yarlson/lnk/internal/lnkerr" 17 | ) 18 | 19 | // Sentinel errors for lnk operations. 20 | var ( 21 | ErrManagedFilesExist = errors.New("Directory already contains managed files") 22 | ErrGitRepoExists = errors.New("Directory contains an existing Git repository") 23 | ErrAlreadyManaged = errors.New("File is already managed by lnk") 24 | ErrNotManaged = errors.New("File is not managed by lnk") 25 | ErrNotInitialized = errors.New("Lnk repository not initialized") 26 | ErrBootstrapNotFound = errors.New("Bootstrap script not found") 27 | ErrBootstrapFailed = errors.New("Bootstrap script failed with error") 28 | ErrBootstrapPerms = errors.New("Failed to make bootstrap script executable") 29 | ) 30 | 31 | // Lnk represents the main application logic 32 | type Lnk struct { 33 | repoPath string 34 | host string // Host-specific configuration 35 | git *git.Git 36 | fs *fs.FileSystem 37 | } 38 | 39 | type Option func(*Lnk) 40 | 41 | // WithHost sets the host for host-specific configuration 42 | func WithHost(host string) Option { 43 | return func(l *Lnk) { 44 | l.host = host 45 | } 46 | } 47 | 48 | // NewLnk creates a new Lnk instance with optional configuration 49 | func NewLnk(opts ...Option) *Lnk { 50 | repoPath := GetRepoPath() 51 | lnk := &Lnk{ 52 | repoPath: repoPath, 53 | host: "", 54 | git: git.New(repoPath), 55 | fs: fs.New(), 56 | } 57 | 58 | for _, opt := range opts { 59 | opt(lnk) 60 | } 61 | 62 | return lnk 63 | } 64 | 65 | // HasUserContent checks if the repository contains managed files 66 | // by looking for .lnk tracker files (common or host-specific) 67 | func (l *Lnk) HasUserContent() bool { 68 | // Check for common tracker file 69 | commonTracker := filepath.Join(l.repoPath, ".lnk") 70 | if _, err := os.Stat(commonTracker); err == nil { 71 | return true 72 | } 73 | 74 | // Check for host-specific tracker files if host is set 75 | if l.host != "" { 76 | hostTracker := filepath.Join(l.repoPath, fmt.Sprintf(".lnk.%s", l.host)) 77 | if _, err := os.Stat(hostTracker); err == nil { 78 | return true 79 | } 80 | } else { 81 | // If no specific host is set, check for any host-specific tracker files 82 | // This handles cases where we want to detect any managed content 83 | pattern := filepath.Join(l.repoPath, ".lnk.*") 84 | matches, err := filepath.Glob(pattern) 85 | if err == nil && len(matches) > 0 { 86 | return true 87 | } 88 | } 89 | 90 | return false 91 | } 92 | 93 | // GetCurrentHostname returns the current system hostname 94 | func GetCurrentHostname() (string, error) { 95 | hostname, err := os.Hostname() 96 | if err != nil { 97 | return "", fmt.Errorf("failed to get hostname: %w", err) 98 | } 99 | return hostname, nil 100 | } 101 | 102 | // GetRepoPath returns the path to the lnk repository directory 103 | // It respects XDG_CONFIG_HOME if set, otherwise defaults to ~/.config/lnk 104 | func GetRepoPath() string { 105 | xdgConfig := os.Getenv("XDG_CONFIG_HOME") 106 | if xdgConfig == "" { 107 | homeDir, err := os.UserHomeDir() 108 | if err != nil { 109 | // Fallback to current directory if we can't get home 110 | xdgConfig = "." 111 | } else { 112 | xdgConfig = filepath.Join(homeDir, ".config") 113 | } 114 | } 115 | return filepath.Join(xdgConfig, "lnk") 116 | } 117 | 118 | // getHostStoragePath returns the storage path for host-specific or common files 119 | func (l *Lnk) getHostStoragePath() string { 120 | if l.host == "" { 121 | // Common configuration - store in root of repo 122 | return l.repoPath 123 | } 124 | // Host-specific configuration - store in host subdirectory 125 | return filepath.Join(l.repoPath, l.host+".lnk") 126 | } 127 | 128 | // getLnkFileName returns the appropriate .lnk tracking file name 129 | func (l *Lnk) getLnkFileName() string { 130 | if l.host == "" { 131 | return ".lnk" 132 | } 133 | return ".lnk." + l.host 134 | } 135 | 136 | // getRelativePath converts an absolute path to a relative path from home directory 137 | func getRelativePath(absPath string) (string, error) { 138 | homeDir, err := os.UserHomeDir() 139 | if err != nil { 140 | return "", fmt.Errorf("failed to get home directory: %w", err) 141 | } 142 | 143 | // Check if the file is under home directory 144 | relPath, err := filepath.Rel(homeDir, absPath) 145 | if err != nil { 146 | return "", fmt.Errorf("failed to get relative path: %w", err) 147 | } 148 | 149 | // If the relative path starts with "..", the file is outside home directory 150 | // In this case, use the absolute path as relative (without the leading slash) 151 | if strings.HasPrefix(relPath, "..") { 152 | // Use absolute path but remove leading slash and drive letter (for cross-platform) 153 | cleanPath := strings.TrimPrefix(absPath, "/") 154 | return cleanPath, nil 155 | } 156 | 157 | return relPath, nil 158 | } 159 | 160 | // Init initializes the lnk repository 161 | func (l *Lnk) Init() error { 162 | return l.InitWithRemote("") 163 | } 164 | 165 | // InitWithRemote initializes the lnk repository, optionally cloning from a remote 166 | func (l *Lnk) InitWithRemote(remoteURL string) error { 167 | return l.InitWithRemoteForce(remoteURL, false) 168 | } 169 | 170 | // InitWithRemoteForce initializes the lnk repository with optional force override 171 | func (l *Lnk) InitWithRemoteForce(remoteURL string, force bool) error { 172 | if remoteURL != "" { 173 | // Safety check: prevent data loss by checking for existing managed files 174 | if l.HasUserContent() { 175 | if !force { 176 | return lnkerr.WithPathAndSuggestion(ErrManagedFilesExist, l.repoPath, "use 'lnk pull' to update from remote instead of 'lnk init -r'") 177 | } 178 | } 179 | // Clone from remote 180 | return l.Clone(remoteURL) 181 | } 182 | 183 | // Create the repository directory 184 | if err := os.MkdirAll(l.repoPath, 0755); err != nil { 185 | return fmt.Errorf("failed to create lnk directory: %w", err) 186 | } 187 | 188 | // Check if there's already a Git repository 189 | if l.git.IsGitRepository() { 190 | // Repository exists, check if it's a lnk repository 191 | if l.git.IsLnkRepository() { 192 | // It's a lnk repository, init is idempotent - do nothing 193 | return nil 194 | } else { 195 | // It's not a lnk repository, error to prevent data loss 196 | return lnkerr.WithPathAndSuggestion(ErrGitRepoExists, l.repoPath, "backup or move the existing repository before initializing lnk") 197 | } 198 | } 199 | 200 | // No existing repository, initialize Git repository 201 | return l.git.Init() 202 | } 203 | 204 | // Clone clones a repository from the given URL 205 | func (l *Lnk) Clone(url string) error { 206 | return l.git.Clone(url) 207 | } 208 | 209 | // AddRemote adds a remote to the repository 210 | func (l *Lnk) AddRemote(name, url string) error { 211 | return l.git.AddRemote(name, url) 212 | } 213 | 214 | // Add moves a file or directory to the repository and creates a symlink 215 | func (l *Lnk) Add(filePath string) error { 216 | // Validate the file or directory 217 | if err := l.fs.ValidateFileForAdd(filePath); err != nil { 218 | return err 219 | } 220 | 221 | // Get absolute path 222 | absPath, err := filepath.Abs(filePath) 223 | if err != nil { 224 | return fmt.Errorf("failed to get absolute path: %w", err) 225 | } 226 | 227 | // Get relative path for tracking 228 | relativePath, err := getRelativePath(absPath) 229 | if err != nil { 230 | return fmt.Errorf("failed to get relative path: %w", err) 231 | } 232 | 233 | // Generate repository path from relative path 234 | storagePath := l.getHostStoragePath() 235 | destPath := filepath.Join(storagePath, relativePath) 236 | 237 | // Ensure destination directory exists (including parent directories for host-specific files) 238 | destDir := filepath.Dir(destPath) 239 | if err := os.MkdirAll(destDir, 0755); err != nil { 240 | return fmt.Errorf("failed to create destination directory: %w", err) 241 | } 242 | 243 | // Check if this relative path is already managed 244 | managedItems, err := l.getManagedItems() 245 | if err != nil { 246 | return fmt.Errorf("failed to get managed items: %w", err) 247 | } 248 | for _, item := range managedItems { 249 | if item == relativePath { 250 | return lnkerr.WithPath(ErrAlreadyManaged, relativePath) 251 | } 252 | } 253 | 254 | // Check if it's a directory or file 255 | info, err := os.Stat(absPath) 256 | if err != nil { 257 | return fmt.Errorf("failed to stat path: %w", err) 258 | } 259 | 260 | // Move to repository (handles both files and directories) 261 | if err := l.fs.Move(absPath, destPath, info); err != nil { 262 | return err 263 | } 264 | 265 | // Create symlink 266 | if err := l.fs.CreateSymlink(destPath, absPath); err != nil { 267 | // Try to restore the original if symlink creation fails 268 | _ = l.fs.Move(destPath, absPath, info) 269 | return err 270 | } 271 | 272 | // Add to .lnk tracking file using relative path 273 | if err := l.addManagedItem(relativePath); err != nil { 274 | // Try to restore the original state if tracking fails 275 | _ = os.Remove(absPath) 276 | _ = l.fs.Move(destPath, absPath, info) 277 | return fmt.Errorf("failed to update tracking file: %w", err) 278 | } 279 | 280 | // Add both the item and .lnk file to git in a single commit 281 | // For host-specific files, we need to add the relative path from repo root 282 | gitPath := relativePath 283 | if l.host != "" { 284 | gitPath = filepath.Join(l.host+".lnk", relativePath) 285 | } 286 | if err := l.git.Add(gitPath); err != nil { 287 | // Try to restore the original state if git add fails 288 | _ = os.Remove(absPath) 289 | _ = l.removeManagedItem(relativePath) 290 | _ = l.fs.Move(destPath, absPath, info) 291 | return err 292 | } 293 | 294 | // Add .lnk file to the same commit 295 | if err := l.git.Add(l.getLnkFileName()); err != nil { 296 | // Try to restore the original state if git add fails 297 | _ = os.Remove(absPath) 298 | _ = l.removeManagedItem(relativePath) 299 | _ = l.fs.Move(destPath, absPath, info) 300 | return err 301 | } 302 | 303 | // Commit both changes together 304 | basename := filepath.Base(relativePath) 305 | if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil { 306 | // Try to restore the original state if commit fails 307 | _ = os.Remove(absPath) 308 | _ = l.removeManagedItem(relativePath) 309 | _ = l.fs.Move(destPath, absPath, info) 310 | return err 311 | } 312 | 313 | return nil 314 | } 315 | 316 | // AddMultiple adds multiple files or directories to the repository in a single transaction 317 | func (l *Lnk) AddMultiple(paths []string) error { 318 | if len(paths) == 0 { 319 | return nil 320 | } 321 | 322 | // Phase 1: Validate all paths first 323 | var relativePaths []string 324 | var absolutePaths []string 325 | var infos []os.FileInfo 326 | 327 | for _, filePath := range paths { 328 | // Validate the file or directory 329 | if err := l.fs.ValidateFileForAdd(filePath); err != nil { 330 | return fmt.Errorf("validation failed for %s: %w", filePath, err) 331 | } 332 | 333 | // Get absolute path 334 | absPath, err := filepath.Abs(filePath) 335 | if err != nil { 336 | return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err) 337 | } 338 | 339 | // Get relative path for tracking 340 | relativePath, err := getRelativePath(absPath) 341 | if err != nil { 342 | return fmt.Errorf("failed to get relative path for %s: %w", filePath, err) 343 | } 344 | 345 | // Check if this relative path is already managed 346 | managedItems, err := l.getManagedItems() 347 | if err != nil { 348 | return fmt.Errorf("failed to get managed items: %w", err) 349 | } 350 | for _, item := range managedItems { 351 | if item == relativePath { 352 | return lnkerr.WithPath(ErrAlreadyManaged, relativePath) 353 | } 354 | } 355 | 356 | // Get file info 357 | info, err := os.Stat(absPath) 358 | if err != nil { 359 | return fmt.Errorf("failed to stat path %s: %w", filePath, err) 360 | } 361 | 362 | relativePaths = append(relativePaths, relativePath) 363 | absolutePaths = append(absolutePaths, absPath) 364 | infos = append(infos, info) 365 | } 366 | 367 | // Phase 2: Process all files - move to repository and create symlinks 368 | var rollbackActions []func() error 369 | 370 | for i, absPath := range absolutePaths { 371 | relativePath := relativePaths[i] 372 | info := infos[i] 373 | 374 | // Generate repository path from relative path 375 | storagePath := l.getHostStoragePath() 376 | destPath := filepath.Join(storagePath, relativePath) 377 | 378 | // Ensure destination directory exists 379 | destDir := filepath.Dir(destPath) 380 | if err := os.MkdirAll(destDir, 0755); err != nil { 381 | // Rollback previous operations 382 | l.rollbackOperations(rollbackActions) 383 | return fmt.Errorf("failed to create destination directory: %w", err) 384 | } 385 | 386 | // Move to repository 387 | if err := l.fs.Move(absPath, destPath, info); err != nil { 388 | // Rollback previous operations 389 | l.rollbackOperations(rollbackActions) 390 | return fmt.Errorf("failed to move %s: %w", absPath, err) 391 | } 392 | 393 | // Create symlink 394 | if err := l.fs.CreateSymlink(destPath, absPath); err != nil { 395 | // Try to restore the file we just moved, then rollback others 396 | _ = l.fs.Move(destPath, absPath, info) 397 | l.rollbackOperations(rollbackActions) 398 | return fmt.Errorf("failed to create symlink for %s: %w", absPath, err) 399 | } 400 | 401 | // Add to tracking 402 | if err := l.addManagedItem(relativePath); err != nil { 403 | // Restore this file and rollback others 404 | _ = os.Remove(absPath) 405 | _ = l.fs.Move(destPath, absPath, info) 406 | l.rollbackOperations(rollbackActions) 407 | return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err) 408 | } 409 | 410 | // Add rollback action for this file 411 | rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info) 412 | rollbackActions = append(rollbackActions, rollbackAction) 413 | } 414 | 415 | // Phase 3: Git operations - add all files and create single commit 416 | for i, relativePath := range relativePaths { 417 | // For host-specific files, we need to add the relative path from repo root 418 | gitPath := relativePath 419 | if l.host != "" { 420 | gitPath = filepath.Join(l.host+".lnk", relativePath) 421 | } 422 | if err := l.git.Add(gitPath); err != nil { 423 | // Rollback all operations 424 | l.rollbackOperations(rollbackActions) 425 | return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err) 426 | } 427 | } 428 | 429 | // Add .lnk file to the same commit 430 | if err := l.git.Add(l.getLnkFileName()); err != nil { 431 | // Rollback all operations 432 | l.rollbackOperations(rollbackActions) 433 | return fmt.Errorf("failed to add tracking file to git: %w", err) 434 | } 435 | 436 | // Commit all changes together 437 | commitMessage := fmt.Sprintf("lnk: added %d files", len(paths)) 438 | if err := l.git.Commit(commitMessage); err != nil { 439 | // Rollback all operations 440 | l.rollbackOperations(rollbackActions) 441 | return fmt.Errorf("failed to commit changes: %w", err) 442 | } 443 | 444 | return nil 445 | } 446 | 447 | // createRollbackAction creates a rollback function for a single file operation 448 | func (l *Lnk) createRollbackAction(absPath, destPath, relativePath string, info os.FileInfo) func() error { 449 | return func() error { 450 | _ = os.Remove(absPath) 451 | _ = l.removeManagedItem(relativePath) 452 | return l.fs.Move(destPath, absPath, info) 453 | } 454 | } 455 | 456 | // rollbackOperations executes rollback actions in reverse order 457 | func (l *Lnk) rollbackOperations(rollbackActions []func() error) { 458 | for i := len(rollbackActions) - 1; i >= 0; i-- { 459 | _ = rollbackActions[i]() 460 | } 461 | } 462 | 463 | // Remove removes a symlink and restores the original file or directory 464 | func (l *Lnk) Remove(filePath string) error { 465 | // Get absolute path 466 | absPath, err := filepath.Abs(filePath) 467 | if err != nil { 468 | return fmt.Errorf("failed to get absolute path: %w", err) 469 | } 470 | 471 | // Validate that this is a symlink managed by lnk 472 | if err := l.fs.ValidateSymlinkForRemove(absPath, l.repoPath); err != nil { 473 | return err 474 | } 475 | 476 | // Get relative path for tracking 477 | relativePath, err := getRelativePath(absPath) 478 | if err != nil { 479 | return fmt.Errorf("failed to get relative path: %w", err) 480 | } 481 | 482 | // Check if this relative path is managed 483 | managedItems, err := l.getManagedItems() 484 | if err != nil { 485 | return fmt.Errorf("failed to get managed items: %w", err) 486 | } 487 | 488 | found := false 489 | for _, item := range managedItems { 490 | if item == relativePath { 491 | found = true 492 | break 493 | } 494 | } 495 | if !found { 496 | return lnkerr.WithPath(ErrNotManaged, relativePath) 497 | } 498 | 499 | // Get the target path in the repository 500 | target, err := os.Readlink(absPath) 501 | if err != nil { 502 | return fmt.Errorf("failed to read symlink: %w", err) 503 | } 504 | 505 | // Convert relative path to absolute if needed 506 | if !filepath.IsAbs(target) { 507 | target = filepath.Join(filepath.Dir(absPath), target) 508 | } 509 | 510 | // Check if target is a directory or file 511 | info, err := os.Stat(target) 512 | if err != nil { 513 | return fmt.Errorf("failed to stat target: %w", err) 514 | } 515 | 516 | // Remove the symlink 517 | if err := os.Remove(absPath); err != nil { 518 | return fmt.Errorf("failed to remove symlink: %w", err) 519 | } 520 | 521 | // Remove from .lnk tracking file using relative path 522 | if err := l.removeManagedItem(relativePath); err != nil { 523 | return fmt.Errorf("failed to update tracking file: %w", err) 524 | } 525 | 526 | // Generate the correct git path for removal 527 | gitPath := relativePath 528 | if l.host != "" { 529 | gitPath = filepath.Join(l.host+".lnk", relativePath) 530 | } 531 | if err := l.git.Remove(gitPath); err != nil { 532 | return err 533 | } 534 | 535 | // Add .lnk file to the same commit 536 | if err := l.git.Add(l.getLnkFileName()); err != nil { 537 | return err 538 | } 539 | 540 | // Commit both changes together 541 | basename := filepath.Base(relativePath) 542 | if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil { 543 | return err 544 | } 545 | 546 | // Move back from repository (handles both files and directories) 547 | if err := l.fs.Move(target, absPath, info); err != nil { 548 | return err 549 | } 550 | 551 | return nil 552 | } 553 | 554 | // RemoveForce removes a file from lnk tracking even if the symlink no longer exists. 555 | // This is useful when a user accidentally deletes a managed file without using lnk rm. 556 | func (l *Lnk) RemoveForce(filePath string) error { 557 | // Get relative path for tracking 558 | absPath, err := filepath.Abs(filePath) 559 | if err != nil { 560 | return fmt.Errorf("failed to get absolute path: %w", err) 561 | } 562 | 563 | relativePath, err := getRelativePath(absPath) 564 | if err != nil { 565 | return fmt.Errorf("failed to get relative path: %w", err) 566 | } 567 | 568 | // Check if this relative path is managed 569 | managedItems, err := l.getManagedItems() 570 | if err != nil { 571 | return fmt.Errorf("failed to get managed items: %w", err) 572 | } 573 | 574 | found := false 575 | for _, item := range managedItems { 576 | if item == relativePath { 577 | found = true 578 | break 579 | } 580 | } 581 | if !found { 582 | return lnkerr.WithPath(ErrNotManaged, relativePath) 583 | } 584 | 585 | // Remove symlink if it exists (ignore errors - it may already be gone) 586 | _ = os.Remove(absPath) 587 | 588 | // Remove from .lnk tracking file 589 | if err := l.removeManagedItem(relativePath); err != nil { 590 | return fmt.Errorf("failed to update tracking file: %w", err) 591 | } 592 | 593 | // Generate the correct git path for removal 594 | gitPath := relativePath 595 | if l.host != "" { 596 | gitPath = filepath.Join(l.host+".lnk", relativePath) 597 | } 598 | 599 | // Remove from git (ignore errors - file may not be in git index) 600 | _ = l.git.Remove(gitPath) 601 | 602 | // Add .lnk file to the commit 603 | if err := l.git.Add(l.getLnkFileName()); err != nil { 604 | return err 605 | } 606 | 607 | // Commit the change 608 | basename := filepath.Base(relativePath) 609 | if err := l.git.Commit(fmt.Sprintf("lnk: force removed %s", basename)); err != nil { 610 | return err 611 | } 612 | 613 | // Try to delete the repository copy if it exists 614 | repoPath := filepath.Join(l.repoPath, gitPath) 615 | if _, err := os.Stat(repoPath); err == nil { 616 | // File exists in repo, remove it 617 | if err := os.RemoveAll(repoPath); err != nil { 618 | return fmt.Errorf("failed to remove repository copy: %w", err) 619 | } 620 | } 621 | 622 | return nil 623 | } 624 | 625 | // GetCommits returns the list of commits for testing purposes 626 | func (l *Lnk) GetCommits() ([]string, error) { 627 | return l.git.GetCommits() 628 | } 629 | 630 | // StatusInfo contains repository sync status information 631 | type StatusInfo struct { 632 | Ahead int 633 | Behind int 634 | Remote string 635 | Dirty bool 636 | } 637 | 638 | // Status returns the repository sync status 639 | func (l *Lnk) Status() (*StatusInfo, error) { 640 | // Check if repository is initialized 641 | if !l.git.IsGitRepository() { 642 | return nil, lnkerr.WithSuggestion(ErrNotInitialized, "run 'lnk init' first") 643 | } 644 | 645 | gitStatus, err := l.git.GetStatus() 646 | if err != nil { 647 | return nil, err 648 | } 649 | 650 | return &StatusInfo{ 651 | Ahead: gitStatus.Ahead, 652 | Behind: gitStatus.Behind, 653 | Remote: gitStatus.Remote, 654 | Dirty: gitStatus.Dirty, 655 | }, nil 656 | } 657 | 658 | // Push stages all changes and creates a sync commit, then pushes to remote 659 | func (l *Lnk) Push(message string) error { 660 | // Check if repository is initialized 661 | if !l.git.IsGitRepository() { 662 | return lnkerr.WithSuggestion(ErrNotInitialized, "run 'lnk init' first") 663 | } 664 | 665 | // Check if there are any changes 666 | hasChanges, err := l.git.HasChanges() 667 | if err != nil { 668 | return err 669 | } 670 | 671 | if hasChanges { 672 | // Stage all changes 673 | if err := l.git.AddAll(); err != nil { 674 | return err 675 | } 676 | 677 | // Create a sync commit 678 | if err := l.git.Commit(message); err != nil { 679 | return err 680 | } 681 | } 682 | 683 | // Push to remote (this will be a no-op in tests since we don't have real remotes) 684 | // In real usage, this would push to the actual remote repository 685 | return l.git.Push() 686 | } 687 | 688 | // Pull fetches changes from remote and restores symlinks as needed 689 | func (l *Lnk) Pull() ([]string, error) { 690 | // Check if repository is initialized 691 | if !l.git.IsGitRepository() { 692 | return nil, lnkerr.WithSuggestion(ErrNotInitialized, "run 'lnk init' first") 693 | } 694 | 695 | // Pull changes from remote (this will be a no-op in tests since we don't have real remotes) 696 | if err := l.git.Pull(); err != nil { 697 | return nil, err 698 | } 699 | 700 | // Find all managed files in the repository and restore symlinks 701 | restored, err := l.RestoreSymlinks() 702 | if err != nil { 703 | return nil, fmt.Errorf("failed to restore symlinks: %w", err) 704 | } 705 | 706 | return restored, nil 707 | } 708 | 709 | // List returns the list of files and directories currently managed by lnk 710 | func (l *Lnk) List() ([]string, error) { 711 | // Check if repository is initialized 712 | if !l.git.IsGitRepository() { 713 | return nil, lnkerr.WithSuggestion(ErrNotInitialized, "run 'lnk init' first") 714 | } 715 | 716 | // Get managed items from .lnk file 717 | managedItems, err := l.getManagedItems() 718 | if err != nil { 719 | return nil, fmt.Errorf("failed to get managed items: %w", err) 720 | } 721 | 722 | return managedItems, nil 723 | } 724 | 725 | // RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks 726 | func (l *Lnk) RestoreSymlinks() ([]string, error) { 727 | var restored []string 728 | 729 | // Get managed items from .lnk file (now containing relative paths) 730 | managedItems, err := l.getManagedItems() 731 | if err != nil { 732 | return nil, fmt.Errorf("failed to get managed items: %w", err) 733 | } 734 | 735 | homeDir, err := os.UserHomeDir() 736 | if err != nil { 737 | return nil, fmt.Errorf("failed to get home directory: %w", err) 738 | } 739 | 740 | for _, relativePath := range managedItems { 741 | // Generate repository name from relative path 742 | storagePath := l.getHostStoragePath() 743 | repoItem := filepath.Join(storagePath, relativePath) 744 | 745 | // Check if item exists in repository 746 | if _, err := os.Stat(repoItem); os.IsNotExist(err) { 747 | continue // Skip missing items 748 | } 749 | 750 | // Determine where the symlink should be created 751 | symlinkPath := filepath.Join(homeDir, relativePath) 752 | 753 | // Check if symlink already exists and is correct 754 | if l.isValidSymlink(symlinkPath, repoItem) { 755 | continue 756 | } 757 | 758 | // Ensure parent directory exists 759 | symlinkDir := filepath.Dir(symlinkPath) 760 | if err := os.MkdirAll(symlinkDir, 0755); err != nil { 761 | return nil, fmt.Errorf("failed to create directory %s: %w", symlinkDir, err) 762 | } 763 | 764 | // Remove existing file/symlink if it exists 765 | if _, err := os.Lstat(symlinkPath); err == nil { 766 | if err := os.RemoveAll(symlinkPath); err != nil { 767 | return nil, fmt.Errorf("failed to remove existing item %s: %w", symlinkPath, err) 768 | } 769 | } 770 | 771 | // Create symlink 772 | if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil { 773 | return nil, err 774 | } 775 | 776 | restored = append(restored, relativePath) 777 | } 778 | 779 | return restored, nil 780 | } 781 | 782 | // isValidSymlink checks if the given path is a symlink pointing to the expected target 783 | func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool { 784 | info, err := os.Lstat(symlinkPath) 785 | if err != nil { 786 | return false 787 | } 788 | 789 | // Check if it's a symlink 790 | if info.Mode()&os.ModeSymlink == 0 { 791 | return false 792 | } 793 | 794 | // Check if it points to the correct target 795 | target, err := os.Readlink(symlinkPath) 796 | if err != nil { 797 | return false 798 | } 799 | 800 | // Convert relative path to absolute if needed 801 | if !filepath.IsAbs(target) { 802 | target = filepath.Join(filepath.Dir(symlinkPath), target) 803 | } 804 | 805 | // Clean both paths for comparison 806 | targetAbs, err := filepath.Abs(target) 807 | if err != nil { 808 | return false 809 | } 810 | 811 | expectedAbs, err := filepath.Abs(expectedTarget) 812 | if err != nil { 813 | return false 814 | } 815 | 816 | return targetAbs == expectedAbs 817 | } 818 | 819 | // getManagedItems returns the list of managed files and directories from .lnk file 820 | func (l *Lnk) getManagedItems() ([]string, error) { 821 | lnkFile := filepath.Join(l.repoPath, l.getLnkFileName()) 822 | 823 | // If .lnk file doesn't exist, return empty list 824 | if _, err := os.Stat(lnkFile); os.IsNotExist(err) { 825 | return []string{}, nil 826 | } 827 | 828 | content, err := os.ReadFile(lnkFile) 829 | if err != nil { 830 | return nil, fmt.Errorf("failed to read .lnk file: %w", err) 831 | } 832 | 833 | if len(content) == 0 { 834 | return []string{}, nil 835 | } 836 | 837 | lines := strings.Split(strings.TrimSpace(string(content)), "\n") 838 | var items []string 839 | for _, line := range lines { 840 | line = strings.TrimSpace(line) 841 | if line != "" { 842 | items = append(items, line) 843 | } 844 | } 845 | 846 | return items, nil 847 | } 848 | 849 | // addManagedItem adds an item to the .lnk tracking file 850 | func (l *Lnk) addManagedItem(relativePath string) error { 851 | // Get current items 852 | items, err := l.getManagedItems() 853 | if err != nil { 854 | return fmt.Errorf("failed to get managed items: %w", err) 855 | } 856 | 857 | // Check if already exists 858 | for _, item := range items { 859 | if item == relativePath { 860 | return nil // Already managed 861 | } 862 | } 863 | 864 | // Add new item using relative path 865 | items = append(items, relativePath) 866 | 867 | // Sort for consistent ordering 868 | sort.Strings(items) 869 | 870 | return l.writeManagedItems(items) 871 | } 872 | 873 | // removeManagedItem removes an item from the .lnk tracking file 874 | func (l *Lnk) removeManagedItem(relativePath string) error { 875 | // Get current items 876 | items, err := l.getManagedItems() 877 | if err != nil { 878 | return fmt.Errorf("failed to get managed items: %w", err) 879 | } 880 | 881 | // Remove item using relative path 882 | var newItems []string 883 | for _, item := range items { 884 | if item != relativePath { 885 | newItems = append(newItems, item) 886 | } 887 | } 888 | 889 | return l.writeManagedItems(newItems) 890 | } 891 | 892 | // writeManagedItems writes the list of managed items to .lnk file 893 | func (l *Lnk) writeManagedItems(items []string) error { 894 | lnkFile := filepath.Join(l.repoPath, l.getLnkFileName()) 895 | 896 | content := strings.Join(items, "\n") 897 | if len(items) > 0 { 898 | content += "\n" 899 | } 900 | 901 | err := os.WriteFile(lnkFile, []byte(content), 0644) 902 | if err != nil { 903 | return fmt.Errorf("failed to write .lnk file: %w", err) 904 | } 905 | 906 | return nil 907 | } 908 | 909 | // FindBootstrapScript searches for a bootstrap script in the repository 910 | func (l *Lnk) FindBootstrapScript() (string, error) { 911 | // Check if repository is initialized 912 | if !l.git.IsGitRepository() { 913 | return "", lnkerr.WithSuggestion(ErrNotInitialized, "run 'lnk init' first") 914 | } 915 | 916 | // Look for bootstrap.sh - simple, opinionated choice 917 | scriptPath := filepath.Join(l.repoPath, "bootstrap.sh") 918 | if _, err := os.Stat(scriptPath); err == nil { 919 | return "bootstrap.sh", nil 920 | } 921 | 922 | return "", nil // No bootstrap script found 923 | } 924 | 925 | // RunBootstrapScript executes the bootstrap script with configurable I/O 926 | func (l *Lnk) RunBootstrapScript(scriptName string, stdout, stderr io.Writer, stdin io.Reader) error { 927 | scriptPath := filepath.Join(l.repoPath, scriptName) 928 | 929 | // Verify the script exists 930 | if _, err := os.Stat(scriptPath); os.IsNotExist(err) { 931 | return lnkerr.WithPath(ErrBootstrapNotFound, scriptName) 932 | } 933 | 934 | // Make sure it's executable 935 | if err := os.Chmod(scriptPath, 0755); err != nil { 936 | return lnkerr.Wrap(ErrBootstrapPerms) 937 | } 938 | 939 | // Run with bash (since we only support bootstrap.sh) 940 | cmd := exec.Command("bash", scriptPath) 941 | 942 | // Set working directory to the repository 943 | cmd.Dir = l.repoPath 944 | 945 | // Connect to provided I/O streams 946 | cmd.Stdout = stdout 947 | cmd.Stderr = stderr 948 | cmd.Stdin = stdin 949 | 950 | // Run the script 951 | if err := cmd.Run(); err != nil { 952 | return lnkerr.WithSuggestion(ErrBootstrapFailed, err.Error()) 953 | } 954 | 955 | return nil 956 | } 957 | 958 | // walkDirectory walks through a directory and returns all regular files 959 | func (l *Lnk) walkDirectory(dirPath string) ([]string, error) { 960 | var files []string 961 | 962 | err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { 963 | if err != nil { 964 | return err 965 | } 966 | 967 | // Skip directories - we only want files 968 | if info.IsDir() { 969 | return nil 970 | } 971 | 972 | // Handle symlinks: include them as files if they point to regular files 973 | if info.Mode()&os.ModeSymlink != 0 { 974 | // For symlinks, we'll include them but the AddMultiple logic 975 | // will handle validation appropriately 976 | files = append(files, path) 977 | return nil 978 | } 979 | 980 | // Include regular files 981 | if info.Mode().IsRegular() { 982 | files = append(files, path) 983 | } 984 | 985 | return nil 986 | }) 987 | 988 | if err != nil { 989 | return nil, fmt.Errorf("failed to walk directory %s: %w", dirPath, err) 990 | } 991 | 992 | return files, nil 993 | } 994 | 995 | // ProgressCallback defines the signature for progress reporting callbacks 996 | type ProgressCallback func(current, total int, currentFile string) 997 | 998 | // AddRecursiveWithProgress adds directory contents individually with progress reporting 999 | func (l *Lnk) AddRecursiveWithProgress(paths []string, progress ProgressCallback) error { 1000 | var allFiles []string 1001 | 1002 | for _, path := range paths { 1003 | // Get absolute path 1004 | absPath, err := filepath.Abs(path) 1005 | if err != nil { 1006 | return fmt.Errorf("failed to get absolute path for %s: %w", path, err) 1007 | } 1008 | 1009 | // Check if it's a directory 1010 | info, err := os.Stat(absPath) 1011 | if err != nil { 1012 | return fmt.Errorf("failed to stat %s: %w", path, err) 1013 | } 1014 | 1015 | if info.IsDir() { 1016 | // Walk directory to get all files 1017 | files, err := l.walkDirectory(absPath) 1018 | if err != nil { 1019 | return fmt.Errorf("failed to walk directory %s: %w", path, err) 1020 | } 1021 | allFiles = append(allFiles, files...) 1022 | } else { 1023 | // It's a regular file, add it directly 1024 | allFiles = append(allFiles, absPath) 1025 | } 1026 | } 1027 | 1028 | // Use AddMultiple for batch processing 1029 | if len(allFiles) == 0 { 1030 | return fmt.Errorf("no files found to add") 1031 | } 1032 | 1033 | // Apply progress threshold: only show progress for >10 files 1034 | const progressThreshold = 10 1035 | if len(allFiles) > progressThreshold && progress != nil { 1036 | return l.addMultipleWithProgress(allFiles, progress) 1037 | } 1038 | 1039 | // For small operations, use regular AddMultiple without progress 1040 | return l.AddMultiple(allFiles) 1041 | } 1042 | 1043 | // addMultipleWithProgress adds multiple files with progress reporting 1044 | func (l *Lnk) addMultipleWithProgress(paths []string, progress ProgressCallback) error { 1045 | if len(paths) == 0 { 1046 | return nil 1047 | } 1048 | 1049 | // Phase 1: Validate all paths first (same as AddMultiple) 1050 | var relativePaths []string 1051 | var absolutePaths []string 1052 | var infos []os.FileInfo 1053 | 1054 | for _, filePath := range paths { 1055 | // Validate the file or directory 1056 | if err := l.fs.ValidateFileForAdd(filePath); err != nil { 1057 | return fmt.Errorf("validation failed for %s: %w", filePath, err) 1058 | } 1059 | 1060 | // Get absolute path 1061 | absPath, err := filepath.Abs(filePath) 1062 | if err != nil { 1063 | return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err) 1064 | } 1065 | 1066 | // Get relative path for tracking 1067 | relativePath, err := getRelativePath(absPath) 1068 | if err != nil { 1069 | return fmt.Errorf("failed to get relative path for %s: %w", filePath, err) 1070 | } 1071 | 1072 | // Check if this relative path is already managed 1073 | managedItems, err := l.getManagedItems() 1074 | if err != nil { 1075 | return fmt.Errorf("failed to get managed items: %w", err) 1076 | } 1077 | for _, item := range managedItems { 1078 | if item == relativePath { 1079 | return lnkerr.WithPath(ErrAlreadyManaged, relativePath) 1080 | } 1081 | } 1082 | 1083 | // Get file info 1084 | info, err := os.Stat(absPath) 1085 | if err != nil { 1086 | return fmt.Errorf("failed to stat path %s: %w", filePath, err) 1087 | } 1088 | 1089 | relativePaths = append(relativePaths, relativePath) 1090 | absolutePaths = append(absolutePaths, absPath) 1091 | infos = append(infos, info) 1092 | } 1093 | 1094 | // Phase 2: Process all files with progress reporting 1095 | var rollbackActions []func() error 1096 | total := len(absolutePaths) 1097 | 1098 | for i, absPath := range absolutePaths { 1099 | // Report progress 1100 | if progress != nil { 1101 | progress(i+1, total, filepath.Base(absPath)) 1102 | } 1103 | 1104 | relativePath := relativePaths[i] 1105 | info := infos[i] 1106 | 1107 | // Generate repository path from relative path 1108 | storagePath := l.getHostStoragePath() 1109 | destPath := filepath.Join(storagePath, relativePath) 1110 | 1111 | // Ensure destination directory exists 1112 | destDir := filepath.Dir(destPath) 1113 | if err := os.MkdirAll(destDir, 0755); err != nil { 1114 | // Rollback previous operations 1115 | l.rollbackOperations(rollbackActions) 1116 | return fmt.Errorf("failed to create destination directory: %w", err) 1117 | } 1118 | 1119 | // Move to repository 1120 | if err := l.fs.Move(absPath, destPath, info); err != nil { 1121 | // Rollback previous operations 1122 | l.rollbackOperations(rollbackActions) 1123 | return fmt.Errorf("failed to move %s: %w", absPath, err) 1124 | } 1125 | 1126 | // Create symlink 1127 | if err := l.fs.CreateSymlink(destPath, absPath); err != nil { 1128 | // Try to restore the file we just moved, then rollback others 1129 | _ = l.fs.Move(destPath, absPath, info) 1130 | l.rollbackOperations(rollbackActions) 1131 | return fmt.Errorf("failed to create symlink for %s: %w", absPath, err) 1132 | } 1133 | 1134 | // Add to tracking 1135 | if err := l.addManagedItem(relativePath); err != nil { 1136 | // Restore this file and rollback others 1137 | _ = os.Remove(absPath) 1138 | _ = l.fs.Move(destPath, absPath, info) 1139 | l.rollbackOperations(rollbackActions) 1140 | return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err) 1141 | } 1142 | 1143 | // Add rollback action for this file 1144 | rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info) 1145 | rollbackActions = append(rollbackActions, rollbackAction) 1146 | } 1147 | 1148 | // Phase 3: Git operations - add all files and create single commit 1149 | for i, relativePath := range relativePaths { 1150 | // For host-specific files, we need to add the relative path from repo root 1151 | gitPath := relativePath 1152 | if l.host != "" { 1153 | gitPath = filepath.Join(l.host+".lnk", relativePath) 1154 | } 1155 | if err := l.git.Add(gitPath); err != nil { 1156 | // Rollback all operations 1157 | l.rollbackOperations(rollbackActions) 1158 | return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err) 1159 | } 1160 | } 1161 | 1162 | // Add .lnk file to the same commit 1163 | if err := l.git.Add(l.getLnkFileName()); err != nil { 1164 | // Rollback all operations 1165 | l.rollbackOperations(rollbackActions) 1166 | return fmt.Errorf("failed to add tracking file to git: %w", err) 1167 | } 1168 | 1169 | // Commit all changes together 1170 | commitMessage := fmt.Sprintf("lnk: added %d files recursively", len(paths)) 1171 | if err := l.git.Commit(commitMessage); err != nil { 1172 | // Rollback all operations 1173 | l.rollbackOperations(rollbackActions) 1174 | return fmt.Errorf("failed to commit changes: %w", err) 1175 | } 1176 | 1177 | return nil 1178 | } 1179 | 1180 | // AddRecursive adds directory contents individually instead of the directory as a whole 1181 | func (l *Lnk) AddRecursive(paths []string) error { 1182 | var allFiles []string 1183 | 1184 | for _, path := range paths { 1185 | // Get absolute path 1186 | absPath, err := filepath.Abs(path) 1187 | if err != nil { 1188 | return fmt.Errorf("failed to get absolute path for %s: %w", path, err) 1189 | } 1190 | 1191 | // Check if it's a directory 1192 | info, err := os.Stat(absPath) 1193 | if err != nil { 1194 | return fmt.Errorf("failed to stat %s: %w", path, err) 1195 | } 1196 | 1197 | if info.IsDir() { 1198 | // Walk directory to get all files 1199 | files, err := l.walkDirectory(absPath) 1200 | if err != nil { 1201 | return fmt.Errorf("failed to walk directory %s: %w", path, err) 1202 | } 1203 | allFiles = append(allFiles, files...) 1204 | } else { 1205 | // It's a regular file, add it directly 1206 | allFiles = append(allFiles, absPath) 1207 | } 1208 | } 1209 | 1210 | // Use AddMultiple for batch processing 1211 | if len(allFiles) == 0 { 1212 | return fmt.Errorf("no files found to add") 1213 | } 1214 | 1215 | return l.AddMultiple(allFiles) 1216 | } 1217 | 1218 | // PreviewAdd simulates an add operation and returns files that would be affected 1219 | func (l *Lnk) PreviewAdd(paths []string, recursive bool) ([]string, error) { 1220 | var allFiles []string 1221 | 1222 | for _, path := range paths { 1223 | // Get absolute path 1224 | absPath, err := filepath.Abs(path) 1225 | if err != nil { 1226 | return nil, fmt.Errorf("failed to get absolute path for %s: %w", path, err) 1227 | } 1228 | 1229 | // Check if it's a directory 1230 | info, err := os.Stat(absPath) 1231 | if err != nil { 1232 | return nil, fmt.Errorf("failed to stat %s: %w", path, err) 1233 | } 1234 | 1235 | if info.IsDir() && recursive { 1236 | // Walk directory to get all files (same logic as AddRecursive) 1237 | files, err := l.walkDirectory(absPath) 1238 | if err != nil { 1239 | return nil, fmt.Errorf("failed to walk directory %s: %w", path, err) 1240 | } 1241 | allFiles = append(allFiles, files...) 1242 | } else { 1243 | // It's a regular file or non-recursive directory, add it directly 1244 | allFiles = append(allFiles, absPath) 1245 | } 1246 | } 1247 | 1248 | // Validate files (same validation as AddMultiple but without making changes) 1249 | var validFiles []string 1250 | for _, filePath := range allFiles { 1251 | // Validate the file or directory 1252 | if err := l.fs.ValidateFileForAdd(filePath); err != nil { 1253 | return nil, fmt.Errorf("validation failed for %s: %w", filePath, err) 1254 | } 1255 | 1256 | // Get relative path for tracking 1257 | relativePath, err := getRelativePath(filePath) 1258 | if err != nil { 1259 | return nil, fmt.Errorf("failed to get relative path for %s: %w", filePath, err) 1260 | } 1261 | 1262 | // Check if this relative path is already managed 1263 | managedItems, err := l.getManagedItems() 1264 | if err != nil { 1265 | return nil, fmt.Errorf("failed to get managed items: %w", err) 1266 | } 1267 | for _, item := range managedItems { 1268 | if item == relativePath { 1269 | return nil, fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath) 1270 | } 1271 | } 1272 | 1273 | validFiles = append(validFiles, filePath) 1274 | } 1275 | 1276 | return validFiles, nil 1277 | } 1278 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/suite" 14 | ) 15 | 16 | type CLITestSuite struct { 17 | suite.Suite 18 | tempDir string 19 | originalDir string 20 | stdout *bytes.Buffer 21 | stderr *bytes.Buffer 22 | } 23 | 24 | func (suite *CLITestSuite) SetupTest() { 25 | // Create temp directory and change to it 26 | tempDir, err := os.MkdirTemp("", "lnk-cli-test-*") 27 | suite.Require().NoError(err) 28 | suite.tempDir = tempDir 29 | 30 | originalDir, err := os.Getwd() 31 | suite.Require().NoError(err) 32 | suite.originalDir = originalDir 33 | 34 | err = os.Chdir(tempDir) 35 | suite.Require().NoError(err) 36 | 37 | // Set HOME to temp directory for consistent relative path calculation 38 | suite.T().Setenv("HOME", tempDir) 39 | 40 | // Set XDG_CONFIG_HOME to tempDir/.config for config files 41 | suite.T().Setenv("XDG_CONFIG_HOME", filepath.Join(tempDir, ".config")) 42 | 43 | // Capture output 44 | suite.stdout = &bytes.Buffer{} 45 | suite.stderr = &bytes.Buffer{} 46 | } 47 | 48 | func (suite *CLITestSuite) TearDownTest() { 49 | err := os.Chdir(suite.originalDir) 50 | suite.Require().NoError(err) 51 | err = os.RemoveAll(suite.tempDir) 52 | suite.Require().NoError(err) 53 | } 54 | 55 | func (suite *CLITestSuite) runCommand(args ...string) error { 56 | rootCmd := NewRootCommand() 57 | rootCmd.SetOut(suite.stdout) 58 | rootCmd.SetErr(suite.stderr) 59 | rootCmd.SetArgs(args) 60 | return rootCmd.Execute() 61 | } 62 | 63 | func (suite *CLITestSuite) TestInitCommand() { 64 | err := suite.runCommand("init") 65 | suite.NoError(err) 66 | 67 | // Check output 68 | output := suite.stdout.String() 69 | suite.Contains(output, "Initialized empty lnk repository") 70 | suite.Contains(output, "Location:") 71 | suite.Contains(output, "Next steps:") 72 | suite.Contains(output, "lnk add ") 73 | 74 | // Verify actual effect 75 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 76 | suite.DirExists(lnkDir) 77 | 78 | gitDir := filepath.Join(lnkDir, ".git") 79 | suite.DirExists(gitDir) 80 | } 81 | 82 | func (suite *CLITestSuite) TestAddCommand() { 83 | // Initialize first 84 | err := suite.runCommand("init") 85 | suite.Require().NoError(err) 86 | suite.stdout.Reset() 87 | 88 | // Create test file 89 | testFile := filepath.Join(suite.tempDir, ".bashrc") 90 | err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 91 | suite.Require().NoError(err) 92 | 93 | // Test add command 94 | err = suite.runCommand("add", testFile) 95 | suite.NoError(err) 96 | 97 | // Check output 98 | output := suite.stdout.String() 99 | suite.Contains(output, "Added .bashrc to lnk") 100 | suite.Contains(output, "→") 101 | suite.Contains(output, "sync to remote") 102 | 103 | // Verify symlink was created 104 | info, err := os.Lstat(testFile) 105 | suite.NoError(err) 106 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 107 | 108 | // Verify the file exists in repo with preserved directory structure 109 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 110 | repoFile := filepath.Join(lnkDir, ".bashrc") 111 | suite.FileExists(repoFile) 112 | 113 | // Verify content is preserved in storage 114 | storedContent, err := os.ReadFile(repoFile) 115 | suite.NoError(err) 116 | suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent)) 117 | 118 | // Verify .lnk file contains the correct entry 119 | lnkFile := filepath.Join(lnkDir, ".lnk") 120 | lnkContent, err := os.ReadFile(lnkFile) 121 | suite.NoError(err) 122 | suite.Equal(".bashrc\n", string(lnkContent)) 123 | } 124 | 125 | func (suite *CLITestSuite) TestRemoveCommand() { 126 | // Setup: init and add a file 127 | _ = suite.runCommand("init") 128 | testFile := filepath.Join(suite.tempDir, ".vimrc") 129 | _ = os.WriteFile(testFile, []byte("set number"), 0644) 130 | _ = suite.runCommand("add", testFile) 131 | suite.stdout.Reset() 132 | 133 | // Test remove command 134 | err := suite.runCommand("rm", testFile) 135 | suite.NoError(err) 136 | 137 | // Check output 138 | output := suite.stdout.String() 139 | suite.Contains(output, "Removed .vimrc from lnk") 140 | suite.Contains(output, "→") 141 | suite.Contains(output, "Original file restored") 142 | 143 | // Verify symlink is gone and regular file is restored 144 | info, err := os.Lstat(testFile) 145 | suite.NoError(err) 146 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink 147 | 148 | // Verify content is preserved 149 | content, err := os.ReadFile(testFile) 150 | suite.NoError(err) 151 | suite.Equal("set number", string(content)) 152 | } 153 | 154 | func (suite *CLITestSuite) TestStatusCommand() { 155 | // Initialize first 156 | err := suite.runCommand("init") 157 | suite.Require().NoError(err) 158 | suite.stdout.Reset() 159 | 160 | // Test status without remote - should fail 161 | err = suite.runCommand("status") 162 | suite.Error(err) 163 | suite.Contains(err.Error(), "No remote repository is configured") 164 | } 165 | 166 | func (suite *CLITestSuite) TestListCommand() { 167 | // Test list without init - should fail 168 | err := suite.runCommand("list") 169 | suite.Error(err) 170 | suite.Contains(err.Error(), "Lnk repository not initialized") 171 | 172 | // Initialize first 173 | err = suite.runCommand("init") 174 | suite.Require().NoError(err) 175 | suite.stdout.Reset() 176 | 177 | // Test list with no managed files 178 | err = suite.runCommand("list") 179 | suite.NoError(err) 180 | output := suite.stdout.String() 181 | suite.Contains(output, "No files currently managed by lnk") 182 | suite.Contains(output, "lnk add ") 183 | suite.stdout.Reset() 184 | 185 | // Add a file 186 | testFile := filepath.Join(suite.tempDir, ".bashrc") 187 | err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 188 | suite.Require().NoError(err) 189 | err = suite.runCommand("add", testFile) 190 | suite.Require().NoError(err) 191 | suite.stdout.Reset() 192 | 193 | // Test list with one managed file 194 | err = suite.runCommand("list") 195 | suite.NoError(err) 196 | output = suite.stdout.String() 197 | suite.Contains(output, "Files managed by lnk") 198 | suite.Contains(output, "1 item") 199 | suite.Contains(output, ".bashrc") 200 | suite.stdout.Reset() 201 | 202 | // Add another file 203 | testFile2 := filepath.Join(suite.tempDir, ".vimrc") 204 | err = os.WriteFile(testFile2, []byte("set number"), 0644) 205 | suite.Require().NoError(err) 206 | err = suite.runCommand("add", testFile2) 207 | suite.Require().NoError(err) 208 | suite.stdout.Reset() 209 | 210 | // Test list with multiple managed files 211 | err = suite.runCommand("list") 212 | suite.NoError(err) 213 | output = suite.stdout.String() 214 | suite.Contains(output, "Files managed by lnk") 215 | suite.Contains(output, "2 items") 216 | suite.Contains(output, ".bashrc") 217 | suite.Contains(output, ".vimrc") 218 | 219 | // Verify both files exist in storage with correct content 220 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 221 | 222 | bashrcStorage := filepath.Join(lnkDir, ".bashrc") 223 | suite.FileExists(bashrcStorage) 224 | bashrcContent, err := os.ReadFile(bashrcStorage) 225 | suite.NoError(err) 226 | suite.Equal("export PATH=/usr/local/bin:$PATH", string(bashrcContent)) 227 | 228 | vimrcStorage := filepath.Join(lnkDir, ".vimrc") 229 | suite.FileExists(vimrcStorage) 230 | vimrcContent, err := os.ReadFile(vimrcStorage) 231 | suite.NoError(err) 232 | suite.Equal("set number", string(vimrcContent)) 233 | 234 | // Verify .lnk file contains both entries (sorted) 235 | lnkFile := filepath.Join(lnkDir, ".lnk") 236 | lnkContent, err := os.ReadFile(lnkFile) 237 | suite.NoError(err) 238 | suite.Equal(".bashrc\n.vimrc\n", string(lnkContent)) 239 | } 240 | 241 | func (suite *CLITestSuite) TestErrorHandling() { 242 | tests := []struct { 243 | name string 244 | args []string 245 | wantErr bool 246 | errContains string 247 | outContains string 248 | }{ 249 | { 250 | name: "add nonexistent file", 251 | args: []string{"add", "/nonexistent/file"}, 252 | wantErr: true, 253 | errContains: "File or directory not found", 254 | }, 255 | { 256 | name: "status without init", 257 | args: []string{"status"}, 258 | wantErr: true, 259 | errContains: "Lnk repository not initialized", 260 | }, 261 | { 262 | name: "help command", 263 | args: []string{"--help"}, 264 | wantErr: false, 265 | outContains: "Lnk - Git-native dotfiles management", 266 | }, 267 | { 268 | name: "version command", 269 | args: []string{"--version"}, 270 | wantErr: false, 271 | outContains: "lnk version", 272 | }, 273 | { 274 | name: "init help", 275 | args: []string{"init", "--help"}, 276 | wantErr: false, 277 | outContains: "Creates the lnk directory", 278 | }, 279 | { 280 | name: "add help", 281 | args: []string{"add", "--help"}, 282 | wantErr: false, 283 | outContains: "Moves files to the lnk repository", 284 | }, 285 | { 286 | name: "list help", 287 | args: []string{"list", "--help"}, 288 | wantErr: false, 289 | outContains: "Display all files and directories", 290 | }, 291 | } 292 | 293 | for _, tt := range tests { 294 | suite.Run(tt.name, func() { 295 | suite.stdout.Reset() 296 | suite.stderr.Reset() 297 | 298 | err := suite.runCommand(tt.args...) 299 | 300 | if tt.wantErr { 301 | suite.Error(err, "Expected error for %s", tt.name) 302 | if tt.errContains != "" { 303 | suite.Contains(err.Error(), tt.errContains, "Wrong error message for %s", tt.name) 304 | } 305 | } else { 306 | suite.NoError(err, "Unexpected error for %s", tt.name) 307 | } 308 | 309 | if tt.outContains != "" { 310 | output := suite.stdout.String() 311 | suite.Contains(output, tt.outContains, "Expected output not found for %s", tt.name) 312 | } 313 | }) 314 | } 315 | } 316 | 317 | func (suite *CLITestSuite) TestCompleteWorkflow() { 318 | // Test realistic user workflow 319 | steps := []struct { 320 | name string 321 | args []string 322 | setup func() 323 | verify func(output string) 324 | }{ 325 | { 326 | name: "initialize repository", 327 | args: []string{"init"}, 328 | verify: func(output string) { 329 | suite.Contains(output, "Initialized empty lnk repository") 330 | }, 331 | }, 332 | { 333 | name: "add config file", 334 | args: []string{"add", filepath.Join(suite.tempDir, ".bashrc")}, 335 | setup: func() { 336 | testFile := filepath.Join(suite.tempDir, ".bashrc") 337 | _ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 338 | }, 339 | verify: func(output string) { 340 | suite.Contains(output, "Added .bashrc to lnk") 341 | 342 | // Verify storage and .lnk file 343 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 344 | bashrcStorage := filepath.Join(lnkDir, ".bashrc") 345 | suite.FileExists(bashrcStorage) 346 | 347 | storedContent, err := os.ReadFile(bashrcStorage) 348 | suite.NoError(err) 349 | suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent)) 350 | 351 | lnkFile := filepath.Join(lnkDir, ".lnk") 352 | lnkContent, err := os.ReadFile(lnkFile) 353 | suite.NoError(err) 354 | suite.Equal(".bashrc\n", string(lnkContent)) 355 | }, 356 | }, 357 | { 358 | name: "add another file", 359 | args: []string{"add", filepath.Join(suite.tempDir, ".vimrc")}, 360 | setup: func() { 361 | testFile := filepath.Join(suite.tempDir, ".vimrc") 362 | _ = os.WriteFile(testFile, []byte("set number"), 0644) 363 | }, 364 | verify: func(output string) { 365 | suite.Contains(output, "Added .vimrc to lnk") 366 | 367 | // Verify storage and .lnk file now contains both files 368 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 369 | vimrcStorage := filepath.Join(lnkDir, ".vimrc") 370 | suite.FileExists(vimrcStorage) 371 | 372 | storedContent, err := os.ReadFile(vimrcStorage) 373 | suite.NoError(err) 374 | suite.Equal("set number", string(storedContent)) 375 | 376 | lnkFile := filepath.Join(lnkDir, ".lnk") 377 | lnkContent, err := os.ReadFile(lnkFile) 378 | suite.NoError(err) 379 | suite.Equal(".bashrc\n.vimrc\n", string(lnkContent)) 380 | }, 381 | }, 382 | { 383 | name: "remove file", 384 | args: []string{"rm", filepath.Join(suite.tempDir, ".vimrc")}, 385 | verify: func(output string) { 386 | suite.Contains(output, "Removed .vimrc from lnk") 387 | }, 388 | }, 389 | } 390 | 391 | for _, step := range steps { 392 | suite.Run(step.name, func() { 393 | if step.setup != nil { 394 | step.setup() 395 | } 396 | 397 | suite.stdout.Reset() 398 | suite.stderr.Reset() 399 | 400 | err := suite.runCommand(step.args...) 401 | suite.NoError(err, "Step %s failed: %v", step.name, err) 402 | 403 | output := suite.stdout.String() 404 | if step.verify != nil { 405 | step.verify(output) 406 | } 407 | }) 408 | } 409 | } 410 | 411 | func (suite *CLITestSuite) TestRemoveUnmanagedFile() { 412 | // Initialize repository 413 | _ = suite.runCommand("init") 414 | 415 | // Create a regular file (not managed by lnk) 416 | testFile := filepath.Join(suite.tempDir, ".regularfile") 417 | _ = os.WriteFile(testFile, []byte("content"), 0644) 418 | 419 | // Try to remove it 420 | err := suite.runCommand("rm", testFile) 421 | suite.Error(err) 422 | suite.Contains(err.Error(), "File is not managed by lnk") 423 | } 424 | 425 | func (suite *CLITestSuite) TestAddDirectory() { 426 | // Initialize repository 427 | _ = suite.runCommand("init") 428 | suite.stdout.Reset() 429 | 430 | // Create a directory with files 431 | testDir := filepath.Join(suite.tempDir, ".ssh") 432 | _ = os.MkdirAll(testDir, 0755) 433 | configFile := filepath.Join(testDir, "config") 434 | _ = os.WriteFile(configFile, []byte("Host example.com"), 0644) 435 | 436 | // Add the directory 437 | err := suite.runCommand("add", testDir) 438 | suite.NoError(err) 439 | 440 | // Check output 441 | output := suite.stdout.String() 442 | suite.Contains(output, "Added .ssh to lnk") 443 | 444 | // Verify directory is now a symlink 445 | info, err := os.Lstat(testDir) 446 | suite.NoError(err) 447 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 448 | 449 | // Verify the directory exists in repo with preserved directory structure 450 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 451 | repoDir := filepath.Join(lnkDir, ".ssh") 452 | suite.DirExists(repoDir) 453 | 454 | // Verify directory content is preserved 455 | repoConfigFile := filepath.Join(repoDir, "config") 456 | suite.FileExists(repoConfigFile) 457 | storedContent, err := os.ReadFile(repoConfigFile) 458 | suite.NoError(err) 459 | suite.Equal("Host example.com", string(storedContent)) 460 | 461 | // Verify .lnk file contains the directory entry 462 | lnkFile := filepath.Join(lnkDir, ".lnk") 463 | lnkContent, err := os.ReadFile(lnkFile) 464 | suite.NoError(err) 465 | suite.Equal(".ssh\n", string(lnkContent)) 466 | } 467 | 468 | func (suite *CLITestSuite) TestSameBasenameFilesBug() { 469 | // Initialize repository 470 | err := suite.runCommand("init") 471 | suite.Require().NoError(err) 472 | suite.stdout.Reset() 473 | 474 | // Create two directories with files having the same basename 475 | dirA := filepath.Join(suite.tempDir, "a") 476 | dirB := filepath.Join(suite.tempDir, "b") 477 | err = os.MkdirAll(dirA, 0755) 478 | suite.Require().NoError(err) 479 | err = os.MkdirAll(dirB, 0755) 480 | suite.Require().NoError(err) 481 | 482 | // Create files with same basename but different content 483 | fileA := filepath.Join(dirA, "config.json") 484 | fileB := filepath.Join(dirB, "config.json") 485 | contentA := `{"name": "config_a"}` 486 | contentB := `{"name": "config_b"}` 487 | 488 | err = os.WriteFile(fileA, []byte(contentA), 0644) 489 | suite.Require().NoError(err) 490 | err = os.WriteFile(fileB, []byte(contentB), 0644) 491 | suite.Require().NoError(err) 492 | 493 | // Add first file 494 | err = suite.runCommand("add", fileA) 495 | suite.NoError(err) 496 | suite.stdout.Reset() 497 | 498 | // Verify first file content is preserved 499 | content, err := os.ReadFile(fileA) 500 | suite.NoError(err) 501 | suite.Equal(contentA, string(content), "First file should preserve its original content") 502 | 503 | // Add second file with same basename - this should work correctly 504 | err = suite.runCommand("add", fileB) 505 | suite.NoError(err, "Adding second file with same basename should work") 506 | 507 | // CORRECT BEHAVIOR: Both files should preserve their original content 508 | contentAfterAddA, err := os.ReadFile(fileA) 509 | suite.NoError(err) 510 | contentAfterAddB, err := os.ReadFile(fileB) 511 | suite.NoError(err) 512 | 513 | suite.Equal(contentA, string(contentAfterAddA), "First file should keep its original content") 514 | suite.Equal(contentB, string(contentAfterAddB), "Second file should keep its original content") 515 | 516 | // Verify both files exist in storage with correct paths and content 517 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 518 | 519 | storageFileA := filepath.Join(lnkDir, "a", "config.json") 520 | suite.FileExists(storageFileA) 521 | storedContentA, err := os.ReadFile(storageFileA) 522 | suite.NoError(err) 523 | suite.Equal(contentA, string(storedContentA)) 524 | 525 | storageFileB := filepath.Join(lnkDir, "b", "config.json") 526 | suite.FileExists(storageFileB) 527 | storedContentB, err := os.ReadFile(storageFileB) 528 | suite.NoError(err) 529 | suite.Equal(contentB, string(storedContentB)) 530 | 531 | // Verify .lnk file contains both entries with correct relative paths 532 | lnkFile := filepath.Join(lnkDir, ".lnk") 533 | lnkContent, err := os.ReadFile(lnkFile) 534 | suite.NoError(err) 535 | suite.Equal("a/config.json\nb/config.json\n", string(lnkContent)) 536 | 537 | // Both files should be removable independently 538 | suite.stdout.Reset() 539 | err = suite.runCommand("rm", fileA) 540 | suite.NoError(err, "First file should be removable") 541 | 542 | // Verify output shows removal 543 | output := suite.stdout.String() 544 | suite.Contains(output, "Removed config.json from lnk") 545 | 546 | // Verify first file is restored with correct content 547 | restoredContentA, err := os.ReadFile(fileA) 548 | suite.NoError(err) 549 | suite.Equal(contentA, string(restoredContentA), "Restored first file should have original content") 550 | 551 | // Second file should still be removable without errors 552 | suite.stdout.Reset() 553 | err = suite.runCommand("rm", fileB) 554 | suite.NoError(err, "Second file should also be removable without errors") 555 | 556 | // Verify second file is restored with correct content 557 | restoredContentB, err := os.ReadFile(fileB) 558 | suite.NoError(err) 559 | suite.Equal(contentB, string(restoredContentB), "Restored second file should have original content") 560 | } 561 | 562 | func (suite *CLITestSuite) TestStatusDirtyRepo() { 563 | // Initialize repository 564 | err := suite.runCommand("init") 565 | suite.Require().NoError(err) 566 | suite.stdout.Reset() 567 | 568 | // Add and commit a file 569 | testFile := filepath.Join(suite.tempDir, "a") 570 | err = os.WriteFile(testFile, []byte("abc"), 0644) 571 | suite.Require().NoError(err) 572 | 573 | err = suite.runCommand("add", testFile) 574 | suite.Require().NoError(err) 575 | suite.stdout.Reset() 576 | 577 | // Verify file is stored correctly 578 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 579 | storageFile := filepath.Join(lnkDir, "a") 580 | suite.FileExists(storageFile) 581 | storedContent, err := os.ReadFile(storageFile) 582 | suite.NoError(err) 583 | suite.Equal("abc", string(storedContent)) 584 | 585 | // Verify .lnk file contains the entry 586 | lnkFile := filepath.Join(lnkDir, ".lnk") 587 | lnkContent, err := os.ReadFile(lnkFile) 588 | suite.NoError(err) 589 | suite.Equal("a\n", string(lnkContent)) 590 | 591 | // Add a remote so status works 592 | cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test/dotfiles.git") 593 | cmd.Dir = lnkDir 594 | err = cmd.Run() 595 | suite.Require().NoError(err) 596 | 597 | // Status should show clean but ahead 598 | err = suite.runCommand("status") 599 | suite.NoError(err) 600 | output := suite.stdout.String() 601 | suite.Contains(output, "1 commit ahead") 602 | suite.NotContains(output, "uncommitted changes") 603 | suite.stdout.Reset() 604 | 605 | // Now edit the managed file (simulating the issue scenario) 606 | err = os.WriteFile(testFile, []byte("def"), 0644) 607 | suite.Require().NoError(err) 608 | 609 | // Status should now detect dirty state and NOT say "up to date" 610 | err = suite.runCommand("status") 611 | suite.NoError(err) 612 | output = suite.stdout.String() 613 | suite.Contains(output, "Repository has uncommitted changes") 614 | suite.NotContains(output, "Repository is up to date") 615 | suite.Contains(output, "lnk push") 616 | } 617 | 618 | func (suite *CLITestSuite) TestMultihostCommands() { 619 | // Initialize repository 620 | err := suite.runCommand("init") 621 | suite.Require().NoError(err) 622 | suite.stdout.Reset() 623 | 624 | // Create test files 625 | testFile1 := filepath.Join(suite.tempDir, ".bashrc") 626 | err = os.WriteFile(testFile1, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 627 | suite.Require().NoError(err) 628 | 629 | testFile2 := filepath.Join(suite.tempDir, ".vimrc") 630 | err = os.WriteFile(testFile2, []byte("set number"), 0644) 631 | suite.Require().NoError(err) 632 | 633 | // Add file to common configuration 634 | err = suite.runCommand("add", testFile1) 635 | suite.NoError(err) 636 | output := suite.stdout.String() 637 | suite.Contains(output, "Added .bashrc to lnk") 638 | suite.NotContains(output, "host:") 639 | suite.stdout.Reset() 640 | 641 | // Add file to host-specific configuration 642 | err = suite.runCommand("add", "--host", "workstation", testFile2) 643 | suite.NoError(err) 644 | output = suite.stdout.String() 645 | suite.Contains(output, "Added .vimrc to lnk (host: workstation)") 646 | suite.Contains(output, "workstation.lnk") 647 | suite.stdout.Reset() 648 | 649 | // Verify storage paths and .lnk files for both common and host-specific 650 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 651 | 652 | // Verify common file storage and tracking 653 | commonStorage := filepath.Join(lnkDir, ".bashrc") 654 | suite.FileExists(commonStorage) 655 | commonContent, err := os.ReadFile(commonStorage) 656 | suite.NoError(err) 657 | suite.Equal("export PATH=/usr/local/bin:$PATH", string(commonContent)) 658 | 659 | commonLnkFile := filepath.Join(lnkDir, ".lnk") 660 | commonLnkContent, err := os.ReadFile(commonLnkFile) 661 | suite.NoError(err) 662 | suite.Equal(".bashrc\n", string(commonLnkContent)) 663 | 664 | // Verify host-specific file storage and tracking 665 | hostStorage := filepath.Join(lnkDir, "workstation.lnk", ".vimrc") 666 | suite.FileExists(hostStorage) 667 | hostContent, err := os.ReadFile(hostStorage) 668 | suite.NoError(err) 669 | suite.Equal("set number", string(hostContent)) 670 | 671 | hostLnkFile := filepath.Join(lnkDir, ".lnk.workstation") 672 | hostLnkContent, err := os.ReadFile(hostLnkFile) 673 | suite.NoError(err) 674 | suite.Equal(".vimrc\n", string(hostLnkContent)) 675 | 676 | // Test list command - common only 677 | err = suite.runCommand("list") 678 | suite.NoError(err) 679 | output = suite.stdout.String() 680 | suite.Contains(output, "Files managed by lnk (common)") 681 | suite.Contains(output, ".bashrc") 682 | suite.NotContains(output, ".vimrc") 683 | suite.stdout.Reset() 684 | 685 | // Test list command - specific host 686 | err = suite.runCommand("list", "--host", "workstation") 687 | suite.NoError(err) 688 | output = suite.stdout.String() 689 | suite.Contains(output, "Files managed by lnk (host: workstation)") 690 | suite.Contains(output, ".vimrc") 691 | suite.NotContains(output, ".bashrc") 692 | suite.stdout.Reset() 693 | 694 | // Test list command - all configurations 695 | err = suite.runCommand("list", "--all") 696 | suite.NoError(err) 697 | output = suite.stdout.String() 698 | suite.Contains(output, "All configurations managed by lnk") 699 | suite.Contains(output, "Common configuration") 700 | suite.Contains(output, "Host: workstation") 701 | suite.Contains(output, ".bashrc") 702 | suite.Contains(output, ".vimrc") 703 | suite.stdout.Reset() 704 | 705 | // Test remove from host-specific 706 | err = suite.runCommand("rm", "--host", "workstation", testFile2) 707 | suite.NoError(err) 708 | output = suite.stdout.String() 709 | suite.Contains(output, "Removed .vimrc from lnk (host: workstation)") 710 | suite.stdout.Reset() 711 | 712 | // Test remove from common 713 | err = suite.runCommand("rm", testFile1) 714 | suite.NoError(err) 715 | output = suite.stdout.String() 716 | suite.Contains(output, "Removed .bashrc from lnk") 717 | suite.NotContains(output, "host:") 718 | suite.stdout.Reset() 719 | 720 | // Verify files are restored 721 | info1, err := os.Lstat(testFile1) 722 | suite.NoError(err) 723 | suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink) 724 | 725 | info2, err := os.Lstat(testFile2) 726 | suite.NoError(err) 727 | suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink) 728 | } 729 | 730 | func (suite *CLITestSuite) TestMultihostErrorHandling() { 731 | // Initialize repository 732 | err := suite.runCommand("init") 733 | suite.Require().NoError(err) 734 | suite.stdout.Reset() 735 | 736 | // Try to remove from non-existent host config 737 | testFile := filepath.Join(suite.tempDir, ".bashrc") 738 | err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 739 | suite.Require().NoError(err) 740 | 741 | err = suite.runCommand("rm", "--host", "nonexistent", testFile) 742 | suite.Error(err) 743 | suite.Contains(err.Error(), "File is not managed by lnk") 744 | 745 | // Try to list non-existent host config 746 | err = suite.runCommand("list", "--host", "nonexistent") 747 | suite.NoError(err) // Should not error, just show empty 748 | output := suite.stdout.String() 749 | suite.Contains(output, "No files currently managed by lnk (host: nonexistent)") 750 | } 751 | 752 | func (suite *CLITestSuite) TestBootstrapCommand() { 753 | // Initialize repository 754 | err := suite.runCommand("init") 755 | suite.Require().NoError(err) 756 | suite.stdout.Reset() 757 | 758 | // Test bootstrap command with no script 759 | err = suite.runCommand("bootstrap") 760 | suite.NoError(err) 761 | output := suite.stdout.String() 762 | suite.Contains(output, "No bootstrap script found") 763 | suite.Contains(output, "bootstrap.sh") 764 | suite.stdout.Reset() 765 | 766 | // Create a bootstrap script 767 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 768 | bootstrapScript := filepath.Join(lnkDir, "bootstrap.sh") 769 | scriptContent := `#!/bin/bash 770 | echo "Bootstrap script executed!" 771 | echo "Working directory: $(pwd)" 772 | touch bootstrap-ran.txt 773 | ` 774 | err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755) 775 | suite.Require().NoError(err) 776 | 777 | // Test bootstrap command with script 778 | err = suite.runCommand("bootstrap") 779 | suite.NoError(err) 780 | output = suite.stdout.String() 781 | suite.Contains(output, "Running bootstrap script") 782 | suite.Contains(output, "bootstrap.sh") 783 | suite.Contains(output, "Bootstrap completed successfully") 784 | 785 | // Verify script actually ran 786 | markerFile := filepath.Join(lnkDir, "bootstrap-ran.txt") 787 | suite.FileExists(markerFile) 788 | } 789 | 790 | func (suite *CLITestSuite) TestInitWithBootstrap() { 791 | // Create a temporary remote repository with bootstrap script 792 | remoteDir := filepath.Join(suite.tempDir, "remote") 793 | err := os.MkdirAll(remoteDir, 0755) 794 | suite.Require().NoError(err) 795 | 796 | // Initialize git repo in remote with main branch 797 | cmd := exec.Command("git", "init", "--bare", "--initial-branch=main") 798 | cmd.Dir = remoteDir 799 | err = cmd.Run() 800 | suite.Require().NoError(err) 801 | 802 | // Create a working repo to populate the remote 803 | workingDir := filepath.Join(suite.tempDir, "working") 804 | err = os.MkdirAll(workingDir, 0755) 805 | suite.Require().NoError(err) 806 | 807 | cmd = exec.Command("git", "clone", remoteDir, workingDir) 808 | err = cmd.Run() 809 | suite.Require().NoError(err) 810 | 811 | // Add a bootstrap script to the working repo 812 | bootstrapScript := filepath.Join(workingDir, "bootstrap.sh") 813 | scriptContent := `#!/bin/bash 814 | echo "Remote bootstrap script executed!" 815 | touch remote-bootstrap-ran.txt 816 | ` 817 | err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755) 818 | suite.Require().NoError(err) 819 | 820 | // Add a dummy config file 821 | configFile := filepath.Join(workingDir, ".bashrc") 822 | err = os.WriteFile(configFile, []byte("echo 'Hello from remote!'"), 0644) 823 | suite.Require().NoError(err) 824 | 825 | // Add .lnk file to track the config 826 | lnkFile := filepath.Join(workingDir, ".lnk") 827 | err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) 828 | suite.Require().NoError(err) 829 | 830 | // Commit and push to remote 831 | cmd = exec.Command("git", "add", ".") 832 | cmd.Dir = workingDir 833 | err = cmd.Run() 834 | suite.Require().NoError(err) 835 | 836 | cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap and config") 837 | cmd.Dir = workingDir 838 | err = cmd.Run() 839 | suite.Require().NoError(err) 840 | 841 | cmd = exec.Command("git", "push", "origin", "main") 842 | cmd.Dir = workingDir 843 | err = cmd.Run() 844 | suite.Require().NoError(err) 845 | 846 | // Now test init with remote and automatic bootstrap 847 | err = suite.runCommand("init", "-r", remoteDir) 848 | suite.NoError(err) 849 | output := suite.stdout.String() 850 | suite.Contains(output, "Cloned from:") 851 | suite.Contains(output, "Looking for bootstrap script") 852 | suite.Contains(output, "Found bootstrap script:") 853 | suite.Contains(output, "bootstrap.sh") 854 | suite.Contains(output, "Running bootstrap script") 855 | suite.Contains(output, "Bootstrap completed successfully") 856 | 857 | // Verify bootstrap actually ran 858 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 859 | markerFile := filepath.Join(lnkDir, "remote-bootstrap-ran.txt") 860 | suite.FileExists(markerFile) 861 | } 862 | 863 | func (suite *CLITestSuite) TestInitWithBootstrapDisabled() { 864 | // Create a temporary remote repository with bootstrap script 865 | remoteDir := filepath.Join(suite.tempDir, "remote") 866 | err := os.MkdirAll(remoteDir, 0755) 867 | suite.Require().NoError(err) 868 | 869 | // Initialize git repo in remote with main branch 870 | cmd := exec.Command("git", "init", "--bare", "--initial-branch=main") 871 | cmd.Dir = remoteDir 872 | err = cmd.Run() 873 | suite.Require().NoError(err) 874 | 875 | // Create a working repo to populate the remote 876 | workingDir := filepath.Join(suite.tempDir, "working") 877 | err = os.MkdirAll(workingDir, 0755) 878 | suite.Require().NoError(err) 879 | 880 | cmd = exec.Command("git", "clone", remoteDir, workingDir) 881 | err = cmd.Run() 882 | suite.Require().NoError(err) 883 | 884 | // Add a bootstrap script 885 | bootstrapScript := filepath.Join(workingDir, "bootstrap.sh") 886 | scriptContent := `#!/bin/bash 887 | echo "This should not run!" 888 | touch should-not-exist.txt 889 | ` 890 | err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755) 891 | suite.Require().NoError(err) 892 | 893 | // Commit and push 894 | cmd = exec.Command("git", "add", ".") 895 | cmd.Dir = workingDir 896 | err = cmd.Run() 897 | suite.Require().NoError(err) 898 | 899 | cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap") 900 | cmd.Dir = workingDir 901 | err = cmd.Run() 902 | suite.Require().NoError(err) 903 | 904 | cmd = exec.Command("git", "push", "origin", "main") 905 | cmd.Dir = workingDir 906 | err = cmd.Run() 907 | suite.Require().NoError(err) 908 | 909 | // Test init with --no-bootstrap flag 910 | err = suite.runCommand("init", "-r", remoteDir, "--no-bootstrap") 911 | suite.NoError(err) 912 | output := suite.stdout.String() 913 | suite.Contains(output, "Cloned from:") 914 | suite.NotContains(output, "Looking for bootstrap script") 915 | suite.NotContains(output, "Running bootstrap script") 916 | 917 | // Verify bootstrap did NOT run 918 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 919 | markerFile := filepath.Join(lnkDir, "should-not-exist.txt") 920 | suite.NoFileExists(markerFile) 921 | } 922 | 923 | func (suite *CLITestSuite) TestAddCommandMultipleFiles() { 924 | // Initialize repository 925 | err := suite.runCommand("init") 926 | suite.Require().NoError(err) 927 | suite.stdout.Reset() 928 | 929 | // Create multiple test files 930 | testFile1 := filepath.Join(suite.tempDir, ".bashrc") 931 | err = os.WriteFile(testFile1, []byte("export PATH1"), 0644) 932 | suite.Require().NoError(err) 933 | 934 | testFile2 := filepath.Join(suite.tempDir, ".vimrc") 935 | err = os.WriteFile(testFile2, []byte("set number"), 0644) 936 | suite.Require().NoError(err) 937 | 938 | testFile3 := filepath.Join(suite.tempDir, ".gitconfig") 939 | err = os.WriteFile(testFile3, []byte("[user]\n name = test"), 0644) 940 | suite.Require().NoError(err) 941 | 942 | // Test add command with multiple files - should succeed 943 | err = suite.runCommand("add", testFile1, testFile2, testFile3) 944 | suite.NoError(err, "Adding multiple files should succeed") 945 | 946 | // Check output shows all files were added 947 | output := suite.stdout.String() 948 | suite.Contains(output, "Added 3 items to lnk") 949 | suite.Contains(output, ".bashrc") 950 | suite.Contains(output, ".vimrc") 951 | suite.Contains(output, ".gitconfig") 952 | 953 | // Verify all files are now symlinks 954 | for _, file := range []string{testFile1, testFile2, testFile3} { 955 | info, err := os.Lstat(file) 956 | suite.NoError(err) 957 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 958 | } 959 | 960 | // Verify all files exist in storage 961 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 962 | suite.FileExists(filepath.Join(lnkDir, ".bashrc")) 963 | suite.FileExists(filepath.Join(lnkDir, ".vimrc")) 964 | suite.FileExists(filepath.Join(lnkDir, ".gitconfig")) 965 | 966 | // Verify .lnk file contains all entries 967 | lnkFile := filepath.Join(lnkDir, ".lnk") 968 | lnkContent, err := os.ReadFile(lnkFile) 969 | suite.NoError(err) 970 | suite.Equal(".bashrc\n.gitconfig\n.vimrc\n", string(lnkContent)) 971 | } 972 | 973 | func (suite *CLITestSuite) TestAddCommandMixedTypes() { 974 | // Initialize repository 975 | err := suite.runCommand("init") 976 | suite.Require().NoError(err) 977 | suite.stdout.Reset() 978 | 979 | // Create a file 980 | testFile := filepath.Join(suite.tempDir, ".vimrc") 981 | err = os.WriteFile(testFile, []byte("set number"), 0644) 982 | suite.Require().NoError(err) 983 | 984 | // Create a directory with content 985 | testDir := filepath.Join(suite.tempDir, ".config", "git") 986 | err = os.MkdirAll(testDir, 0755) 987 | suite.Require().NoError(err) 988 | configFile := filepath.Join(testDir, "config") 989 | err = os.WriteFile(configFile, []byte("[user]"), 0644) 990 | suite.Require().NoError(err) 991 | 992 | // Test add command with mixed files and directories - should succeed 993 | err = suite.runCommand("add", testFile, testDir) 994 | suite.NoError(err, "Adding mixed files and directories should succeed") 995 | 996 | // Check output shows both items were added 997 | output := suite.stdout.String() 998 | suite.Contains(output, "Added 2 items to lnk") 999 | suite.Contains(output, ".vimrc") 1000 | suite.Contains(output, "git") 1001 | 1002 | // Verify both are now symlinks 1003 | info1, err := os.Lstat(testFile) 1004 | suite.NoError(err) 1005 | suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink) 1006 | 1007 | info2, err := os.Lstat(testDir) 1008 | suite.NoError(err) 1009 | suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink) 1010 | 1011 | // Verify storage 1012 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 1013 | suite.FileExists(filepath.Join(lnkDir, ".vimrc")) 1014 | suite.DirExists(filepath.Join(lnkDir, ".config", "git")) 1015 | suite.FileExists(filepath.Join(lnkDir, ".config", "git", "config")) 1016 | } 1017 | 1018 | func (suite *CLITestSuite) TestAddCommandRecursiveFlag() { 1019 | // Initialize repository 1020 | err := suite.runCommand("init") 1021 | suite.Require().NoError(err) 1022 | suite.stdout.Reset() 1023 | 1024 | // Create a directory with nested files 1025 | testDir := filepath.Join(suite.tempDir, ".config", "zed") 1026 | err = os.MkdirAll(testDir, 0755) 1027 | suite.Require().NoError(err) 1028 | 1029 | // Create nested files 1030 | settingsFile := filepath.Join(testDir, "settings.json") 1031 | err = os.WriteFile(settingsFile, []byte(`{"theme": "dark"}`), 0644) 1032 | suite.Require().NoError(err) 1033 | 1034 | keymapFile := filepath.Join(testDir, "keymap.json") 1035 | err = os.WriteFile(keymapFile, []byte(`{"ctrl+s": "save"}`), 0644) 1036 | suite.Require().NoError(err) 1037 | 1038 | // Create a subdirectory with files 1039 | themesDir := filepath.Join(testDir, "themes") 1040 | err = os.MkdirAll(themesDir, 0755) 1041 | suite.Require().NoError(err) 1042 | 1043 | themeFile := filepath.Join(themesDir, "custom.json") 1044 | err = os.WriteFile(themeFile, []byte(`{"colors": {}}`), 0644) 1045 | suite.Require().NoError(err) 1046 | 1047 | // Test recursive flag - should process directory contents individually 1048 | err = suite.runCommand("add", "--recursive", testDir) 1049 | suite.NoError(err, "Adding directory recursively should succeed") 1050 | 1051 | // Check output shows multiple files were processed 1052 | output := suite.stdout.String() 1053 | suite.Contains(output, "Added") // Should show some success message 1054 | 1055 | // Verify individual files are now symlinks (not the directory itself) 1056 | info, err := os.Lstat(settingsFile) 1057 | suite.NoError(err) 1058 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "settings.json should be a symlink") 1059 | 1060 | info, err = os.Lstat(keymapFile) 1061 | suite.NoError(err) 1062 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "keymap.json should be a symlink") 1063 | 1064 | info, err = os.Lstat(themeFile) 1065 | suite.NoError(err) 1066 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "custom.json should be a symlink") 1067 | 1068 | // The directory itself should NOT be a symlink 1069 | info, err = os.Lstat(testDir) 1070 | suite.NoError(err) 1071 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "Directory should not be a symlink") 1072 | 1073 | // Verify files exist individually in storage 1074 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 1075 | suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "settings.json")) 1076 | suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "keymap.json")) 1077 | suite.FileExists(filepath.Join(lnkDir, ".config", "zed", "themes", "custom.json")) 1078 | } 1079 | 1080 | func (suite *CLITestSuite) TestAddCommandRecursiveMultipleDirs() { 1081 | // Initialize repository 1082 | err := suite.runCommand("init") 1083 | suite.Require().NoError(err) 1084 | suite.stdout.Reset() 1085 | 1086 | // Create two directories with files 1087 | dir1 := filepath.Join(suite.tempDir, "dir1") 1088 | dir2 := filepath.Join(suite.tempDir, "dir2") 1089 | err = os.MkdirAll(dir1, 0755) 1090 | suite.Require().NoError(err) 1091 | err = os.MkdirAll(dir2, 0755) 1092 | suite.Require().NoError(err) 1093 | 1094 | // Create files in each directory 1095 | file1 := filepath.Join(dir1, "file1.txt") 1096 | file2 := filepath.Join(dir2, "file2.txt") 1097 | err = os.WriteFile(file1, []byte("content1"), 0644) 1098 | suite.Require().NoError(err) 1099 | err = os.WriteFile(file2, []byte("content2"), 0644) 1100 | suite.Require().NoError(err) 1101 | 1102 | // Test recursive flag with multiple directories 1103 | err = suite.runCommand("add", "--recursive", dir1, dir2) 1104 | suite.NoError(err, "Adding multiple directories recursively should succeed") 1105 | 1106 | // Verify both files are symlinks 1107 | info, err := os.Lstat(file1) 1108 | suite.NoError(err) 1109 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "file1.txt should be a symlink") 1110 | 1111 | info, err = os.Lstat(file2) 1112 | suite.NoError(err) 1113 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "file2.txt should be a symlink") 1114 | 1115 | // Verify directories are not symlinks 1116 | info, err = os.Lstat(dir1) 1117 | suite.NoError(err) 1118 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "dir1 should not be a symlink") 1119 | 1120 | info, err = os.Lstat(dir2) 1121 | suite.NoError(err) 1122 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "dir2 should not be a symlink") 1123 | } 1124 | 1125 | // Task 3.1: Dry-Run Mode Tests 1126 | 1127 | func (suite *CLITestSuite) TestDryRunFlag() { 1128 | // Initialize repository 1129 | err := suite.runCommand("init") 1130 | suite.NoError(err) 1131 | initOutput := suite.stdout.String() 1132 | suite.Contains(initOutput, "Initialized") 1133 | suite.stdout.Reset() 1134 | 1135 | // Create test files 1136 | testFile1 := filepath.Join(suite.tempDir, "test1.txt") 1137 | testFile2 := filepath.Join(suite.tempDir, "test2.txt") 1138 | suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644)) 1139 | suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644)) 1140 | 1141 | // Run add with dry-run flag (should not exist yet) 1142 | err = suite.runCommand("add", "--dry-run", testFile1, testFile2) 1143 | suite.NoError(err, "Dry-run command should succeed") 1144 | output := suite.stdout.String() 1145 | 1146 | // Basic check that some output was produced (flag exists but behavior TBD) 1147 | suite.NotEmpty(output, "Should produce some output") 1148 | 1149 | // Verify files were NOT actually added (no symlinks created) 1150 | info, err := os.Lstat(testFile1) 1151 | suite.NoError(err) 1152 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be a symlink in dry-run") 1153 | 1154 | info, err = os.Lstat(testFile2) 1155 | suite.NoError(err) 1156 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be a symlink in dry-run") 1157 | 1158 | // Verify lnk list shows no managed files 1159 | suite.stdout.Reset() 1160 | err = suite.runCommand("list") 1161 | suite.NoError(err) 1162 | listOutput := suite.stdout.String() 1163 | suite.NotContains(listOutput, "test1.txt", "Files should not be managed after dry-run") 1164 | suite.NotContains(listOutput, "test2.txt", "Files should not be managed after dry-run") 1165 | } 1166 | 1167 | func (suite *CLITestSuite) TestDryRunOutput() { 1168 | // Initialize repository 1169 | err := suite.runCommand("init") 1170 | suite.NoError(err) 1171 | initOutput := suite.stdout.String() 1172 | suite.Contains(initOutput, "Initialized") 1173 | suite.stdout.Reset() 1174 | 1175 | // Create test files 1176 | testFile1 := filepath.Join(suite.tempDir, "test1.txt") 1177 | testFile2 := filepath.Join(suite.tempDir, "test2.txt") 1178 | suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644)) 1179 | suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644)) 1180 | 1181 | // Run add with dry-run flag 1182 | err = suite.runCommand("add", "--dry-run", testFile1, testFile2) 1183 | suite.NoError(err, "Dry-run command should succeed") 1184 | output := suite.stdout.String() 1185 | 1186 | // Verify dry-run shows preview of what would be added 1187 | suite.Contains(output, "Would add", "Should show dry-run preview") 1188 | suite.Contains(output, "test1.txt", "Should show first file") 1189 | suite.Contains(output, "test2.txt", "Should show second file") 1190 | suite.Contains(output, "2 files", "Should show file count") 1191 | 1192 | // Should contain helpful instructions 1193 | suite.Contains(output, "run without --dry-run", "Should provide next steps") 1194 | } 1195 | 1196 | func (suite *CLITestSuite) TestDryRunRecursive() { 1197 | // Initialize repository 1198 | err := suite.runCommand("init") 1199 | suite.NoError(err) 1200 | initOutput := suite.stdout.String() 1201 | suite.Contains(initOutput, "Initialized") 1202 | suite.stdout.Reset() 1203 | 1204 | // Create directory structure with multiple files 1205 | configDir := filepath.Join(suite.tempDir, ".config", "test-app") 1206 | err = os.MkdirAll(configDir, 0755) 1207 | suite.Require().NoError(err) 1208 | 1209 | // Create files in directory 1210 | for i := 1; i <= 15; i++ { 1211 | file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i)) 1212 | suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("config %d", i)), 0644)) 1213 | } 1214 | 1215 | // Run recursive add with dry-run 1216 | err = suite.runCommand("add", "--dry-run", "--recursive", configDir) 1217 | suite.NoError(err, "Dry-run recursive command should succeed") 1218 | output := suite.stdout.String() 1219 | 1220 | // Verify dry-run shows all files that would be added 1221 | suite.Contains(output, "Would add", "Should show dry-run preview") 1222 | suite.Contains(output, "15 files", "Should show correct file count") 1223 | suite.Contains(output, "recursively", "Should indicate recursive mode") 1224 | 1225 | // Should show some of the files 1226 | suite.Contains(output, "config1.json", "Should show first file") 1227 | suite.Contains(output, "config15.json", "Should show last file") 1228 | 1229 | // Verify no actual changes were made 1230 | for i := 1; i <= 15; i++ { 1231 | file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i)) 1232 | info, err := os.Lstat(file) 1233 | suite.NoError(err) 1234 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after dry-run") 1235 | } 1236 | } 1237 | 1238 | // Task 3.2: Enhanced Output and Messaging Tests 1239 | 1240 | func (suite *CLITestSuite) TestEnhancedSuccessOutput() { 1241 | // Initialize repository 1242 | err := suite.runCommand("init") 1243 | suite.NoError(err) 1244 | suite.stdout.Reset() 1245 | 1246 | // Create multiple test files 1247 | testFiles := []string{ 1248 | filepath.Join(suite.tempDir, "config1.txt"), 1249 | filepath.Join(suite.tempDir, "config2.txt"), 1250 | filepath.Join(suite.tempDir, "config3.txt"), 1251 | } 1252 | 1253 | for i, file := range testFiles { 1254 | suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i+1)), 0644)) 1255 | } 1256 | 1257 | // Add multiple files 1258 | args := append([]string{"add"}, testFiles...) 1259 | err = suite.runCommand(args...) 1260 | suite.NoError(err) 1261 | output := suite.stdout.String() 1262 | 1263 | // Should have enhanced formatting with consistent indentation 1264 | suite.Contains(output, "🔗", "Should use link icons") 1265 | suite.Contains(output, "config1.txt", "Should show first file") 1266 | suite.Contains(output, "config2.txt", "Should show second file") 1267 | suite.Contains(output, "config3.txt", "Should show third file") 1268 | 1269 | // Should show organized file list 1270 | suite.Contains(output, " ", "Should have consistent indentation") 1271 | 1272 | // Should include summary information 1273 | suite.Contains(output, "3 items", "Should show total count") 1274 | } 1275 | 1276 | func (suite *CLITestSuite) TestOperationSummary() { 1277 | // Initialize repository 1278 | err := suite.runCommand("init") 1279 | suite.NoError(err) 1280 | suite.stdout.Reset() 1281 | 1282 | // Create directory with files for recursive operation 1283 | configDir := filepath.Join(suite.tempDir, ".config", "test-app") 1284 | err = os.MkdirAll(configDir, 0755) 1285 | suite.Require().NoError(err) 1286 | 1287 | // Create files in directory 1288 | for i := 1; i <= 5; i++ { 1289 | file := filepath.Join(configDir, fmt.Sprintf("file%d.json", i)) 1290 | suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644)) 1291 | } 1292 | 1293 | // Add recursively 1294 | err = suite.runCommand("add", "--recursive", configDir) 1295 | suite.NoError(err) 1296 | output := suite.stdout.String() 1297 | 1298 | // Should show operation summary 1299 | suite.Contains(output, "recursively", "Should indicate operation type") 1300 | suite.Contains(output, "5", "Should show correct file count") 1301 | 1302 | // Should include contextual help message 1303 | suite.Contains(output, "lnk push", "Should suggest next steps") 1304 | suite.Contains(output, "sync to remote", "Should explain next step purpose") 1305 | 1306 | // Should show operation completion confirmation 1307 | suite.Contains(output, "✨", "Should use success emoji") 1308 | suite.Contains(output, "Added", "Should confirm operation completed") 1309 | } 1310 | 1311 | // Task 3.3: Documentation and Help Updates Tests 1312 | 1313 | func (suite *CLITestSuite) TestUpdatedHelpText() { 1314 | // Test main help 1315 | err := suite.runCommand("help") 1316 | suite.NoError(err) 1317 | helpOutput := suite.stdout.String() 1318 | suite.stdout.Reset() 1319 | 1320 | // Should mention bulk operations 1321 | suite.Contains(helpOutput, "multiple files", "Help should mention multiple file support") 1322 | 1323 | // Test add command help 1324 | err = suite.runCommand("add", "--help") 1325 | suite.NoError(err) 1326 | addHelpOutput := suite.stdout.String() 1327 | 1328 | // Should include new flags 1329 | suite.Contains(addHelpOutput, "--recursive", "Help should include recursive flag") 1330 | suite.Contains(addHelpOutput, "--dry-run", "Help should include dry-run flag") 1331 | 1332 | // Should include examples 1333 | suite.Contains(addHelpOutput, "Examples:", "Help should include usage examples") 1334 | suite.Contains(addHelpOutput, "lnk add ~/.bashrc ~/.vimrc", "Help should show multiple file example") 1335 | suite.Contains(addHelpOutput, "lnk add --recursive ~/.config", "Help should show recursive example") 1336 | suite.Contains(addHelpOutput, "lnk add --dry-run", "Help should show dry-run example") 1337 | 1338 | // Should describe what each flag does 1339 | suite.Contains(addHelpOutput, "directory contents individually", "Should explain recursive flag") 1340 | suite.Contains(addHelpOutput, "without making changes", "Should explain dry-run flag") 1341 | } 1342 | 1343 | // Task 3.1: Tests for force flag functionality 1344 | func (suite *CLITestSuite) TestInitCmd_ForceFlag_BypassesSafetyCheck() { 1345 | // Setup: Create .lnk file to simulate existing content 1346 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 1347 | err := os.MkdirAll(lnkDir, 0755) 1348 | suite.Require().NoError(err) 1349 | 1350 | // Initialize git repo first 1351 | cmd := exec.Command("git", "init") 1352 | cmd.Dir = lnkDir 1353 | err = cmd.Run() 1354 | suite.Require().NoError(err) 1355 | 1356 | lnkFile := filepath.Join(lnkDir, ".lnk") 1357 | err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) 1358 | suite.Require().NoError(err) 1359 | 1360 | // Create a dummy remote directory for testing 1361 | remoteDir := filepath.Join(suite.tempDir, "remote") 1362 | err = os.MkdirAll(remoteDir, 0755) 1363 | suite.Require().NoError(err) 1364 | cmd = exec.Command("git", "init", "--bare") 1365 | cmd.Dir = remoteDir 1366 | err = cmd.Run() 1367 | suite.Require().NoError(err) 1368 | 1369 | // Execute init command with --force flag 1370 | err = suite.runCommand("init", "-r", remoteDir, "--force") 1371 | suite.NoError(err, "Force flag should bypass safety check") 1372 | 1373 | // Verify output shows warning 1374 | output := suite.stdout.String() 1375 | suite.Contains(output, "force", "Should show force warning") 1376 | } 1377 | 1378 | func (suite *CLITestSuite) TestInitCmd_NoForceFlag_RespectsSafetyCheck() { 1379 | // Setup: Create .lnk file to simulate existing content 1380 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 1381 | err := os.MkdirAll(lnkDir, 0755) 1382 | suite.Require().NoError(err) 1383 | 1384 | // Initialize git repo first 1385 | cmd := exec.Command("git", "init") 1386 | cmd.Dir = lnkDir 1387 | err = cmd.Run() 1388 | suite.Require().NoError(err) 1389 | 1390 | lnkFile := filepath.Join(lnkDir, ".lnk") 1391 | err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) 1392 | suite.Require().NoError(err) 1393 | 1394 | // Create a dummy remote directory for testing 1395 | remoteDir := filepath.Join(suite.tempDir, "remote") 1396 | err = os.MkdirAll(remoteDir, 0755) 1397 | suite.Require().NoError(err) 1398 | cmd = exec.Command("git", "init", "--bare") 1399 | cmd.Dir = remoteDir 1400 | err = cmd.Run() 1401 | suite.Require().NoError(err) 1402 | 1403 | // Execute init command without --force flag - should fail 1404 | err = suite.runCommand("init", "-r", remoteDir) 1405 | suite.Error(err, "Should respect safety check without force flag") 1406 | suite.Contains(err.Error(), "already contains managed files") 1407 | } 1408 | 1409 | func (suite *CLITestSuite) TestInitCmd_ForceFlag_ShowsWarning() { 1410 | // Setup: Create .lnk file to simulate existing content 1411 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 1412 | err := os.MkdirAll(lnkDir, 0755) 1413 | suite.Require().NoError(err) 1414 | 1415 | // Initialize git repo first 1416 | cmd := exec.Command("git", "init") 1417 | cmd.Dir = lnkDir 1418 | err = cmd.Run() 1419 | suite.Require().NoError(err) 1420 | 1421 | lnkFile := filepath.Join(lnkDir, ".lnk") 1422 | err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) 1423 | suite.Require().NoError(err) 1424 | 1425 | // Create a dummy remote directory for testing 1426 | remoteDir := filepath.Join(suite.tempDir, "remote") 1427 | err = os.MkdirAll(remoteDir, 0755) 1428 | suite.Require().NoError(err) 1429 | cmd = exec.Command("git", "init", "--bare") 1430 | cmd.Dir = remoteDir 1431 | err = cmd.Run() 1432 | suite.Require().NoError(err) 1433 | 1434 | // Execute init command with --force flag 1435 | err = suite.runCommand("init", "-r", remoteDir, "--force") 1436 | suite.NoError(err, "Force flag should bypass safety check") 1437 | 1438 | // Verify output shows appropriate warning 1439 | output := suite.stdout.String() 1440 | suite.Contains(output, "⚠️", "Should show warning emoji") 1441 | suite.Contains(output, "overwrite", "Should warn about overwriting") 1442 | } 1443 | 1444 | // Task 4.1: Integration tests for end-to-end workflows 1445 | func (suite *CLITestSuite) TestE2E_InitAddInit_PreventDataLoss() { 1446 | // Run: lnk init 1447 | err := suite.runCommand("init") 1448 | suite.Require().NoError(err) 1449 | suite.stdout.Reset() 1450 | 1451 | // Create and add test file 1452 | testFile := filepath.Join(suite.tempDir, ".testfile") 1453 | err = os.WriteFile(testFile, []byte("important content"), 0644) 1454 | suite.Require().NoError(err) 1455 | err = suite.runCommand("add", testFile) 1456 | suite.Require().NoError(err) 1457 | 1458 | // Create dummy remote for testing 1459 | remoteDir := filepath.Join(suite.tempDir, "remote") 1460 | err = os.MkdirAll(remoteDir, 0755) 1461 | suite.Require().NoError(err) 1462 | cmd := exec.Command("git", "init", "--bare") 1463 | cmd.Dir = remoteDir 1464 | err = cmd.Run() 1465 | suite.Require().NoError(err) 1466 | 1467 | // Run: lnk init -r → should FAIL 1468 | err = suite.runCommand("init", "-r", remoteDir) 1469 | suite.Error(err, "Should prevent data loss") 1470 | suite.Contains(err.Error(), "already contains managed files") 1471 | 1472 | // Verify testfile still exists and is managed 1473 | suite.FileExists(testFile) 1474 | info, err := os.Lstat(testFile) 1475 | suite.NoError(err) 1476 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "File should still be symlink") 1477 | } 1478 | 1479 | func (suite *CLITestSuite) TestE2E_FreshInit_Success() { 1480 | // Create dummy remote for testing 1481 | remoteDir := filepath.Join(suite.tempDir, "remote") 1482 | err := os.MkdirAll(remoteDir, 0755) 1483 | suite.Require().NoError(err) 1484 | cmd := exec.Command("git", "init", "--bare") 1485 | cmd.Dir = remoteDir 1486 | err = cmd.Run() 1487 | suite.Require().NoError(err) 1488 | 1489 | // Fresh init with remote should succeed 1490 | err = suite.runCommand("init", "-r", remoteDir) 1491 | suite.NoError(err, "Fresh init should succeed") 1492 | 1493 | // Verify repository was created 1494 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 1495 | suite.DirExists(lnkDir) 1496 | gitDir := filepath.Join(lnkDir, ".git") 1497 | suite.DirExists(gitDir) 1498 | 1499 | // Verify success message 1500 | output := suite.stdout.String() 1501 | suite.Contains(output, "Initialized lnk repository") 1502 | suite.Contains(output, "Cloned from:") 1503 | } 1504 | 1505 | func (suite *CLITestSuite) TestE2E_ForceInit_OverwritesContent() { 1506 | // Setup: init and add content first 1507 | err := suite.runCommand("init") 1508 | suite.Require().NoError(err) 1509 | 1510 | testFile := filepath.Join(suite.tempDir, ".testfile") 1511 | err = os.WriteFile(testFile, []byte("original content"), 0644) 1512 | suite.Require().NoError(err) 1513 | err = suite.runCommand("add", testFile) 1514 | suite.Require().NoError(err) 1515 | suite.stdout.Reset() 1516 | 1517 | // Create dummy remote for testing 1518 | remoteDir := filepath.Join(suite.tempDir, "remote") 1519 | err = os.MkdirAll(remoteDir, 0755) 1520 | suite.Require().NoError(err) 1521 | cmd := exec.Command("git", "init", "--bare") 1522 | cmd.Dir = remoteDir 1523 | err = cmd.Run() 1524 | suite.Require().NoError(err) 1525 | 1526 | // Force init should succeed and show warning 1527 | err = suite.runCommand("init", "-r", remoteDir, "--force") 1528 | suite.NoError(err, "Force init should succeed") 1529 | 1530 | // Verify warning was shown 1531 | output := suite.stdout.String() 1532 | suite.Contains(output, "⚠️", "Should show warning") 1533 | suite.Contains(output, "overwrite", "Should warn about overwriting") 1534 | suite.Contains(output, "Initialized lnk repository") 1535 | } 1536 | 1537 | func (suite *CLITestSuite) TestE2E_ErrorMessage_SuggestsCorrectCommand() { 1538 | // Setup: init and add content first 1539 | err := suite.runCommand("init") 1540 | suite.Require().NoError(err) 1541 | 1542 | testFile := filepath.Join(suite.tempDir, ".testfile") 1543 | err = os.WriteFile(testFile, []byte("important content"), 0644) 1544 | suite.Require().NoError(err) 1545 | err = suite.runCommand("add", testFile) 1546 | suite.Require().NoError(err) 1547 | 1548 | // Try init with remote - should fail with helpful message 1549 | err = suite.runCommand("init", "-r", "https://github.com/test/dotfiles.git") 1550 | suite.Error(err, "Should fail with helpful error") 1551 | 1552 | // Verify error message suggests correct alternative 1553 | suite.Contains(err.Error(), "already contains managed files", "Should explain the problem") 1554 | suite.Contains(err.Error(), "lnk pull", "Should suggest pull command") 1555 | suite.Contains(err.Error(), "instead of", "Should explain the alternative") 1556 | suite.Contains(err.Error(), "lnk init -r", "Should show the problematic command") 1557 | } 1558 | 1559 | // Task 6.1: Regression tests to ensure existing functionality unchanged 1560 | func (suite *CLITestSuite) TestRegression_FreshInit_UnchangedBehavior() { 1561 | // Test that fresh init (no existing content) works exactly as before 1562 | err := suite.runCommand("init") 1563 | suite.NoError(err, "Fresh init should work unchanged") 1564 | 1565 | // Verify same output format and behavior 1566 | output := suite.stdout.String() 1567 | suite.Contains(output, "Initialized empty lnk repository") 1568 | suite.Contains(output, "Location:") 1569 | 1570 | // Verify repository structure is created correctly 1571 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 1572 | suite.DirExists(lnkDir) 1573 | gitDir := filepath.Join(lnkDir, ".git") 1574 | suite.DirExists(gitDir) 1575 | } 1576 | 1577 | func (suite *CLITestSuite) TestRegression_ExistingWorkflows_StillWork() { 1578 | // Test that all existing workflows continue to function 1579 | 1580 | // 1. Normal init → add → list → remove workflow 1581 | err := suite.runCommand("init") 1582 | suite.NoError(err, "Init should work") 1583 | suite.stdout.Reset() 1584 | 1585 | // Create and add a file 1586 | testFile := filepath.Join(suite.tempDir, ".bashrc") 1587 | err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 1588 | suite.Require().NoError(err) 1589 | 1590 | err = suite.runCommand("add", testFile) 1591 | suite.NoError(err, "Add should work") 1592 | suite.stdout.Reset() 1593 | 1594 | // List files 1595 | err = suite.runCommand("list") 1596 | suite.NoError(err, "List should work") 1597 | output := suite.stdout.String() 1598 | suite.Contains(output, ".bashrc", "Should list added file") 1599 | suite.stdout.Reset() 1600 | 1601 | // Remove file 1602 | err = suite.runCommand("rm", testFile) 1603 | suite.NoError(err, "Remove should work") 1604 | 1605 | // Verify file is restored as regular file 1606 | info, err := os.Lstat(testFile) 1607 | suite.NoError(err) 1608 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should be regular after remove") 1609 | } 1610 | 1611 | func (suite *CLITestSuite) TestRegression_GitOperations_Unaffected() { 1612 | // Test that Git operations continue to work normally 1613 | err := suite.runCommand("init") 1614 | suite.NoError(err) 1615 | 1616 | // Add a file to create commits 1617 | testFile := filepath.Join(suite.tempDir, ".vimrc") 1618 | err = os.WriteFile(testFile, []byte("set number"), 0644) 1619 | suite.Require().NoError(err) 1620 | 1621 | err = suite.runCommand("add", testFile) 1622 | suite.NoError(err) 1623 | 1624 | // Verify Git repository structure and commits are normal 1625 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 1626 | 1627 | // Check that commits are created normally 1628 | cmd := exec.Command("git", "log", "--oneline", "--format=%s") 1629 | cmd.Dir = lnkDir 1630 | output, err := cmd.Output() 1631 | suite.NoError(err, "Git log should work") 1632 | 1633 | commits := string(output) 1634 | suite.Contains(commits, "lnk: added .vimrc", "Should have normal commit message") 1635 | 1636 | // Check that git status works 1637 | cmd = exec.Command("git", "status", "--porcelain") 1638 | cmd.Dir = lnkDir 1639 | statusOutput, err := cmd.Output() 1640 | suite.NoError(err, "Git status should work") 1641 | suite.Empty(strings.TrimSpace(string(statusOutput)), "Working directory should be clean") 1642 | } 1643 | 1644 | func (suite *CLITestSuite) TestRegression_PerformanceImpact_Minimal() { 1645 | // Test that the new safety checks don't significantly impact performance 1646 | 1647 | // Simple performance check: ensure a single init completes quickly 1648 | start := time.Now() 1649 | err := suite.runCommand("init") 1650 | elapsed := time.Since(start) 1651 | 1652 | suite.NoError(err, "Init should succeed") 1653 | suite.Less(elapsed, 2*time.Second, "Init should complete quickly") 1654 | 1655 | // Test safety check performance on existing repository 1656 | suite.stdout.Reset() 1657 | start = time.Now() 1658 | err = suite.runCommand("init", "-r", "dummy-url") 1659 | elapsed = time.Since(start) 1660 | 1661 | // Should fail quickly due to safety check (not hang) 1662 | suite.Error(err, "Should fail due to safety check") 1663 | suite.Less(elapsed, 1*time.Second, "Safety check should be fast") 1664 | } 1665 | 1666 | // Task 7.1: Tests for help documentation 1667 | func (suite *CLITestSuite) TestInitCommand_HelpText_MentionsForceFlag() { 1668 | err := suite.runCommand("init", "--help") 1669 | suite.NoError(err) 1670 | output := suite.stdout.String() 1671 | suite.Contains(output, "--force", "Help should mention force flag") 1672 | suite.Contains(output, "overwrite", "Help should explain force behavior") 1673 | } 1674 | 1675 | func (suite *CLITestSuite) TestInitCommand_HelpText_ExplainsDataProtection() { 1676 | err := suite.runCommand("init", "--help") 1677 | suite.NoError(err) 1678 | output := suite.stdout.String() 1679 | 1680 | // Should explain what the command does 1681 | suite.Contains(output, "Creates", "Should explain what init does") 1682 | suite.Contains(output, "lnk directory", "Should mention lnk directory") 1683 | 1684 | // Should warn about the force flag risks 1685 | suite.Contains(output, "WARNING", "Should warn about force flag risks") 1686 | suite.Contains(output, "overwrite existing content", "Should mention overwrite risk") 1687 | } 1688 | 1689 | // TestPushPullWithDifferentBranches tests push/pull operations with different default branch names 1690 | func (suite *CLITestSuite) TestPushPullWithDifferentBranches() { 1691 | testCases := []struct { 1692 | name string 1693 | branchName string 1694 | setupRemote func(remoteDir string) error 1695 | }{ 1696 | { 1697 | name: "master branch", 1698 | branchName: "master", 1699 | setupRemote: func(remoteDir string) error { 1700 | cmd := exec.Command("git", "init", "--bare", "--initial-branch=master") 1701 | cmd.Dir = remoteDir 1702 | return cmd.Run() 1703 | }, 1704 | }, 1705 | { 1706 | name: "main branch", 1707 | branchName: "main", 1708 | setupRemote: func(remoteDir string) error { 1709 | cmd := exec.Command("git", "init", "--bare", "--initial-branch=main") 1710 | cmd.Dir = remoteDir 1711 | return cmd.Run() 1712 | }, 1713 | }, 1714 | { 1715 | name: "custom branch", 1716 | branchName: "develop", 1717 | setupRemote: func(remoteDir string) error { 1718 | cmd := exec.Command("git", "init", "--bare", "--initial-branch=develop") 1719 | cmd.Dir = remoteDir 1720 | return cmd.Run() 1721 | }, 1722 | }, 1723 | } 1724 | 1725 | for _, tc := range testCases { 1726 | suite.Run(tc.name, func() { 1727 | // Create a separate temp directory for this test case 1728 | testDir, err := os.MkdirTemp("", "lnk-push-pull-test-*") 1729 | suite.Require().NoError(err) 1730 | defer func() { _ = os.RemoveAll(testDir) }() 1731 | 1732 | // Save current dir and change to test dir 1733 | originalDir, err := os.Getwd() 1734 | suite.Require().NoError(err) 1735 | defer func() { _ = os.Chdir(originalDir) }() 1736 | 1737 | err = os.Chdir(testDir) 1738 | suite.Require().NoError(err) 1739 | 1740 | // Set HOME to test directory 1741 | suite.T().Setenv("HOME", testDir) 1742 | suite.T().Setenv("XDG_CONFIG_HOME", testDir) 1743 | 1744 | // Create remote repository 1745 | remoteDir := filepath.Join(testDir, "remote.git") 1746 | err = os.MkdirAll(remoteDir, 0755) 1747 | suite.Require().NoError(err) 1748 | 1749 | err = tc.setupRemote(remoteDir) 1750 | suite.Require().NoError(err) 1751 | 1752 | // Initialize lnk with remote 1753 | err = suite.runCommand("init", "--remote", remoteDir) 1754 | suite.Require().NoError(err) 1755 | 1756 | // Switch to the test branch if not main/master (since init creates main by default) 1757 | if tc.branchName != "main" { 1758 | lnkDir := filepath.Join(testDir, "lnk") 1759 | cmd := exec.Command("git", "checkout", "-b", tc.branchName) 1760 | cmd.Dir = lnkDir 1761 | _, err = cmd.CombinedOutput() 1762 | suite.Require().NoError(err) 1763 | } 1764 | 1765 | // Add a test file 1766 | testFile := filepath.Join(testDir, ".testrc") 1767 | err = os.WriteFile(testFile, []byte("test config"), 0644) 1768 | suite.Require().NoError(err) 1769 | 1770 | err = suite.runCommand("add", testFile) 1771 | suite.Require().NoError(err) 1772 | 1773 | // Test push operation 1774 | err = suite.runCommand("push", "test push with "+tc.branchName) 1775 | suite.Require().NoError(err, "Push should work with %s branch", tc.branchName) 1776 | 1777 | // Create another test directory to simulate pulling from another machine 1778 | pullTestDir, err := os.MkdirTemp("", "lnk-pull-test-*") 1779 | suite.Require().NoError(err) 1780 | defer func() { _ = os.RemoveAll(pullTestDir) }() 1781 | 1782 | err = os.Chdir(pullTestDir) 1783 | suite.Require().NoError(err) 1784 | 1785 | // Set HOME for pull test 1786 | suite.T().Setenv("HOME", pullTestDir) 1787 | suite.T().Setenv("XDG_CONFIG_HOME", pullTestDir) 1788 | 1789 | // Clone and test pull 1790 | err = suite.runCommand("init", "--remote", remoteDir) 1791 | suite.Require().NoError(err) 1792 | 1793 | err = suite.runCommand("pull") 1794 | suite.Require().NoError(err, "Pull should work with %s branch", tc.branchName) 1795 | 1796 | // Verify the file was pulled correctly 1797 | lnkDir := filepath.Join(pullTestDir, "lnk") 1798 | pulledFile := filepath.Join(lnkDir, ".testrc") 1799 | suite.FileExists(pulledFile, "File should exist after pull with %s branch", tc.branchName) 1800 | 1801 | content, err := os.ReadFile(pulledFile) 1802 | suite.Require().NoError(err) 1803 | suite.Equal("test config", string(content), "File content should match after pull with %s branch", tc.branchName) 1804 | }) 1805 | } 1806 | } 1807 | 1808 | func TestCLISuite(t *testing.T) { 1809 | suite.Run(t, new(CLITestSuite)) 1810 | } 1811 | --------------------------------------------------------------------------------