├── .github └── workflows │ ├── ci.yml │ ├── release.yml │ └── validate.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── RELEASE.md ├── cmd ├── add.go ├── bootstrap.go ├── init.go ├── list.go ├── pull.go ├── push.go ├── rm.go ├── root.go ├── root_test.go ├── status.go └── utils.go ├── go.mod ├── go.sum ├── install.sh ├── internal ├── core │ ├── lnk.go │ └── lnk_test.go ├── fs │ ├── errors.go │ └── filesystem.go └── git │ ├── errors.go │ └── git.go └── main.go /.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 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.24' 23 | 24 | - name: Cache Go modules 25 | uses: actions/cache@v3 26 | with: 27 | path: ~/go/pkg/mod 28 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 29 | restore-keys: | 30 | ${{ runner.os }}-go- 31 | 32 | - name: Download dependencies 33 | run: go mod download 34 | 35 | - name: Format check 36 | run: | 37 | gofmt -l . 38 | test -z "$(gofmt -l .)" 39 | 40 | - name: Vet 41 | run: go vet ./... 42 | 43 | - name: Test 44 | run: go test -v -race -coverprofile=coverage.out ./... 45 | 46 | - name: Upload coverage to Codecov 47 | uses: codecov/codecov-action@v3 48 | with: 49 | file: ./coverage.out 50 | 51 | lint: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v4 56 | 57 | - name: Set up Go 58 | uses: actions/setup-go@v5 59 | with: 60 | go-version: '1.24' 61 | 62 | - name: golangci-lint 63 | uses: golangci/golangci-lint-action@v3 64 | with: 65 | version: latest 66 | 67 | build: 68 | runs-on: ubuntu-latest 69 | needs: [test, lint] 70 | steps: 71 | - name: Checkout 72 | uses: actions/checkout@v4 73 | 74 | - name: Set up Go 75 | uses: actions/setup-go@v5 76 | with: 77 | go-version: '1.24' 78 | 79 | - name: Build 80 | run: go build -v ./... 81 | 82 | - name: Test GoReleaser 83 | uses: goreleaser/goreleaser-action@v6 84 | with: 85 | distribution: goreleaser 86 | version: latest 87 | args: build --snapshot --clean 88 | -------------------------------------------------------------------------------- /.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@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 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 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | paths: 7 | - '.goreleaser.yml' 8 | - 'main.go' 9 | - 'cmd/**' 10 | - 'internal/**' 11 | - 'go.mod' 12 | - 'go.sum' 13 | 14 | jobs: 15 | goreleaser: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: '1.24' 27 | 28 | - name: Check GoReleaser config 29 | uses: goreleaser/goreleaser-action@v6 30 | with: 31 | distribution: goreleaser 32 | version: latest 33 | args: check 34 | 35 | - name: Test GoReleaser build 36 | uses: goreleaser/goreleaser-action@v6 37 | with: 38 | distribution: goreleaser 39 | version: latest 40 | args: build --snapshot --clean 41 | -------------------------------------------------------------------------------- /.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/ -------------------------------------------------------------------------------- /.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}}" -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lnk 2 | 3 | **Git-native dotfiles management that doesn't suck.** 4 | 5 | Move your dotfiles to `~/.config/lnk`, symlink them back, and use Git like normal. Supports both common configurations and host-specific setups. Automatically runs bootstrap scripts to set up your environment. 6 | 7 | ```bash 8 | lnk init -r git@github.com:user/dotfiles.git # Clones & runs bootstrap automatically 9 | lnk add ~/.vimrc ~/.bashrc # Common config 10 | lnk add --host work ~/.ssh/config # Host-specific config 11 | lnk push "setup" 12 | ``` 13 | 14 | ## Install 15 | 16 | ```bash 17 | # Quick install (recommended) 18 | curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash 19 | ``` 20 | 21 | ```bash 22 | # Homebrew (macOS/Linux) 23 | brew tap yarlson/lnk 24 | brew install lnk 25 | ``` 26 | 27 | ```bash 28 | # Manual download 29 | wget https://github.com/yarlson/lnk/releases/latest/download/lnk-$(uname -s | tr '[:upper:]' '[:lower:]')-amd64 30 | chmod +x lnk-* && sudo mv lnk-* /usr/local/bin/lnk 31 | ``` 32 | 33 | ```bash 34 | # From source 35 | git clone https://github.com/yarlson/lnk.git && cd lnk && go build . && sudo mv lnk /usr/local/bin/ 36 | ``` 37 | 38 | ## Usage 39 | 40 | ### Setup 41 | 42 | ```bash 43 | # Fresh start 44 | lnk init 45 | 46 | # With existing repo (runs bootstrap automatically) 47 | lnk init -r git@github.com:user/dotfiles.git 48 | 49 | # Skip automatic bootstrap 50 | lnk init -r git@github.com:user/dotfiles.git --no-bootstrap 51 | 52 | # Run bootstrap script manually 53 | lnk bootstrap 54 | ``` 55 | 56 | ### Daily workflow 57 | 58 | ```bash 59 | # Add files/directories (common config) 60 | lnk add ~/.vimrc ~/.config/nvim ~/.gitconfig 61 | 62 | # Add host-specific files 63 | lnk add --host laptop ~/.ssh/config 64 | lnk add --host work ~/.gitconfig 65 | 66 | # List managed files 67 | lnk list # Common config only 68 | lnk list --host laptop # Laptop-specific config 69 | lnk list --all # All configurations 70 | 71 | # Check status 72 | lnk status 73 | 74 | # Sync changes 75 | lnk push "updated vim config" 76 | lnk pull # Pull common config 77 | lnk pull --host laptop # Pull laptop-specific config 78 | ``` 79 | 80 | ## How it works 81 | 82 | ``` 83 | Common files: 84 | Before: ~/.vimrc (file) 85 | After: ~/.vimrc -> ~/.config/lnk/.vimrc (symlink) 86 | 87 | Host-specific files: 88 | Before: ~/.ssh/config (file) 89 | After: ~/.ssh/config -> ~/.config/lnk/laptop.lnk/.ssh/config (symlink) 90 | ``` 91 | 92 | 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. 93 | 94 | ## Bootstrap Support 95 | 96 | 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. 97 | 98 | ### Examples 99 | 100 | **Simple bootstrap script:** 101 | 102 | ```bash 103 | #!/bin/bash 104 | # bootstrap.sh 105 | echo "Setting up development environment..." 106 | 107 | # Install Homebrew (macOS) 108 | if ! command -v brew &> /dev/null; then 109 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 110 | fi 111 | 112 | # Install packages 113 | brew install git vim tmux 114 | 115 | echo "✅ Setup complete!" 116 | ``` 117 | 118 | **Usage:** 119 | 120 | ```bash 121 | # Automatic bootstrap on clone 122 | lnk init -r git@github.com:you/dotfiles.git 123 | # → Clones repo and runs bootstrap script automatically 124 | 125 | # Skip bootstrap if needed 126 | lnk init -r git@github.com:you/dotfiles.git --no-bootstrap 127 | 128 | # Run bootstrap manually later 129 | lnk bootstrap 130 | ``` 131 | 132 | ## Multihost Support 133 | 134 | Lnk supports both **common configurations** (shared across all machines) and **host-specific configurations** (unique per machine). 135 | 136 | ### File Organization 137 | 138 | ``` 139 | ~/.config/lnk/ 140 | ├── .lnk # Tracks common files 141 | ├── .lnk.laptop # Tracks laptop-specific files 142 | ├── .lnk.work # Tracks work-specific files 143 | ├── .vimrc # Common file 144 | ├── .gitconfig # Common file 145 | ├── laptop.lnk/ # Laptop-specific storage 146 | │ ├── .ssh/ 147 | │ │ └── config 148 | │ └── .tmux.conf 149 | └── work.lnk/ # Work-specific storage 150 | ├── .ssh/ 151 | │ └── config 152 | └── .gitconfig 153 | ``` 154 | 155 | ### Usage Patterns 156 | 157 | ```bash 158 | # Common config (shared everywhere) 159 | lnk add ~/.vimrc ~/.bashrc ~/.gitconfig 160 | 161 | # Host-specific config (unique per machine) 162 | lnk add --host $(hostname) ~/.ssh/config 163 | lnk add --host work ~/.gitconfig 164 | 165 | # List configurations 166 | lnk list # Common only 167 | lnk list --host work # Work host only 168 | lnk list --all # Everything 169 | 170 | # Pull configurations 171 | lnk pull # Common config 172 | lnk pull --host work # Work-specific config 173 | ``` 174 | 175 | ## Why not just Git? 176 | 177 | You could `git init ~/.config/lnk` and manually symlink everything. Lnk just automates the tedious parts: 178 | 179 | - Moving files safely 180 | - Creating relative symlinks 181 | - Handling conflicts 182 | - Tracking what's managed 183 | 184 | ## Examples 185 | 186 | ### First time setup 187 | 188 | ```bash 189 | # Clone dotfiles and run bootstrap automatically 190 | lnk init -r git@github.com:you/dotfiles.git 191 | # → Downloads dependencies, installs packages, configures environment 192 | 193 | # Add common config (shared across all machines) 194 | lnk add ~/.bashrc ~/.vimrc ~/.gitconfig 195 | 196 | # Add host-specific config 197 | lnk add --host $(hostname) ~/.ssh/config ~/.tmux.conf 198 | 199 | lnk push "initial setup" 200 | ``` 201 | 202 | ### On a new machine 203 | 204 | ```bash 205 | # Bootstrap runs automatically 206 | lnk init -r git@github.com:you/dotfiles.git 207 | # → Sets up environment, installs dependencies 208 | 209 | # Pull common config 210 | lnk pull 211 | 212 | # Pull host-specific config (if it exists) 213 | lnk pull --host $(hostname) 214 | 215 | # Or run bootstrap manually if needed 216 | lnk bootstrap 217 | ``` 218 | 219 | ### Daily edits 220 | 221 | ```bash 222 | vim ~/.vimrc # edit normally 223 | lnk list # see common config 224 | lnk list --host $(hostname) # see host-specific config 225 | lnk list --all # see everything 226 | lnk status # check what changed 227 | lnk push "new plugins" # commit & push 228 | ``` 229 | 230 | ### Multi-machine workflow 231 | 232 | ```bash 233 | # On your laptop 234 | lnk add --host laptop ~/.ssh/config 235 | lnk add ~/.vimrc # Common config 236 | lnk push "laptop ssh config" 237 | 238 | # On your work machine 239 | lnk pull # Get common config 240 | lnk add --host work ~/.gitconfig 241 | lnk push "work git config" 242 | 243 | # Back on laptop 244 | lnk pull # Get updates (work config won't affect laptop) 245 | ``` 246 | 247 | ## Commands 248 | 249 | - `lnk init [-r remote] [--no-bootstrap]` - Create repo (runs bootstrap automatically) 250 | - `lnk add [--host HOST] ` - Move files to repo, create symlinks 251 | - `lnk rm [--host HOST] ` - Move files back, remove symlinks 252 | - `lnk list [--host HOST] [--all]` - List files managed by lnk 253 | - `lnk status` - Git status + sync info 254 | - `lnk push [msg]` - Stage all, commit, push 255 | - `lnk pull [--host HOST]` - Pull + restore missing symlinks 256 | - `lnk bootstrap` - Run bootstrap script manually 257 | 258 | ### Command Options 259 | 260 | - `--host HOST` - Manage files for specific host (default: common configuration) 261 | - `--all` - Show all configurations (common + all hosts) when listing 262 | - `-r, --remote URL` - Clone from remote URL when initializing 263 | - `--no-bootstrap` - Skip automatic execution of bootstrap script after cloning 264 | 265 | ## Technical bits 266 | 267 | - **Single binary** (~8MB, no deps) 268 | - **Relative symlinks** (portable) 269 | - **XDG compliant** (`~/.config/lnk`) 270 | - **Multihost support** (common + host-specific configs) 271 | - **Bootstrap support** (automatic environment setup) 272 | - **Git-native** (standard Git repo, no special formats) 273 | 274 | ## Alternatives 275 | 276 | | Tool | Complexity | Why choose it | 277 | | ------- | ---------- | ------------------------------------------------------- | 278 | | **lnk** | Minimal | Just works, no config, Git-native, multihost, bootstrap | 279 | | chezmoi | High | Templates, encryption, cross-platform | 280 | | yadm | Medium | Git power user, encryption | 281 | | dotbot | Low | YAML config, basic features | 282 | | stow | Low | Perl, symlink only | 283 | 284 | ## Contributing 285 | 286 | ```bash 287 | git clone https://github.com/yarlson/lnk.git 288 | cd lnk 289 | make deps # Install golangci-lint 290 | make check # Runs fmt, vet, lint, test 291 | ``` 292 | 293 | **What we use:** 294 | 295 | - **Runtime deps**: Only `cobra` (CLI framework) 296 | - **Test deps**: `testify` for assertions 297 | - **Build pipeline**: Standard Makefile with quality checks 298 | 299 | **Before submitting:** 300 | 301 | ```bash 302 | make check # Runs all quality checks + tests 303 | ``` 304 | 305 | **Adding features:** 306 | 307 | - Put integration tests in `test/integration_test.go` 308 | - Use conventional commits: `feat:`, `fix:`, `docs:` 309 | 310 | ## License 311 | 312 | [MIT](LICENSE) 313 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | This document describes how to create releases for the lnk project using GoReleaser. 4 | 5 | ## Prerequisites 6 | 7 | - Push access to the main repository 8 | - Git tags pushed to GitHub trigger releases automatically 9 | - GoReleaser is configured in `.goreleaser.yml` 10 | - GitHub Actions will handle the release process 11 | - Access to the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap repository 12 | - **Personal Access Token** set up as `HOMEBREW_TAP_TOKEN` secret (see Setup section) 13 | 14 | ## Setup (One-time) 15 | 16 | ### GitHub Personal Access Token 17 | 18 | For GoReleaser to update the Homebrew formula, you need a Personal Access Token: 19 | 20 | 1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic) 21 | 2. Click "Generate new token" → "Generate new token (classic)" 22 | 3. Name: "GoReleaser Homebrew Access" 23 | 4. Scopes: Select `repo` (Full control of private repositories) 24 | 5. Generate and copy the token 25 | 6. In your `yarlson/lnk` repository: 26 | - Go to Settings → Secrets and variables → Actions 27 | - Add new repository secret: `HOMEBREW_TAP_TOKEN` 28 | - Paste the token as the value 29 | 30 | This allows GoReleaser to automatically update the Homebrew formula in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk). 31 | 32 | ## Creating a Release 33 | 34 | ### 1. Ensure everything is ready 35 | 36 | ```bash 37 | # Run all quality checks 38 | make check 39 | 40 | # Test GoReleaser configuration 41 | make goreleaser-check 42 | 43 | # Test build process 44 | make goreleaser-snapshot 45 | ``` 46 | 47 | ### 2. Create and push a version tag 48 | 49 | ```bash 50 | # Create a new tag (replace x.y.z with actual version) 51 | git tag -a v1.0.0 -m "Release v1.0.0" 52 | 53 | # Push the tag to trigger the release 54 | git push origin v1.0.0 55 | ``` 56 | 57 | ### 3. Monitor the release 58 | 59 | - GitHub Actions will automatically build and release when the tag is pushed 60 | - Check the [Actions tab](https://github.com/yarlson/lnk/actions) for build status 61 | - The release will appear in [GitHub Releases](https://github.com/yarlson/lnk/releases) 62 | - The Homebrew formula will be automatically updated in [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) 63 | 64 | ## What GoReleaser Does 65 | 66 | 1. **Builds binaries** for multiple platforms: 67 | 68 | - Linux (amd64, arm64) 69 | - macOS (amd64, arm64) 70 | - Windows (amd64) 71 | 72 | 2. **Creates archives** with consistent naming: 73 | 74 | - `lnk_Linux_x86_64.tar.gz` 75 | - `lnk_Darwin_arm64.tar.gz` 76 | - etc. 77 | 78 | 3. **Generates checksums** for verification 79 | 80 | 4. **Creates GitHub release** with: 81 | 82 | - Automatic changelog from conventional commits 83 | - Installation instructions 84 | - Download links for all platforms 85 | 86 | 5. **Updates Homebrew formula** automatically in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) tap 87 | 88 | ## Manual Release (if needed) 89 | 90 | If you need to create a release manually: 91 | 92 | ```bash 93 | # Export GitHub token 94 | export GITHUB_TOKEN="your_token_here" 95 | 96 | # Create release (requires a git tag) 97 | goreleaser release --clean 98 | ``` 99 | 100 | ## Testing Releases Locally 101 | 102 | ```bash 103 | # Test the build process without releasing 104 | make goreleaser-snapshot 105 | 106 | # Built artifacts will be in dist/ 107 | ls -la dist/ 108 | 109 | # Test a binary 110 | ./dist/lnk_/lnk --version 111 | ``` 112 | 113 | ## Installation Methods 114 | 115 | After a release is published, users can install lnk using multiple methods: 116 | 117 | ### 1. Shell Script (Recommended) 118 | 119 | ```bash 120 | curl -sSL https://raw.githubusercontent.com/yarlson/lnk/main/install.sh | bash 121 | ``` 122 | 123 | ### 2. Homebrew (macOS/Linux) 124 | 125 | ```bash 126 | brew tap yarlson/lnk 127 | brew install lnk 128 | ``` 129 | 130 | ### 3. Manual Download 131 | 132 | ```bash 133 | # Download from GitHub releases 134 | wget https://github.com/yarlson/lnk/releases/latest/download/lnk_Linux_x86_64.tar.gz 135 | tar -xzf lnk_Linux_x86_64.tar.gz 136 | sudo mv lnk /usr/local/bin/ 137 | ``` 138 | 139 | ## Version Numbering 140 | 141 | We use [Semantic Versioning](https://semver.org/): 142 | 143 | - `v1.0.0` - Major release (breaking changes) 144 | - `v1.1.0` - Minor release (new features, backward compatible) 145 | - `v1.1.1` - Patch release (bug fixes) 146 | 147 | ## Changelog 148 | 149 | GoReleaser automatically generates changelogs from git commits using conventional commit format: 150 | 151 | - `feat:` - New features 152 | - `fix:` - Bug fixes 153 | - `docs:` - Documentation changes (excluded from changelog) 154 | - `test:` - Test changes (excluded from changelog) 155 | - `ci:` - CI changes (excluded from changelog) 156 | 157 | ## Homebrew Tap 158 | 159 | The Homebrew formula is automatically maintained in the [homebrew-lnk](https://github.com/yarlson/homebrew-lnk) repository. When a new release is created: 160 | 161 | 1. GoReleaser automatically creates/updates the formula 162 | 2. The formula is committed to the tap repository 163 | 3. Users can immediately install the new version via `brew install yarlson/lnk/lnk` 164 | 165 | ## Troubleshooting 166 | 167 | ### Release failed to create 168 | 169 | 1. Check that the tag follows the format `vX.Y.Z` 170 | 2. Ensure GitHub Actions has proper permissions 171 | 3. Check the Actions log for detailed error messages 172 | 173 | ### Missing binaries in release 174 | 175 | 1. Verify GoReleaser configuration: `make goreleaser-check` 176 | 2. Test build locally: `make goreleaser-snapshot` 177 | 3. Check the build matrix in `.goreleaser.yml` 178 | 179 | ### Changelog is empty 180 | 181 | 1. Ensure commits follow conventional commit format 182 | 2. Check that there are commits since the last tag 183 | 3. Verify changelog configuration in `.goreleaser.yml` 184 | 185 | ### Homebrew formula not updated 186 | 187 | 1. Check that the GITHUB_TOKEN has access to the homebrew-lnk repository 188 | 2. Verify the repository name and owner in `.goreleaser.yml` 189 | 3. Check the release workflow logs for Homebrew-related errors 190 | 4. Ensure the homebrew-lnk repository exists and is accessible 191 | -------------------------------------------------------------------------------- /cmd/add.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/yarlson/lnk/internal/core" 8 | ) 9 | 10 | func newAddCmd() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "add ", 13 | Short: "✨ Add a file to lnk management", 14 | Long: "Moves a file to the lnk repository and creates a symlink in its place.", 15 | Args: cobra.ExactArgs(1), 16 | SilenceUsage: true, 17 | SilenceErrors: true, 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | filePath := args[0] 20 | host, _ := cmd.Flags().GetString("host") 21 | 22 | lnk := core.NewLnk(core.WithHost(host)) 23 | 24 | if err := lnk.Add(filePath); err != nil { 25 | return err 26 | } 27 | 28 | basename := filepath.Base(filePath) 29 | if host != "" { 30 | printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host) 31 | printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath) 32 | } else { 33 | printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename) 34 | printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath) 35 | } 36 | printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n") 37 | return nil 38 | }, 39 | } 40 | 41 | cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)") 42 | return cmd 43 | } 44 | -------------------------------------------------------------------------------- /cmd/bootstrap.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/yarlson/lnk/internal/core" 6 | ) 7 | 8 | func newBootstrapCmd() *cobra.Command { 9 | return &cobra.Command{ 10 | Use: "bootstrap", 11 | Short: "🚀 Run the bootstrap script to set up your environment", 12 | Long: "Executes the bootstrap script from your dotfiles repository to install dependencies and configure your system.", 13 | SilenceUsage: true, 14 | SilenceErrors: true, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | lnk := core.NewLnk() 17 | 18 | scriptPath, err := lnk.FindBootstrapScript() 19 | if err != nil { 20 | return err 21 | } 22 | 23 | if scriptPath == "" { 24 | printf(cmd, "💡 \033[33mNo bootstrap script found\033[0m\n") 25 | printf(cmd, " 📝 Create a \033[1mbootstrap.sh\033[0m file in your dotfiles repository:\n") 26 | printf(cmd, " \033[90m#!/bin/bash\033[0m\n") 27 | printf(cmd, " \033[90mecho \"Setting up environment...\"\033[0m\n") 28 | printf(cmd, " \033[90m# Your setup commands here\033[0m\n") 29 | return nil 30 | } 31 | 32 | printf(cmd, "🚀 \033[1mRunning bootstrap script\033[0m\n") 33 | printf(cmd, " 📄 Script: \033[36m%s\033[0m\n", scriptPath) 34 | printf(cmd, "\n") 35 | 36 | if err := lnk.RunBootstrapScript(scriptPath); err != nil { 37 | return err 38 | } 39 | 40 | printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n") 41 | printf(cmd, " 🎉 Your environment is ready to use\n") 42 | return nil 43 | }, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/yarlson/lnk/internal/core" 6 | ) 7 | 8 | func newInitCmd() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "init", 11 | Short: "🎯 Initialize a new lnk repository", 12 | Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.", 13 | SilenceUsage: true, 14 | SilenceErrors: true, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | remote, _ := cmd.Flags().GetString("remote") 17 | noBootstrap, _ := cmd.Flags().GetBool("no-bootstrap") 18 | 19 | lnk := core.NewLnk() 20 | if err := lnk.InitWithRemote(remote); err != nil { 21 | return err 22 | } 23 | 24 | if remote != "" { 25 | printf(cmd, "🎯 \033[1mInitialized lnk repository\033[0m\n") 26 | printf(cmd, " 📦 Cloned from: \033[36m%s\033[0m\n", remote) 27 | printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n") 28 | 29 | // Try to run bootstrap script if not disabled 30 | if !noBootstrap { 31 | printf(cmd, "\n🔍 \033[1mLooking for bootstrap script...\033[0m\n") 32 | 33 | scriptPath, err := lnk.FindBootstrapScript() 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if scriptPath != "" { 39 | printf(cmd, " ✅ Found bootstrap script: \033[36m%s\033[0m\n", scriptPath) 40 | printf(cmd, "\n🚀 \033[1mRunning bootstrap script...\033[0m\n") 41 | printf(cmd, "\n") 42 | 43 | if err := lnk.RunBootstrapScript(scriptPath); err != nil { 44 | printf(cmd, "\n⚠️ \033[33mBootstrap script failed, but repository was initialized successfully\033[0m\n") 45 | printf(cmd, " 💡 You can run it manually with: \033[1mlnk bootstrap\033[0m\n") 46 | printf(cmd, " 🔧 Error: %v\n", err) 47 | } else { 48 | printf(cmd, "\n✅ \033[1;32mBootstrap completed successfully!\033[0m\n") 49 | } 50 | } else { 51 | printf(cmd, " 💡 No bootstrap script found\n") 52 | } 53 | } 54 | 55 | printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n") 56 | printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n") 57 | printf(cmd, " • Use \033[1mlnk add \033[0m to manage new files\n") 58 | } else { 59 | printf(cmd, "🎯 \033[1mInitialized empty lnk repository\033[0m\n") 60 | printf(cmd, " 📁 Location: \033[90m~/.config/lnk\033[0m\n") 61 | printf(cmd, "\n💡 \033[33mNext steps:\033[0m\n") 62 | printf(cmd, " • Run \033[1mlnk add \033[0m to start managing dotfiles\n") 63 | printf(cmd, " • Add a remote with: \033[1mgit remote add origin \033[0m\n") 64 | } 65 | 66 | return nil 67 | }, 68 | } 69 | 70 | cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository") 71 | cmd.Flags().Bool("no-bootstrap", false, "Skip automatic execution of bootstrap script after cloning") 72 | return cmd 73 | } 74 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/yarlson/lnk/internal/core" 10 | ) 11 | 12 | func newListCmd() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "list", 15 | Short: "📋 List files managed by lnk", 16 | Long: "Display all files and directories currently managed by lnk.", 17 | SilenceUsage: true, 18 | SilenceErrors: true, 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | host, _ := cmd.Flags().GetString("host") 21 | all, _ := cmd.Flags().GetBool("all") 22 | 23 | if host != "" { 24 | // Show specific host configuration 25 | return listHostConfig(cmd, host) 26 | } 27 | 28 | if all { 29 | // Show all configurations (common + all hosts) 30 | return listAllConfigs(cmd) 31 | } 32 | 33 | // Default: show common configuration 34 | return listCommonConfig(cmd) 35 | }, 36 | } 37 | 38 | cmd.Flags().StringP("host", "H", "", "List files for specific host") 39 | cmd.Flags().BoolP("all", "a", false, "List files for all hosts and common configuration") 40 | return cmd 41 | } 42 | 43 | func listCommonConfig(cmd *cobra.Command) error { 44 | lnk := core.NewLnk() 45 | managedItems, err := lnk.List() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | if len(managedItems) == 0 { 51 | printf(cmd, "📋 \033[1mNo files currently managed by lnk (common)\033[0m\n") 52 | printf(cmd, " 💡 Use \033[1mlnk add \033[0m to start managing files\n") 53 | return nil 54 | } 55 | 56 | printf(cmd, "📋 \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems)) 57 | if len(managedItems) > 1 { 58 | printf(cmd, "s") 59 | } 60 | printf(cmd, "\033[0m):\n\n") 61 | 62 | for _, item := range managedItems { 63 | printf(cmd, " 🔗 \033[36m%s\033[0m\n", item) 64 | } 65 | 66 | printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n") 67 | return nil 68 | } 69 | 70 | func listHostConfig(cmd *cobra.Command, host string) error { 71 | lnk := core.NewLnk(core.WithHost(host)) 72 | managedItems, err := lnk.List() 73 | if err != nil { 74 | return err 75 | } 76 | 77 | if len(managedItems) == 0 { 78 | printf(cmd, "📋 \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host) 79 | printf(cmd, " 💡 Use \033[1mlnk add --host %s \033[0m to start managing files\n", host) 80 | return nil 81 | } 82 | 83 | printf(cmd, "📋 \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems)) 84 | if len(managedItems) > 1 { 85 | printf(cmd, "s") 86 | } 87 | printf(cmd, "\033[0m):\n\n") 88 | 89 | for _, item := range managedItems { 90 | printf(cmd, " 🔗 \033[36m%s\033[0m\n", item) 91 | } 92 | 93 | printf(cmd, "\n💡 Use \033[1mlnk status\033[0m to check sync status\n") 94 | return nil 95 | } 96 | 97 | func listAllConfigs(cmd *cobra.Command) error { 98 | // List common configuration 99 | printf(cmd, "📋 \033[1mAll configurations managed by lnk\033[0m\n\n") 100 | 101 | lnk := core.NewLnk() 102 | commonItems, err := lnk.List() 103 | if err != nil { 104 | return err 105 | } 106 | 107 | printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems)) 108 | if len(commonItems) > 1 { 109 | printf(cmd, "s") 110 | } 111 | printf(cmd, "\033[0m):\n") 112 | 113 | if len(commonItems) == 0 { 114 | printf(cmd, " \033[90m(no files)\033[0m\n") 115 | } else { 116 | for _, item := range commonItems { 117 | printf(cmd, " 🔗 \033[36m%s\033[0m\n", item) 118 | } 119 | } 120 | 121 | // Find all host-specific configurations 122 | hosts, err := findHostConfigs() 123 | if err != nil { 124 | return err 125 | } 126 | 127 | for _, host := range hosts { 128 | printf(cmd, "\n🖥️ \033[1mHost: %s\033[0m", host) 129 | 130 | hostLnk := core.NewLnk(core.WithHost(host)) 131 | hostItems, err := hostLnk.List() 132 | if err != nil { 133 | printf(cmd, " \033[31m(error: %v)\033[0m\n", err) 134 | continue 135 | } 136 | 137 | printf(cmd, " (\033[36m%d item", len(hostItems)) 138 | if len(hostItems) > 1 { 139 | printf(cmd, "s") 140 | } 141 | printf(cmd, "\033[0m):\n") 142 | 143 | if len(hostItems) == 0 { 144 | printf(cmd, " \033[90m(no files)\033[0m\n") 145 | } else { 146 | for _, item := range hostItems { 147 | printf(cmd, " 🔗 \033[36m%s\033[0m\n", item) 148 | } 149 | } 150 | } 151 | 152 | printf(cmd, "\n💡 Use \033[1mlnk list --host \033[0m to see specific host configuration\n") 153 | return nil 154 | } 155 | 156 | func findHostConfigs() ([]string, error) { 157 | repoPath := getRepoPath() 158 | 159 | // Check if repo exists 160 | if _, err := os.Stat(repoPath); os.IsNotExist(err) { 161 | return []string{}, nil 162 | } 163 | 164 | entries, err := os.ReadDir(repoPath) 165 | if err != nil { 166 | return nil, err 167 | } 168 | 169 | var hosts []string 170 | for _, entry := range entries { 171 | name := entry.Name() 172 | // Look for .lnk. files 173 | if strings.HasPrefix(name, ".lnk.") && name != ".lnk" { 174 | host := strings.TrimPrefix(name, ".lnk.") 175 | hosts = append(hosts, host) 176 | } 177 | } 178 | 179 | return hosts, nil 180 | } 181 | 182 | func getRepoPath() string { 183 | xdgConfig := os.Getenv("XDG_CONFIG_HOME") 184 | if xdgConfig == "" { 185 | homeDir, err := os.UserHomeDir() 186 | if err != nil { 187 | xdgConfig = "." 188 | } else { 189 | xdgConfig = filepath.Join(homeDir, ".config") 190 | } 191 | } 192 | return filepath.Join(xdgConfig, "lnk") 193 | } 194 | -------------------------------------------------------------------------------- /cmd/pull.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/yarlson/lnk/internal/core" 6 | ) 7 | 8 | func newPullCmd() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "pull", 11 | Short: "⬇️ Pull changes from remote and restore symlinks", 12 | Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.", 13 | SilenceUsage: true, 14 | SilenceErrors: true, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | host, _ := cmd.Flags().GetString("host") 17 | 18 | lnk := core.NewLnk(core.WithHost(host)) 19 | 20 | restored, err := lnk.Pull() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | if len(restored) > 0 { 26 | if host != "" { 27 | printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host) 28 | } else { 29 | printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n") 30 | } 31 | printf(cmd, " 🔗 Restored \033[1m%d symlink", len(restored)) 32 | if len(restored) > 1 { 33 | printf(cmd, "s") 34 | } 35 | printf(cmd, "\033[0m:\n") 36 | for _, file := range restored { 37 | printf(cmd, " ✨ \033[36m%s\033[0m\n", file) 38 | } 39 | printf(cmd, "\n 🎉 Your dotfiles are synced and ready!\n") 40 | } else { 41 | if host != "" { 42 | printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes (host: %s)\033[0m\n", host) 43 | } else { 44 | printf(cmd, "⬇️ \033[1;32mSuccessfully pulled changes\033[0m\n") 45 | } 46 | printf(cmd, " ✅ All symlinks already in place\n") 47 | printf(cmd, " 🎉 Everything is up to date!\n") 48 | } 49 | 50 | return nil 51 | }, 52 | } 53 | 54 | cmd.Flags().StringP("host", "H", "", "Pull and restore symlinks for specific host (default: common configuration)") 55 | return cmd 56 | } 57 | -------------------------------------------------------------------------------- /cmd/push.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/yarlson/lnk/internal/core" 6 | ) 7 | 8 | func newPushCmd() *cobra.Command { 9 | return &cobra.Command{ 10 | Use: "push [message]", 11 | Short: "🚀 Push local changes to remote repository", 12 | Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.", 13 | Args: cobra.MaximumNArgs(1), 14 | SilenceUsage: true, 15 | SilenceErrors: true, 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | message := "lnk: sync configuration files" 18 | if len(args) > 0 { 19 | message = args[0] 20 | } 21 | 22 | lnk := core.NewLnk() 23 | if err := lnk.Push(message); err != nil { 24 | return err 25 | } 26 | 27 | printf(cmd, "🚀 \033[1;32mSuccessfully pushed changes\033[0m\n") 28 | printf(cmd, " 💾 Commit: \033[90m%s\033[0m\n", message) 29 | printf(cmd, " 📡 Synced to remote\n") 30 | printf(cmd, " ✨ Your dotfiles are up to date!\n") 31 | return nil 32 | }, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cmd/rm.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/yarlson/lnk/internal/core" 8 | ) 9 | 10 | func newRemoveCmd() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "rm ", 13 | Short: "🗑️ Remove a file from lnk management", 14 | Long: "Removes a symlink and restores the original file from the lnk repository.", 15 | Args: cobra.ExactArgs(1), 16 | SilenceUsage: true, 17 | SilenceErrors: true, 18 | RunE: func(cmd *cobra.Command, args []string) error { 19 | filePath := args[0] 20 | host, _ := cmd.Flags().GetString("host") 21 | 22 | lnk := core.NewLnk(core.WithHost(host)) 23 | 24 | if err := lnk.Remove(filePath); err != nil { 25 | return err 26 | } 27 | 28 | basename := filepath.Base(filePath) 29 | if host != "" { 30 | printf(cmd, "🗑️ \033[1mRemoved %s from lnk (host: %s)\033[0m\n", basename, host) 31 | printf(cmd, " ↩️ \033[90m~/.config/lnk/%s.lnk/%s\033[0m → \033[36m%s\033[0m\n", host, basename, filePath) 32 | } else { 33 | printf(cmd, "🗑️ \033[1mRemoved %s from lnk\033[0m\n", basename) 34 | printf(cmd, " ↩️ \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath) 35 | } 36 | printf(cmd, " 📄 Original file restored\n") 37 | return nil 38 | }, 39 | } 40 | 41 | cmd.Flags().StringP("host", "H", "", "Remove file from specific host configuration (default: common configuration)") 42 | return cmd 43 | } 44 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | version = "dev" 12 | buildTime = "unknown" 13 | ) 14 | 15 | // NewRootCommand creates a new root command (testable) 16 | func NewRootCommand() *cobra.Command { 17 | rootCmd := &cobra.Command{ 18 | Use: "lnk", 19 | Short: "🔗 Dotfiles, linked. No fluff.", 20 | Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck. 21 | 22 | Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal. 23 | Supports both common configurations and host-specific setups. 24 | 25 | ✨ Examples: 26 | lnk init # Fresh start 27 | lnk init -r # Clone existing dotfiles (runs bootstrap automatically) 28 | lnk add ~/.vimrc ~/.bashrc # Start managing common files 29 | lnk add --host work ~/.ssh/config # Manage host-specific files 30 | lnk list --all # Show all configurations 31 | lnk pull --host work # Pull host-specific changes 32 | lnk push "setup complete" # Sync to remote 33 | lnk bootstrap # Run bootstrap script manually 34 | 35 | 🚀 Bootstrap Support: 36 | Automatically runs bootstrap.sh when cloning a repository. 37 | Use --no-bootstrap to disable. 38 | 39 | 🎯 Simple, fast, Git-native, and multi-host ready.`, 40 | SilenceUsage: true, 41 | SilenceErrors: true, 42 | Version: fmt.Sprintf("%s (built %s)", version, buildTime), 43 | } 44 | 45 | // Add subcommands 46 | rootCmd.AddCommand(newInitCmd()) 47 | rootCmd.AddCommand(newAddCmd()) 48 | rootCmd.AddCommand(newRemoveCmd()) 49 | rootCmd.AddCommand(newListCmd()) 50 | rootCmd.AddCommand(newStatusCmd()) 51 | rootCmd.AddCommand(newPushCmd()) 52 | rootCmd.AddCommand(newPullCmd()) 53 | rootCmd.AddCommand(newBootstrapCmd()) 54 | 55 | return rootCmd 56 | } 57 | 58 | // SetVersion sets the version information for the CLI 59 | func SetVersion(v, bt string) { 60 | version = v 61 | buildTime = bt 62 | } 63 | 64 | func Execute() { 65 | rootCmd := NewRootCommand() 66 | if err := rootCmd.Execute(); err != nil { 67 | _, _ = fmt.Fprintln(os.Stderr, err) 68 | os.Exit(1) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/suite" 11 | ) 12 | 13 | type CLITestSuite struct { 14 | suite.Suite 15 | tempDir string 16 | originalDir string 17 | stdout *bytes.Buffer 18 | stderr *bytes.Buffer 19 | } 20 | 21 | func (suite *CLITestSuite) SetupTest() { 22 | // Create temp directory and change to it 23 | tempDir, err := os.MkdirTemp("", "lnk-cli-test-*") 24 | suite.Require().NoError(err) 25 | suite.tempDir = tempDir 26 | 27 | originalDir, err := os.Getwd() 28 | suite.Require().NoError(err) 29 | suite.originalDir = originalDir 30 | 31 | err = os.Chdir(tempDir) 32 | suite.Require().NoError(err) 33 | 34 | // Set HOME to temp directory for consistent relative path calculation 35 | suite.T().Setenv("HOME", tempDir) 36 | 37 | // Set XDG_CONFIG_HOME to tempDir/.config for config files 38 | suite.T().Setenv("XDG_CONFIG_HOME", filepath.Join(tempDir, ".config")) 39 | 40 | // Capture output 41 | suite.stdout = &bytes.Buffer{} 42 | suite.stderr = &bytes.Buffer{} 43 | } 44 | 45 | func (suite *CLITestSuite) TearDownTest() { 46 | err := os.Chdir(suite.originalDir) 47 | suite.Require().NoError(err) 48 | err = os.RemoveAll(suite.tempDir) 49 | suite.Require().NoError(err) 50 | } 51 | 52 | func (suite *CLITestSuite) runCommand(args ...string) error { 53 | rootCmd := NewRootCommand() 54 | rootCmd.SetOut(suite.stdout) 55 | rootCmd.SetErr(suite.stderr) 56 | rootCmd.SetArgs(args) 57 | return rootCmd.Execute() 58 | } 59 | 60 | func (suite *CLITestSuite) TestInitCommand() { 61 | err := suite.runCommand("init") 62 | suite.NoError(err) 63 | 64 | // Check output 65 | output := suite.stdout.String() 66 | suite.Contains(output, "Initialized empty lnk repository") 67 | suite.Contains(output, "Location:") 68 | suite.Contains(output, "Next steps:") 69 | suite.Contains(output, "lnk add ") 70 | 71 | // Verify actual effect 72 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 73 | suite.DirExists(lnkDir) 74 | 75 | gitDir := filepath.Join(lnkDir, ".git") 76 | suite.DirExists(gitDir) 77 | } 78 | 79 | func (suite *CLITestSuite) TestAddCommand() { 80 | // Initialize first 81 | err := suite.runCommand("init") 82 | suite.Require().NoError(err) 83 | suite.stdout.Reset() 84 | 85 | // Create test file 86 | testFile := filepath.Join(suite.tempDir, ".bashrc") 87 | err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 88 | suite.Require().NoError(err) 89 | 90 | // Test add command 91 | err = suite.runCommand("add", testFile) 92 | suite.NoError(err) 93 | 94 | // Check output 95 | output := suite.stdout.String() 96 | suite.Contains(output, "Added .bashrc to lnk") 97 | suite.Contains(output, "→") 98 | suite.Contains(output, "sync to remote") 99 | 100 | // Verify symlink was created 101 | info, err := os.Lstat(testFile) 102 | suite.NoError(err) 103 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 104 | 105 | // Verify the file exists in repo with preserved directory structure 106 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 107 | repoFile := filepath.Join(lnkDir, ".bashrc") 108 | suite.FileExists(repoFile) 109 | 110 | // Verify content is preserved in storage 111 | storedContent, err := os.ReadFile(repoFile) 112 | suite.NoError(err) 113 | suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent)) 114 | 115 | // Verify .lnk file contains the correct entry 116 | lnkFile := filepath.Join(lnkDir, ".lnk") 117 | lnkContent, err := os.ReadFile(lnkFile) 118 | suite.NoError(err) 119 | suite.Equal(".bashrc\n", string(lnkContent)) 120 | } 121 | 122 | func (suite *CLITestSuite) TestRemoveCommand() { 123 | // Setup: init and add a file 124 | _ = suite.runCommand("init") 125 | testFile := filepath.Join(suite.tempDir, ".vimrc") 126 | _ = os.WriteFile(testFile, []byte("set number"), 0644) 127 | _ = suite.runCommand("add", testFile) 128 | suite.stdout.Reset() 129 | 130 | // Test remove command 131 | err := suite.runCommand("rm", testFile) 132 | suite.NoError(err) 133 | 134 | // Check output 135 | output := suite.stdout.String() 136 | suite.Contains(output, "Removed .vimrc from lnk") 137 | suite.Contains(output, "→") 138 | suite.Contains(output, "Original file restored") 139 | 140 | // Verify symlink is gone and regular file is restored 141 | info, err := os.Lstat(testFile) 142 | suite.NoError(err) 143 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink 144 | 145 | // Verify content is preserved 146 | content, err := os.ReadFile(testFile) 147 | suite.NoError(err) 148 | suite.Equal("set number", string(content)) 149 | } 150 | 151 | func (suite *CLITestSuite) TestStatusCommand() { 152 | // Initialize first 153 | err := suite.runCommand("init") 154 | suite.Require().NoError(err) 155 | suite.stdout.Reset() 156 | 157 | // Test status without remote - should fail 158 | err = suite.runCommand("status") 159 | suite.Error(err) 160 | suite.Contains(err.Error(), "No remote repository is configured") 161 | } 162 | 163 | func (suite *CLITestSuite) TestListCommand() { 164 | // Test list without init - should fail 165 | err := suite.runCommand("list") 166 | suite.Error(err) 167 | suite.Contains(err.Error(), "Lnk repository not initialized") 168 | 169 | // Initialize first 170 | err = suite.runCommand("init") 171 | suite.Require().NoError(err) 172 | suite.stdout.Reset() 173 | 174 | // Test list with no managed files 175 | err = suite.runCommand("list") 176 | suite.NoError(err) 177 | output := suite.stdout.String() 178 | suite.Contains(output, "No files currently managed by lnk") 179 | suite.Contains(output, "lnk add ") 180 | suite.stdout.Reset() 181 | 182 | // Add a file 183 | testFile := filepath.Join(suite.tempDir, ".bashrc") 184 | err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 185 | suite.Require().NoError(err) 186 | err = suite.runCommand("add", testFile) 187 | suite.Require().NoError(err) 188 | suite.stdout.Reset() 189 | 190 | // Test list with one managed file 191 | err = suite.runCommand("list") 192 | suite.NoError(err) 193 | output = suite.stdout.String() 194 | suite.Contains(output, "Files managed by lnk") 195 | suite.Contains(output, "1 item") 196 | suite.Contains(output, ".bashrc") 197 | suite.stdout.Reset() 198 | 199 | // Add another file 200 | testFile2 := filepath.Join(suite.tempDir, ".vimrc") 201 | err = os.WriteFile(testFile2, []byte("set number"), 0644) 202 | suite.Require().NoError(err) 203 | err = suite.runCommand("add", testFile2) 204 | suite.Require().NoError(err) 205 | suite.stdout.Reset() 206 | 207 | // Test list with multiple managed files 208 | err = suite.runCommand("list") 209 | suite.NoError(err) 210 | output = suite.stdout.String() 211 | suite.Contains(output, "Files managed by lnk") 212 | suite.Contains(output, "2 items") 213 | suite.Contains(output, ".bashrc") 214 | suite.Contains(output, ".vimrc") 215 | 216 | // Verify both files exist in storage with correct content 217 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 218 | 219 | bashrcStorage := filepath.Join(lnkDir, ".bashrc") 220 | suite.FileExists(bashrcStorage) 221 | bashrcContent, err := os.ReadFile(bashrcStorage) 222 | suite.NoError(err) 223 | suite.Equal("export PATH=/usr/local/bin:$PATH", string(bashrcContent)) 224 | 225 | vimrcStorage := filepath.Join(lnkDir, ".vimrc") 226 | suite.FileExists(vimrcStorage) 227 | vimrcContent, err := os.ReadFile(vimrcStorage) 228 | suite.NoError(err) 229 | suite.Equal("set number", string(vimrcContent)) 230 | 231 | // Verify .lnk file contains both entries (sorted) 232 | lnkFile := filepath.Join(lnkDir, ".lnk") 233 | lnkContent, err := os.ReadFile(lnkFile) 234 | suite.NoError(err) 235 | suite.Equal(".bashrc\n.vimrc\n", string(lnkContent)) 236 | } 237 | 238 | func (suite *CLITestSuite) TestErrorHandling() { 239 | tests := []struct { 240 | name string 241 | args []string 242 | wantErr bool 243 | errContains string 244 | outContains string 245 | }{ 246 | { 247 | name: "add nonexistent file", 248 | args: []string{"add", "/nonexistent/file"}, 249 | wantErr: true, 250 | errContains: "File or directory not found", 251 | }, 252 | { 253 | name: "status without init", 254 | args: []string{"status"}, 255 | wantErr: true, 256 | errContains: "Lnk repository not initialized", 257 | }, 258 | { 259 | name: "help command", 260 | args: []string{"--help"}, 261 | wantErr: false, 262 | outContains: "Lnk - Git-native dotfiles management", 263 | }, 264 | { 265 | name: "version command", 266 | args: []string{"--version"}, 267 | wantErr: false, 268 | outContains: "lnk version", 269 | }, 270 | { 271 | name: "init help", 272 | args: []string{"init", "--help"}, 273 | wantErr: false, 274 | outContains: "Creates the lnk directory", 275 | }, 276 | { 277 | name: "add help", 278 | args: []string{"add", "--help"}, 279 | wantErr: false, 280 | outContains: "Moves a file to the lnk repository", 281 | }, 282 | { 283 | name: "list help", 284 | args: []string{"list", "--help"}, 285 | wantErr: false, 286 | outContains: "Display all files and directories", 287 | }, 288 | } 289 | 290 | for _, tt := range tests { 291 | suite.Run(tt.name, func() { 292 | suite.stdout.Reset() 293 | suite.stderr.Reset() 294 | 295 | err := suite.runCommand(tt.args...) 296 | 297 | if tt.wantErr { 298 | suite.Error(err, "Expected error for %s", tt.name) 299 | if tt.errContains != "" { 300 | suite.Contains(err.Error(), tt.errContains, "Wrong error message for %s", tt.name) 301 | } 302 | } else { 303 | suite.NoError(err, "Unexpected error for %s", tt.name) 304 | } 305 | 306 | if tt.outContains != "" { 307 | output := suite.stdout.String() 308 | suite.Contains(output, tt.outContains, "Expected output not found for %s", tt.name) 309 | } 310 | }) 311 | } 312 | } 313 | 314 | func (suite *CLITestSuite) TestCompleteWorkflow() { 315 | // Test realistic user workflow 316 | steps := []struct { 317 | name string 318 | args []string 319 | setup func() 320 | verify func(output string) 321 | }{ 322 | { 323 | name: "initialize repository", 324 | args: []string{"init"}, 325 | verify: func(output string) { 326 | suite.Contains(output, "Initialized empty lnk repository") 327 | }, 328 | }, 329 | { 330 | name: "add config file", 331 | args: []string{"add", filepath.Join(suite.tempDir, ".bashrc")}, 332 | setup: func() { 333 | testFile := filepath.Join(suite.tempDir, ".bashrc") 334 | _ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 335 | }, 336 | verify: func(output string) { 337 | suite.Contains(output, "Added .bashrc to lnk") 338 | 339 | // Verify storage and .lnk file 340 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 341 | bashrcStorage := filepath.Join(lnkDir, ".bashrc") 342 | suite.FileExists(bashrcStorage) 343 | 344 | storedContent, err := os.ReadFile(bashrcStorage) 345 | suite.NoError(err) 346 | suite.Equal("export PATH=/usr/local/bin:$PATH", string(storedContent)) 347 | 348 | lnkFile := filepath.Join(lnkDir, ".lnk") 349 | lnkContent, err := os.ReadFile(lnkFile) 350 | suite.NoError(err) 351 | suite.Equal(".bashrc\n", string(lnkContent)) 352 | }, 353 | }, 354 | { 355 | name: "add another file", 356 | args: []string{"add", filepath.Join(suite.tempDir, ".vimrc")}, 357 | setup: func() { 358 | testFile := filepath.Join(suite.tempDir, ".vimrc") 359 | _ = os.WriteFile(testFile, []byte("set number"), 0644) 360 | }, 361 | verify: func(output string) { 362 | suite.Contains(output, "Added .vimrc to lnk") 363 | 364 | // Verify storage and .lnk file now contains both files 365 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 366 | vimrcStorage := filepath.Join(lnkDir, ".vimrc") 367 | suite.FileExists(vimrcStorage) 368 | 369 | storedContent, err := os.ReadFile(vimrcStorage) 370 | suite.NoError(err) 371 | suite.Equal("set number", string(storedContent)) 372 | 373 | lnkFile := filepath.Join(lnkDir, ".lnk") 374 | lnkContent, err := os.ReadFile(lnkFile) 375 | suite.NoError(err) 376 | suite.Equal(".bashrc\n.vimrc\n", string(lnkContent)) 377 | }, 378 | }, 379 | { 380 | name: "remove file", 381 | args: []string{"rm", filepath.Join(suite.tempDir, ".vimrc")}, 382 | verify: func(output string) { 383 | suite.Contains(output, "Removed .vimrc from lnk") 384 | }, 385 | }, 386 | } 387 | 388 | for _, step := range steps { 389 | suite.Run(step.name, func() { 390 | if step.setup != nil { 391 | step.setup() 392 | } 393 | 394 | suite.stdout.Reset() 395 | suite.stderr.Reset() 396 | 397 | err := suite.runCommand(step.args...) 398 | suite.NoError(err, "Step %s failed: %v", step.name, err) 399 | 400 | output := suite.stdout.String() 401 | if step.verify != nil { 402 | step.verify(output) 403 | } 404 | }) 405 | } 406 | } 407 | 408 | func (suite *CLITestSuite) TestRemoveUnmanagedFile() { 409 | // Initialize repository 410 | _ = suite.runCommand("init") 411 | 412 | // Create a regular file (not managed by lnk) 413 | testFile := filepath.Join(suite.tempDir, ".regularfile") 414 | _ = os.WriteFile(testFile, []byte("content"), 0644) 415 | 416 | // Try to remove it 417 | err := suite.runCommand("rm", testFile) 418 | suite.Error(err) 419 | suite.Contains(err.Error(), "File is not managed by lnk") 420 | } 421 | 422 | func (suite *CLITestSuite) TestAddDirectory() { 423 | // Initialize repository 424 | _ = suite.runCommand("init") 425 | suite.stdout.Reset() 426 | 427 | // Create a directory with files 428 | testDir := filepath.Join(suite.tempDir, ".ssh") 429 | _ = os.MkdirAll(testDir, 0755) 430 | configFile := filepath.Join(testDir, "config") 431 | _ = os.WriteFile(configFile, []byte("Host example.com"), 0644) 432 | 433 | // Add the directory 434 | err := suite.runCommand("add", testDir) 435 | suite.NoError(err) 436 | 437 | // Check output 438 | output := suite.stdout.String() 439 | suite.Contains(output, "Added .ssh to lnk") 440 | 441 | // Verify directory is now a symlink 442 | info, err := os.Lstat(testDir) 443 | suite.NoError(err) 444 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 445 | 446 | // Verify the directory exists in repo with preserved directory structure 447 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 448 | repoDir := filepath.Join(lnkDir, ".ssh") 449 | suite.DirExists(repoDir) 450 | 451 | // Verify directory content is preserved 452 | repoConfigFile := filepath.Join(repoDir, "config") 453 | suite.FileExists(repoConfigFile) 454 | storedContent, err := os.ReadFile(repoConfigFile) 455 | suite.NoError(err) 456 | suite.Equal("Host example.com", string(storedContent)) 457 | 458 | // Verify .lnk file contains the directory entry 459 | lnkFile := filepath.Join(lnkDir, ".lnk") 460 | lnkContent, err := os.ReadFile(lnkFile) 461 | suite.NoError(err) 462 | suite.Equal(".ssh\n", string(lnkContent)) 463 | } 464 | 465 | func (suite *CLITestSuite) TestSameBasenameFilesBug() { 466 | // Initialize repository 467 | err := suite.runCommand("init") 468 | suite.Require().NoError(err) 469 | suite.stdout.Reset() 470 | 471 | // Create two directories with files having the same basename 472 | dirA := filepath.Join(suite.tempDir, "a") 473 | dirB := filepath.Join(suite.tempDir, "b") 474 | err = os.MkdirAll(dirA, 0755) 475 | suite.Require().NoError(err) 476 | err = os.MkdirAll(dirB, 0755) 477 | suite.Require().NoError(err) 478 | 479 | // Create files with same basename but different content 480 | fileA := filepath.Join(dirA, "config.json") 481 | fileB := filepath.Join(dirB, "config.json") 482 | contentA := `{"name": "config_a"}` 483 | contentB := `{"name": "config_b"}` 484 | 485 | err = os.WriteFile(fileA, []byte(contentA), 0644) 486 | suite.Require().NoError(err) 487 | err = os.WriteFile(fileB, []byte(contentB), 0644) 488 | suite.Require().NoError(err) 489 | 490 | // Add first file 491 | err = suite.runCommand("add", fileA) 492 | suite.NoError(err) 493 | suite.stdout.Reset() 494 | 495 | // Verify first file content is preserved 496 | content, err := os.ReadFile(fileA) 497 | suite.NoError(err) 498 | suite.Equal(contentA, string(content), "First file should preserve its original content") 499 | 500 | // Add second file with same basename - this should work correctly 501 | err = suite.runCommand("add", fileB) 502 | suite.NoError(err, "Adding second file with same basename should work") 503 | 504 | // CORRECT BEHAVIOR: Both files should preserve their original content 505 | contentAfterAddA, err := os.ReadFile(fileA) 506 | suite.NoError(err) 507 | contentAfterAddB, err := os.ReadFile(fileB) 508 | suite.NoError(err) 509 | 510 | suite.Equal(contentA, string(contentAfterAddA), "First file should keep its original content") 511 | suite.Equal(contentB, string(contentAfterAddB), "Second file should keep its original content") 512 | 513 | // Verify both files exist in storage with correct paths and content 514 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 515 | 516 | storageFileA := filepath.Join(lnkDir, "a", "config.json") 517 | suite.FileExists(storageFileA) 518 | storedContentA, err := os.ReadFile(storageFileA) 519 | suite.NoError(err) 520 | suite.Equal(contentA, string(storedContentA)) 521 | 522 | storageFileB := filepath.Join(lnkDir, "b", "config.json") 523 | suite.FileExists(storageFileB) 524 | storedContentB, err := os.ReadFile(storageFileB) 525 | suite.NoError(err) 526 | suite.Equal(contentB, string(storedContentB)) 527 | 528 | // Verify .lnk file contains both entries with correct relative paths 529 | lnkFile := filepath.Join(lnkDir, ".lnk") 530 | lnkContent, err := os.ReadFile(lnkFile) 531 | suite.NoError(err) 532 | suite.Equal("a/config.json\nb/config.json\n", string(lnkContent)) 533 | 534 | // Both files should be removable independently 535 | suite.stdout.Reset() 536 | err = suite.runCommand("rm", fileA) 537 | suite.NoError(err, "First file should be removable") 538 | 539 | // Verify output shows removal 540 | output := suite.stdout.String() 541 | suite.Contains(output, "Removed config.json from lnk") 542 | 543 | // Verify first file is restored with correct content 544 | restoredContentA, err := os.ReadFile(fileA) 545 | suite.NoError(err) 546 | suite.Equal(contentA, string(restoredContentA), "Restored first file should have original content") 547 | 548 | // Second file should still be removable without errors 549 | suite.stdout.Reset() 550 | err = suite.runCommand("rm", fileB) 551 | suite.NoError(err, "Second file should also be removable without errors") 552 | 553 | // Verify second file is restored with correct content 554 | restoredContentB, err := os.ReadFile(fileB) 555 | suite.NoError(err) 556 | suite.Equal(contentB, string(restoredContentB), "Restored second file should have original content") 557 | } 558 | 559 | func (suite *CLITestSuite) TestStatusDirtyRepo() { 560 | // Initialize repository 561 | err := suite.runCommand("init") 562 | suite.Require().NoError(err) 563 | suite.stdout.Reset() 564 | 565 | // Add and commit a file 566 | testFile := filepath.Join(suite.tempDir, "a") 567 | err = os.WriteFile(testFile, []byte("abc"), 0644) 568 | suite.Require().NoError(err) 569 | 570 | err = suite.runCommand("add", testFile) 571 | suite.Require().NoError(err) 572 | suite.stdout.Reset() 573 | 574 | // Verify file is stored correctly 575 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 576 | storageFile := filepath.Join(lnkDir, "a") 577 | suite.FileExists(storageFile) 578 | storedContent, err := os.ReadFile(storageFile) 579 | suite.NoError(err) 580 | suite.Equal("abc", string(storedContent)) 581 | 582 | // Verify .lnk file contains the entry 583 | lnkFile := filepath.Join(lnkDir, ".lnk") 584 | lnkContent, err := os.ReadFile(lnkFile) 585 | suite.NoError(err) 586 | suite.Equal("a\n", string(lnkContent)) 587 | 588 | // Add a remote so status works 589 | cmd := exec.Command("git", "remote", "add", "origin", "https://github.com/test/dotfiles.git") 590 | cmd.Dir = lnkDir 591 | err = cmd.Run() 592 | suite.Require().NoError(err) 593 | 594 | // Status should show clean but ahead 595 | err = suite.runCommand("status") 596 | suite.NoError(err) 597 | output := suite.stdout.String() 598 | suite.Contains(output, "1 commit ahead") 599 | suite.NotContains(output, "uncommitted changes") 600 | suite.stdout.Reset() 601 | 602 | // Now edit the managed file (simulating the issue scenario) 603 | err = os.WriteFile(testFile, []byte("def"), 0644) 604 | suite.Require().NoError(err) 605 | 606 | // Status should now detect dirty state and NOT say "up to date" 607 | err = suite.runCommand("status") 608 | suite.NoError(err) 609 | output = suite.stdout.String() 610 | suite.Contains(output, "Repository has uncommitted changes") 611 | suite.NotContains(output, "Repository is up to date") 612 | suite.Contains(output, "lnk push") 613 | } 614 | 615 | func (suite *CLITestSuite) TestMultihostCommands() { 616 | // Initialize repository 617 | err := suite.runCommand("init") 618 | suite.Require().NoError(err) 619 | suite.stdout.Reset() 620 | 621 | // Create test files 622 | testFile1 := filepath.Join(suite.tempDir, ".bashrc") 623 | err = os.WriteFile(testFile1, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 624 | suite.Require().NoError(err) 625 | 626 | testFile2 := filepath.Join(suite.tempDir, ".vimrc") 627 | err = os.WriteFile(testFile2, []byte("set number"), 0644) 628 | suite.Require().NoError(err) 629 | 630 | // Add file to common configuration 631 | err = suite.runCommand("add", testFile1) 632 | suite.NoError(err) 633 | output := suite.stdout.String() 634 | suite.Contains(output, "Added .bashrc to lnk") 635 | suite.NotContains(output, "host:") 636 | suite.stdout.Reset() 637 | 638 | // Add file to host-specific configuration 639 | err = suite.runCommand("add", "--host", "workstation", testFile2) 640 | suite.NoError(err) 641 | output = suite.stdout.String() 642 | suite.Contains(output, "Added .vimrc to lnk (host: workstation)") 643 | suite.Contains(output, "workstation.lnk") 644 | suite.stdout.Reset() 645 | 646 | // Verify storage paths and .lnk files for both common and host-specific 647 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 648 | 649 | // Verify common file storage and tracking 650 | commonStorage := filepath.Join(lnkDir, ".bashrc") 651 | suite.FileExists(commonStorage) 652 | commonContent, err := os.ReadFile(commonStorage) 653 | suite.NoError(err) 654 | suite.Equal("export PATH=/usr/local/bin:$PATH", string(commonContent)) 655 | 656 | commonLnkFile := filepath.Join(lnkDir, ".lnk") 657 | commonLnkContent, err := os.ReadFile(commonLnkFile) 658 | suite.NoError(err) 659 | suite.Equal(".bashrc\n", string(commonLnkContent)) 660 | 661 | // Verify host-specific file storage and tracking 662 | hostStorage := filepath.Join(lnkDir, "workstation.lnk", ".vimrc") 663 | suite.FileExists(hostStorage) 664 | hostContent, err := os.ReadFile(hostStorage) 665 | suite.NoError(err) 666 | suite.Equal("set number", string(hostContent)) 667 | 668 | hostLnkFile := filepath.Join(lnkDir, ".lnk.workstation") 669 | hostLnkContent, err := os.ReadFile(hostLnkFile) 670 | suite.NoError(err) 671 | suite.Equal(".vimrc\n", string(hostLnkContent)) 672 | 673 | // Test list command - common only 674 | err = suite.runCommand("list") 675 | suite.NoError(err) 676 | output = suite.stdout.String() 677 | suite.Contains(output, "Files managed by lnk (common)") 678 | suite.Contains(output, ".bashrc") 679 | suite.NotContains(output, ".vimrc") 680 | suite.stdout.Reset() 681 | 682 | // Test list command - specific host 683 | err = suite.runCommand("list", "--host", "workstation") 684 | suite.NoError(err) 685 | output = suite.stdout.String() 686 | suite.Contains(output, "Files managed by lnk (host: workstation)") 687 | suite.Contains(output, ".vimrc") 688 | suite.NotContains(output, ".bashrc") 689 | suite.stdout.Reset() 690 | 691 | // Test list command - all configurations 692 | err = suite.runCommand("list", "--all") 693 | suite.NoError(err) 694 | output = suite.stdout.String() 695 | suite.Contains(output, "All configurations managed by lnk") 696 | suite.Contains(output, "Common configuration") 697 | suite.Contains(output, "Host: workstation") 698 | suite.Contains(output, ".bashrc") 699 | suite.Contains(output, ".vimrc") 700 | suite.stdout.Reset() 701 | 702 | // Test remove from host-specific 703 | err = suite.runCommand("rm", "--host", "workstation", testFile2) 704 | suite.NoError(err) 705 | output = suite.stdout.String() 706 | suite.Contains(output, "Removed .vimrc from lnk (host: workstation)") 707 | suite.stdout.Reset() 708 | 709 | // Test remove from common 710 | err = suite.runCommand("rm", testFile1) 711 | suite.NoError(err) 712 | output = suite.stdout.String() 713 | suite.Contains(output, "Removed .bashrc from lnk") 714 | suite.NotContains(output, "host:") 715 | suite.stdout.Reset() 716 | 717 | // Verify files are restored 718 | info1, err := os.Lstat(testFile1) 719 | suite.NoError(err) 720 | suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink) 721 | 722 | info2, err := os.Lstat(testFile2) 723 | suite.NoError(err) 724 | suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink) 725 | } 726 | 727 | func (suite *CLITestSuite) TestMultihostErrorHandling() { 728 | // Initialize repository 729 | err := suite.runCommand("init") 730 | suite.Require().NoError(err) 731 | suite.stdout.Reset() 732 | 733 | // Try to remove from non-existent host config 734 | testFile := filepath.Join(suite.tempDir, ".bashrc") 735 | err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 736 | suite.Require().NoError(err) 737 | 738 | err = suite.runCommand("rm", "--host", "nonexistent", testFile) 739 | suite.Error(err) 740 | suite.Contains(err.Error(), "File is not managed by lnk") 741 | 742 | // Try to list non-existent host config 743 | err = suite.runCommand("list", "--host", "nonexistent") 744 | suite.NoError(err) // Should not error, just show empty 745 | output := suite.stdout.String() 746 | suite.Contains(output, "No files currently managed by lnk (host: nonexistent)") 747 | } 748 | 749 | func (suite *CLITestSuite) TestBootstrapCommand() { 750 | // Initialize repository 751 | err := suite.runCommand("init") 752 | suite.Require().NoError(err) 753 | suite.stdout.Reset() 754 | 755 | // Test bootstrap command with no script 756 | err = suite.runCommand("bootstrap") 757 | suite.NoError(err) 758 | output := suite.stdout.String() 759 | suite.Contains(output, "No bootstrap script found") 760 | suite.Contains(output, "bootstrap.sh") 761 | suite.stdout.Reset() 762 | 763 | // Create a bootstrap script 764 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 765 | bootstrapScript := filepath.Join(lnkDir, "bootstrap.sh") 766 | scriptContent := `#!/bin/bash 767 | echo "Bootstrap script executed!" 768 | echo "Working directory: $(pwd)" 769 | touch bootstrap-ran.txt 770 | ` 771 | err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755) 772 | suite.Require().NoError(err) 773 | 774 | // Test bootstrap command with script 775 | err = suite.runCommand("bootstrap") 776 | suite.NoError(err) 777 | output = suite.stdout.String() 778 | suite.Contains(output, "Running bootstrap script") 779 | suite.Contains(output, "bootstrap.sh") 780 | suite.Contains(output, "Bootstrap completed successfully") 781 | 782 | // Verify script actually ran 783 | markerFile := filepath.Join(lnkDir, "bootstrap-ran.txt") 784 | suite.FileExists(markerFile) 785 | } 786 | 787 | func (suite *CLITestSuite) TestInitWithBootstrap() { 788 | // Create a temporary remote repository with bootstrap script 789 | remoteDir := filepath.Join(suite.tempDir, "remote") 790 | err := os.MkdirAll(remoteDir, 0755) 791 | suite.Require().NoError(err) 792 | 793 | // Initialize git repo in remote 794 | cmd := exec.Command("git", "init", "--bare") 795 | cmd.Dir = remoteDir 796 | err = cmd.Run() 797 | suite.Require().NoError(err) 798 | 799 | // Create a working repo to populate the remote 800 | workingDir := filepath.Join(suite.tempDir, "working") 801 | err = os.MkdirAll(workingDir, 0755) 802 | suite.Require().NoError(err) 803 | 804 | cmd = exec.Command("git", "clone", remoteDir, workingDir) 805 | err = cmd.Run() 806 | suite.Require().NoError(err) 807 | 808 | // Add a bootstrap script to the working repo 809 | bootstrapScript := filepath.Join(workingDir, "bootstrap.sh") 810 | scriptContent := `#!/bin/bash 811 | echo "Remote bootstrap script executed!" 812 | touch remote-bootstrap-ran.txt 813 | ` 814 | err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755) 815 | suite.Require().NoError(err) 816 | 817 | // Add a dummy config file 818 | configFile := filepath.Join(workingDir, ".bashrc") 819 | err = os.WriteFile(configFile, []byte("echo 'Hello from remote!'"), 0644) 820 | suite.Require().NoError(err) 821 | 822 | // Add .lnk file to track the config 823 | lnkFile := filepath.Join(workingDir, ".lnk") 824 | err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) 825 | suite.Require().NoError(err) 826 | 827 | // Commit and push to remote 828 | cmd = exec.Command("git", "add", ".") 829 | cmd.Dir = workingDir 830 | err = cmd.Run() 831 | suite.Require().NoError(err) 832 | 833 | cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap and config") 834 | cmd.Dir = workingDir 835 | err = cmd.Run() 836 | suite.Require().NoError(err) 837 | 838 | cmd = exec.Command("git", "push", "origin", "master") 839 | cmd.Dir = workingDir 840 | err = cmd.Run() 841 | suite.Require().NoError(err) 842 | 843 | // Now test init with remote and automatic bootstrap 844 | err = suite.runCommand("init", "-r", remoteDir) 845 | suite.NoError(err) 846 | output := suite.stdout.String() 847 | suite.Contains(output, "Cloned from:") 848 | suite.Contains(output, "Looking for bootstrap script") 849 | suite.Contains(output, "Found bootstrap script:") 850 | suite.Contains(output, "bootstrap.sh") 851 | suite.Contains(output, "Running bootstrap script") 852 | suite.Contains(output, "Bootstrap completed successfully") 853 | 854 | // Verify bootstrap actually ran 855 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 856 | markerFile := filepath.Join(lnkDir, "remote-bootstrap-ran.txt") 857 | suite.FileExists(markerFile) 858 | } 859 | 860 | func (suite *CLITestSuite) TestInitWithBootstrapDisabled() { 861 | // Create a temporary remote repository with bootstrap script 862 | remoteDir := filepath.Join(suite.tempDir, "remote") 863 | err := os.MkdirAll(remoteDir, 0755) 864 | suite.Require().NoError(err) 865 | 866 | // Initialize git repo in remote 867 | cmd := exec.Command("git", "init", "--bare") 868 | cmd.Dir = remoteDir 869 | err = cmd.Run() 870 | suite.Require().NoError(err) 871 | 872 | // Create a working repo to populate the remote 873 | workingDir := filepath.Join(suite.tempDir, "working") 874 | err = os.MkdirAll(workingDir, 0755) 875 | suite.Require().NoError(err) 876 | 877 | cmd = exec.Command("git", "clone", remoteDir, workingDir) 878 | err = cmd.Run() 879 | suite.Require().NoError(err) 880 | 881 | // Add a bootstrap script 882 | bootstrapScript := filepath.Join(workingDir, "bootstrap.sh") 883 | scriptContent := `#!/bin/bash 884 | echo "This should not run!" 885 | touch should-not-exist.txt 886 | ` 887 | err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755) 888 | suite.Require().NoError(err) 889 | 890 | // Commit and push 891 | cmd = exec.Command("git", "add", ".") 892 | cmd.Dir = workingDir 893 | err = cmd.Run() 894 | suite.Require().NoError(err) 895 | 896 | cmd = exec.Command("git", "-c", "user.email=test@example.com", "-c", "user.name=Test User", "commit", "-m", "Add bootstrap") 897 | cmd.Dir = workingDir 898 | err = cmd.Run() 899 | suite.Require().NoError(err) 900 | 901 | cmd = exec.Command("git", "push", "origin", "master") 902 | cmd.Dir = workingDir 903 | err = cmd.Run() 904 | suite.Require().NoError(err) 905 | 906 | // Test init with --no-bootstrap flag 907 | err = suite.runCommand("init", "-r", remoteDir, "--no-bootstrap") 908 | suite.NoError(err) 909 | output := suite.stdout.String() 910 | suite.Contains(output, "Cloned from:") 911 | suite.NotContains(output, "Looking for bootstrap script") 912 | suite.NotContains(output, "Running bootstrap script") 913 | 914 | // Verify bootstrap did NOT run 915 | lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") 916 | markerFile := filepath.Join(lnkDir, "should-not-exist.txt") 917 | suite.NoFileExists(markerFile) 918 | } 919 | 920 | func TestCLISuite(t *testing.T) { 921 | suite.Run(t, new(CLITestSuite)) 922 | } 923 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/yarlson/lnk/internal/core" 6 | ) 7 | 8 | func newStatusCmd() *cobra.Command { 9 | return &cobra.Command{ 10 | Use: "status", 11 | Short: "📊 Show repository sync status", 12 | Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.", 13 | SilenceUsage: true, 14 | SilenceErrors: true, 15 | RunE: func(cmd *cobra.Command, args []string) error { 16 | lnk := core.NewLnk() 17 | status, err := lnk.Status() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | if status.Dirty { 23 | displayDirtyStatus(cmd, status) 24 | return nil 25 | } 26 | 27 | if status.Ahead == 0 && status.Behind == 0 { 28 | displayUpToDateStatus(cmd, status) 29 | return nil 30 | } 31 | 32 | displaySyncStatus(cmd, status) 33 | return nil 34 | }, 35 | } 36 | } 37 | 38 | func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) { 39 | printf(cmd, "⚠️ \033[1;33mRepository has uncommitted changes\033[0m\n") 40 | printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote) 41 | 42 | if status.Ahead == 0 && status.Behind == 0 { 43 | printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n") 44 | return 45 | } 46 | 47 | printf(cmd, "\n") 48 | displayAheadBehindInfo(cmd, status, true) 49 | printf(cmd, "\n💡 Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n") 50 | } 51 | 52 | func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) { 53 | printf(cmd, "✅ \033[1;32mRepository is up to date\033[0m\n") 54 | printf(cmd, " 📡 Synced with \033[36m%s\033[0m\n", status.Remote) 55 | } 56 | 57 | func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) { 58 | printf(cmd, "📊 \033[1mRepository Status\033[0m\n") 59 | printf(cmd, " 📡 Remote: \033[36m%s\033[0m\n", status.Remote) 60 | printf(cmd, "\n") 61 | 62 | displayAheadBehindInfo(cmd, status, false) 63 | 64 | if status.Ahead > 0 && status.Behind == 0 { 65 | printf(cmd, "\n💡 Run \033[1mlnk push\033[0m to sync your changes\n") 66 | } else if status.Behind > 0 { 67 | printf(cmd, "\n💡 Run \033[1mlnk pull\033[0m to get latest changes\n") 68 | } 69 | } 70 | 71 | func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) { 72 | if status.Ahead > 0 { 73 | commitText := getCommitText(status.Ahead) 74 | if isDirty { 75 | printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m (excluding uncommitted changes)\n", status.Ahead, commitText) 76 | } else { 77 | printf(cmd, " ⬆️ \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText) 78 | } 79 | } 80 | 81 | if status.Behind > 0 { 82 | commitText := getCommitText(status.Behind) 83 | printf(cmd, " ⬇️ \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText) 84 | } 85 | } 86 | 87 | func getCommitText(count int) string { 88 | if count == 1 { 89 | return "commit" 90 | } 91 | return "commits" 92 | } 93 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // printf is a helper function to simplify output formatting in commands 10 | func printf(cmd *cobra.Command, format string, args ...interface{}) { 11 | _, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...) 12 | } 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yarlson/lnk 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/spf13/cobra v1.9.1 7 | github.com/stretchr/testify v1.10.0 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.6 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /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.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 10 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 11 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 12 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 13 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 14 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /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.0.2" 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" -------------------------------------------------------------------------------- /internal/core/lnk.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/yarlson/lnk/internal/fs" 12 | "github.com/yarlson/lnk/internal/git" 13 | ) 14 | 15 | // Lnk represents the main application logic 16 | type Lnk struct { 17 | repoPath string 18 | host string // Host-specific configuration 19 | git *git.Git 20 | fs *fs.FileSystem 21 | } 22 | 23 | type Option func(*Lnk) 24 | 25 | // WithHost sets the host for host-specific configuration 26 | func WithHost(host string) Option { 27 | return func(l *Lnk) { 28 | l.host = host 29 | } 30 | } 31 | 32 | // NewLnk creates a new Lnk instance with optional configuration 33 | func NewLnk(opts ...Option) *Lnk { 34 | repoPath := getRepoPath() 35 | lnk := &Lnk{ 36 | repoPath: repoPath, 37 | host: "", 38 | git: git.New(repoPath), 39 | fs: fs.New(), 40 | } 41 | 42 | for _, opt := range opts { 43 | opt(lnk) 44 | } 45 | 46 | return lnk 47 | } 48 | 49 | // GetCurrentHostname returns the current system hostname 50 | func GetCurrentHostname() (string, error) { 51 | hostname, err := os.Hostname() 52 | if err != nil { 53 | return "", fmt.Errorf("failed to get hostname: %w", err) 54 | } 55 | return hostname, nil 56 | } 57 | 58 | // getRepoPath returns the path to the lnk repository directory 59 | func getRepoPath() string { 60 | xdgConfig := os.Getenv("XDG_CONFIG_HOME") 61 | if xdgConfig == "" { 62 | homeDir, err := os.UserHomeDir() 63 | if err != nil { 64 | // Fallback to current directory if we can't get home 65 | xdgConfig = "." 66 | } else { 67 | xdgConfig = filepath.Join(homeDir, ".config") 68 | } 69 | } 70 | return filepath.Join(xdgConfig, "lnk") 71 | } 72 | 73 | // getHostStoragePath returns the storage path for host-specific or common files 74 | func (l *Lnk) getHostStoragePath() string { 75 | if l.host == "" { 76 | // Common configuration - store in root of repo 77 | return l.repoPath 78 | } 79 | // Host-specific configuration - store in host subdirectory 80 | return filepath.Join(l.repoPath, l.host+".lnk") 81 | } 82 | 83 | // getLnkFileName returns the appropriate .lnk tracking file name 84 | func (l *Lnk) getLnkFileName() string { 85 | if l.host == "" { 86 | return ".lnk" 87 | } 88 | return ".lnk." + l.host 89 | } 90 | 91 | // getRelativePath converts an absolute path to a relative path from home directory 92 | func getRelativePath(absPath string) (string, error) { 93 | homeDir, err := os.UserHomeDir() 94 | if err != nil { 95 | return "", fmt.Errorf("failed to get home directory: %w", err) 96 | } 97 | 98 | // Check if the file is under home directory 99 | relPath, err := filepath.Rel(homeDir, absPath) 100 | if err != nil { 101 | return "", fmt.Errorf("failed to get relative path: %w", err) 102 | } 103 | 104 | // If the relative path starts with "..", the file is outside home directory 105 | // In this case, use the absolute path as relative (without the leading slash) 106 | if strings.HasPrefix(relPath, "..") { 107 | // Use absolute path but remove leading slash and drive letter (for cross-platform) 108 | cleanPath := strings.TrimPrefix(absPath, "/") 109 | return cleanPath, nil 110 | } 111 | 112 | return relPath, nil 113 | } 114 | 115 | // Init initializes the lnk repository 116 | func (l *Lnk) Init() error { 117 | return l.InitWithRemote("") 118 | } 119 | 120 | // InitWithRemote initializes the lnk repository, optionally cloning from a remote 121 | func (l *Lnk) InitWithRemote(remoteURL string) error { 122 | if remoteURL != "" { 123 | // Clone from remote 124 | return l.Clone(remoteURL) 125 | } 126 | 127 | // Create the repository directory 128 | if err := os.MkdirAll(l.repoPath, 0755); err != nil { 129 | return fmt.Errorf("failed to create lnk directory: %w", err) 130 | } 131 | 132 | // Check if there's already a Git repository 133 | if l.git.IsGitRepository() { 134 | // Repository exists, check if it's a lnk repository 135 | if l.git.IsLnkRepository() { 136 | // It's a lnk repository, init is idempotent - do nothing 137 | return nil 138 | } else { 139 | // It's not a lnk repository, error to prevent data loss 140 | return fmt.Errorf("❌ Directory \033[31m%s\033[0m contains an existing Git repository\n 💡 Please backup or move the existing repository before initializing lnk", l.repoPath) 141 | } 142 | } 143 | 144 | // No existing repository, initialize Git repository 145 | return l.git.Init() 146 | } 147 | 148 | // Clone clones a repository from the given URL 149 | func (l *Lnk) Clone(url string) error { 150 | return l.git.Clone(url) 151 | } 152 | 153 | // AddRemote adds a remote to the repository 154 | func (l *Lnk) AddRemote(name, url string) error { 155 | return l.git.AddRemote(name, url) 156 | } 157 | 158 | // Add moves a file or directory to the repository and creates a symlink 159 | func (l *Lnk) Add(filePath string) error { 160 | // Validate the file or directory 161 | if err := l.fs.ValidateFileForAdd(filePath); err != nil { 162 | return err 163 | } 164 | 165 | // Get absolute path 166 | absPath, err := filepath.Abs(filePath) 167 | if err != nil { 168 | return fmt.Errorf("failed to get absolute path: %w", err) 169 | } 170 | 171 | // Get relative path for tracking 172 | relativePath, err := getRelativePath(absPath) 173 | if err != nil { 174 | return fmt.Errorf("failed to get relative path: %w", err) 175 | } 176 | 177 | // Generate repository path from relative path 178 | storagePath := l.getHostStoragePath() 179 | destPath := filepath.Join(storagePath, relativePath) 180 | 181 | // Ensure destination directory exists (including parent directories for host-specific files) 182 | destDir := filepath.Dir(destPath) 183 | if err := os.MkdirAll(destDir, 0755); err != nil { 184 | return fmt.Errorf("failed to create destination directory: %w", err) 185 | } 186 | 187 | // Check if this relative path is already managed 188 | managedItems, err := l.getManagedItems() 189 | if err != nil { 190 | return fmt.Errorf("failed to get managed items: %w", err) 191 | } 192 | for _, item := range managedItems { 193 | if item == relativePath { 194 | return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath) 195 | } 196 | } 197 | 198 | // Check if it's a directory or file 199 | info, err := os.Stat(absPath) 200 | if err != nil { 201 | return fmt.Errorf("failed to stat path: %w", err) 202 | } 203 | 204 | // Move to repository (handles both files and directories) 205 | if err := l.fs.Move(absPath, destPath, info); err != nil { 206 | return err 207 | } 208 | 209 | // Create symlink 210 | if err := l.fs.CreateSymlink(destPath, absPath); err != nil { 211 | // Try to restore the original if symlink creation fails 212 | _ = l.fs.Move(destPath, absPath, info) 213 | return err 214 | } 215 | 216 | // Add to .lnk tracking file using relative path 217 | if err := l.addManagedItem(relativePath); err != nil { 218 | // Try to restore the original state if tracking fails 219 | _ = os.Remove(absPath) 220 | _ = l.fs.Move(destPath, absPath, info) 221 | return fmt.Errorf("failed to update tracking file: %w", err) 222 | } 223 | 224 | // Add both the item and .lnk file to git in a single commit 225 | // For host-specific files, we need to add the relative path from repo root 226 | gitPath := relativePath 227 | if l.host != "" { 228 | gitPath = filepath.Join(l.host+".lnk", relativePath) 229 | } 230 | if err := l.git.Add(gitPath); err != nil { 231 | // Try to restore the original state if git add fails 232 | _ = os.Remove(absPath) 233 | _ = l.removeManagedItem(relativePath) 234 | _ = l.fs.Move(destPath, absPath, info) 235 | return err 236 | } 237 | 238 | // Add .lnk file to the same commit 239 | if err := l.git.Add(l.getLnkFileName()); err != nil { 240 | // Try to restore the original state if git add fails 241 | _ = os.Remove(absPath) 242 | _ = l.removeManagedItem(relativePath) 243 | _ = l.fs.Move(destPath, absPath, info) 244 | return err 245 | } 246 | 247 | // Commit both changes together 248 | basename := filepath.Base(relativePath) 249 | if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil { 250 | // Try to restore the original state if commit fails 251 | _ = os.Remove(absPath) 252 | _ = l.removeManagedItem(relativePath) 253 | _ = l.fs.Move(destPath, absPath, info) 254 | return err 255 | } 256 | 257 | return nil 258 | } 259 | 260 | // Remove removes a symlink and restores the original file or directory 261 | func (l *Lnk) Remove(filePath string) error { 262 | // Get absolute path 263 | absPath, err := filepath.Abs(filePath) 264 | if err != nil { 265 | return fmt.Errorf("failed to get absolute path: %w", err) 266 | } 267 | 268 | // Validate that this is a symlink managed by lnk 269 | if err := l.fs.ValidateSymlinkForRemove(absPath, l.repoPath); err != nil { 270 | return err 271 | } 272 | 273 | // Get relative path for tracking 274 | relativePath, err := getRelativePath(absPath) 275 | if err != nil { 276 | return fmt.Errorf("failed to get relative path: %w", err) 277 | } 278 | 279 | // Check if this relative path is managed 280 | managedItems, err := l.getManagedItems() 281 | if err != nil { 282 | return fmt.Errorf("failed to get managed items: %w", err) 283 | } 284 | 285 | found := false 286 | for _, item := range managedItems { 287 | if item == relativePath { 288 | found = true 289 | break 290 | } 291 | } 292 | if !found { 293 | return fmt.Errorf("❌ File is not managed by lnk: \033[31m%s\033[0m", relativePath) 294 | } 295 | 296 | // Get the target path in the repository 297 | target, err := os.Readlink(absPath) 298 | if err != nil { 299 | return fmt.Errorf("failed to read symlink: %w", err) 300 | } 301 | 302 | // Convert relative path to absolute if needed 303 | if !filepath.IsAbs(target) { 304 | target = filepath.Join(filepath.Dir(absPath), target) 305 | } 306 | 307 | // Check if target is a directory or file 308 | info, err := os.Stat(target) 309 | if err != nil { 310 | return fmt.Errorf("failed to stat target: %w", err) 311 | } 312 | 313 | // Remove the symlink 314 | if err := os.Remove(absPath); err != nil { 315 | return fmt.Errorf("failed to remove symlink: %w", err) 316 | } 317 | 318 | // Remove from .lnk tracking file using relative path 319 | if err := l.removeManagedItem(relativePath); err != nil { 320 | return fmt.Errorf("failed to update tracking file: %w", err) 321 | } 322 | 323 | // Generate the correct git path for removal 324 | gitPath := relativePath 325 | if l.host != "" { 326 | gitPath = filepath.Join(l.host+".lnk", relativePath) 327 | } 328 | if err := l.git.Remove(gitPath); err != nil { 329 | return err 330 | } 331 | 332 | // Add .lnk file to the same commit 333 | if err := l.git.Add(l.getLnkFileName()); err != nil { 334 | return err 335 | } 336 | 337 | // Commit both changes together 338 | basename := filepath.Base(relativePath) 339 | if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil { 340 | return err 341 | } 342 | 343 | // Move back from repository (handles both files and directories) 344 | if err := l.fs.Move(target, absPath, info); err != nil { 345 | return err 346 | } 347 | 348 | return nil 349 | } 350 | 351 | // GetCommits returns the list of commits for testing purposes 352 | func (l *Lnk) GetCommits() ([]string, error) { 353 | return l.git.GetCommits() 354 | } 355 | 356 | // StatusInfo contains repository sync status information 357 | type StatusInfo struct { 358 | Ahead int 359 | Behind int 360 | Remote string 361 | Dirty bool 362 | } 363 | 364 | // Status returns the repository sync status 365 | func (l *Lnk) Status() (*StatusInfo, error) { 366 | // Check if repository is initialized 367 | if !l.git.IsGitRepository() { 368 | return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first") 369 | } 370 | 371 | gitStatus, err := l.git.GetStatus() 372 | if err != nil { 373 | return nil, err 374 | } 375 | 376 | return &StatusInfo{ 377 | Ahead: gitStatus.Ahead, 378 | Behind: gitStatus.Behind, 379 | Remote: gitStatus.Remote, 380 | Dirty: gitStatus.Dirty, 381 | }, nil 382 | } 383 | 384 | // Push stages all changes and creates a sync commit, then pushes to remote 385 | func (l *Lnk) Push(message string) error { 386 | // Check if repository is initialized 387 | if !l.git.IsGitRepository() { 388 | return fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first") 389 | } 390 | 391 | // Check if there are any changes 392 | hasChanges, err := l.git.HasChanges() 393 | if err != nil { 394 | return err 395 | } 396 | 397 | if hasChanges { 398 | // Stage all changes 399 | if err := l.git.AddAll(); err != nil { 400 | return err 401 | } 402 | 403 | // Create a sync commit 404 | if err := l.git.Commit(message); err != nil { 405 | return err 406 | } 407 | } 408 | 409 | // Push to remote (this will be a no-op in tests since we don't have real remotes) 410 | // In real usage, this would push to the actual remote repository 411 | return l.git.Push() 412 | } 413 | 414 | // Pull fetches changes from remote and restores symlinks as needed 415 | func (l *Lnk) Pull() ([]string, error) { 416 | // Check if repository is initialized 417 | if !l.git.IsGitRepository() { 418 | return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first") 419 | } 420 | 421 | // Pull changes from remote (this will be a no-op in tests since we don't have real remotes) 422 | if err := l.git.Pull(); err != nil { 423 | return nil, err 424 | } 425 | 426 | // Find all managed files in the repository and restore symlinks 427 | restored, err := l.RestoreSymlinks() 428 | if err != nil { 429 | return nil, fmt.Errorf("failed to restore symlinks: %w", err) 430 | } 431 | 432 | return restored, nil 433 | } 434 | 435 | // List returns the list of files and directories currently managed by lnk 436 | func (l *Lnk) List() ([]string, error) { 437 | // Check if repository is initialized 438 | if !l.git.IsGitRepository() { 439 | return nil, fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first") 440 | } 441 | 442 | // Get managed items from .lnk file 443 | managedItems, err := l.getManagedItems() 444 | if err != nil { 445 | return nil, fmt.Errorf("failed to get managed items: %w", err) 446 | } 447 | 448 | return managedItems, nil 449 | } 450 | 451 | // RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks 452 | func (l *Lnk) RestoreSymlinks() ([]string, error) { 453 | var restored []string 454 | 455 | // Get managed items from .lnk file (now containing relative paths) 456 | managedItems, err := l.getManagedItems() 457 | if err != nil { 458 | return nil, fmt.Errorf("failed to get managed items: %w", err) 459 | } 460 | 461 | homeDir, err := os.UserHomeDir() 462 | if err != nil { 463 | return nil, fmt.Errorf("failed to get home directory: %w", err) 464 | } 465 | 466 | for _, relativePath := range managedItems { 467 | // Generate repository name from relative path 468 | storagePath := l.getHostStoragePath() 469 | repoItem := filepath.Join(storagePath, relativePath) 470 | 471 | // Check if item exists in repository 472 | if _, err := os.Stat(repoItem); os.IsNotExist(err) { 473 | continue // Skip missing items 474 | } 475 | 476 | // Determine where the symlink should be created 477 | symlinkPath := filepath.Join(homeDir, relativePath) 478 | 479 | // Check if symlink already exists and is correct 480 | if l.isValidSymlink(symlinkPath, repoItem) { 481 | continue 482 | } 483 | 484 | // Ensure parent directory exists 485 | symlinkDir := filepath.Dir(symlinkPath) 486 | if err := os.MkdirAll(symlinkDir, 0755); err != nil { 487 | return nil, fmt.Errorf("failed to create directory %s: %w", symlinkDir, err) 488 | } 489 | 490 | // Remove existing file/symlink if it exists 491 | if _, err := os.Lstat(symlinkPath); err == nil { 492 | if err := os.RemoveAll(symlinkPath); err != nil { 493 | return nil, fmt.Errorf("failed to remove existing item %s: %w", symlinkPath, err) 494 | } 495 | } 496 | 497 | // Create symlink 498 | if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil { 499 | return nil, err 500 | } 501 | 502 | restored = append(restored, relativePath) 503 | } 504 | 505 | return restored, nil 506 | } 507 | 508 | // isValidSymlink checks if the given path is a symlink pointing to the expected target 509 | func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool { 510 | info, err := os.Lstat(symlinkPath) 511 | if err != nil { 512 | return false 513 | } 514 | 515 | // Check if it's a symlink 516 | if info.Mode()&os.ModeSymlink == 0 { 517 | return false 518 | } 519 | 520 | // Check if it points to the correct target 521 | target, err := os.Readlink(symlinkPath) 522 | if err != nil { 523 | return false 524 | } 525 | 526 | // Convert relative path to absolute if needed 527 | if !filepath.IsAbs(target) { 528 | target = filepath.Join(filepath.Dir(symlinkPath), target) 529 | } 530 | 531 | // Clean both paths for comparison 532 | targetAbs, err := filepath.Abs(target) 533 | if err != nil { 534 | return false 535 | } 536 | 537 | expectedAbs, err := filepath.Abs(expectedTarget) 538 | if err != nil { 539 | return false 540 | } 541 | 542 | return targetAbs == expectedAbs 543 | } 544 | 545 | // getManagedItems returns the list of managed files and directories from .lnk file 546 | func (l *Lnk) getManagedItems() ([]string, error) { 547 | lnkFile := filepath.Join(l.repoPath, l.getLnkFileName()) 548 | 549 | // If .lnk file doesn't exist, return empty list 550 | if _, err := os.Stat(lnkFile); os.IsNotExist(err) { 551 | return []string{}, nil 552 | } 553 | 554 | content, err := os.ReadFile(lnkFile) 555 | if err != nil { 556 | return nil, fmt.Errorf("failed to read .lnk file: %w", err) 557 | } 558 | 559 | if len(content) == 0 { 560 | return []string{}, nil 561 | } 562 | 563 | lines := strings.Split(strings.TrimSpace(string(content)), "\n") 564 | var items []string 565 | for _, line := range lines { 566 | line = strings.TrimSpace(line) 567 | if line != "" { 568 | items = append(items, line) 569 | } 570 | } 571 | 572 | return items, nil 573 | } 574 | 575 | // addManagedItem adds an item to the .lnk tracking file 576 | func (l *Lnk) addManagedItem(relativePath string) error { 577 | // Get current items 578 | items, err := l.getManagedItems() 579 | if err != nil { 580 | return fmt.Errorf("failed to get managed items: %w", err) 581 | } 582 | 583 | // Check if already exists 584 | for _, item := range items { 585 | if item == relativePath { 586 | return nil // Already managed 587 | } 588 | } 589 | 590 | // Add new item using relative path 591 | items = append(items, relativePath) 592 | 593 | // Sort for consistent ordering 594 | sort.Strings(items) 595 | 596 | return l.writeManagedItems(items) 597 | } 598 | 599 | // removeManagedItem removes an item from the .lnk tracking file 600 | func (l *Lnk) removeManagedItem(relativePath string) error { 601 | // Get current items 602 | items, err := l.getManagedItems() 603 | if err != nil { 604 | return fmt.Errorf("failed to get managed items: %w", err) 605 | } 606 | 607 | // Remove item using relative path 608 | var newItems []string 609 | for _, item := range items { 610 | if item != relativePath { 611 | newItems = append(newItems, item) 612 | } 613 | } 614 | 615 | return l.writeManagedItems(newItems) 616 | } 617 | 618 | // writeManagedItems writes the list of managed items to .lnk file 619 | func (l *Lnk) writeManagedItems(items []string) error { 620 | lnkFile := filepath.Join(l.repoPath, l.getLnkFileName()) 621 | 622 | content := strings.Join(items, "\n") 623 | if len(items) > 0 { 624 | content += "\n" 625 | } 626 | 627 | err := os.WriteFile(lnkFile, []byte(content), 0644) 628 | if err != nil { 629 | return fmt.Errorf("failed to write .lnk file: %w", err) 630 | } 631 | 632 | return nil 633 | } 634 | 635 | // FindBootstrapScript searches for a bootstrap script in the repository 636 | func (l *Lnk) FindBootstrapScript() (string, error) { 637 | // Check if repository is initialized 638 | if !l.git.IsGitRepository() { 639 | return "", fmt.Errorf("❌ Lnk repository not initialized\n 💡 Run \033[1mlnk init\033[0m first") 640 | } 641 | 642 | // Look for bootstrap.sh - simple, opinionated choice 643 | scriptPath := filepath.Join(l.repoPath, "bootstrap.sh") 644 | if _, err := os.Stat(scriptPath); err == nil { 645 | return "bootstrap.sh", nil 646 | } 647 | 648 | return "", nil // No bootstrap script found 649 | } 650 | 651 | // RunBootstrapScript executes the bootstrap script 652 | func (l *Lnk) RunBootstrapScript(scriptName string) error { 653 | scriptPath := filepath.Join(l.repoPath, scriptName) 654 | 655 | // Verify the script exists 656 | if _, err := os.Stat(scriptPath); os.IsNotExist(err) { 657 | return fmt.Errorf("❌ Bootstrap script not found: \033[31m%s\033[0m", scriptName) 658 | } 659 | 660 | // Make sure it's executable 661 | if err := os.Chmod(scriptPath, 0755); err != nil { 662 | return fmt.Errorf("❌ Failed to make bootstrap script executable: %w", err) 663 | } 664 | 665 | // Run with bash (since we only support bootstrap.sh) 666 | cmd := exec.Command("bash", scriptPath) 667 | 668 | // Set working directory to the repository 669 | cmd.Dir = l.repoPath 670 | 671 | // Connect to stdout/stderr for user to see output 672 | cmd.Stdout = os.Stdout 673 | cmd.Stderr = os.Stderr 674 | cmd.Stdin = os.Stdin 675 | 676 | // Run the script 677 | if err := cmd.Run(); err != nil { 678 | return fmt.Errorf("❌ Bootstrap script failed with error: %w", err) 679 | } 680 | 681 | return nil 682 | } 683 | -------------------------------------------------------------------------------- /internal/core/lnk_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/suite" 11 | ) 12 | 13 | type CoreTestSuite struct { 14 | suite.Suite 15 | tempDir string 16 | originalDir string 17 | lnk *Lnk 18 | } 19 | 20 | func (suite *CoreTestSuite) SetupTest() { 21 | // Create temporary directory for each test 22 | tempDir, err := os.MkdirTemp("", "lnk-test-*") 23 | suite.Require().NoError(err) 24 | suite.tempDir = tempDir 25 | 26 | // Change to temp directory 27 | originalDir, err := os.Getwd() 28 | suite.Require().NoError(err) 29 | suite.originalDir = originalDir 30 | 31 | err = os.Chdir(tempDir) 32 | suite.Require().NoError(err) 33 | 34 | // Set XDG_CONFIG_HOME to temp directory 35 | suite.T().Setenv("XDG_CONFIG_HOME", tempDir) 36 | 37 | // Initialize Lnk instance 38 | suite.lnk = NewLnk() 39 | } 40 | 41 | func (suite *CoreTestSuite) TearDownTest() { 42 | // Return to original directory 43 | err := os.Chdir(suite.originalDir) 44 | suite.Require().NoError(err) 45 | 46 | // Clean up temp directory 47 | err = os.RemoveAll(suite.tempDir) 48 | suite.Require().NoError(err) 49 | } 50 | 51 | // Test core initialization functionality 52 | func (suite *CoreTestSuite) TestCoreInit() { 53 | err := suite.lnk.Init() 54 | suite.Require().NoError(err) 55 | 56 | // Check that the lnk directory was created 57 | lnkDir := filepath.Join(suite.tempDir, "lnk") 58 | suite.DirExists(lnkDir) 59 | 60 | // Check that Git repo was initialized 61 | gitDir := filepath.Join(lnkDir, ".git") 62 | suite.DirExists(gitDir) 63 | } 64 | 65 | // Test core add/remove functionality with files 66 | func (suite *CoreTestSuite) TestCoreFileOperations() { 67 | // Initialize first 68 | err := suite.lnk.Init() 69 | suite.Require().NoError(err) 70 | 71 | // Create a test file 72 | testFile := filepath.Join(suite.tempDir, ".bashrc") 73 | content := "export PATH=$PATH:/usr/local/bin" 74 | err = os.WriteFile(testFile, []byte(content), 0644) 75 | suite.Require().NoError(err) 76 | 77 | // Add the file 78 | err = suite.lnk.Add(testFile) 79 | suite.Require().NoError(err) 80 | 81 | // Verify symlink and repo file 82 | info, err := os.Lstat(testFile) 83 | suite.Require().NoError(err) 84 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 85 | 86 | // The repository file will preserve the directory structure 87 | lnkDir := filepath.Join(suite.tempDir, "lnk") 88 | 89 | // Find the .bashrc file in the repository (it should be at the relative path) 90 | repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc") 91 | suite.FileExists(repoFile) 92 | 93 | // Verify content is preserved 94 | repoContent, err := os.ReadFile(repoFile) 95 | suite.Require().NoError(err) 96 | suite.Equal(content, string(repoContent)) 97 | 98 | // Test remove 99 | err = suite.lnk.Remove(testFile) 100 | suite.Require().NoError(err) 101 | 102 | // Verify symlink is gone and regular file is restored 103 | info, err = os.Lstat(testFile) 104 | suite.Require().NoError(err) 105 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink 106 | 107 | // Verify content is preserved 108 | restoredContent, err := os.ReadFile(testFile) 109 | suite.Require().NoError(err) 110 | suite.Equal(content, string(restoredContent)) 111 | } 112 | 113 | // Test core add/remove functionality with directories 114 | func (suite *CoreTestSuite) TestCoreDirectoryOperations() { 115 | err := suite.lnk.Init() 116 | suite.Require().NoError(err) 117 | 118 | // Create a directory with files 119 | testDir := filepath.Join(suite.tempDir, "testdir") 120 | err = os.MkdirAll(testDir, 0755) 121 | suite.Require().NoError(err) 122 | 123 | testFile := filepath.Join(testDir, "config.txt") 124 | content := "test config" 125 | err = os.WriteFile(testFile, []byte(content), 0644) 126 | suite.Require().NoError(err) 127 | 128 | // Add the directory 129 | err = suite.lnk.Add(testDir) 130 | suite.Require().NoError(err) 131 | 132 | // Verify directory is now a symlink 133 | info, err := os.Lstat(testDir) 134 | suite.Require().NoError(err) 135 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 136 | 137 | // Check that the repository directory preserves the structure 138 | lnkDir := filepath.Join(suite.tempDir, "lnk") 139 | 140 | // The directory should be at the relative path 141 | repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir") 142 | suite.DirExists(repoDir) 143 | 144 | // Remove the directory 145 | err = suite.lnk.Remove(testDir) 146 | suite.Require().NoError(err) 147 | 148 | // Verify symlink is gone and regular directory is restored 149 | info, err = os.Lstat(testDir) 150 | suite.Require().NoError(err) 151 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink 152 | suite.True(info.IsDir()) // Is a directory 153 | 154 | // Verify content is preserved 155 | restoredContent, err := os.ReadFile(testFile) 156 | suite.Require().NoError(err) 157 | suite.Equal(content, string(restoredContent)) 158 | } 159 | 160 | // Test .lnk file tracking functionality 161 | func (suite *CoreTestSuite) TestLnkFileTracking() { 162 | err := suite.lnk.Init() 163 | suite.Require().NoError(err) 164 | 165 | // Add multiple items 166 | testFile := filepath.Join(suite.tempDir, ".bashrc") 167 | err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) 168 | suite.Require().NoError(err) 169 | err = suite.lnk.Add(testFile) 170 | suite.Require().NoError(err) 171 | 172 | testDir := filepath.Join(suite.tempDir, ".ssh") 173 | err = os.MkdirAll(testDir, 0700) 174 | suite.Require().NoError(err) 175 | configFile := filepath.Join(testDir, "config") 176 | err = os.WriteFile(configFile, []byte("Host example.com"), 0600) 177 | suite.Require().NoError(err) 178 | err = suite.lnk.Add(testDir) 179 | suite.Require().NoError(err) 180 | 181 | // Check .lnk file contains both entries 182 | lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") 183 | suite.FileExists(lnkFile) 184 | 185 | lnkContent, err := os.ReadFile(lnkFile) 186 | suite.Require().NoError(err) 187 | 188 | lines := strings.Split(strings.TrimSpace(string(lnkContent)), "\n") 189 | suite.Len(lines, 2) 190 | 191 | // The .lnk file now contains relative paths, not basenames 192 | // Check that the content contains references to .bashrc and .ssh 193 | content := string(lnkContent) 194 | suite.Contains(content, ".bashrc", ".lnk file should contain reference to .bashrc") 195 | suite.Contains(content, ".ssh", ".lnk file should contain reference to .ssh") 196 | 197 | // Remove one item and verify tracking is updated 198 | err = suite.lnk.Remove(testFile) 199 | suite.Require().NoError(err) 200 | 201 | lnkContent, err = os.ReadFile(lnkFile) 202 | suite.Require().NoError(err) 203 | 204 | lines = strings.Split(strings.TrimSpace(string(lnkContent)), "\n") 205 | suite.Len(lines, 1) 206 | 207 | content = string(lnkContent) 208 | suite.Contains(content, ".ssh", ".lnk file should still contain reference to .ssh") 209 | suite.NotContains(content, ".bashrc", ".lnk file should not contain reference to .bashrc after removal") 210 | } 211 | 212 | // Test XDG_CONFIG_HOME fallback 213 | func (suite *CoreTestSuite) TestXDGConfigHomeFallback() { 214 | // Test fallback to ~/.config/lnk when XDG_CONFIG_HOME is not set 215 | suite.T().Setenv("XDG_CONFIG_HOME", "") 216 | 217 | homeDir := filepath.Join(suite.tempDir, "home") 218 | err := os.MkdirAll(homeDir, 0755) 219 | suite.Require().NoError(err) 220 | suite.T().Setenv("HOME", homeDir) 221 | 222 | lnk := NewLnk() 223 | err = lnk.Init() 224 | suite.Require().NoError(err) 225 | 226 | // Check that the lnk directory was created under ~/.config/lnk 227 | expectedDir := filepath.Join(homeDir, ".config", "lnk") 228 | suite.DirExists(expectedDir) 229 | } 230 | 231 | // Test symlink restoration (pull functionality) 232 | func (suite *CoreTestSuite) TestSymlinkRestoration() { 233 | _ = suite.lnk.Init() 234 | 235 | // Create a file in the repo directly (simulating a pull) 236 | repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc") 237 | content := "export PATH=$PATH:/usr/local/bin" 238 | err := os.WriteFile(repoFile, []byte(content), 0644) 239 | suite.Require().NoError(err) 240 | 241 | // Create .lnk file to track it 242 | lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") 243 | err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) 244 | suite.Require().NoError(err) 245 | 246 | // Get home directory for the test 247 | homeDir, err := os.UserHomeDir() 248 | suite.Require().NoError(err) 249 | 250 | targetFile := filepath.Join(homeDir, ".bashrc") 251 | 252 | // Clean up the test file after the test 253 | defer func() { 254 | _ = os.Remove(targetFile) 255 | }() 256 | 257 | // Test symlink restoration 258 | restored, err := suite.lnk.RestoreSymlinks() 259 | suite.Require().NoError(err) 260 | 261 | // Should have restored the symlink 262 | suite.Len(restored, 1) 263 | suite.Equal(".bashrc", restored[0]) 264 | 265 | // Check that file is now a symlink 266 | info, err := os.Lstat(targetFile) 267 | suite.NoError(err) 268 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 269 | } 270 | 271 | // Test error conditions 272 | func (suite *CoreTestSuite) TestErrorConditions() { 273 | // Test add nonexistent file 274 | err := suite.lnk.Init() 275 | suite.Require().NoError(err) 276 | 277 | err = suite.lnk.Add("/nonexistent/file") 278 | suite.Error(err) 279 | suite.Contains(err.Error(), "File or directory not found") 280 | 281 | // Test remove unmanaged file 282 | testFile := filepath.Join(suite.tempDir, ".regularfile") 283 | err = os.WriteFile(testFile, []byte("content"), 0644) 284 | suite.Require().NoError(err) 285 | 286 | err = suite.lnk.Remove(testFile) 287 | suite.Error(err) 288 | suite.Contains(err.Error(), "File is not managed by lnk") 289 | 290 | // Test status without remote 291 | _, err = suite.lnk.Status() 292 | suite.Error(err) 293 | suite.Contains(err.Error(), "No remote repository is configured") 294 | } 295 | 296 | // Test git operations 297 | func (suite *CoreTestSuite) TestGitOperations() { 298 | err := suite.lnk.Init() 299 | suite.Require().NoError(err) 300 | 301 | // Add a file to create a commit 302 | testFile := filepath.Join(suite.tempDir, ".bashrc") 303 | content := "export PATH=$PATH:/usr/local/bin" 304 | err = os.WriteFile(testFile, []byte(content), 0644) 305 | suite.Require().NoError(err) 306 | 307 | err = suite.lnk.Add(testFile) 308 | suite.Require().NoError(err) 309 | 310 | // Check that Git commit was made 311 | commits, err := suite.lnk.GetCommits() 312 | suite.Require().NoError(err) 313 | suite.Len(commits, 1) 314 | suite.Contains(commits[0], "lnk: added .bashrc") 315 | 316 | // Test add remote 317 | err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") 318 | suite.Require().NoError(err) 319 | 320 | // Test status with remote 321 | status, err := suite.lnk.Status() 322 | suite.Require().NoError(err) 323 | suite.Equal(1, status.Ahead) 324 | suite.Equal(0, status.Behind) 325 | } 326 | 327 | // Test edge case: files with same basename from different directories should be handled properly 328 | func (suite *CoreTestSuite) TestSameBasenameFilesOverwrite() { 329 | err := suite.lnk.Init() 330 | suite.Require().NoError(err) 331 | 332 | // Create two directories with files having the same basename 333 | dirA := filepath.Join(suite.tempDir, "a") 334 | dirB := filepath.Join(suite.tempDir, "b") 335 | err = os.MkdirAll(dirA, 0755) 336 | suite.Require().NoError(err) 337 | err = os.MkdirAll(dirB, 0755) 338 | suite.Require().NoError(err) 339 | 340 | // Create files with same basename but different content 341 | fileA := filepath.Join(dirA, "config.json") 342 | fileB := filepath.Join(dirB, "config.json") 343 | contentA := `{"name": "config_a"}` 344 | contentB := `{"name": "config_b"}` 345 | 346 | err = os.WriteFile(fileA, []byte(contentA), 0644) 347 | suite.Require().NoError(err) 348 | err = os.WriteFile(fileB, []byte(contentB), 0644) 349 | suite.Require().NoError(err) 350 | 351 | // Add first file 352 | err = suite.lnk.Add(fileA) 353 | suite.Require().NoError(err) 354 | 355 | // Verify first file is managed correctly and preserves content 356 | info, err := os.Lstat(fileA) 357 | suite.Require().NoError(err) 358 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 359 | 360 | symlinkContentA, err := os.ReadFile(fileA) 361 | suite.Require().NoError(err) 362 | suite.Equal(contentA, string(symlinkContentA), "First file should preserve its original content") 363 | 364 | // Add second file - this should work without overwriting the first 365 | err = suite.lnk.Add(fileB) 366 | suite.Require().NoError(err) 367 | 368 | // Verify second file is managed 369 | info, err = os.Lstat(fileB) 370 | suite.Require().NoError(err) 371 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 372 | 373 | // CORRECT BEHAVIOR: Both files should preserve their original content 374 | symlinkContentA, err = os.ReadFile(fileA) 375 | suite.Require().NoError(err) 376 | symlinkContentB, err := os.ReadFile(fileB) 377 | suite.Require().NoError(err) 378 | 379 | suite.Equal(contentA, string(symlinkContentA), "First file should keep its original content") 380 | suite.Equal(contentB, string(symlinkContentB), "Second file should keep its original content") 381 | 382 | // Both files should be removable independently 383 | err = suite.lnk.Remove(fileA) 384 | suite.Require().NoError(err, "First file should be removable") 385 | 386 | // First file should be restored with correct content 387 | info, err = os.Lstat(fileA) 388 | suite.Require().NoError(err) 389 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore 390 | 391 | restoredContentA, err := os.ReadFile(fileA) 392 | suite.Require().NoError(err) 393 | suite.Equal(contentA, string(restoredContentA), "Restored file should have original content") 394 | 395 | // Second file should still be manageable and removable 396 | err = suite.lnk.Remove(fileB) 397 | suite.Require().NoError(err, "Second file should also be removable without errors") 398 | 399 | // Second file should be restored with correct content 400 | info, err = os.Lstat(fileB) 401 | suite.Require().NoError(err) 402 | suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore 403 | 404 | restoredContentB, err := os.ReadFile(fileB) 405 | suite.Require().NoError(err) 406 | suite.Equal(contentB, string(restoredContentB), "Second restored file should have original content") 407 | } 408 | 409 | // Test another variant: adding files with same basename should work correctly 410 | func (suite *CoreTestSuite) TestSameBasenameSequentialAdd() { 411 | err := suite.lnk.Init() 412 | suite.Require().NoError(err) 413 | 414 | // Create subdirectories in different locations 415 | configDir := filepath.Join(suite.tempDir, "config") 416 | backupDir := filepath.Join(suite.tempDir, "backup") 417 | err = os.MkdirAll(configDir, 0755) 418 | suite.Require().NoError(err) 419 | err = os.MkdirAll(backupDir, 0755) 420 | suite.Require().NoError(err) 421 | 422 | // Create files with same basename (.bashrc) 423 | configBashrc := filepath.Join(configDir, ".bashrc") 424 | backupBashrc := filepath.Join(backupDir, ".bashrc") 425 | 426 | originalContent := "export PATH=/usr/local/bin:$PATH" 427 | backupContent := "export PATH=/opt/bin:$PATH" 428 | 429 | err = os.WriteFile(configBashrc, []byte(originalContent), 0644) 430 | suite.Require().NoError(err) 431 | err = os.WriteFile(backupBashrc, []byte(backupContent), 0644) 432 | suite.Require().NoError(err) 433 | 434 | // Add first .bashrc 435 | err = suite.lnk.Add(configBashrc) 436 | suite.Require().NoError(err) 437 | 438 | // Add second .bashrc - should work without overwriting the first 439 | err = suite.lnk.Add(backupBashrc) 440 | suite.Require().NoError(err) 441 | 442 | // Check .lnk tracking file should track both properly 443 | lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") 444 | lnkContent, err := os.ReadFile(lnkFile) 445 | suite.Require().NoError(err) 446 | 447 | // Both entries should be tracked and distinguishable 448 | content := string(lnkContent) 449 | suite.Contains(content, ".bashrc", "Both .bashrc files should be tracked") 450 | 451 | // Both files should maintain their distinct content 452 | content1, err := os.ReadFile(configBashrc) 453 | suite.Require().NoError(err) 454 | content2, err := os.ReadFile(backupBashrc) 455 | suite.Require().NoError(err) 456 | 457 | suite.Equal(originalContent, string(content1), "First file should keep original content") 458 | suite.Equal(backupContent, string(content2), "Second file should keep its distinct content") 459 | 460 | // Both should be removable independently 461 | err = suite.lnk.Remove(configBashrc) 462 | suite.Require().NoError(err, "First .bashrc should be removable") 463 | 464 | err = suite.lnk.Remove(backupBashrc) 465 | suite.Require().NoError(err, "Second .bashrc should be removable") 466 | } 467 | 468 | // Test dirty repository status detection 469 | func (suite *CoreTestSuite) TestStatusDetectsDirtyRepo() { 470 | err := suite.lnk.Init() 471 | suite.Require().NoError(err) 472 | 473 | // Add and commit a file 474 | testFile := filepath.Join(suite.tempDir, "a") 475 | err = os.WriteFile(testFile, []byte("abc"), 0644) 476 | suite.Require().NoError(err) 477 | 478 | err = suite.lnk.Add(testFile) 479 | suite.Require().NoError(err) 480 | 481 | // Add a remote so status works 482 | err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") 483 | suite.Require().NoError(err) 484 | 485 | // Check status - should be clean but ahead of remote 486 | status, err := suite.lnk.Status() 487 | suite.Require().NoError(err) 488 | suite.Equal(1, status.Ahead) 489 | suite.Equal(0, status.Behind) 490 | suite.False(status.Dirty, "Repository should not be dirty after commit") 491 | 492 | // Now edit the managed file (simulating the issue scenario) 493 | err = os.WriteFile(testFile, []byte("def"), 0644) 494 | suite.Require().NoError(err) 495 | 496 | // Check status again - should detect dirty state 497 | status, err = suite.lnk.Status() 498 | suite.Require().NoError(err) 499 | suite.Equal(1, status.Ahead) 500 | suite.Equal(0, status.Behind) 501 | suite.True(status.Dirty, "Repository should be dirty after editing managed file") 502 | } 503 | 504 | // Test list functionality 505 | func (suite *CoreTestSuite) TestListManagedItems() { 506 | // Test list without init - should fail 507 | _, err := suite.lnk.List() 508 | suite.Error(err) 509 | suite.Contains(err.Error(), "Lnk repository not initialized") 510 | 511 | // Initialize repository 512 | err = suite.lnk.Init() 513 | suite.Require().NoError(err) 514 | 515 | // Test list with no managed files 516 | items, err := suite.lnk.List() 517 | suite.Require().NoError(err) 518 | suite.Empty(items) 519 | 520 | // Add a file 521 | testFile := filepath.Join(suite.tempDir, ".bashrc") 522 | content := "export PATH=$PATH:/usr/local/bin" 523 | err = os.WriteFile(testFile, []byte(content), 0644) 524 | suite.Require().NoError(err) 525 | 526 | err = suite.lnk.Add(testFile) 527 | suite.Require().NoError(err) 528 | 529 | // Test list with one managed file 530 | items, err = suite.lnk.List() 531 | suite.Require().NoError(err) 532 | suite.Len(items, 1) 533 | suite.Contains(items[0], ".bashrc") 534 | 535 | // Add a directory 536 | testDir := filepath.Join(suite.tempDir, ".config") 537 | err = os.MkdirAll(testDir, 0755) 538 | suite.Require().NoError(err) 539 | configFile := filepath.Join(testDir, "app.conf") 540 | err = os.WriteFile(configFile, []byte("setting=value"), 0644) 541 | suite.Require().NoError(err) 542 | 543 | err = suite.lnk.Add(testDir) 544 | suite.Require().NoError(err) 545 | 546 | // Test list with multiple managed items 547 | items, err = suite.lnk.List() 548 | suite.Require().NoError(err) 549 | suite.Len(items, 2) 550 | 551 | // Check that both items are present 552 | found := make(map[string]bool) 553 | for _, item := range items { 554 | if strings.Contains(item, ".bashrc") { 555 | found[".bashrc"] = true 556 | } 557 | if strings.Contains(item, ".config") { 558 | found[".config"] = true 559 | } 560 | } 561 | suite.True(found[".bashrc"], "Should contain .bashrc") 562 | suite.True(found[".config"], "Should contain .config") 563 | 564 | // Remove one item and verify list is updated 565 | err = suite.lnk.Remove(testFile) 566 | suite.Require().NoError(err) 567 | 568 | items, err = suite.lnk.List() 569 | suite.Require().NoError(err) 570 | suite.Len(items, 1) 571 | suite.Contains(items[0], ".config") 572 | } 573 | 574 | // Test multihost functionality 575 | func (suite *CoreTestSuite) TestMultihostFileOperations() { 576 | err := suite.lnk.Init() 577 | suite.Require().NoError(err) 578 | 579 | // Create test files for different hosts 580 | testFile1 := filepath.Join(suite.tempDir, ".bashrc") 581 | content1 := "export PATH=$PATH:/usr/local/bin" 582 | err = os.WriteFile(testFile1, []byte(content1), 0644) 583 | suite.Require().NoError(err) 584 | 585 | testFile2 := filepath.Join(suite.tempDir, ".vimrc") 586 | content2 := "set number" 587 | err = os.WriteFile(testFile2, []byte(content2), 0644) 588 | suite.Require().NoError(err) 589 | 590 | // Add file to common configuration 591 | commonLnk := NewLnk() 592 | err = commonLnk.Add(testFile1) 593 | suite.Require().NoError(err) 594 | 595 | // Add file to host-specific configuration 596 | hostLnk := NewLnk(WithHost("workstation")) 597 | err = hostLnk.Add(testFile2) 598 | suite.Require().NoError(err) 599 | 600 | // Verify both files are symlinks 601 | info1, err := os.Lstat(testFile1) 602 | suite.Require().NoError(err) 603 | suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink) 604 | 605 | info2, err := os.Lstat(testFile2) 606 | suite.Require().NoError(err) 607 | suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink) 608 | 609 | // Verify common configuration tracking 610 | commonItems, err := commonLnk.List() 611 | suite.Require().NoError(err) 612 | suite.Len(commonItems, 1) 613 | suite.Contains(commonItems[0], ".bashrc") 614 | 615 | // Verify host-specific configuration tracking 616 | hostItems, err := hostLnk.List() 617 | suite.Require().NoError(err) 618 | suite.Len(hostItems, 1) 619 | suite.Contains(hostItems[0], ".vimrc") 620 | 621 | // Verify files are stored in correct locations 622 | lnkDir := filepath.Join(suite.tempDir, "lnk") 623 | 624 | // Common file should be in root 625 | commonFile := filepath.Join(lnkDir, ".lnk") 626 | suite.FileExists(commonFile) 627 | 628 | // Host-specific file should be in host subdirectory 629 | hostDir := filepath.Join(lnkDir, "workstation.lnk") 630 | suite.DirExists(hostDir) 631 | 632 | hostTrackingFile := filepath.Join(lnkDir, ".lnk.workstation") 633 | suite.FileExists(hostTrackingFile) 634 | 635 | // Test removal 636 | err = commonLnk.Remove(testFile1) 637 | suite.Require().NoError(err) 638 | 639 | err = hostLnk.Remove(testFile2) 640 | suite.Require().NoError(err) 641 | 642 | // Verify files are restored 643 | info1, err = os.Lstat(testFile1) 644 | suite.Require().NoError(err) 645 | suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink) 646 | 647 | info2, err = os.Lstat(testFile2) 648 | suite.Require().NoError(err) 649 | suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink) 650 | } 651 | 652 | // Test hostname detection 653 | func (suite *CoreTestSuite) TestHostnameDetection() { 654 | hostname, err := GetCurrentHostname() 655 | suite.NoError(err) 656 | suite.NotEmpty(hostname) 657 | } 658 | 659 | // Test host-specific symlink restoration 660 | func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() { 661 | err := suite.lnk.Init() 662 | suite.Require().NoError(err) 663 | 664 | // Create files directly in host-specific storage (simulating a pull) 665 | hostLnk := NewLnk(WithHost("testhost")) 666 | 667 | // Ensure host storage directory exists 668 | hostStoragePath := hostLnk.getHostStoragePath() 669 | err = os.MkdirAll(hostStoragePath, 0755) 670 | suite.Require().NoError(err) 671 | 672 | // Create a file in host storage 673 | repoFile := filepath.Join(hostStoragePath, ".bashrc") 674 | content := "export HOST=testhost" 675 | err = os.WriteFile(repoFile, []byte(content), 0644) 676 | suite.Require().NoError(err) 677 | 678 | // Create host tracking file 679 | trackingFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost") 680 | err = os.WriteFile(trackingFile, []byte(".bashrc\n"), 0644) 681 | suite.Require().NoError(err) 682 | 683 | // Get home directory for the test 684 | homeDir, err := os.UserHomeDir() 685 | suite.Require().NoError(err) 686 | 687 | targetFile := filepath.Join(homeDir, ".bashrc") 688 | 689 | // Clean up the test file after the test 690 | defer func() { 691 | _ = os.Remove(targetFile) 692 | }() 693 | 694 | // Test symlink restoration 695 | restored, err := hostLnk.RestoreSymlinks() 696 | suite.Require().NoError(err) 697 | 698 | // Should have restored the symlink 699 | suite.Len(restored, 1) 700 | suite.Equal(".bashrc", restored[0]) 701 | 702 | // Check that file is now a symlink 703 | info, err := os.Lstat(targetFile) 704 | suite.NoError(err) 705 | suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) 706 | } 707 | 708 | // Test that common and host-specific configurations don't interfere 709 | func (suite *CoreTestSuite) TestMultihostIsolation() { 710 | err := suite.lnk.Init() 711 | suite.Require().NoError(err) 712 | 713 | // Create same file for common and host-specific 714 | testFile := filepath.Join(suite.tempDir, ".gitconfig") 715 | commonContent := "[user]\n\tname = Common User" 716 | err = os.WriteFile(testFile, []byte(commonContent), 0644) 717 | suite.Require().NoError(err) 718 | 719 | // Add to common 720 | commonLnk := NewLnk() 721 | err = commonLnk.Add(testFile) 722 | suite.Require().NoError(err) 723 | 724 | // Remove and recreate with different content 725 | err = commonLnk.Remove(testFile) 726 | suite.Require().NoError(err) 727 | 728 | hostContent := "[user]\n\tname = Work User" 729 | err = os.WriteFile(testFile, []byte(hostContent), 0644) 730 | suite.Require().NoError(err) 731 | 732 | // Add to host-specific 733 | hostLnk := NewLnk(WithHost("work")) 734 | err = hostLnk.Add(testFile) 735 | suite.Require().NoError(err) 736 | 737 | // Verify tracking files are separate 738 | commonItems, err := commonLnk.List() 739 | suite.Require().NoError(err) 740 | suite.Len(commonItems, 0) // Should be empty after removal 741 | 742 | hostItems, err := hostLnk.List() 743 | suite.Require().NoError(err) 744 | suite.Len(hostItems, 1) 745 | suite.Contains(hostItems[0], ".gitconfig") 746 | 747 | // Verify content is correct 748 | symlinkContent, err := os.ReadFile(testFile) 749 | suite.Require().NoError(err) 750 | suite.Equal(hostContent, string(symlinkContent)) 751 | } 752 | 753 | // Test bootstrap script detection 754 | func (suite *CoreTestSuite) TestFindBootstrapScript() { 755 | err := suite.lnk.Init() 756 | suite.Require().NoError(err) 757 | 758 | // Test with no bootstrap script 759 | scriptPath, err := suite.lnk.FindBootstrapScript() 760 | suite.NoError(err) 761 | suite.Empty(scriptPath) 762 | 763 | // Test with bootstrap.sh 764 | bootstrapScript := filepath.Join(suite.tempDir, "lnk", "bootstrap.sh") 765 | err = os.WriteFile(bootstrapScript, []byte("#!/bin/bash\necho 'test'"), 0644) 766 | suite.Require().NoError(err) 767 | 768 | scriptPath, err = suite.lnk.FindBootstrapScript() 769 | suite.NoError(err) 770 | suite.Equal("bootstrap.sh", scriptPath) 771 | } 772 | 773 | // Test bootstrap script execution 774 | func (suite *CoreTestSuite) TestRunBootstrapScript() { 775 | err := suite.lnk.Init() 776 | suite.Require().NoError(err) 777 | 778 | // Create a test script that creates a marker file 779 | bootstrapScript := filepath.Join(suite.tempDir, "lnk", "test.sh") 780 | markerFile := filepath.Join(suite.tempDir, "lnk", "bootstrap-executed.txt") 781 | scriptContent := fmt.Sprintf("#!/bin/bash\ntouch %s\necho 'Bootstrap executed'", markerFile) 782 | 783 | err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755) 784 | suite.Require().NoError(err) 785 | 786 | // Run the bootstrap script 787 | err = suite.lnk.RunBootstrapScript("test.sh") 788 | suite.NoError(err) 789 | 790 | // Verify the marker file was created 791 | suite.FileExists(markerFile) 792 | } 793 | 794 | // Test bootstrap script execution with error 795 | func (suite *CoreTestSuite) TestRunBootstrapScriptWithError() { 796 | err := suite.lnk.Init() 797 | suite.Require().NoError(err) 798 | 799 | // Create a script that will fail 800 | bootstrapScript := filepath.Join(suite.tempDir, "lnk", "failing.sh") 801 | scriptContent := "#!/bin/bash\nexit 1" 802 | 803 | err = os.WriteFile(bootstrapScript, []byte(scriptContent), 0755) 804 | suite.Require().NoError(err) 805 | 806 | // Run the bootstrap script - should fail 807 | err = suite.lnk.RunBootstrapScript("failing.sh") 808 | suite.Error(err) 809 | suite.Contains(err.Error(), "Bootstrap script failed") 810 | } 811 | 812 | // Test running bootstrap on non-existent script 813 | func (suite *CoreTestSuite) TestRunBootstrapScriptNotFound() { 814 | err := suite.lnk.Init() 815 | suite.Require().NoError(err) 816 | 817 | // Try to run non-existent script 818 | err = suite.lnk.RunBootstrapScript("nonexistent.sh") 819 | suite.Error(err) 820 | suite.Contains(err.Error(), "Bootstrap script not found") 821 | } 822 | 823 | func TestCoreSuite(t *testing.T) { 824 | suite.Run(t, new(CoreTestSuite)) 825 | } 826 | -------------------------------------------------------------------------------- /internal/fs/errors.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import "fmt" 4 | 5 | // ANSI color codes for consistent formatting 6 | const ( 7 | colorReset = "\033[0m" 8 | colorRed = "\033[31m" 9 | colorBold = "\033[1m" 10 | ) 11 | 12 | // formatError creates a consistently formatted error message with ❌ prefix 13 | func formatError(message string, args ...interface{}) string { 14 | return fmt.Sprintf("❌ "+message, args...) 15 | } 16 | 17 | // formatPath formats a file path with red color 18 | func formatPath(path string) string { 19 | return fmt.Sprintf("%s%s%s", colorRed, path, colorReset) 20 | } 21 | 22 | // formatCommand formats a command with bold styling 23 | func formatCommand(command string) string { 24 | return fmt.Sprintf("%s%s%s", colorBold, command, colorReset) 25 | } 26 | 27 | // FileNotExistsError represents an error when a file does not exist 28 | type FileNotExistsError struct { 29 | Path string 30 | Err error 31 | } 32 | 33 | func (e *FileNotExistsError) Error() string { 34 | return formatError("File or directory not found: %s", formatPath(e.Path)) 35 | } 36 | 37 | func (e *FileNotExistsError) Unwrap() error { 38 | return e.Err 39 | } 40 | 41 | // FileCheckError represents an error when failing to check a file 42 | type FileCheckError struct { 43 | Err error 44 | } 45 | 46 | func (e *FileCheckError) Error() string { 47 | return formatError("Unable to access file. Please check file permissions and try again.") 48 | } 49 | 50 | func (e *FileCheckError) Unwrap() error { 51 | return e.Err 52 | } 53 | 54 | // UnsupportedFileTypeError represents an error when a file type is not supported 55 | type UnsupportedFileTypeError struct { 56 | Path string 57 | } 58 | 59 | func (e *UnsupportedFileTypeError) Error() string { 60 | return formatError("Cannot manage this type of file: %s\n 💡 lnk can only manage regular files and directories", formatPath(e.Path)) 61 | } 62 | 63 | func (e *UnsupportedFileTypeError) Unwrap() error { 64 | return nil 65 | } 66 | 67 | // NotManagedByLnkError represents an error when a file is not managed by lnk 68 | type NotManagedByLnkError struct { 69 | Path string 70 | } 71 | 72 | func (e *NotManagedByLnkError) Error() string { 73 | return formatError("File is not managed by lnk: %s\n 💡 Use %s to manage this file first", 74 | formatPath(e.Path), formatCommand("lnk add")) 75 | } 76 | 77 | func (e *NotManagedByLnkError) Unwrap() error { 78 | return nil 79 | } 80 | 81 | // SymlinkReadError represents an error when failing to read a symlink 82 | type SymlinkReadError struct { 83 | Err error 84 | } 85 | 86 | func (e *SymlinkReadError) Error() string { 87 | return formatError("Unable to read symlink. The file may be corrupted or have invalid permissions.") 88 | } 89 | 90 | func (e *SymlinkReadError) Unwrap() error { 91 | return e.Err 92 | } 93 | 94 | // DirectoryCreationError represents an error when failing to create a directory 95 | type DirectoryCreationError struct { 96 | Operation string 97 | Err error 98 | } 99 | 100 | func (e *DirectoryCreationError) Error() string { 101 | return formatError("Failed to create directory. Please check permissions and available disk space.") 102 | } 103 | 104 | func (e *DirectoryCreationError) Unwrap() error { 105 | return e.Err 106 | } 107 | 108 | // RelativePathCalculationError represents an error when failing to calculate relative path 109 | type RelativePathCalculationError struct { 110 | Err error 111 | } 112 | 113 | func (e *RelativePathCalculationError) Error() string { 114 | return formatError("Unable to create symlink due to path configuration issues. Please check file locations.") 115 | } 116 | 117 | func (e *RelativePathCalculationError) Unwrap() error { 118 | return e.Err 119 | } 120 | -------------------------------------------------------------------------------- /internal/fs/filesystem.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | // FileSystem handles file system operations 10 | type FileSystem struct{} 11 | 12 | // New creates a new FileSystem instance 13 | func New() *FileSystem { 14 | return &FileSystem{} 15 | } 16 | 17 | // ValidateFileForAdd validates that a file or directory can be added to lnk 18 | func (fs *FileSystem) ValidateFileForAdd(filePath string) error { 19 | // Check if file exists and get its info 20 | info, err := os.Stat(filePath) 21 | if err != nil { 22 | if os.IsNotExist(err) { 23 | return &FileNotExistsError{Path: filePath, Err: err} 24 | } 25 | 26 | return &FileCheckError{Err: err} 27 | } 28 | 29 | // Allow both regular files and directories 30 | if !info.Mode().IsRegular() && !info.IsDir() { 31 | return &UnsupportedFileTypeError{Path: filePath} 32 | } 33 | 34 | return nil 35 | } 36 | 37 | // ValidateSymlinkForRemove validates that a symlink can be removed from lnk 38 | func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error { 39 | // Check if file exists and is a symlink 40 | info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks 41 | if err != nil { 42 | if os.IsNotExist(err) { 43 | return &FileNotExistsError{Path: filePath, Err: err} 44 | } 45 | 46 | return &FileCheckError{Err: err} 47 | } 48 | 49 | if info.Mode()&os.ModeSymlink == 0 { 50 | return &NotManagedByLnkError{Path: filePath} 51 | } 52 | 53 | // Get symlink target and resolve to absolute path 54 | target, err := os.Readlink(filePath) 55 | if err != nil { 56 | return &SymlinkReadError{Err: err} 57 | } 58 | 59 | if !filepath.IsAbs(target) { 60 | target = filepath.Join(filepath.Dir(filePath), target) 61 | } 62 | 63 | // Clean paths and check if target is inside the repository 64 | target = filepath.Clean(target) 65 | repoPath = filepath.Clean(repoPath) 66 | 67 | if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath { 68 | return &NotManagedByLnkError{Path: filePath} 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // Move moves a file or directory from source to destination based on the file info 75 | func (fs *FileSystem) Move(src, dst string, info os.FileInfo) error { 76 | if info.IsDir() { 77 | return fs.MoveDirectory(src, dst) 78 | } 79 | return fs.MoveFile(src, dst) 80 | } 81 | 82 | // MoveFile moves a file from source to destination 83 | func (fs *FileSystem) MoveFile(src, dst string) error { 84 | // Ensure destination directory exists 85 | if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { 86 | return &DirectoryCreationError{Operation: "destination directory", Err: err} 87 | } 88 | 89 | // Move the file 90 | return os.Rename(src, dst) 91 | } 92 | 93 | // CreateSymlink creates a relative symlink from target to linkPath 94 | func (fs *FileSystem) CreateSymlink(target, linkPath string) error { 95 | // Calculate relative path from linkPath to target 96 | relTarget, err := filepath.Rel(filepath.Dir(linkPath), target) 97 | if err != nil { 98 | return &RelativePathCalculationError{Err: err} 99 | } 100 | 101 | // Create the symlink 102 | return os.Symlink(relTarget, linkPath) 103 | } 104 | 105 | // MoveDirectory moves a directory from source to destination recursively 106 | func (fs *FileSystem) MoveDirectory(src, dst string) error { 107 | // Ensure destination parent directory exists 108 | if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { 109 | return &DirectoryCreationError{Operation: "destination parent directory", Err: err} 110 | } 111 | 112 | // Move the directory 113 | return os.Rename(src, dst) 114 | } 115 | -------------------------------------------------------------------------------- /internal/git/errors.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "fmt" 4 | 5 | // ANSI color codes for consistent formatting 6 | const ( 7 | colorReset = "\033[0m" 8 | colorBold = "\033[1m" 9 | colorGreen = "\033[32m" 10 | colorYellow = "\033[33m" 11 | ) 12 | 13 | // formatError creates a consistently formatted error message with ❌ prefix 14 | func formatError(message string, args ...interface{}) string { 15 | return fmt.Sprintf("❌ "+message, args...) 16 | } 17 | 18 | // formatURL formats a URL with styling 19 | func formatURL(url string) string { 20 | return fmt.Sprintf("%s%s%s", colorBold, url, colorReset) 21 | } 22 | 23 | // formatRemote formats a remote name with styling 24 | func formatRemote(remote string) string { 25 | return fmt.Sprintf("%s%s%s", colorGreen, remote, colorReset) 26 | } 27 | 28 | // GitInitError represents an error during git initialization 29 | type GitInitError struct { 30 | Output string 31 | Err error 32 | } 33 | 34 | func (e *GitInitError) Error() string { 35 | return formatError("Failed to initialize git repository. Please ensure git is installed and try again.") 36 | } 37 | 38 | func (e *GitInitError) Unwrap() error { 39 | return e.Err 40 | } 41 | 42 | // BranchSetupError represents an error setting up the default branch 43 | type BranchSetupError struct { 44 | Err error 45 | } 46 | 47 | func (e *BranchSetupError) Error() string { 48 | return formatError("Failed to set up the default branch. Please check your git installation.") 49 | } 50 | 51 | func (e *BranchSetupError) Unwrap() error { 52 | return e.Err 53 | } 54 | 55 | // RemoteExistsError represents an error when a remote already exists with different URL 56 | type RemoteExistsError struct { 57 | Remote string 58 | ExistingURL string 59 | NewURL string 60 | } 61 | 62 | func (e *RemoteExistsError) Error() string { 63 | return formatError("Remote %s is already configured with a different repository (%s). Cannot add %s.", 64 | formatRemote(e.Remote), formatURL(e.ExistingURL), formatURL(e.NewURL)) 65 | } 66 | 67 | func (e *RemoteExistsError) Unwrap() error { 68 | return nil 69 | } 70 | 71 | // GitCommandError represents a generic git command execution error 72 | type GitCommandError struct { 73 | Command string 74 | Output string 75 | Err error 76 | } 77 | 78 | func (e *GitCommandError) Error() string { 79 | // Provide user-friendly messages based on common command types 80 | switch e.Command { 81 | case "add": 82 | return formatError("Failed to stage files for commit. Please check file permissions and try again.") 83 | case "commit": 84 | return formatError("Failed to create commit. Please ensure you have staged changes and try again.") 85 | case "remote add": 86 | return formatError("Failed to add remote repository. Please check the repository URL and try again.") 87 | case "rm": 88 | return formatError("Failed to remove file from git tracking. Please check if the file exists and try again.") 89 | case "log": 90 | return formatError("Failed to retrieve commit history.") 91 | case "remote": 92 | return formatError("Failed to retrieve remote repository information.") 93 | case "clone": 94 | return formatError("Failed to clone repository. Please check the repository URL and your network connection.") 95 | default: 96 | return formatError("Git operation failed. Please check your repository state and try again.") 97 | } 98 | } 99 | 100 | func (e *GitCommandError) Unwrap() error { 101 | return e.Err 102 | } 103 | 104 | // NoRemoteError represents an error when no remote is configured 105 | type NoRemoteError struct{} 106 | 107 | func (e *NoRemoteError) Error() string { 108 | return formatError("No remote repository is configured. Please add a remote repository first.") 109 | } 110 | 111 | func (e *NoRemoteError) Unwrap() error { 112 | return nil 113 | } 114 | 115 | // RemoteNotFoundError represents an error when a specific remote is not found 116 | type RemoteNotFoundError struct { 117 | Remote string 118 | Err error 119 | } 120 | 121 | func (e *RemoteNotFoundError) Error() string { 122 | return formatError("Remote repository %s is not configured.", formatRemote(e.Remote)) 123 | } 124 | 125 | func (e *RemoteNotFoundError) Unwrap() error { 126 | return e.Err 127 | } 128 | 129 | // GitConfigError represents an error with git configuration 130 | type GitConfigError struct { 131 | Setting string 132 | Err error 133 | } 134 | 135 | func (e *GitConfigError) Error() string { 136 | return formatError("Failed to configure git settings. Please check your git installation.") 137 | } 138 | 139 | func (e *GitConfigError) Unwrap() error { 140 | return e.Err 141 | } 142 | 143 | // UncommittedChangesError represents an error checking for uncommitted changes 144 | type UncommittedChangesError struct { 145 | Err error 146 | } 147 | 148 | func (e *UncommittedChangesError) Error() string { 149 | return formatError("Failed to check repository status. Please verify your git repository is valid.") 150 | } 151 | 152 | func (e *UncommittedChangesError) Unwrap() error { 153 | return e.Err 154 | } 155 | 156 | // DirectoryRemovalError represents an error removing a directory 157 | type DirectoryRemovalError struct { 158 | Path string 159 | Err error 160 | } 161 | 162 | func (e *DirectoryRemovalError) Error() string { 163 | return formatError("Failed to prepare directory for operation. Please check directory permissions.") 164 | } 165 | 166 | func (e *DirectoryRemovalError) Unwrap() error { 167 | return e.Err 168 | } 169 | 170 | // DirectoryCreationError represents an error creating a directory 171 | type DirectoryCreationError struct { 172 | Path string 173 | Err error 174 | } 175 | 176 | func (e *DirectoryCreationError) Error() string { 177 | return formatError("Failed to create directory. Please check permissions and available disk space.") 178 | } 179 | 180 | func (e *DirectoryCreationError) Unwrap() error { 181 | return e.Err 182 | } 183 | 184 | // PushError represents an error during git push operation 185 | type PushError struct { 186 | Reason string 187 | Output string 188 | Err error 189 | } 190 | 191 | func (e *PushError) Error() string { 192 | if e.Reason != "" { 193 | return formatError("Cannot push changes: %s", e.Reason) 194 | } 195 | return formatError("Failed to push changes to remote repository. Please check your network connection and repository permissions.") 196 | } 197 | 198 | func (e *PushError) Unwrap() error { 199 | return e.Err 200 | } 201 | 202 | // PullError represents an error during git pull operation 203 | type PullError struct { 204 | Reason string 205 | Output string 206 | Err error 207 | } 208 | 209 | func (e *PullError) Error() string { 210 | if e.Reason != "" { 211 | return formatError("Cannot pull changes: %s", e.Reason) 212 | } 213 | return formatError("Failed to pull changes from remote repository. Please check your network connection and resolve any conflicts.") 214 | } 215 | 216 | func (e *PullError) Unwrap() error { 217 | return e.Err 218 | } 219 | -------------------------------------------------------------------------------- /internal/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | // Git handles Git operations 12 | type Git struct { 13 | repoPath string 14 | } 15 | 16 | // New creates a new Git instance 17 | func New(repoPath string) *Git { 18 | return &Git{ 19 | repoPath: repoPath, 20 | } 21 | } 22 | 23 | // Init initializes a new Git repository 24 | func (g *Git) Init() error { 25 | // Try using git init -b main first (Git 2.28+) 26 | cmd := exec.Command("git", "init", "-b", "main") 27 | cmd.Dir = g.repoPath 28 | 29 | _, err := cmd.CombinedOutput() 30 | if err != nil { 31 | // Fallback to regular init + branch rename for older Git versions 32 | cmd = exec.Command("git", "init") 33 | cmd.Dir = g.repoPath 34 | 35 | output, err := cmd.CombinedOutput() 36 | if err != nil { 37 | return &GitInitError{Output: string(output), Err: err} 38 | } 39 | 40 | // Set the default branch to main 41 | cmd = exec.Command("git", "symbolic-ref", "HEAD", "refs/heads/main") 42 | cmd.Dir = g.repoPath 43 | 44 | if err := cmd.Run(); err != nil { 45 | return &BranchSetupError{Err: err} 46 | } 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // AddRemote adds a remote to the repository 53 | func (g *Git) AddRemote(name, url string) error { 54 | // Check if remote already exists 55 | existingURL, err := g.getRemoteURL(name) 56 | if err == nil { 57 | // Remote exists, check if URL matches 58 | if existingURL == url { 59 | // Same URL, idempotent - do nothing 60 | return nil 61 | } 62 | // Different URL, error 63 | return &RemoteExistsError{Remote: name, ExistingURL: existingURL, NewURL: url} 64 | } 65 | 66 | // Remote doesn't exist, add it 67 | cmd := exec.Command("git", "remote", "add", name, url) 68 | cmd.Dir = g.repoPath 69 | 70 | output, err := cmd.CombinedOutput() 71 | if err != nil { 72 | return &GitCommandError{Command: "remote add", Output: string(output), Err: err} 73 | } 74 | 75 | return nil 76 | } 77 | 78 | // getRemoteURL returns the URL for a remote, or error if not found 79 | func (g *Git) getRemoteURL(name string) (string, error) { 80 | cmd := exec.Command("git", "remote", "get-url", name) 81 | cmd.Dir = g.repoPath 82 | 83 | output, err := cmd.Output() 84 | if err != nil { 85 | return "", err 86 | } 87 | 88 | return strings.TrimSpace(string(output)), nil 89 | } 90 | 91 | // IsGitRepository checks if the directory contains a Git repository 92 | func (g *Git) IsGitRepository() bool { 93 | gitDir := filepath.Join(g.repoPath, ".git") 94 | _, err := os.Stat(gitDir) 95 | return err == nil 96 | } 97 | 98 | // IsLnkRepository checks if the repository appears to be managed by lnk 99 | func (g *Git) IsLnkRepository() bool { 100 | if !g.IsGitRepository() { 101 | return false 102 | } 103 | 104 | // Check if this looks like a lnk repository 105 | // We consider it a lnk repo if: 106 | // 1. It has no commits (fresh repo), OR 107 | // 2. All commits start with "lnk:" pattern 108 | 109 | commits, err := g.GetCommits() 110 | if err != nil { 111 | return false 112 | } 113 | 114 | // If no commits, it's a fresh repo - could be lnk 115 | if len(commits) == 0 { 116 | return true 117 | } 118 | 119 | // If all commits start with "lnk:", it's definitely ours 120 | // If ANY commit doesn't start with "lnk:", it's probably not ours 121 | for _, commit := range commits { 122 | if !strings.HasPrefix(commit, "lnk:") { 123 | return false 124 | } 125 | } 126 | 127 | return true 128 | } 129 | 130 | // AddAndCommit stages a file and commits it 131 | func (g *Git) AddAndCommit(filename, message string) error { 132 | // Stage the file 133 | if err := g.Add(filename); err != nil { 134 | return err 135 | } 136 | 137 | // Commit the changes 138 | if err := g.Commit(message); err != nil { 139 | return err 140 | } 141 | 142 | return nil 143 | } 144 | 145 | // RemoveAndCommit removes a file from Git and commits the change 146 | func (g *Git) RemoveAndCommit(filename, message string) error { 147 | // Remove the file from Git 148 | if err := g.Remove(filename); err != nil { 149 | return err 150 | } 151 | 152 | // Commit the changes 153 | if err := g.Commit(message); err != nil { 154 | return err 155 | } 156 | 157 | return nil 158 | } 159 | 160 | // Add stages a file 161 | func (g *Git) Add(filename string) error { 162 | cmd := exec.Command("git", "add", filename) 163 | cmd.Dir = g.repoPath 164 | 165 | output, err := cmd.CombinedOutput() 166 | if err != nil { 167 | return &GitCommandError{Command: "add", Output: string(output), Err: err} 168 | } 169 | 170 | return nil 171 | } 172 | 173 | // Remove removes a file from Git tracking 174 | func (g *Git) Remove(filename string) error { 175 | // Check if it's a directory that needs -r flag 176 | fullPath := filepath.Join(g.repoPath, filename) 177 | info, err := os.Stat(fullPath) 178 | 179 | var cmd *exec.Cmd 180 | if err == nil && info.IsDir() { 181 | // Use -r and --cached flags for directories (only remove from git, not filesystem) 182 | cmd = exec.Command("git", "rm", "-r", "--cached", filename) 183 | } else { 184 | // Regular file (only remove from git, not filesystem) 185 | cmd = exec.Command("git", "rm", "--cached", filename) 186 | } 187 | 188 | cmd.Dir = g.repoPath 189 | 190 | output, err := cmd.CombinedOutput() 191 | if err != nil { 192 | return &GitCommandError{Command: "rm", Output: string(output), Err: err} 193 | } 194 | 195 | return nil 196 | } 197 | 198 | // Commit creates a commit with the given message 199 | func (g *Git) Commit(message string) error { 200 | // Configure git user if not already configured 201 | if err := g.ensureGitConfig(); err != nil { 202 | return err 203 | } 204 | 205 | cmd := exec.Command("git", "commit", "-m", message) 206 | cmd.Dir = g.repoPath 207 | 208 | output, err := cmd.CombinedOutput() 209 | if err != nil { 210 | return &GitCommandError{Command: "commit", Output: string(output), Err: err} 211 | } 212 | 213 | return nil 214 | } 215 | 216 | // ensureGitConfig ensures that git user.name and user.email are configured 217 | func (g *Git) ensureGitConfig() error { 218 | // Check if user.name is configured 219 | cmd := exec.Command("git", "config", "user.name") 220 | cmd.Dir = g.repoPath 221 | if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 { 222 | // Set a default user.name 223 | cmd = exec.Command("git", "config", "user.name", "Lnk User") 224 | cmd.Dir = g.repoPath 225 | if err := cmd.Run(); err != nil { 226 | return &GitConfigError{Setting: "user.name", Err: err} 227 | } 228 | } 229 | 230 | // Check if user.email is configured 231 | cmd = exec.Command("git", "config", "user.email") 232 | cmd.Dir = g.repoPath 233 | if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 { 234 | // Set a default user.email 235 | cmd = exec.Command("git", "config", "user.email", "lnk@localhost") 236 | cmd.Dir = g.repoPath 237 | if err := cmd.Run(); err != nil { 238 | return &GitConfigError{Setting: "user.email", Err: err} 239 | } 240 | } 241 | 242 | return nil 243 | } 244 | 245 | // GetCommits returns the list of commit messages for testing purposes 246 | func (g *Git) GetCommits() ([]string, error) { 247 | // Check if .git directory exists 248 | gitDir := filepath.Join(g.repoPath, ".git") 249 | if _, err := os.Stat(gitDir); os.IsNotExist(err) { 250 | return []string{}, nil 251 | } 252 | 253 | cmd := exec.Command("git", "log", "--oneline", "--format=%s") 254 | cmd.Dir = g.repoPath 255 | 256 | output, err := cmd.CombinedOutput() 257 | if err != nil { 258 | // If there are no commits yet, return empty slice 259 | outputStr := string(output) 260 | if strings.Contains(outputStr, "does not have any commits yet") { 261 | return []string{}, nil 262 | } 263 | return nil, &GitCommandError{Command: "log", Output: outputStr, Err: err} 264 | } 265 | 266 | commits := strings.Split(strings.TrimSpace(string(output)), "\n") 267 | if len(commits) == 1 && commits[0] == "" { 268 | return []string{}, nil 269 | } 270 | 271 | return commits, nil 272 | } 273 | 274 | // GetRemoteInfo returns information about the default remote 275 | func (g *Git) GetRemoteInfo() (string, error) { 276 | // First try to get origin remote 277 | url, err := g.getRemoteURL("origin") 278 | if err != nil { 279 | // If origin doesn't exist, try to get any remote 280 | cmd := exec.Command("git", "remote") 281 | cmd.Dir = g.repoPath 282 | 283 | output, err := cmd.Output() 284 | if err != nil { 285 | return "", &GitCommandError{Command: "remote", Output: string(output), Err: err} 286 | } 287 | 288 | remotes := strings.Split(strings.TrimSpace(string(output)), "\n") 289 | if len(remotes) == 0 || remotes[0] == "" { 290 | return "", &NoRemoteError{} 291 | } 292 | 293 | // Use the first remote 294 | url, err = g.getRemoteURL(remotes[0]) 295 | if err != nil { 296 | return "", &RemoteNotFoundError{Remote: remotes[0], Err: err} 297 | } 298 | } 299 | 300 | return url, nil 301 | } 302 | 303 | // StatusInfo contains repository status information 304 | type StatusInfo struct { 305 | Ahead int 306 | Behind int 307 | Remote string 308 | Dirty bool 309 | } 310 | 311 | // GetStatus returns the repository status relative to remote 312 | func (g *Git) GetStatus() (*StatusInfo, error) { 313 | // Check if we have a remote 314 | _, err := g.GetRemoteInfo() 315 | if err != nil { 316 | return nil, err 317 | } 318 | 319 | // Check for uncommitted changes 320 | dirty, err := g.HasChanges() 321 | if err != nil { 322 | return nil, &UncommittedChangesError{Err: err} 323 | } 324 | 325 | // Get the remote tracking branch 326 | cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") 327 | cmd.Dir = g.repoPath 328 | 329 | output, err := cmd.Output() 330 | if err != nil { 331 | // No upstream branch set, assume origin/main 332 | remoteBranch := "origin/main" 333 | return &StatusInfo{ 334 | Ahead: g.getAheadCount(remoteBranch), 335 | Behind: 0, // Can't be behind if no upstream 336 | Remote: remoteBranch, 337 | Dirty: dirty, 338 | }, nil 339 | } 340 | 341 | remoteBranch := strings.TrimSpace(string(output)) 342 | 343 | return &StatusInfo{ 344 | Ahead: g.getAheadCount(remoteBranch), 345 | Behind: g.getBehindCount(remoteBranch), 346 | Remote: remoteBranch, 347 | Dirty: dirty, 348 | }, nil 349 | } 350 | 351 | // getAheadCount returns how many commits ahead of remote 352 | func (g *Git) getAheadCount(remoteBranch string) int { 353 | cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch)) 354 | cmd.Dir = g.repoPath 355 | 356 | output, err := cmd.Output() 357 | if err != nil { 358 | // If remote branch doesn't exist, count all local commits 359 | cmd = exec.Command("git", "rev-list", "--count", "HEAD") 360 | cmd.Dir = g.repoPath 361 | 362 | output, err = cmd.Output() 363 | if err != nil { 364 | return 0 365 | } 366 | } 367 | 368 | count := strings.TrimSpace(string(output)) 369 | if count == "" { 370 | return 0 371 | } 372 | 373 | // Convert to int 374 | var ahead int 375 | if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil { 376 | return 0 377 | } 378 | 379 | return ahead 380 | } 381 | 382 | // getBehindCount returns how many commits behind remote 383 | func (g *Git) getBehindCount(remoteBranch string) int { 384 | cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch)) 385 | cmd.Dir = g.repoPath 386 | 387 | output, err := cmd.Output() 388 | if err != nil { 389 | return 0 390 | } 391 | 392 | count := strings.TrimSpace(string(output)) 393 | if count == "" { 394 | return 0 395 | } 396 | 397 | // Convert to int 398 | var behind int 399 | if _, err := fmt.Sscanf(count, "%d", &behind); err != nil { 400 | return 0 401 | } 402 | 403 | return behind 404 | } 405 | 406 | // HasChanges checks if there are uncommitted changes 407 | func (g *Git) HasChanges() (bool, error) { 408 | cmd := exec.Command("git", "status", "--porcelain") 409 | cmd.Dir = g.repoPath 410 | 411 | output, err := cmd.Output() 412 | if err != nil { 413 | return false, &GitCommandError{Command: "status", Output: string(output), Err: err} 414 | } 415 | 416 | return len(strings.TrimSpace(string(output))) > 0, nil 417 | } 418 | 419 | // AddAll stages all changes in the repository 420 | func (g *Git) AddAll() error { 421 | cmd := exec.Command("git", "add", "-A") 422 | cmd.Dir = g.repoPath 423 | 424 | output, err := cmd.CombinedOutput() 425 | if err != nil { 426 | return &GitCommandError{Command: "add", Output: string(output), Err: err} 427 | } 428 | 429 | return nil 430 | } 431 | 432 | // Push pushes changes to remote 433 | func (g *Git) Push() error { 434 | // First ensure we have a remote configured 435 | _, err := g.GetRemoteInfo() 436 | if err != nil { 437 | return &PushError{Reason: err.Error(), Err: err} 438 | } 439 | 440 | cmd := exec.Command("git", "push", "-u", "origin", "main") 441 | cmd.Dir = g.repoPath 442 | 443 | output, err := cmd.CombinedOutput() 444 | if err != nil { 445 | return &PushError{Output: string(output), Err: err} 446 | } 447 | 448 | return nil 449 | } 450 | 451 | // Pull pulls changes from remote 452 | func (g *Git) Pull() error { 453 | // First ensure we have a remote configured 454 | _, err := g.GetRemoteInfo() 455 | if err != nil { 456 | return &PullError{Reason: err.Error(), Err: err} 457 | } 458 | 459 | cmd := exec.Command("git", "pull", "origin", "main") 460 | cmd.Dir = g.repoPath 461 | 462 | output, err := cmd.CombinedOutput() 463 | if err != nil { 464 | return &PullError{Output: string(output), Err: err} 465 | } 466 | 467 | return nil 468 | } 469 | 470 | // Clone clones a repository from the given URL 471 | func (g *Git) Clone(url string) error { 472 | // Remove the directory if it exists to ensure clean clone 473 | if err := os.RemoveAll(g.repoPath); err != nil { 474 | return &DirectoryRemovalError{Path: g.repoPath, Err: err} 475 | } 476 | 477 | // Create parent directory 478 | parentDir := filepath.Dir(g.repoPath) 479 | if err := os.MkdirAll(parentDir, 0755); err != nil { 480 | return &DirectoryCreationError{Path: parentDir, Err: err} 481 | } 482 | 483 | // Clone the repository 484 | cmd := exec.Command("git", "clone", url, g.repoPath) 485 | output, err := cmd.CombinedOutput() 486 | if err != nil { 487 | return &GitCommandError{Command: "clone", Output: string(output), Err: err} 488 | } 489 | 490 | // Set up upstream tracking for main branch 491 | cmd = exec.Command("git", "branch", "--set-upstream-to=origin/main", "main") 492 | cmd.Dir = g.repoPath 493 | _, err = cmd.CombinedOutput() 494 | if err != nil { 495 | // If main doesn't exist, try master 496 | cmd = exec.Command("git", "branch", "--set-upstream-to=origin/master", "master") 497 | cmd.Dir = g.repoPath 498 | _, err = cmd.CombinedOutput() 499 | if err != nil { 500 | // If that also fails, try to set upstream for current branch 501 | cmd = exec.Command("git", "branch", "--set-upstream-to=origin/HEAD") 502 | cmd.Dir = g.repoPath 503 | _, _ = cmd.CombinedOutput() // Ignore error as this is best effort 504 | } 505 | } 506 | 507 | return nil 508 | } 509 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------