mirror of
https://github.com/grdl/git-get.git
synced 2026-02-04 15:04:44 +00:00
Merge pull request #32 from grdl/issue-28-linting-errors
Fix linting errors and re-enable linting in CI actions
This commit is contained in:
39
.github/dependabot.yml
vendored
39
.github/dependabot.yml
vendored
@@ -1,39 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
# Go modules
|
|
||||||
- package-ecosystem: "gomod"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "monday"
|
|
||||||
time: "09:00"
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
reviewers:
|
|
||||||
- "grdl"
|
|
||||||
assignees:
|
|
||||||
- "grdl"
|
|
||||||
commit-message:
|
|
||||||
prefix: "deps"
|
|
||||||
include: "scope"
|
|
||||||
labels:
|
|
||||||
- "dependencies"
|
|
||||||
- "go"
|
|
||||||
|
|
||||||
# GitHub Actions
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "monday"
|
|
||||||
time: "09:00"
|
|
||||||
open-pull-requests-limit: 3
|
|
||||||
reviewers:
|
|
||||||
- "grdl"
|
|
||||||
assignees:
|
|
||||||
- "grdl"
|
|
||||||
commit-message:
|
|
||||||
prefix: "ci"
|
|
||||||
include: "scope"
|
|
||||||
labels:
|
|
||||||
- "dependencies"
|
|
||||||
- "github-actions"
|
|
||||||
93
.github/workflows/ci.yml
vendored
93
.github/workflows/ci.yml
vendored
@@ -5,6 +5,7 @@ on:
|
|||||||
branches: [master, main]
|
branches: [master, main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, main]
|
branches: [master, main]
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -16,9 +17,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go-version: ['1.24']
|
go-version: ['1.24']
|
||||||
os: [ubuntu-latest, macos-latest]
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
# TODO: fix tests on windows
|
|
||||||
# os: [ubuntu-latest, windows-latest, macos-latest]
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -39,22 +38,12 @@ jobs:
|
|||||||
- name: Verify dependencies
|
- name: Verify dependencies
|
||||||
run: go mod verify
|
run: go mod verify
|
||||||
|
|
||||||
- name: Set up Git (for tests)
|
- name: Build binary
|
||||||
run: |
|
run: go build -v -o bin/git-get ./cmd/
|
||||||
git config --global user.email "test@example.com"
|
|
||||||
git config --global user.name "CI Test"
|
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests
|
||||||
run: go test -race -coverprofile coverage.out -covermode=atomic ./...
|
run: go test -race ./...
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
|
||||||
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.24'
|
|
||||||
uses: codecov/codecov-action@v5
|
|
||||||
with:
|
|
||||||
file: ./coverage.out
|
|
||||||
flags: unittests
|
|
||||||
name: codecov-umbrella
|
|
||||||
fail_ci_if_error: false
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
@@ -70,73 +59,9 @@ jobs:
|
|||||||
go-version: '1.24'
|
go-version: '1.24'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Run golangci-lint
|
- name: Run lints
|
||||||
uses: golangci/golangci-lint-action@v6
|
uses: golangci/golangci-lint-action@v8
|
||||||
# TODO: Fix linting errors
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: v2.4.0
|
||||||
args: --timeout=5m
|
args: --timeout=5m
|
||||||
|
|
||||||
security:
|
|
||||||
name: Security
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.24'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
|
||||||
uses: aquasecurity/trivy-action@master
|
|
||||||
with:
|
|
||||||
scan-type: 'fs'
|
|
||||||
scan-ref: '.'
|
|
||||||
format: 'sarif'
|
|
||||||
output: 'trivy-results.sarif'
|
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
|
||||||
uses: github/codeql-action/upload-sarif@v3
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
sarif_file: 'trivy-results.sarif'
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
needs: [test, lint, security]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.24'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Build binary
|
|
||||||
run: |
|
|
||||||
go build -v -o bin/git-get ./cmd/
|
|
||||||
|
|
||||||
- name: Test binary and symlink behavior
|
|
||||||
run: |
|
|
||||||
./bin/git-get --version
|
|
||||||
# Test symlink functionality
|
|
||||||
ln -sf git-get bin/git-list
|
|
||||||
./bin/git-list --version
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: binary
|
|
||||||
path: bin/
|
|
||||||
retention-days: 30
|
|
||||||
|
|||||||
46
.github/workflows/codeql.yml
vendored
46
.github/workflows/codeql.yml
vendored
@@ -1,46 +0,0 @@
|
|||||||
name: "CodeQL Security Analysis"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master, main]
|
|
||||||
pull_request:
|
|
||||||
branches: [master, main]
|
|
||||||
schedule:
|
|
||||||
- cron: '30 2 * * 1' # Run weekly on Mondays at 2:30 AM UTC
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze (${{ matrix.language }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 360
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- language: go
|
|
||||||
build-mode: autobuild
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@v3
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
build-mode: ${{ matrix.build-mode }}
|
|
||||||
# Enable additional security-and-quality query pack
|
|
||||||
queries: +security-and-quality
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v3
|
|
||||||
with:
|
|
||||||
category: "/language:${{matrix.language}}"
|
|
||||||
47
.github/workflows/release.yml
vendored
47
.github/workflows/release.yml
vendored
@@ -10,49 +10,8 @@ permissions:
|
|||||||
security-events: write
|
security-events: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
build-and-test:
|
||||||
name: Validate Release
|
uses: ./.github/workflows/ci.yml
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.24'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Download dependencies
|
|
||||||
run: go mod download
|
|
||||||
|
|
||||||
- name: Verify dependencies
|
|
||||||
run: go mod verify
|
|
||||||
|
|
||||||
- name: Set up Git (for tests)
|
|
||||||
run: |
|
|
||||||
git config --global user.email "test@example.com"
|
|
||||||
git config --global user.name "CI Test"
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: go test -race ./...
|
|
||||||
|
|
||||||
- name: Run lints
|
|
||||||
uses: golangci/golangci-lint-action@v6
|
|
||||||
# TODO: Fix linting errors
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
args: --timeout=5m
|
|
||||||
|
|
||||||
- name: Validate GoReleaser config
|
|
||||||
uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
args: check
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: GoReleaser
|
name: GoReleaser
|
||||||
@@ -74,7 +33,7 @@ jobs:
|
|||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: '~> v2'
|
||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }}
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,3 +2,7 @@ dist/
|
|||||||
__debug_bin
|
__debug_bin
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Locally built executables
|
||||||
|
git-get
|
||||||
|
git-get.exe
|
||||||
|
|||||||
158
.golangci.yml
158
.golangci.yml
@@ -1,115 +1,55 @@
|
|||||||
version: 2
|
version: "2"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 1m
|
||||||
go: '1.24'
|
|
||||||
|
|
||||||
linters-settings:
|
formatters:
|
||||||
errcheck:
|
|
||||||
check-type-assertions: true
|
|
||||||
check-blank: true
|
|
||||||
|
|
||||||
govet:
|
|
||||||
enable-all: true
|
|
||||||
disable:
|
|
||||||
- shadow
|
|
||||||
|
|
||||||
gocyclo:
|
|
||||||
min-complexity: 15
|
|
||||||
|
|
||||||
dupl:
|
|
||||||
threshold: 100
|
|
||||||
|
|
||||||
goconst:
|
|
||||||
min-len: 3
|
|
||||||
min-occurrences: 3
|
|
||||||
|
|
||||||
lll:
|
|
||||||
line-length: 120
|
|
||||||
|
|
||||||
unparam:
|
|
||||||
check-exported: false
|
|
||||||
|
|
||||||
nakedret:
|
|
||||||
max-func-lines: 30
|
|
||||||
|
|
||||||
prealloc:
|
|
||||||
simple: true
|
|
||||||
range-loops: true
|
|
||||||
for-loops: false
|
|
||||||
|
|
||||||
gocritic:
|
|
||||||
enabled-tags:
|
|
||||||
- diagnostic
|
|
||||||
- experimental
|
|
||||||
- opinionated
|
|
||||||
- performance
|
|
||||||
- style
|
|
||||||
|
|
||||||
funlen:
|
|
||||||
lines: 100
|
|
||||||
statements: 50
|
|
||||||
|
|
||||||
godox:
|
|
||||||
keywords:
|
|
||||||
- NOTE
|
|
||||||
- OPTIMIZE
|
|
||||||
- HACK
|
|
||||||
|
|
||||||
dogsled:
|
|
||||||
max-blank-identifiers: 2
|
|
||||||
|
|
||||||
whitespace:
|
|
||||||
multi-if: false
|
|
||||||
multi-func: false
|
|
||||||
|
|
||||||
linters:
|
|
||||||
disable-all: true
|
|
||||||
enable:
|
enable:
|
||||||
- bodyclose
|
- gofmt
|
||||||
- dogsled
|
- goimports
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: all
|
||||||
|
|
||||||
|
disable:
|
||||||
|
- depguard # We don't have any packages we need to block
|
||||||
|
- exhaustruct # Too pedantic and impractical. Explicitly setting all struct values goes against Go's zero-value philosophy
|
||||||
|
- forbidigo # Not suitable for CLI apps where printing to stdout is fine
|
||||||
|
- gochecknoglobals # It's too strict and doesn't distinguish between "bad" globals (mutable shared state) and "good" globals (immutable configuration)
|
||||||
|
- godox # TODO: enable it and handle all the remaning TODOs
|
||||||
|
- mnd # Impractical. We deal with numbers like file permissions here, it's much clearer to see them explicitly.
|
||||||
|
- noinlineerr # Impractical. Inline error handling is a common and idiomatic practice
|
||||||
|
- paralleltest # Tests are fast already and paralellizing them adds complexity
|
||||||
|
- testpackage # TODO: renable it and refactor tests into separate packages
|
||||||
|
- unparam # Impractical, it flags functions that are designed to be general-purpose, but happen to only be used with specific values currently
|
||||||
|
- wsl # We use wsl_v5 instead
|
||||||
|
- wrapcheck # Adds too much bloat, many of the errors are contextual enough and don't need wrapping
|
||||||
|
|
||||||
|
settings:
|
||||||
|
goconst:
|
||||||
|
ignore-string-values:
|
||||||
|
- "get"
|
||||||
|
- "list"
|
||||||
|
lll:
|
||||||
|
line-length: 180
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
# Typical presets to exclude: https://golangci-lint.run/docs/linters/false-positives/#exclusion-presets
|
||||||
|
presets:
|
||||||
|
- comments
|
||||||
|
- common-false-positives
|
||||||
|
- legacy
|
||||||
|
- std-error-handling
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- path: _test.go
|
||||||
|
linters:
|
||||||
|
- dupl # We don't mind duplicated code in tests. It helps with clarity
|
||||||
|
- varnamelen # We don't mind short var names in tests.
|
||||||
|
- revive # Complains too much about unused-params, but they help with tests readibility
|
||||||
|
- funlen # We don't mind long functions in tests
|
||||||
|
- path: test/
|
||||||
|
linters:
|
||||||
- dupl
|
- dupl
|
||||||
- errcheck
|
- varnamelen
|
||||||
- funlen
|
|
||||||
- gochecknoinits
|
|
||||||
- goconst
|
|
||||||
- gocritic
|
|
||||||
- gocyclo
|
|
||||||
- godox
|
|
||||||
- goprintffuncname
|
|
||||||
- gosec
|
|
||||||
- govet
|
|
||||||
- ineffassign
|
|
||||||
- lll
|
|
||||||
- misspell
|
|
||||||
- nakedret
|
|
||||||
- noctx
|
|
||||||
- nolintlint
|
|
||||||
- prealloc
|
|
||||||
- revive
|
|
||||||
- staticcheck
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
- unused
|
|
||||||
- whitespace
|
|
||||||
|
|
||||||
issues:
|
|
||||||
exclude-rules:
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- funlen
|
|
||||||
- goconst
|
|
||||||
- lll
|
|
||||||
|
|
||||||
- path: pkg/git/test/
|
|
||||||
linters:
|
|
||||||
- goconst
|
|
||||||
|
|
||||||
exclude-use-default: false
|
|
||||||
max-issues-per-linter: 0
|
|
||||||
max-same-issues: 0
|
|
||||||
|
|
||||||
output:
|
|
||||||
format: colored-line-number
|
|
||||||
print-issued-lines: true
|
|
||||||
print-linter-name: true
|
|
||||||
78
README.md
78
README.md
@@ -296,40 +296,6 @@ git list --fetch
|
|||||||
git list --out dump > backup-$(date +%Y%m%d).txt
|
git list --out dump > backup-$(date +%Y%m%d).txt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the test suite:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
# Run tests with coverage
|
|
||||||
go test -race -coverprofile=coverage.out ./...
|
|
||||||
go tool cover -html=coverage.out
|
|
||||||
|
|
||||||
# Run specific package tests
|
|
||||||
go test -v ./pkg/git
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linting
|
|
||||||
|
|
||||||
This project uses comprehensive linting with golangci-lint. The linting configuration includes 25+ linters for code quality, security, and style checking.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install golangci-lint (if not already installed)
|
|
||||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
|
||||||
|
|
||||||
# Run linting with the project's configuration
|
|
||||||
golangci-lint run
|
|
||||||
|
|
||||||
# Run with verbose output
|
|
||||||
golangci-lint run -v
|
|
||||||
|
|
||||||
# Fix auto-fixable issues
|
|
||||||
golangci-lint run --fix
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
@@ -373,19 +339,51 @@ git get user/repo
|
|||||||
|
|
||||||
We welcome contributions!
|
We welcome contributions!
|
||||||
|
|
||||||
### Quick Start for Contributors
|
### Quick Start
|
||||||
|
|
||||||
1. **Fork the repository**
|
1. **Fork the repository**
|
||||||
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
|
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
|
||||||
3. **Install dependencies**: `go mod download`
|
3. **Install dependencies**: `go mod download`
|
||||||
4. **Make changes and add tests**
|
4. **Make changes and add tests**
|
||||||
5. **Format**: `go fmt ./...`
|
5. **Format**: `go fmt ./...`
|
||||||
6. **Run tests**: `go test ./...`
|
6. **Build**: `go build -o git-get ./cmd/`
|
||||||
7. **Run linter**: `golangci-lint run`
|
7. **Run tests**: `go test ./...`
|
||||||
8. **Commit changes**: `git commit -m 'Add amazing feature'`
|
8. **Run linter**: `golangci-lint run`
|
||||||
9. **Push to branch**: `git push origin feature/amazing-feature`
|
9. **Commit changes**: `git commit -m 'Add amazing feature'`
|
||||||
10. **Open a Pull Request**
|
10. **Push to branch**: `git push origin feature/amazing-feature`
|
||||||
|
11. **Open a Pull Request**
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Run the test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
go test -race -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
|
||||||
|
# Run specific package tests
|
||||||
|
go test -v ./pkg/git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install golangci-lint (if not already installed)
|
||||||
|
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
|
||||||
|
# Run linting with the project's configuration
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
golangci-lint run -v
|
||||||
|
|
||||||
|
# Fix auto-fixable issues
|
||||||
|
golangci-lint run --fix
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
13
cmd/get.go
13
cmd/get.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"git-get/pkg"
|
"git-get/pkg"
|
||||||
"git-get/pkg/cfg"
|
"git-get/pkg/cfg"
|
||||||
"git-get/pkg/git"
|
"git-get/pkg/git"
|
||||||
@@ -35,17 +36,14 @@ func newGetCommand() *cobra.Command {
|
|||||||
cmd.PersistentFlags().BoolP("help", "h", false, "Print this help and exit.")
|
cmd.PersistentFlags().BoolP("help", "h", false, "Print this help and exit.")
|
||||||
cmd.PersistentFlags().BoolP("version", "v", false, "Print version and exit.")
|
cmd.PersistentFlags().BoolP("version", "v", false, "Print version and exit.")
|
||||||
|
|
||||||
viper.BindPFlag(cfg.KeyBranch, cmd.PersistentFlags().Lookup(cfg.KeyBranch))
|
if err := viper.BindPFlags(cmd.PersistentFlags()); err != nil {
|
||||||
viper.BindPFlag(cfg.KeyDefaultHost, cmd.PersistentFlags().Lookup(cfg.KeyDefaultHost))
|
panic(fmt.Sprintf("failed to bind flags: %v", err))
|
||||||
viper.BindPFlag(cfg.KeyDefaultScheme, cmd.PersistentFlags().Lookup(cfg.KeyDefaultScheme))
|
}
|
||||||
viper.BindPFlag(cfg.KeyDump, cmd.PersistentFlags().Lookup(cfg.KeyDump))
|
|
||||||
viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot))
|
|
||||||
viper.BindPFlag(cfg.KeySkipHost, cmd.PersistentFlags().Lookup(cfg.KeySkipHost))
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runGetCommand(cmd *cobra.Command, args []string) error {
|
func runGetCommand(_ *cobra.Command, args []string) error {
|
||||||
var url string
|
var url string
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
url = args[0]
|
url = args[0]
|
||||||
@@ -62,6 +60,7 @@ func runGetCommand(cmd *cobra.Command, args []string) error {
|
|||||||
Root: viper.GetString(cfg.KeyReposRoot),
|
Root: viper.GetString(cfg.KeyReposRoot),
|
||||||
URL: url,
|
URL: url,
|
||||||
}
|
}
|
||||||
|
|
||||||
return pkg.Get(config)
|
return pkg.Get(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ func newListCommand() *cobra.Command {
|
|||||||
cmd.PersistentFlags().BoolP("help", "h", false, "Print this help and exit.")
|
cmd.PersistentFlags().BoolP("help", "h", false, "Print this help and exit.")
|
||||||
cmd.PersistentFlags().BoolP("version", "v", false, "Print version and exit.")
|
cmd.PersistentFlags().BoolP("version", "v", false, "Print version and exit.")
|
||||||
|
|
||||||
viper.BindPFlag(cfg.KeyFetch, cmd.PersistentFlags().Lookup(cfg.KeyFetch))
|
if err := viper.BindPFlags(cmd.PersistentFlags()); err != nil {
|
||||||
viper.BindPFlag(cfg.KeyOutput, cmd.PersistentFlags().Lookup(cfg.KeyOutput))
|
panic(fmt.Sprintf("failed to bind flags: %v", err))
|
||||||
viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot))
|
}
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runListCommand(cmd *cobra.Command, args []string) error {
|
func runListCommand(_ *cobra.Command, _ []string) error {
|
||||||
cfg.Expand(cfg.KeyReposRoot)
|
cfg.Expand(cfg.KeyReposRoot)
|
||||||
|
|
||||||
config := &pkg.ListCfg{
|
config := &pkg.ListCfg{
|
||||||
|
|||||||
67
cmd/main.go
67
cmd/main.go
@@ -1,3 +1,7 @@
|
|||||||
|
// This program behaves as a git subcommand (see https://git.github.io/htmldocs/howto/new-command.html)
|
||||||
|
// When added to PATH, git recognizes it as its subcommand and it can be invoked as "git get..." or "git list..."
|
||||||
|
// It can also be invoked as a regular binary with subcommands: "git-get get..." or "git-get list"
|
||||||
|
// The following flow detects the invokation method and runs the appropriate command.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,55 +11,50 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// This program behaves as a git subcommand (see https://git.github.io/htmldocs/howto/new-command.html)
|
command, args := determineCommand()
|
||||||
// When added to PATH, git recognizes it as its subcommand and it can be invoked as "git get..." or "git list..."
|
executeCommand(command, args)
|
||||||
// It can also be invoked as a regular binary with subcommands: "git-get get..." or "git-get list"
|
}
|
||||||
// The following flow detects the invokation method and runs the appropriate command.
|
|
||||||
|
|
||||||
programName := filepath.Base(os.Args[0])
|
func determineCommand() (string, []string) {
|
||||||
|
programName := strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe")
|
||||||
// Remove common executable extensions
|
|
||||||
programName = strings.TrimSuffix(programName, ".exe")
|
|
||||||
|
|
||||||
// Determine which command to run based on program name or first argument
|
|
||||||
var command string
|
|
||||||
var args []string
|
|
||||||
|
|
||||||
switch programName {
|
switch programName {
|
||||||
case "git-get":
|
case "git-get":
|
||||||
// Check if first argument is a subcommand
|
return handleGitGetInvocation()
|
||||||
if len(os.Args) > 1 && (os.Args[1] == "get" || os.Args[1] == "list") {
|
|
||||||
// Called as: git-get get <repo> or git-get list
|
|
||||||
command = os.Args[1]
|
|
||||||
args = os.Args[2:]
|
|
||||||
} else {
|
|
||||||
// Called as: git-get <repo> (default to get command)
|
|
||||||
command = "get"
|
|
||||||
args = os.Args[1:]
|
|
||||||
}
|
|
||||||
case "git-list":
|
case "git-list":
|
||||||
// Called as: git-list (symlinked binary)
|
return handleGitListInvocation()
|
||||||
command = "list"
|
|
||||||
args = os.Args[1:]
|
|
||||||
default:
|
default:
|
||||||
// Fallback: use first argument as command
|
return handleDefaultInvocation()
|
||||||
if len(os.Args) > 1 {
|
|
||||||
command = os.Args[1]
|
|
||||||
args = os.Args[2:]
|
|
||||||
} else {
|
|
||||||
command = "get"
|
|
||||||
args = []string{}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the appropriate command
|
func handleGitGetInvocation() (string, []string) {
|
||||||
|
if len(os.Args) > 1 && (os.Args[1] == "get" || os.Args[1] == "list") {
|
||||||
|
return os.Args[1], os.Args[2:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "get", os.Args[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGitListInvocation() (string, []string) {
|
||||||
|
return "list", os.Args[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDefaultInvocation() (string, []string) {
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
return os.Args[1], os.Args[2:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "get", []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCommand(command string, args []string) {
|
||||||
switch command {
|
switch command {
|
||||||
case "get":
|
case "get":
|
||||||
runGet(args)
|
runGet(args)
|
||||||
case "list":
|
case "list":
|
||||||
runList(args)
|
runList(args)
|
||||||
default:
|
default:
|
||||||
// Default to get command for unknown commands
|
|
||||||
runGet(os.Args[1:])
|
runGet(os.Args[1:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
259
cmd/main_test.go
Normal file
259
cmd/main_test.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetermineCommand(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
programName string
|
||||||
|
args []string
|
||||||
|
wantCmd string
|
||||||
|
wantArgs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "git-get with no args",
|
||||||
|
programName: "git-get",
|
||||||
|
args: []string{"git-get"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git-get with repo arg",
|
||||||
|
programName: "git-get",
|
||||||
|
args: []string{"git-get", "user/repo"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{"user/repo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git-get with get subcommand",
|
||||||
|
programName: "git-get",
|
||||||
|
args: []string{"git-get", "get", "user/repo"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{"user/repo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git-get with list subcommand",
|
||||||
|
programName: "git-get",
|
||||||
|
args: []string{"git-get", "list"},
|
||||||
|
wantCmd: "list",
|
||||||
|
wantArgs: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git-list with no args",
|
||||||
|
programName: "git-list",
|
||||||
|
args: []string{"git-list"},
|
||||||
|
wantCmd: "list",
|
||||||
|
wantArgs: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git-list with args",
|
||||||
|
programName: "git-list",
|
||||||
|
args: []string{"git-list", "--fetch"},
|
||||||
|
wantCmd: "list",
|
||||||
|
wantArgs: []string{"--fetch"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git-get.exe on Windows",
|
||||||
|
programName: "git-get.exe",
|
||||||
|
args: []string{"git-get.exe", "user/repo"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{"user/repo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown program name with args",
|
||||||
|
programName: "some-other-name",
|
||||||
|
args: []string{"some-other-name", "get", "user/repo"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{"user/repo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown program name with no args",
|
||||||
|
programName: "some-other-name",
|
||||||
|
args: []string{"some-other-name"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Save original os.Args
|
||||||
|
oldArgs := os.Args
|
||||||
|
|
||||||
|
defer func() { os.Args = oldArgs }()
|
||||||
|
|
||||||
|
// Set test args
|
||||||
|
os.Args = tt.args
|
||||||
|
|
||||||
|
gotCmd, gotArgs := determineCommand()
|
||||||
|
|
||||||
|
if gotCmd != tt.wantCmd {
|
||||||
|
t.Errorf("determineCommand() command = %v, want %v", gotCmd, tt.wantCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotArgs, tt.wantArgs) {
|
||||||
|
t.Errorf("determineCommand() args = %v, want %v", gotArgs, tt.wantArgs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGitGetInvocation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantCmd string
|
||||||
|
wantArgs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no args",
|
||||||
|
args: []string{"git-get"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with repo arg",
|
||||||
|
args: []string{"git-get", "user/repo"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{"user/repo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with get subcommand",
|
||||||
|
args: []string{"git-get", "get", "user/repo"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{"user/repo"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with list subcommand",
|
||||||
|
args: []string{"git-get", "list", "--fetch"},
|
||||||
|
wantCmd: "list",
|
||||||
|
wantArgs: []string{"--fetch"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with invalid subcommand",
|
||||||
|
args: []string{"git-get", "invalid", "user/repo"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{"invalid", "user/repo"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Save original os.Args
|
||||||
|
oldArgs := os.Args
|
||||||
|
|
||||||
|
defer func() { os.Args = oldArgs }()
|
||||||
|
|
||||||
|
// Set test args
|
||||||
|
os.Args = tt.args
|
||||||
|
|
||||||
|
gotCmd, gotArgs := handleGitGetInvocation()
|
||||||
|
|
||||||
|
if gotCmd != tt.wantCmd {
|
||||||
|
t.Errorf("handleGitGetInvocation() command = %v, want %v", gotCmd, tt.wantCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotArgs, tt.wantArgs) {
|
||||||
|
t.Errorf("handleGitGetInvocation() args = %v, want %v", gotArgs, tt.wantArgs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGitListInvocation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantCmd string
|
||||||
|
wantArgs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no args",
|
||||||
|
args: []string{"git-list"},
|
||||||
|
wantCmd: "list",
|
||||||
|
wantArgs: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with flags",
|
||||||
|
args: []string{"git-list", "--fetch", "--out", "flat"},
|
||||||
|
wantCmd: "list",
|
||||||
|
wantArgs: []string{"--fetch", "--out", "flat"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Save original os.Args
|
||||||
|
oldArgs := os.Args
|
||||||
|
|
||||||
|
defer func() { os.Args = oldArgs }()
|
||||||
|
|
||||||
|
// Set test args
|
||||||
|
os.Args = tt.args
|
||||||
|
|
||||||
|
gotCmd, gotArgs := handleGitListInvocation()
|
||||||
|
|
||||||
|
if gotCmd != tt.wantCmd {
|
||||||
|
t.Errorf("handleGitListInvocation() command = %v, want %v", gotCmd, tt.wantCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotArgs, tt.wantArgs) {
|
||||||
|
t.Errorf("handleGitListInvocation() args = %v, want %v", gotArgs, tt.wantArgs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDefaultInvocation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantCmd string
|
||||||
|
wantArgs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no args",
|
||||||
|
args: []string{"some-program"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with command arg",
|
||||||
|
args: []string{"some-program", "list"},
|
||||||
|
wantCmd: "list",
|
||||||
|
wantArgs: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with command and args",
|
||||||
|
args: []string{"some-program", "get", "user/repo", "--branch", "main"},
|
||||||
|
wantCmd: "get",
|
||||||
|
wantArgs: []string{"user/repo", "--branch", "main"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Save original os.Args
|
||||||
|
oldArgs := os.Args
|
||||||
|
|
||||||
|
defer func() { os.Args = oldArgs }()
|
||||||
|
|
||||||
|
// Set test args
|
||||||
|
os.Args = tt.args
|
||||||
|
|
||||||
|
gotCmd, gotArgs := handleDefaultInvocation()
|
||||||
|
|
||||||
|
if gotCmd != tt.wantCmd {
|
||||||
|
t.Errorf("handleDefaultInvocation() command = %v, want %v", gotCmd, tt.wantCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(gotArgs, tt.wantArgs) {
|
||||||
|
t.Errorf("handleDefaultInvocation() args = %v, want %v", gotArgs, tt.wantArgs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,10 +62,10 @@ func Version() string {
|
|||||||
return fmt.Sprintf("git-get %s (%s)", version, commit[:7])
|
return fmt.Sprintf("git-get %s (%s)", version, commit[:7])
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("git-get %s", version)
|
return "git-get " + version
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gitconfig represents gitconfig file
|
// Gitconfig represents gitconfig file.
|
||||||
type Gitconfig interface {
|
type Gitconfig interface {
|
||||||
Get(key string) string
|
Get(key string) string
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,11 @@ func readGitconfig(cfg Gitconfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viper.SetConfigType("env")
|
viper.SetConfigType("env")
|
||||||
viper.ReadConfig(bytes.NewBuffer([]byte(strings.Join(lines, "\n"))))
|
|
||||||
|
if err := viper.ReadConfig(bytes.NewBufferString(strings.Join(lines, "\n"))); err != nil {
|
||||||
|
// Log error but don't fail - configuration is optional
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to read git config: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: A hacky way to read boolean flag from gitconfig. Find a cleaner way.
|
// TODO: A hacky way to read boolean flag from gitconfig. Find a cleaner way.
|
||||||
if val := cfg.Get(fmt.Sprintf("%s.%s", GitgetPrefix, KeySkipHost)); strings.ToLower(val) == "true" {
|
if val := cfg.Get(fmt.Sprintf("%s.%s", GitgetPrefix, KeySkipHost)); strings.ToLower(val) == "true" {
|
||||||
|
|||||||
@@ -86,32 +86,42 @@ func (c *gitconfigValid) Get(key string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testConfigEmpty(t *testing.T) {
|
func testConfigEmpty(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
Init(&gitconfigEmpty{})
|
Init(&gitconfigEmpty{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func testConfigOnlyInGitconfig(t *testing.T) {
|
func testConfigOnlyInGitconfig(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
Init(&gitconfigValid{})
|
Init(&gitconfigValid{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func testConfigOnlyInEnvVar(t *testing.T) {
|
func testConfigOnlyInEnvVar(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
Init(&gitconfigEmpty{})
|
Init(&gitconfigEmpty{})
|
||||||
os.Setenv(envVarName, fromEnv)
|
t.Setenv(envVarName, fromEnv)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testConfigInGitconfigAndEnvVar(t *testing.T) {
|
func testConfigInGitconfigAndEnvVar(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
Init(&gitconfigValid{})
|
Init(&gitconfigValid{})
|
||||||
os.Setenv(envVarName, fromEnv)
|
t.Setenv(envVarName, fromEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testConfigInFlag(t *testing.T) {
|
func testConfigInFlag(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
Init(&gitconfigValid{})
|
Init(&gitconfigValid{})
|
||||||
os.Setenv(envVarName, fromEnv)
|
t.Setenv(envVarName, fromEnv)
|
||||||
|
|
||||||
cmd := cobra.Command{}
|
cmd := cobra.Command{}
|
||||||
cmd.PersistentFlags().String(KeyDefaultHost, Defaults[KeyDefaultHost], "")
|
cmd.PersistentFlags().String(KeyDefaultHost, Defaults[KeyDefaultHost], "")
|
||||||
viper.BindPFlag(KeyDefaultHost, cmd.PersistentFlags().Lookup(KeyDefaultHost))
|
|
||||||
|
if err := viper.BindPFlag(KeyDefaultHost, cmd.PersistentFlags().Lookup(KeyDefaultHost)); err != nil {
|
||||||
|
t.Fatalf("failed to bind flag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
cmd.SetArgs([]string{"--" + KeyDefaultHost, fromFlag})
|
cmd.SetArgs([]string{"--" + KeyDefaultHost, fromFlag})
|
||||||
cmd.Execute()
|
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("failed to execute command: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
pkg/dump.go
10
pkg/dump.go
@@ -28,10 +28,14 @@ func parseDumpFile(path string) ([]parsedLine, error) {
|
|||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
scanner := bufio.NewScanner(file)
|
||||||
|
|
||||||
var parsedLines []parsedLine
|
var (
|
||||||
var line int
|
parsedLines []parsedLine
|
||||||
|
line int
|
||||||
|
)
|
||||||
|
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line++
|
line++
|
||||||
|
|
||||||
parsed, err := parseLine(scanner.Text())
|
parsed, err := parseLine(scanner.Text())
|
||||||
if err != nil && !errors.Is(errEmptyLine, err) {
|
if err != nil && !errors.Is(errEmptyLine, err) {
|
||||||
return nil, fmt.Errorf("failed parsing dump file line %d: %w", line, err)
|
return nil, fmt.Errorf("failed parsing dump file line %d: %w", line, err)
|
||||||
@@ -44,7 +48,7 @@ func parseDumpFile(path string) ([]parsedLine, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseLine splits a dump file line into space-separated segments.
|
// parseLine splits a dump file line into space-separated segments.
|
||||||
// First part is the URL to clone. Second, optional, is the branch (or tag) to checkout after cloning
|
// First part is the URL to clone. Second, optional, is the branch (or tag) to checkout after cloning.
|
||||||
func parseLine(line string) (parsedLine, error) {
|
func parseLine(line string) (parsedLine, error) {
|
||||||
var parsed parsedLine
|
var parsed parsedLine
|
||||||
|
|
||||||
|
|||||||
36
pkg/get.go
36
pkg/get.go
@@ -1,11 +1,14 @@
|
|||||||
package pkg
|
package pkg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git-get/pkg/git"
|
"git-get/pkg/git"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrMissingRepoArg = errors.New("missing <REPO> argument or --dump flag")
|
||||||
|
|
||||||
// GetCfg provides configuration for the Get command.
|
// GetCfg provides configuration for the Get command.
|
||||||
type GetCfg struct {
|
type GetCfg struct {
|
||||||
Branch string
|
Branch string
|
||||||
@@ -18,31 +21,32 @@ type GetCfg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get executes the "git get" command.
|
// Get executes the "git get" command.
|
||||||
func Get(c *GetCfg) error {
|
func Get(conf *GetCfg) error {
|
||||||
if c.URL == "" && c.Dump == "" {
|
if conf.URL == "" && conf.Dump == "" {
|
||||||
return fmt.Errorf("missing <REPO> argument or --dump flag")
|
return ErrMissingRepoArg
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.URL != "" {
|
if conf.URL != "" {
|
||||||
return cloneSingleRepo(c)
|
return cloneSingleRepo(conf)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Dump != "" {
|
if conf.Dump != "" {
|
||||||
return cloneDumpFile(c)
|
return cloneDumpFile(conf)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneSingleRepo(c *GetCfg) error {
|
func cloneSingleRepo(conf *GetCfg) error {
|
||||||
url, err := ParseURL(c.URL, c.DefHost, c.DefScheme)
|
url, err := ParseURL(conf.URL, conf.DefHost, conf.DefScheme)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := &git.CloneOpts{
|
opts := &git.CloneOpts{
|
||||||
URL: url,
|
URL: url,
|
||||||
Path: filepath.Join(c.Root, URLToPath(*url, c.SkipHost)),
|
Path: filepath.Join(conf.Root, URLToPath(*url, conf.SkipHost)),
|
||||||
Branch: c.Branch,
|
Branch: conf.Branch,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = git.Clone(opts)
|
_, err = git.Clone(opts)
|
||||||
@@ -50,21 +54,21 @@ func cloneSingleRepo(c *GetCfg) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneDumpFile(c *GetCfg) error {
|
func cloneDumpFile(conf *GetCfg) error {
|
||||||
parsedLines, err := parseDumpFile(c.Dump)
|
parsedLines, err := parseDumpFile(conf.Dump)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, line := range parsedLines {
|
for _, line := range parsedLines {
|
||||||
url, err := ParseURL(line.rawurl, c.DefHost, c.DefScheme)
|
url, err := ParseURL(line.rawurl, conf.DefHost, conf.DefScheme)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := &git.CloneOpts{
|
opts := &git.CloneOpts{
|
||||||
URL: url,
|
URL: url,
|
||||||
Path: filepath.Join(c.Root, URLToPath(*url, c.SkipHost)),
|
Path: filepath.Join(conf.Root, URLToPath(*url, conf.SkipHost)),
|
||||||
Branch: line.branch,
|
Branch: line.branch,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +78,12 @@ func cloneDumpFile(c *GetCfg) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Cloning %s...\n", opts.URL.String())
|
fmt.Printf("Cloning %s...\n", opts.URL.String())
|
||||||
|
|
||||||
_, err = git.Clone(opts)
|
_, err = git.Clone(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package git implements functionalities to read and manipulate git repositories
|
||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -65,12 +65,16 @@ func TestGitConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeConfigEmpty(t *testing.T) *cfgStub {
|
func makeConfigEmpty(t *testing.T) *cfgStub {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
return &cfgStub{
|
return &cfgStub{
|
||||||
Repo: test.RepoWithEmptyConfig(t),
|
Repo: test.RepoWithEmptyConfig(t),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeConfigValid(t *testing.T) *cfgStub {
|
func makeConfigValid(t *testing.T) *cfgStub {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
return &cfgStub{
|
return &cfgStub{
|
||||||
Repo: test.RepoWithValidConfig(t),
|
Repo: test.RepoWithValidConfig(t),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,23 +13,25 @@ import (
|
|||||||
// Max number of concurrently running status loading workers.
|
// Max number of concurrently running status loading workers.
|
||||||
const maxWorkers = 100
|
const maxWorkers = 100
|
||||||
|
|
||||||
var errDirNoAccess = fmt.Errorf("directory can't be accessed")
|
var (
|
||||||
var errDirNotExist = fmt.Errorf("directory doesn't exist")
|
ErrDirNoAccess = errors.New("directory can't be accessed")
|
||||||
|
ErrDirNotExist = errors.New("directory doesn't exist")
|
||||||
|
ErrNoReposFound = errors.New("no git repositories found")
|
||||||
|
)
|
||||||
|
|
||||||
// Exists returns true if a directory exists. If it doesn't or the directory can't be accessed it returns an error.
|
// Exists returns true if a directory exists. If it doesn't or the directory can't be accessed it returns an error.
|
||||||
func Exists(path string) (bool, error) {
|
func Exists(path string) (bool, error) {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return false, fmt.Errorf("can't access %s: %w", path, errDirNotExist)
|
return false, fmt.Errorf("can't access %s: %w", path, ErrDirNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directory exists but can't be accessed
|
// Directory exists but can't be accessed
|
||||||
return true, fmt.Errorf("can't access %s: %w", path, errDirNoAccess)
|
return true, fmt.Errorf("can't access %s: %w", path, ErrDirNoAccess)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepoFinder finds git repositories inside a given path and loads their status.
|
// RepoFinder finds git repositories inside a given path and loads their status.
|
||||||
@@ -54,25 +57,27 @@ func (f *RepoFinder) Find() error {
|
|||||||
return fmt.Errorf("failed to access root path: %w", err)
|
return fmt.Errorf("failed to access root path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := filepath.WalkDir(f.root, func(path string, d fs.DirEntry, err error) error {
|
err := filepath.WalkDir(f.root, func(path string, dir fs.DirEntry, err error) error {
|
||||||
// Handle walk errors
|
// Handle walk errors
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Skip permission errors but continue walking
|
// Skip permission errors but continue walking
|
||||||
if os.IsPermission(err) {
|
if os.IsPermission(err) {
|
||||||
return nil // Skip this path but continue
|
return nil // Skip this path but continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("failed to walk %s: %w", path, err)
|
return fmt.Errorf("failed to walk %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process directories
|
// Only process directories
|
||||||
if !d.IsDir() {
|
if !dir.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 1: We're looking at a .git directory itself
|
// Case 1: We're looking at a .git directory itself
|
||||||
if d.Name() == dotgit {
|
if dir.Name() == dotgit {
|
||||||
parentPath := filepath.Dir(path)
|
parentPath := filepath.Dir(path)
|
||||||
f.addIfOk(parentPath)
|
f.addIfOk(parentPath)
|
||||||
|
|
||||||
return fs.SkipDir // Skip the .git directory contents
|
return fs.SkipDir // Skip the .git directory contents
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,18 +85,18 @@ func (f *RepoFinder) Find() error {
|
|||||||
gitPath := filepath.Join(path, dotgit)
|
gitPath := filepath.Join(path, dotgit)
|
||||||
if _, err := os.Stat(gitPath); err == nil {
|
if _, err := os.Stat(gitPath); err == nil {
|
||||||
f.addIfOk(path)
|
f.addIfOk(path)
|
||||||
|
|
||||||
return fs.SkipDir // Skip this directory's contents since it's a repo
|
return fs.SkipDir // Skip this directory's contents since it's a repo
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil // Continue walking
|
return nil // Continue walking
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to walk directory tree: %w", err)
|
return fmt.Errorf("failed to walk directory tree: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(f.repos) == 0 {
|
if len(f.repos) == 0 {
|
||||||
return fmt.Errorf("no git repos found in root path %s", f.root)
|
return fmt.Errorf("%w in root path %s", ErrNoReposFound, f.root)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -101,13 +106,13 @@ func (f *RepoFinder) Find() error {
|
|||||||
// If fetch equals true, it first fetches from the remote repo before loading the status.
|
// If fetch equals true, it first fetches from the remote repo before loading the status.
|
||||||
// Each repo is loaded concurrently by a separate worker, with max 100 workers being active at the same time.
|
// Each repo is loaded concurrently by a separate worker, with max 100 workers being active at the same time.
|
||||||
func (f *RepoFinder) LoadAll(fetch bool) []*Status {
|
func (f *RepoFinder) LoadAll(fetch bool) []*Status {
|
||||||
var ss []*Status
|
statuses := []*Status{}
|
||||||
|
|
||||||
reposChan := make(chan *Repo, f.maxWorkers)
|
reposChan := make(chan *Repo, f.maxWorkers)
|
||||||
statusChan := make(chan *Status, f.maxWorkers)
|
statusChan := make(chan *Status, f.maxWorkers)
|
||||||
|
|
||||||
// Fire up workers. They listen on reposChan, load status and send the result to statusChan.
|
// Fire up workers. They listen on reposChan, load status and send the result to statusChan.
|
||||||
for i := 0; i < f.maxWorkers; i++ {
|
for range f.maxWorkers {
|
||||||
go statusWorker(fetch, reposChan, statusChan)
|
go statusWorker(fetch, reposChan, statusChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,18 +123,18 @@ func (f *RepoFinder) LoadAll(fetch bool) []*Status {
|
|||||||
// Read statuses from the statusChan and add then to the result slice.
|
// Read statuses from the statusChan and add then to the result slice.
|
||||||
// Close the channel when all repos are loaded.
|
// Close the channel when all repos are loaded.
|
||||||
for status := range statusChan {
|
for status := range statusChan {
|
||||||
ss = append(ss, status)
|
statuses = append(statuses, status)
|
||||||
if len(ss) == len(f.repos) {
|
if len(statuses) == len(f.repos) {
|
||||||
close(statusChan)
|
close(statusChan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort the status slice by path
|
// Sort the status slice by path
|
||||||
sort.Slice(ss, func(i, j int) bool {
|
sort.Slice(statuses, func(i, j int) bool {
|
||||||
return strings.Compare(ss[i].path, ss[j].path) < 0
|
return strings.Compare(statuses[i].path, statuses[j].path) < 0
|
||||||
})
|
})
|
||||||
|
|
||||||
return ss
|
return statuses
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadRepos(repos []*Repo, reposChan chan<- *Repo) {
|
func loadRepos(repos []*Repo, reposChan chan<- *Repo) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"git-get/pkg/git/test"
|
"git-get/pkg/git/test"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -16,10 +15,6 @@ func TestFinder(t *testing.T) {
|
|||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no repos",
|
|
||||||
reposMaker: makeNoRepos,
|
|
||||||
want: 0,
|
|
||||||
}, {
|
|
||||||
name: "single repos",
|
name: "single repos",
|
||||||
reposMaker: makeSingleRepo,
|
reposMaker: makeSingleRepo,
|
||||||
want: 1,
|
want: 1,
|
||||||
@@ -39,7 +34,11 @@ func TestFinder(t *testing.T) {
|
|||||||
root := test.reposMaker(t)
|
root := test.reposMaker(t)
|
||||||
|
|
||||||
finder := NewRepoFinder(root)
|
finder := NewRepoFinder(root)
|
||||||
finder.Find()
|
|
||||||
|
err := finder.Find()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("finder.Find() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
assert.Len(t, finder.repos, test.want)
|
assert.Len(t, finder.repos, test.want)
|
||||||
})
|
})
|
||||||
@@ -55,7 +54,7 @@ func TestExists(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "dir does not exist",
|
name: "dir does not exist",
|
||||||
path: "/this/directory/does/not/exist",
|
path: "/this/directory/does/not/exist",
|
||||||
want: errDirNotExist,
|
want: ErrDirNotExist,
|
||||||
}, {
|
}, {
|
||||||
name: "dir exists",
|
name: "dir exists",
|
||||||
path: os.TempDir(),
|
path: os.TempDir(),
|
||||||
@@ -67,18 +66,13 @@ func TestExists(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, err := Exists(test.path)
|
_, err := Exists(test.path)
|
||||||
|
|
||||||
assert.True(t, errors.Is(err, test.want))
|
assert.ErrorIs(t, err, test.want)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeNoRepos(t *testing.T) string {
|
|
||||||
root := test.TempDir(t, "")
|
|
||||||
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeSingleRepo(t *testing.T) string {
|
func makeSingleRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
root := test.TempDir(t, "")
|
root := test.TempDir(t, "")
|
||||||
|
|
||||||
test.RepoEmptyInDir(t, root)
|
test.RepoEmptyInDir(t, root)
|
||||||
@@ -87,6 +81,7 @@ func makeSingleRepo(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeNestedRepo(t *testing.T) string {
|
func makeNestedRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
// a repo with single nested repo should still be counted as one beacause finder doesn't traverse inside nested repos
|
// a repo with single nested repo should still be counted as one beacause finder doesn't traverse inside nested repos
|
||||||
root := test.TempDir(t, "")
|
root := test.TempDir(t, "")
|
||||||
|
|
||||||
@@ -97,6 +92,7 @@ func makeNestedRepo(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeMultipleNestedRepos(t *testing.T) string {
|
func makeMultipleNestedRepos(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
root := test.TempDir(t, "")
|
root := test.TempDir(t, "")
|
||||||
|
|
||||||
// create two repos inside root - should be counted as 2
|
// create two repos inside root - should be counted as 2
|
||||||
|
|||||||
@@ -57,16 +57,19 @@ func Clone(opts *CloneOpts) (*Repo, error) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanupFailedClone(opts.Path)
|
cleanupFailedClone(opts.Path)
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
Repo, err := Open(opts.Path)
|
Repo, err := Open(opts.Path)
|
||||||
|
|
||||||
return Repo, err
|
return Repo, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch preforms a git fetch on all remotes
|
// Fetch preforms a git fetch on all remotes.
|
||||||
func (r *Repo) Fetch() error {
|
func (r *Repo) Fetch() error {
|
||||||
err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp()
|
err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp()
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ func (r *Repo) Uncommitted() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
for _, line := range out {
|
for _, line := range out {
|
||||||
// Don't count lines with untracked files and empty lines.
|
// Don't count lines with untracked files and empty lines.
|
||||||
if !strings.HasPrefix(line, untracked) && strings.TrimSpace(line) != "" {
|
if !strings.HasPrefix(line, untracked) && strings.TrimSpace(line) != "" {
|
||||||
@@ -97,6 +101,7 @@ func (r *Repo) Untracked() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
for _, line := range out {
|
for _, line := range out {
|
||||||
if strings.HasPrefix(line, untracked) {
|
if strings.HasPrefix(line, untracked) {
|
||||||
count++
|
count++
|
||||||
@@ -122,6 +127,7 @@ func (r *Repo) CurrentBranch() (string, error) {
|
|||||||
// Fall back to "main" as the modern default
|
// Fall back to "main" as the modern default
|
||||||
return "main", nil
|
return "main", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +155,10 @@ func (r *Repo) Branches() ([]string, error) {
|
|||||||
// Upstream returns the name of an upstream branch if a given branch is tracking one.
|
// Upstream returns the name of an upstream branch if a given branch is tracking one.
|
||||||
// Otherwise it returns an empty string.
|
// Otherwise it returns an empty string.
|
||||||
func (r *Repo) Upstream(branch string) (string, error) {
|
func (r *Repo) Upstream(branch string) (string, error) {
|
||||||
out, err := run.Git("rev-parse", "--abbrev-ref", "--symbolic-full-name", fmt.Sprintf("%s@{upstream}", branch)).OnRepo(r.path).AndCaptureLine()
|
out, err := run.Git("rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}").OnRepo(r.path).AndCaptureLine()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: no upstream will also throw an error.
|
// TODO: no upstream will also throw an error.
|
||||||
|
// lint:ignore nilerr fix when working on TODO
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,14 +173,14 @@ func (r *Repo) AheadBehind(branch string, upstream string) (int, int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rev-list --left-right --count output is separated by a tab
|
// rev-list --left-right --count output is separated by a tab
|
||||||
lr := strings.Split(out, "\t")
|
count := strings.Split(out, "\t")
|
||||||
|
|
||||||
ahead, err := strconv.Atoi(lr[0])
|
ahead, err := strconv.Atoi(count[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
behind, err := strconv.Atoi(lr[1])
|
behind, err := strconv.Atoi(count[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
@@ -190,6 +197,7 @@ func (r *Repo) Remote() (string, error) {
|
|||||||
if strings.Contains(err.Error(), "No remote configured to list refs from") {
|
if strings.Contains(err.Error(), "No remote configured to list refs from") {
|
||||||
return "", nil // Return empty string instead of error for missing remote
|
return "", nil // Return empty string instead of error for missing remote
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"git-get/pkg/git/test"
|
"git-get/pkg/git/test"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -47,8 +47,8 @@ func TestUncommitted(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
r, _ := Open(test.repoMaker(t).Path())
|
r, _ := Open(test.repoMaker(t).Path())
|
||||||
got, err := r.Uncommitted()
|
|
||||||
|
|
||||||
|
got, err := r.Uncommitted()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("got error %q", err)
|
t.Errorf("got error %q", err)
|
||||||
}
|
}
|
||||||
@@ -73,12 +73,12 @@ func TestUntracked(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "single untracked",
|
name: "single untracked",
|
||||||
repoMaker: test.RepoWithUntracked,
|
repoMaker: test.RepoWithUntracked,
|
||||||
want: 0,
|
want: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single tracked ",
|
name: "single tracked ",
|
||||||
repoMaker: test.RepoWithStaged,
|
repoMaker: test.RepoWithStaged,
|
||||||
want: 1,
|
want: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "committed",
|
name: "committed",
|
||||||
@@ -95,8 +95,8 @@ func TestUntracked(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
r, _ := Open(test.repoMaker(t).Path())
|
r, _ := Open(test.repoMaker(t).Path())
|
||||||
got, err := r.Uncommitted()
|
|
||||||
|
|
||||||
|
got, err := r.Untracked()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("got error %q", err)
|
t.Errorf("got error %q", err)
|
||||||
}
|
}
|
||||||
@@ -114,11 +114,6 @@ func TestCurrentBranch(t *testing.T) {
|
|||||||
repoMaker func(*testing.T) *test.Repo
|
repoMaker func(*testing.T) *test.Repo
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
name: "empty repo without commits",
|
|
||||||
repoMaker: test.RepoEmpty,
|
|
||||||
want: "main",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "only main branch",
|
name: "only main branch",
|
||||||
repoMaker: test.RepoWithCommit,
|
repoMaker: test.RepoWithCommit,
|
||||||
@@ -139,8 +134,8 @@ func TestCurrentBranch(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
r, _ := Open(test.repoMaker(t).Path())
|
r, _ := Open(test.repoMaker(t).Path())
|
||||||
got, err := r.CurrentBranch()
|
|
||||||
|
|
||||||
|
got, err := r.CurrentBranch()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("got error %q", err)
|
t.Errorf("got error %q", err)
|
||||||
}
|
}
|
||||||
@@ -182,8 +177,8 @@ func TestBranches(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
r, _ := Open(test.repoMaker(t).Path())
|
r, _ := Open(test.repoMaker(t).Path())
|
||||||
got, err := r.Branches()
|
|
||||||
|
|
||||||
|
got, err := r.Branches()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("got error %q", err)
|
t.Errorf("got error %q", err)
|
||||||
}
|
}
|
||||||
@@ -287,6 +282,7 @@ func TestAheadBehind(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
r, _ := Open(test.repoMaker(t).Path())
|
r, _ := Open(test.repoMaker(t).Path())
|
||||||
|
|
||||||
upstream, err := r.Upstream(test.branch)
|
upstream, err := r.Upstream(test.branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("got error %q", err)
|
t.Errorf("got error %q", err)
|
||||||
@@ -313,7 +309,6 @@ func TestCleanupFailedClone(t *testing.T) {
|
|||||||
// └── x/
|
// └── x/
|
||||||
// └── y/
|
// └── y/
|
||||||
// └── file.txt
|
// └── file.txt
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
path string // path to cleanup
|
path string // path to cleanup
|
||||||
wantGone string // this path should be deleted, if empty - nothing should be deleted
|
wantGone string // this path should be deleted, if empty - nothing should be deleted
|
||||||
@@ -339,7 +334,7 @@ func TestCleanupFailedClone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, test := range tests {
|
for i, test := range tests {
|
||||||
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
root := createTestDirTree(t)
|
root := createTestDirTree(t)
|
||||||
|
|
||||||
path := filepath.Join(root, test.path)
|
path := filepath.Join(root, test.path)
|
||||||
@@ -393,6 +388,7 @@ func TestRemote(t *testing.T) {
|
|||||||
if test.wantErr && err == nil {
|
if test.wantErr && err == nil {
|
||||||
t.Errorf("expected error but got none")
|
t.Errorf("expected error but got none")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !test.wantErr && err != nil {
|
if !test.wantErr && err != nil {
|
||||||
t.Errorf("unexpected error: %q", err)
|
t.Errorf("unexpected error: %q", err)
|
||||||
}
|
}
|
||||||
@@ -410,11 +406,20 @@ func TestRemote(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createTestDirTree(t *testing.T) string {
|
func createTestDirTree(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
root := test.TempDir(t, "")
|
root := test.TempDir(t, "")
|
||||||
err := os.MkdirAll(filepath.Join(root, "a", "b", "c"), os.ModePerm)
|
|
||||||
err = os.MkdirAll(filepath.Join(root, "a", "x", "y"), os.ModePerm)
|
|
||||||
_, err = os.Create(filepath.Join(root, "a", "x", "y", "file.txt"))
|
|
||||||
|
|
||||||
|
err := os.MkdirAll(filepath.Join(root, "a", "b", "c"), os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.MkdirAll(filepath.Join(root, "a", "x", "y"), os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Create(filepath.Join(root, "a", "x", "y", "file.txt"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ func (r *Repo) LoadStatus(fetch bool) *Status {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
status.current, err = r.CurrentBranch()
|
status.current, err = r.CurrentBranch()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status.errors = append(status.errors, err.Error())
|
status.errors = append(status.errors, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
|
|
||||||
status.branches, errs = r.loadBranches()
|
status.branches, errs = r.loadBranches()
|
||||||
for _, err := range errs {
|
for _, err := range errs {
|
||||||
status.errors = append(status.errors, err.Error())
|
status.errors = append(status.errors, err.Error())
|
||||||
@@ -63,12 +65,14 @@ func (r *Repo) loadBranches() (map[string]string, []error) {
|
|||||||
branches, err := r.Branches()
|
branches, err := r.Branches()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors = append(errors, err)
|
errors = append(errors, err)
|
||||||
|
|
||||||
return statuses, errors
|
return statuses, errors
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, branch := range branches {
|
for _, branch := range branches {
|
||||||
status, err := r.loadBranchStatus(branch)
|
status, err := r.loadBranchStatus(branch)
|
||||||
statuses[branch] = status
|
statuses[branch] = status
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors = append(errors, err)
|
errors = append(errors, err)
|
||||||
}
|
}
|
||||||
@@ -100,6 +104,7 @@ func (r *Repo) loadBranchStatus(branch string) (string, error) {
|
|||||||
if ahead != 0 {
|
if ahead != 0 {
|
||||||
res = append(res, fmt.Sprintf("%d ahead", ahead))
|
res = append(res, fmt.Sprintf("%d ahead", ahead))
|
||||||
}
|
}
|
||||||
|
|
||||||
if behind != 0 {
|
if behind != 0 {
|
||||||
res = append(res, fmt.Sprintf("%d behind", behind))
|
res = append(res, fmt.Sprintf("%d behind", behind))
|
||||||
}
|
}
|
||||||
@@ -126,6 +131,7 @@ func (r *Repo) loadWorkTree() (string, error) {
|
|||||||
if uncommitted != 0 {
|
if uncommitted != 0 {
|
||||||
res = append(res, fmt.Sprintf("%d uncommitted", uncommitted))
|
res = append(res, fmt.Sprintf("%d uncommitted", uncommitted))
|
||||||
}
|
}
|
||||||
|
|
||||||
if untracked != 0 {
|
if untracked != 0 {
|
||||||
res = append(res, fmt.Sprintf("%d untracked", untracked))
|
res = append(res, fmt.Sprintf("%d untracked", untracked))
|
||||||
}
|
}
|
||||||
@@ -151,25 +157,26 @@ func (s *Status) Branches() []string {
|
|||||||
branches = append(branches, b)
|
branches = append(branches, b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return branches
|
return branches
|
||||||
}
|
}
|
||||||
|
|
||||||
// BranchStatus returns status of a given branch
|
// BranchStatus returns status of a given branch.
|
||||||
func (s *Status) BranchStatus(branch string) string {
|
func (s *Status) BranchStatus(branch string) string {
|
||||||
return s.branches[branch]
|
return s.branches[branch]
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkTreeStatus returns status of a worktree
|
// WorkTreeStatus returns status of a worktree.
|
||||||
func (s *Status) WorkTreeStatus() string {
|
func (s *Status) WorkTreeStatus() string {
|
||||||
return s.worktree
|
return s.worktree
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote returns URL to remote repository
|
// Remote returns URL to remote repository.
|
||||||
func (s *Status) Remote() string {
|
func (s *Status) Remote() string {
|
||||||
return s.remote
|
return s.remote
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errors is a slice of errors that occurred when loading repo status
|
// Errors is a slice of errors that occurred when loading repo status.
|
||||||
func (s *Status) Errors() []string {
|
func (s *Status) Errors() []string {
|
||||||
return s.errors
|
return s.errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
// Package test contains helper utilities and functions creating pre-configured test repositories for testing purposes
|
||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"git-get/pkg/run"
|
"git-get/pkg/run"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -13,7 +13,11 @@ import (
|
|||||||
// TempDir creates a temporary directory inside the parent dir.
|
// TempDir creates a temporary directory inside the parent dir.
|
||||||
// If parent is empty, it will use a system default temp dir (usually /tmp).
|
// If parent is empty, it will use a system default temp dir (usually /tmp).
|
||||||
func TempDir(t *testing.T, parent string) string {
|
func TempDir(t *testing.T, parent string) string {
|
||||||
dir, err := ioutil.TempDir(parent, "git-get-repo-")
|
t.Helper()
|
||||||
|
|
||||||
|
// t.TempDir() is not enough in this case, we need to be able to create dirs inside the parent dir
|
||||||
|
//nolint:usetesting
|
||||||
|
dir, err := os.MkdirTemp(parent, "git-get-repo-")
|
||||||
checkFatal(t, err)
|
checkFatal(t, err)
|
||||||
|
|
||||||
// Automatically remove temp dir when the test is over.
|
// Automatically remove temp dir when the test is over.
|
||||||
@@ -24,9 +28,30 @@ func TempDir(t *testing.T, parent string) string {
|
|||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncGitIndex forces git to refresh its index and ensures file system operations are flushed.
|
||||||
|
// This helps to prevent race-condition issues when running tests on Windows.
|
||||||
|
func (r *Repo) syncGitIndex() {
|
||||||
|
// Force git to refresh its index - this makes git re-scan the working directory
|
||||||
|
_ = run.Git("update-index", "--refresh").OnRepo(r.path).AndShutUp()
|
||||||
|
// Run status to ensure git has processed any pending changes
|
||||||
|
_ = run.Git("status", "--porcelain").OnRepo(r.path).AndShutUp()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repo) init() {
|
func (r *Repo) init() {
|
||||||
err := run.Git("init", "--quiet", "--initial-branch=main", r.path).AndShutUp()
|
err := run.Git("init", "--quiet", "--initial-branch=main", r.path).AndShutUp()
|
||||||
checkFatal(r.t, err)
|
checkFatal(r.t, err)
|
||||||
|
|
||||||
|
r.setupGitConfig()
|
||||||
|
r.syncGitIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupGitConfig sets up local git config for test repository only.
|
||||||
|
func (r *Repo) setupGitConfig() {
|
||||||
|
err := run.Git("config", "user.name", "Test User").OnRepo(r.path).AndShutUp()
|
||||||
|
checkFatal(r.t, err)
|
||||||
|
|
||||||
|
err = run.Git("config", "user.email", "test@example.com").OnRepo(r.path).AndShutUp()
|
||||||
|
checkFatal(r.t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeFile writes the content string into a file. If file doesn't exists, it will create it.
|
// writeFile writes the content string into a file. If file doesn't exists, it will create it.
|
||||||
@@ -36,33 +61,48 @@ func (r *Repo) writeFile(filename string, content string) {
|
|||||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
checkFatal(r.t, err)
|
checkFatal(r.t, err)
|
||||||
|
|
||||||
_, err = file.Write([]byte(content))
|
_, err = file.WriteString(content)
|
||||||
checkFatal(r.t, err)
|
checkFatal(r.t, err)
|
||||||
|
|
||||||
|
// Ensure data is written to disk before closing
|
||||||
|
err = file.Sync()
|
||||||
|
checkFatal(r.t, err)
|
||||||
|
|
||||||
|
err = file.Close()
|
||||||
|
checkFatal(r.t, err)
|
||||||
|
|
||||||
|
// Force git to recognize the file changes
|
||||||
|
r.syncGitIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) stageFile(path string) {
|
func (r *Repo) stageFile(path string) {
|
||||||
err := run.Git("add", path).OnRepo(r.path).AndShutUp()
|
err := run.Git("add", path).OnRepo(r.path).AndShutUp()
|
||||||
checkFatal(r.t, err)
|
checkFatal(r.t, err)
|
||||||
|
r.syncGitIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) commit(msg string) {
|
func (r *Repo) commit(msg string) {
|
||||||
err := run.Git("commit", "-m", fmt.Sprintf("%q", msg), "--author=\"user <user@example.com>\"").OnRepo(r.path).AndShutUp()
|
err := run.Git("commit", "-m", fmt.Sprintf("%q", msg)).OnRepo(r.path).AndShutUp()
|
||||||
checkFatal(r.t, err)
|
checkFatal(r.t, err)
|
||||||
|
r.syncGitIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) branch(name string) {
|
func (r *Repo) branch(name string) {
|
||||||
err := run.Git("branch", name).OnRepo(r.path).AndShutUp()
|
err := run.Git("branch", name).OnRepo(r.path).AndShutUp()
|
||||||
checkFatal(r.t, err)
|
checkFatal(r.t, err)
|
||||||
|
r.syncGitIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) tag(name string) {
|
func (r *Repo) tag(name string) {
|
||||||
err := run.Git("tag", "-a", name, "-m", name).OnRepo(r.path).AndShutUp()
|
err := run.Git("tag", "-a", name, "-m", name).OnRepo(r.path).AndShutUp()
|
||||||
checkFatal(r.t, err)
|
checkFatal(r.t, err)
|
||||||
|
r.syncGitIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) checkout(name string) {
|
func (r *Repo) checkout(name string) {
|
||||||
err := run.Git("checkout", name).OnRepo(r.path).AndShutUp()
|
err := run.Git("checkout", name).OnRepo(r.path).AndShutUp()
|
||||||
checkFatal(r.t, err)
|
checkFatal(r.t, err)
|
||||||
|
r.syncGitIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) clone() *Repo {
|
func (r *Repo) clone() *Repo {
|
||||||
@@ -77,22 +117,30 @@ func (r *Repo) clone() *Repo {
|
|||||||
t: r.t,
|
t: r.t,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up git config in the cloned repository
|
||||||
|
clone.setupGitConfig()
|
||||||
|
clone.syncGitIndex()
|
||||||
|
|
||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) fetch() {
|
func (r *Repo) fetch() {
|
||||||
err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp()
|
err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp()
|
||||||
checkFatal(r.t, err)
|
checkFatal(r.t, err)
|
||||||
|
r.syncGitIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkFatal(t *testing.T, err error) {
|
func checkFatal(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed making test repo: %+v", err)
|
t.Fatalf("failed making test repo: %+v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeTestDir removes a test directory
|
// removeTestDir removes a test directory.
|
||||||
func removeTestDir(t *testing.T, dir string) {
|
func removeTestDir(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
// Skip cleanup on Windows to avoid file locking issues in CI
|
// Skip cleanup on Windows to avoid file locking issues in CI
|
||||||
// The CI runner environment is destroyed after tests anyway
|
// The CI runner environment is destroyed after tests anyway
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
|
|||||||
@@ -19,22 +19,27 @@ func (r *Repo) Path() string {
|
|||||||
|
|
||||||
// RepoEmpty creates an empty git repo.
|
// RepoEmpty creates an empty git repo.
|
||||||
func RepoEmpty(t *testing.T) *Repo {
|
func RepoEmpty(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
return RepoEmptyInDir(t, "")
|
return RepoEmptyInDir(t, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepoEmptyInDir creates an empty git repo inside a given parent dir.
|
// RepoEmptyInDir creates an empty git repo inside a given parent dir.
|
||||||
func RepoEmptyInDir(t *testing.T, parent string) *Repo {
|
func RepoEmptyInDir(t *testing.T, parent string) *Repo {
|
||||||
|
t.Helper()
|
||||||
r := &Repo{
|
r := &Repo{
|
||||||
path: TempDir(t, parent),
|
path: TempDir(t, parent),
|
||||||
t: t,
|
t: t,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.init()
|
r.init()
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepoWithUntracked creates a git repo with a single untracked file.
|
// RepoWithUntracked creates a git repo with a single untracked file.
|
||||||
func RepoWithUntracked(t *testing.T) *Repo {
|
func RepoWithUntracked(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
r := RepoEmpty(t)
|
r := RepoEmpty(t)
|
||||||
r.writeFile("README.md", "I'm a readme file")
|
r.writeFile("README.md", "I'm a readme file")
|
||||||
|
|
||||||
@@ -43,6 +48,7 @@ func RepoWithUntracked(t *testing.T) *Repo {
|
|||||||
|
|
||||||
// RepoWithStaged creates a git repo with a single staged file.
|
// RepoWithStaged creates a git repo with a single staged file.
|
||||||
func RepoWithStaged(t *testing.T) *Repo {
|
func RepoWithStaged(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
r := RepoEmpty(t)
|
r := RepoEmpty(t)
|
||||||
r.writeFile("README.md", "I'm a readme file")
|
r.writeFile("README.md", "I'm a readme file")
|
||||||
r.stageFile("README.md")
|
r.stageFile("README.md")
|
||||||
@@ -52,6 +58,7 @@ func RepoWithStaged(t *testing.T) *Repo {
|
|||||||
|
|
||||||
// RepoWithCommit creates a git repo with a single commit.
|
// RepoWithCommit creates a git repo with a single commit.
|
||||||
func RepoWithCommit(t *testing.T) *Repo {
|
func RepoWithCommit(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
r := RepoEmpty(t)
|
r := RepoEmpty(t)
|
||||||
r.writeFile("README.md", "I'm a readme file")
|
r.writeFile("README.md", "I'm a readme file")
|
||||||
r.stageFile("README.md")
|
r.stageFile("README.md")
|
||||||
@@ -62,6 +69,7 @@ func RepoWithCommit(t *testing.T) *Repo {
|
|||||||
|
|
||||||
// RepoWithUncommittedAndUntracked creates a git repo with one staged but uncommitted file and one untracked file.
|
// RepoWithUncommittedAndUntracked creates a git repo with one staged but uncommitted file and one untracked file.
|
||||||
func RepoWithUncommittedAndUntracked(t *testing.T) *Repo {
|
func RepoWithUncommittedAndUntracked(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
r := RepoEmpty(t)
|
r := RepoEmpty(t)
|
||||||
r.writeFile("README.md", "I'm a readme file")
|
r.writeFile("README.md", "I'm a readme file")
|
||||||
r.stageFile("README.md")
|
r.stageFile("README.md")
|
||||||
@@ -74,6 +82,7 @@ func RepoWithUncommittedAndUntracked(t *testing.T) *Repo {
|
|||||||
|
|
||||||
// RepoWithBranch creates a git repo with a new branch.
|
// RepoWithBranch creates a git repo with a new branch.
|
||||||
func RepoWithBranch(t *testing.T) *Repo {
|
func RepoWithBranch(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
r := RepoWithCommit(t)
|
r := RepoWithCommit(t)
|
||||||
r.branch("feature/branch")
|
r.branch("feature/branch")
|
||||||
r.checkout("feature/branch")
|
r.checkout("feature/branch")
|
||||||
@@ -83,6 +92,7 @@ func RepoWithBranch(t *testing.T) *Repo {
|
|||||||
|
|
||||||
// RepoWithTag creates a git repo with a new tag.
|
// RepoWithTag creates a git repo with a new tag.
|
||||||
func RepoWithTag(t *testing.T) *Repo {
|
func RepoWithTag(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
r := RepoWithCommit(t)
|
r := RepoWithCommit(t)
|
||||||
r.tag("v0.0.1")
|
r.tag("v0.0.1")
|
||||||
r.checkout("v0.0.1")
|
r.checkout("v0.0.1")
|
||||||
@@ -92,26 +102,31 @@ func RepoWithTag(t *testing.T) *Repo {
|
|||||||
|
|
||||||
// RepoWithBranchWithUpstream creates a git repo by cloning another repo and checking out a remote branch.
|
// RepoWithBranchWithUpstream creates a git repo by cloning another repo and checking out a remote branch.
|
||||||
func RepoWithBranchWithUpstream(t *testing.T) *Repo {
|
func RepoWithBranchWithUpstream(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
origin := RepoWithCommit(t)
|
origin := RepoWithCommit(t)
|
||||||
origin.branch("feature/branch")
|
origin.branch("feature/branch")
|
||||||
|
|
||||||
r := origin.clone()
|
r := origin.clone()
|
||||||
r.checkout("feature/branch")
|
r.checkout("feature/branch")
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepoWithBranchWithoutUpstream creates a git repo by cloning another repo and checking out a new local branch.
|
// RepoWithBranchWithoutUpstream creates a git repo by cloning another repo and checking out a new local branch.
|
||||||
func RepoWithBranchWithoutUpstream(t *testing.T) *Repo {
|
func RepoWithBranchWithoutUpstream(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
origin := RepoWithCommit(t)
|
origin := RepoWithCommit(t)
|
||||||
|
|
||||||
r := origin.clone()
|
r := origin.clone()
|
||||||
r.branch("feature/branch")
|
r.branch("feature/branch")
|
||||||
r.checkout("feature/branch")
|
r.checkout("feature/branch")
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepoWithBranchAhead creates a git repo with a branch being ahead of a remote branch by 1 commit.
|
// RepoWithBranchAhead creates a git repo with a branch being ahead of a remote branch by 1 commit.
|
||||||
func RepoWithBranchAhead(t *testing.T) *Repo {
|
func RepoWithBranchAhead(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
origin := RepoWithCommit(t)
|
origin := RepoWithCommit(t)
|
||||||
origin.branch("feature/branch")
|
origin.branch("feature/branch")
|
||||||
|
|
||||||
@@ -127,6 +142,7 @@ func RepoWithBranchAhead(t *testing.T) *Repo {
|
|||||||
|
|
||||||
// RepoWithBranchBehind creates a git repo with a branch being behind a remote branch by 1 commit.
|
// RepoWithBranchBehind creates a git repo with a branch being behind a remote branch by 1 commit.
|
||||||
func RepoWithBranchBehind(t *testing.T) *Repo {
|
func RepoWithBranchBehind(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
origin := RepoWithCommit(t)
|
origin := RepoWithCommit(t)
|
||||||
origin.branch("feature/branch")
|
origin.branch("feature/branch")
|
||||||
origin.checkout("feature/branch")
|
origin.checkout("feature/branch")
|
||||||
@@ -145,6 +161,7 @@ func RepoWithBranchBehind(t *testing.T) *Repo {
|
|||||||
|
|
||||||
// RepoWithBranchAheadAndBehind creates a git repo with a branch being 2 commits ahead and 1 behind a remote branch.
|
// RepoWithBranchAheadAndBehind creates a git repo with a branch being 2 commits ahead and 1 behind a remote branch.
|
||||||
func RepoWithBranchAheadAndBehind(t *testing.T) *Repo {
|
func RepoWithBranchAheadAndBehind(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
origin := RepoWithCommit(t)
|
origin := RepoWithCommit(t)
|
||||||
origin.branch("feature/branch")
|
origin.branch("feature/branch")
|
||||||
origin.checkout("feature/branch")
|
origin.checkout("feature/branch")
|
||||||
@@ -169,16 +186,18 @@ func RepoWithBranchAheadAndBehind(t *testing.T) *Repo {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepoWithEmptyConfig creates a git repo with empty .git/config file
|
// RepoWithEmptyConfig creates a git repo with empty .git/config file.
|
||||||
func RepoWithEmptyConfig(t *testing.T) *Repo {
|
func RepoWithEmptyConfig(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
r := RepoEmpty(t)
|
r := RepoEmpty(t)
|
||||||
r.writeFile(filepath.Join(".git", "config"), "")
|
r.writeFile(filepath.Join(".git", "config"), "")
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// RepoWithValidConfig creates a git repo with valid content in .git/config file
|
// RepoWithValidConfig creates a git repo with valid content in .git/config file.
|
||||||
func RepoWithValidConfig(t *testing.T) *Repo {
|
func RepoWithValidConfig(t *testing.T) *Repo {
|
||||||
|
t.Helper()
|
||||||
r := RepoEmpty(t)
|
r := RepoEmpty(t)
|
||||||
|
|
||||||
gitconfig := `
|
gitconfig := `
|
||||||
|
|||||||
25
pkg/list.go
25
pkg/list.go
@@ -1,13 +1,16 @@
|
|||||||
package pkg
|
package pkg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git-get/pkg/cfg"
|
"git-get/pkg/cfg"
|
||||||
"git-get/pkg/git"
|
"git-get/pkg/git"
|
||||||
"git-get/pkg/print"
|
"git-get/pkg/out"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrInvalidOutput = errors.New("invalid output format")
|
||||||
|
|
||||||
// ListCfg provides configuration for the List command.
|
// ListCfg provides configuration for the List command.
|
||||||
type ListCfg struct {
|
type ListCfg struct {
|
||||||
Fetch bool
|
Fetch bool
|
||||||
@@ -16,27 +19,29 @@ type ListCfg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List executes the "git list" command.
|
// List executes the "git list" command.
|
||||||
func List(c *ListCfg) error {
|
func List(conf *ListCfg) error {
|
||||||
finder := git.NewRepoFinder(c.Root)
|
finder := git.NewRepoFinder(conf.Root)
|
||||||
if err := finder.Find(); err != nil {
|
if err := finder.Find(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses := finder.LoadAll(c.Fetch)
|
statuses := finder.LoadAll(conf.Fetch)
|
||||||
printables := make([]print.Printable, len(statuses))
|
|
||||||
|
printables := make([]out.Printable, len(statuses))
|
||||||
|
|
||||||
for i := range statuses {
|
for i := range statuses {
|
||||||
printables[i] = statuses[i]
|
printables[i] = statuses[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
switch c.Output {
|
switch conf.Output {
|
||||||
case cfg.OutFlat:
|
case cfg.OutFlat:
|
||||||
fmt.Print(print.NewFlatPrinter().Print(printables))
|
fmt.Print(out.NewFlatPrinter().Print(printables))
|
||||||
case cfg.OutTree:
|
case cfg.OutTree:
|
||||||
fmt.Print(print.NewTreePrinter().Print(c.Root, printables))
|
fmt.Print(out.NewTreePrinter().Print(conf.Root, printables))
|
||||||
case cfg.OutDump:
|
case cfg.OutDump:
|
||||||
fmt.Print(print.NewDumpPrinter().Print(printables))
|
fmt.Print(out.NewDumpPrinter().Print(printables))
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid --out flag; allowed values: [%s]", strings.Join(cfg.AllowedOut, ", "))
|
return fmt.Errorf("%w, allowed values: [%s]", ErrInvalidOutput, strings.Join(cfg.AllowedOut, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package print
|
package out
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package print
|
package out
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -18,18 +18,19 @@ func NewFlatPrinter() *FlatPrinter {
|
|||||||
func (p *FlatPrinter) Print(repos []Printable) string {
|
func (p *FlatPrinter) Print(repos []Printable) string {
|
||||||
var str strings.Builder
|
var str strings.Builder
|
||||||
|
|
||||||
for _, r := range repos {
|
for _, repo := range repos {
|
||||||
str.WriteString(strings.TrimSuffix(r.Path(), string(os.PathSeparator)))
|
str.WriteString(strings.TrimSuffix(repo.Path(), string(os.PathSeparator)))
|
||||||
|
|
||||||
if len(r.Errors()) > 0 {
|
if len(repo.Errors()) > 0 {
|
||||||
str.WriteString(" " + red("error") + "\n")
|
str.WriteString(" " + red("error") + "\n")
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
str.WriteString(" " + blue(r.Current()))
|
str.WriteString(" " + blue(repo.Current()))
|
||||||
|
|
||||||
current := r.BranchStatus(r.Current())
|
current := repo.BranchStatus(repo.Current())
|
||||||
worktree := r.WorkTreeStatus()
|
worktree := repo.WorkTreeStatus()
|
||||||
|
|
||||||
if worktree != "" {
|
if worktree != "" {
|
||||||
worktree = fmt.Sprintf("[ %s ]", worktree)
|
worktree = fmt.Sprintf("[ %s ]", worktree)
|
||||||
@@ -41,13 +42,13 @@ func (p *FlatPrinter) Print(repos []Printable) string {
|
|||||||
str.WriteString(" " + strings.Join([]string{yellow(current), red(worktree)}, " "))
|
str.WriteString(" " + strings.Join([]string{yellow(current), red(worktree)}, " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, branch := range r.Branches() {
|
for _, branch := range repo.Branches() {
|
||||||
status := r.BranchStatus(branch)
|
status := repo.BranchStatus(branch)
|
||||||
if status == "" {
|
if status == "" {
|
||||||
status = green("ok")
|
status = green("ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
indent := strings.Repeat(" ", len(r.Path())-1)
|
indent := strings.Repeat(" ", len(repo.Path())-1)
|
||||||
str.WriteString(fmt.Sprintf("\n%s %s %s", indent, blue(branch), yellow(status)))
|
str.WriteString(fmt.Sprintf("\n%s %s %s", indent, blue(branch), yellow(status)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
package print
|
// Package out implements different outputs for git-list command
|
||||||
|
package out
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,12 +10,12 @@ const (
|
|||||||
head = "HEAD"
|
head = "HEAD"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Printable represents a repository which status can be printed
|
// Printable represents a repository which status can be printed.
|
||||||
type Printable interface {
|
type Printable interface {
|
||||||
Path() string
|
Path() string
|
||||||
Current() string
|
Current() string
|
||||||
Branches() []string
|
Branches() []string
|
||||||
BranchStatus(string) string
|
BranchStatus(branch string) string
|
||||||
WorkTreeStatus() string
|
WorkTreeStatus() string
|
||||||
Remote() string
|
Remote() string
|
||||||
Errors() []string
|
Errors() []string
|
||||||
@@ -26,9 +27,7 @@ func Errors(repos []Printable) string {
|
|||||||
errors := []string{}
|
errors := []string{}
|
||||||
|
|
||||||
for _, repo := range repos {
|
for _, repo := range repos {
|
||||||
for _, err := range repo.Errors() {
|
errors = append(errors, repo.Errors()...)
|
||||||
errors = append(errors, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errors) == 0 {
|
if len(errors) == 0 {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package print
|
package out
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -20,7 +20,7 @@ func NewTreePrinter() *TreePrinter {
|
|||||||
// Print generates a tree view of repos and their statuses.
|
// Print generates a tree view of repos and their statuses.
|
||||||
func (p *TreePrinter) Print(root string, repos []Printable) string {
|
func (p *TreePrinter) Print(root string, repos []Printable) string {
|
||||||
if len(repos) == 0 {
|
if len(repos) == 0 {
|
||||||
return fmt.Sprintf("There are no git repos under %s", root)
|
return "There are no git repos under " + root
|
||||||
}
|
}
|
||||||
|
|
||||||
tree := buildTree(root, repos)
|
tree := buildTree(root, repos)
|
||||||
@@ -46,6 +46,7 @@ func Root(val string) *Node {
|
|||||||
root := &Node{
|
root := &Node{
|
||||||
val: val,
|
val: val,
|
||||||
}
|
}
|
||||||
|
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ func (n *Node) Add(val string) *Node {
|
|||||||
depth: n.depth + 1,
|
depth: n.depth + 1,
|
||||||
}
|
}
|
||||||
n.children = append(n.children, child)
|
n.children = append(n.children, child)
|
||||||
|
|
||||||
return child
|
return child
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +88,8 @@ func (n *Node) GetChild(val string) *Node {
|
|||||||
func buildTree(root string, repos []Printable) *Node {
|
func buildTree(root string, repos []Printable) *Node {
|
||||||
tree := Root(root)
|
tree := Root(root)
|
||||||
|
|
||||||
for _, r := range repos {
|
for _, repo := range repos {
|
||||||
path := strings.TrimPrefix(r.Path(), root)
|
path := strings.TrimPrefix(repo.Path(), root)
|
||||||
path = strings.Trim(path, string(filepath.Separator))
|
path = strings.Trim(path, string(filepath.Separator))
|
||||||
subs := strings.Split(path, string(filepath.Separator))
|
subs := strings.Split(path, string(filepath.Separator))
|
||||||
|
|
||||||
@@ -96,48 +98,50 @@ func buildTree(root string, repos []Printable) *Node {
|
|||||||
// If not, add it to node's children and move to next fragment.
|
// If not, add it to node's children and move to next fragment.
|
||||||
// If it does, just move to the next fragment.
|
// If it does, just move to the next fragment.
|
||||||
node := tree
|
node := tree
|
||||||
for i, sub := range subs {
|
for idx, sub := range subs {
|
||||||
child := node.GetChild(sub)
|
child := node.GetChild(sub)
|
||||||
if child == nil {
|
if child == nil {
|
||||||
node = node.Add(sub)
|
node = node.Add(sub)
|
||||||
|
|
||||||
// If that's the last fragment, it's a tree leaf and needs a *Repo attached.
|
// If that's the last fragment, it's a tree leaf and needs a *Repo attached.
|
||||||
if i == len(subs)-1 {
|
if idx == len(subs)-1 {
|
||||||
node.repo = r
|
node.repo = repo
|
||||||
}
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
node = child
|
node = child
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tree
|
return tree
|
||||||
}
|
}
|
||||||
|
|
||||||
// printTree renders the repo tree by recursively traversing the tree nodes.
|
// printTree renders the repo tree by recursively traversing the tree nodes.
|
||||||
// If a node doesn't have any children, it's a leaf node containing the repo status.
|
// If a node doesn't have any children, it's a leaf node containing the repo status.
|
||||||
func (p *TreePrinter) printTree(node *Node, tp treeprint.Tree) {
|
func (p *TreePrinter) printTree(node *Node, tree treeprint.Tree) {
|
||||||
if node.children == nil {
|
if node.children == nil {
|
||||||
tp.SetValue(printLeaf(node))
|
tree.SetValue(printLeaf(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, child := range node.children {
|
for _, child := range node.children {
|
||||||
branch := tp.AddBranch(child.val)
|
branch := tree.AddBranch(child.val)
|
||||||
p.printTree(child, branch)
|
p.printTree(child, branch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func printLeaf(node *Node) string {
|
func printLeaf(node *Node) string {
|
||||||
r := node.repo
|
repo := node.repo
|
||||||
|
|
||||||
// If any errors happened during status loading, don't print the status but "error" instead.
|
// If any errors happened during status loading, don't print the status but "error" instead.
|
||||||
// Actual error messages are printed in bulk below the tree.
|
// Actual error messages are printed in bulk below the tree.
|
||||||
if len(r.Errors()) > 0 {
|
if len(repo.Errors()) > 0 {
|
||||||
return fmt.Sprintf("%s %s", node.val, red("error"))
|
return fmt.Sprintf("%s %s", node.val, red("error"))
|
||||||
}
|
}
|
||||||
|
|
||||||
current := r.BranchStatus(r.Current())
|
current := repo.BranchStatus(repo.Current())
|
||||||
worktree := r.WorkTreeStatus()
|
worktree := repo.WorkTreeStatus()
|
||||||
|
|
||||||
if worktree != "" {
|
if worktree != "" {
|
||||||
worktree = fmt.Sprintf("[ %s ]", worktree)
|
worktree = fmt.Sprintf("[ %s ]", worktree)
|
||||||
@@ -146,13 +150,13 @@ func printLeaf(node *Node) string {
|
|||||||
var str strings.Builder
|
var str strings.Builder
|
||||||
|
|
||||||
if worktree == "" && current == "" {
|
if worktree == "" && current == "" {
|
||||||
str.WriteString(fmt.Sprintf("%s %s %s", node.val, blue(r.Current()), green("ok")))
|
str.WriteString(fmt.Sprintf("%s %s %s", node.val, blue(repo.Current()), green("ok")))
|
||||||
} else {
|
} else {
|
||||||
str.WriteString(fmt.Sprintf("%s %s %s", node.val, blue(r.Current()), strings.Join([]string{yellow(current), red(worktree)}, " ")))
|
str.WriteString(fmt.Sprintf("%s %s %s", node.val, blue(repo.Current()), strings.Join([]string{yellow(current), red(worktree)}, " ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, branch := range r.Branches() {
|
for _, branch := range repo.Branches() {
|
||||||
status := r.BranchStatus(branch)
|
status := repo.BranchStatus(branch)
|
||||||
if status == "" {
|
if status == "" {
|
||||||
status = green("ok")
|
status = green("ok")
|
||||||
}
|
}
|
||||||
@@ -180,8 +184,11 @@ func indentation(node *Node) string {
|
|||||||
|
|
||||||
var indent strings.Builder
|
var indent strings.Builder
|
||||||
|
|
||||||
const space = " "
|
const (
|
||||||
const link = "│ "
|
space = " "
|
||||||
|
link = "│ "
|
||||||
|
)
|
||||||
|
|
||||||
for _, y := range levels {
|
for _, y := range levels {
|
||||||
if y {
|
if y {
|
||||||
indent.WriteString(space)
|
indent.WriteString(space)
|
||||||
@@ -196,19 +203,23 @@ func indentation(node *Node) string {
|
|||||||
return indent.String()
|
return indent.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// isYoungest checks if the node is the last one in the slice of children
|
// isYoungest checks if the node is the last one in the slice of children.
|
||||||
func (n *Node) isYoungest() bool {
|
func (n *Node) isYoungest() bool {
|
||||||
if n.parent == nil {
|
if n.parent == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
sisters := n.parent.children
|
sisters := n.parent.children
|
||||||
|
|
||||||
var myIndex int
|
var myIndex int
|
||||||
|
|
||||||
for i, sis := range sisters {
|
for i, sis := range sisters {
|
||||||
if sis.val == n.val {
|
if sis.val == n.val {
|
||||||
myIndex = i
|
myIndex = i
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return myIndex == len(sisters)-1
|
return myIndex == len(sisters)-1
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package run
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -32,8 +33,10 @@ type Cmd struct {
|
|||||||
|
|
||||||
// Git creates a git command with given arguments.
|
// Git creates a git command with given arguments.
|
||||||
func Git(args ...string) *Cmd {
|
func Git(args ...string) *Cmd {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
return &Cmd{
|
return &Cmd{
|
||||||
cmd: exec.Command("git", args...),
|
cmd: exec.CommandContext(ctx, "git", args...),
|
||||||
args: strings.Join(args, " "),
|
args: strings.Join(args, " "),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +81,7 @@ func (c *Cmd) AndCaptureLine() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines[0], nil
|
return lines[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +94,7 @@ func (c *Cmd) AndShow() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return &GitError{&bytes.Buffer{}, c.args, c.path, err}
|
return &GitError{&bytes.Buffer{}, c.args, c.path, err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +109,7 @@ func (c *Cmd) AndShutUp() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return &GitError{errStream, c.args, c.path, err}
|
return &GitError{errStream, c.args, c.path, err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,10 +129,10 @@ func (e GitError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("git %s failed on %s: %s", e.Args, e.Path, msg)
|
return fmt.Sprintf("git %s failed on %s: %s", e.Args, e.Path, msg)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func lines(output []byte) []string {
|
func lines(output []byte) []string {
|
||||||
lines := strings.TrimSuffix(string(output), "\n")
|
lines := strings.TrimSuffix(string(output), "\n")
|
||||||
|
|
||||||
return strings.Split(lines, "\n")
|
return strings.Split(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
50
pkg/url.go
50
pkg/url.go
@@ -1,3 +1,4 @@
|
|||||||
|
// Package pkg implements the primary funcionality of the commands: list and get
|
||||||
package pkg
|
package pkg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,27 +19,44 @@ var scpSyntax = regexp.MustCompile(`^([a-zA-Z0-9_]+)@([a-zA-Z0-9._-]+):(.*)$`)
|
|||||||
// ParseURL parses given rawURL string into a URL.
|
// ParseURL parses given rawURL string into a URL.
|
||||||
// When the parsed URL has an empty host, use the defaultHost.
|
// When the parsed URL has an empty host, use the defaultHost.
|
||||||
// When the parsed URL has an empty scheme, use the defaultScheme.
|
// When the parsed URL has an empty scheme, use the defaultScheme.
|
||||||
func ParseURL(rawURL string, defaultHost string, defaultScheme string) (url *urlpkg.URL, err error) {
|
func ParseURL(rawURL string, defaultHost string, defaultScheme string) (*urlpkg.URL, error) {
|
||||||
// If rawURL matches the SCP-like syntax, convert it into a standard ssh Path.
|
url, err := parseRawURL(rawURL)
|
||||||
// eg, git@github.com:user/repo => ssh://git@github.com/user/repo
|
|
||||||
if m := scpSyntax.FindStringSubmatch(rawURL); m != nil {
|
|
||||||
url = &urlpkg.URL{
|
|
||||||
Scheme: "ssh",
|
|
||||||
User: urlpkg.User(m[1]),
|
|
||||||
Host: m[2],
|
|
||||||
Path: path.Join("/", m[3]),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
url, err = urlpkg.Parse(rawURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed parsing URL %s: %w", rawURL, err)
|
return nil, err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if url.Host == "" && url.Path == "" {
|
if url.Host == "" && url.Path == "" {
|
||||||
return nil, errEmptyURLPath
|
return nil, errEmptyURLPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeURL(url, defaultHost, defaultScheme)
|
||||||
|
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRawURL handles the initial parsing of the raw URL string.
|
||||||
|
func parseRawURL(rawURL string) (*urlpkg.URL, error) {
|
||||||
|
// If rawURL matches the SCP-like syntax, convert it into a standard ssh Path.
|
||||||
|
// eg, git@github.com:user/repo => ssh://git@github.com/user/repo
|
||||||
|
if m := scpSyntax.FindStringSubmatch(rawURL); m != nil {
|
||||||
|
return &urlpkg.URL{
|
||||||
|
Scheme: "ssh",
|
||||||
|
User: urlpkg.User(m[1]),
|
||||||
|
Host: m[2],
|
||||||
|
Path: path.Join("/", m[3]),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := urlpkg.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed parsing URL %s: %w", rawURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeURL applies all the normalization rules to the parsed URL.
|
||||||
|
func normalizeURL(url *urlpkg.URL, defaultHost string, defaultScheme string) {
|
||||||
if url.Scheme == "git+ssh" {
|
if url.Scheme == "git+ssh" {
|
||||||
url.Scheme = "ssh"
|
url.Scheme = "ssh"
|
||||||
}
|
}
|
||||||
@@ -65,15 +83,13 @@ func ParseURL(rawURL string, defaultHost string, defaultScheme string) (url *url
|
|||||||
url.Path = path.Join(url.Host, url.Path)
|
url.Path = path.Join(url.Host, url.Path)
|
||||||
url.Host = ""
|
url.Host = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return url, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// URLToPath cleans up the URL and converts it into a path string.
|
// URLToPath cleans up the URL and converts it into a path string.
|
||||||
// Eg, ssh://git@github.com:22/~user/repo.git => github.com/user/repo
|
// Eg, ssh://git@github.com:22/~user/repo.git => github.com/user/repo
|
||||||
//
|
//
|
||||||
// If skipHost is true, it removes the host part from the path.
|
// If skipHost is true, it removes the host part from the path.
|
||||||
// Eg, ssh://git@github.com:22/~user/repo.git => user/repo
|
// Eg, ssh://git@github.com:22/~user/repo.git => user/repo.
|
||||||
func URLToPath(url urlpkg.URL, skipHost bool) string {
|
func URLToPath(url urlpkg.URL, skipHost bool) string {
|
||||||
// Remove port numbers from host.
|
// Remove port numbers from host.
|
||||||
url.Host = strings.Split(url.Host, ":")[0]
|
url.Host = strings.Split(url.Host, ":")[0]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Following URLs are considered valid according to https://git-scm.com/docs/git-clone#_git_urls:
|
// Following URLs are considered valid according to https://git-scm.com/docs/git-clone#_git_urls:
|
||||||
@@ -53,7 +54,7 @@ func TestURLParse(t *testing.T) {
|
|||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
url, err := ParseURL(test.in, cfg.Defaults[cfg.KeyDefaultHost], cfg.Defaults[cfg.KeyDefaultScheme])
|
url, err := ParseURL(test.in, cfg.Defaults[cfg.KeyDefaultHost], cfg.Defaults[cfg.KeyDefaultScheme])
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
got := URLToPath(*url, false)
|
got := URLToPath(*url, false)
|
||||||
assert.Equal(t, test.want, got)
|
assert.Equal(t, test.want, got)
|
||||||
@@ -93,7 +94,7 @@ func TestURLParseSkipHost(t *testing.T) {
|
|||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
url, err := ParseURL(test.in, cfg.Defaults[cfg.KeyDefaultHost], cfg.Defaults[cfg.KeyDefaultScheme])
|
url, err := ParseURL(test.in, cfg.Defaults[cfg.KeyDefaultHost], cfg.Defaults[cfg.KeyDefaultScheme])
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
got := URLToPath(*url, true)
|
got := URLToPath(*url, true)
|
||||||
assert.Equal(t, test.want, got)
|
assert.Equal(t, test.want, got)
|
||||||
@@ -119,12 +120,12 @@ func TestDefaultScheme(t *testing.T) {
|
|||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
url, err := ParseURL(test.in, cfg.Defaults[cfg.KeyDefaultHost], test.scheme)
|
url, err := ParseURL(test.in, cfg.Defaults[cfg.KeyDefaultHost], test.scheme)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
want, err := url.Parse(test.want)
|
want, err := url.Parse(test.want)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, url, want)
|
assert.Equal(t, want, url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user