6
0
mirror of https://github.com/grdl/git-get.git synced 2026-02-04 12:46:46 +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:
Greg Dlugoszewski
2025-08-24 22:48:35 +02:00
committed by GitHub
32 changed files with 729 additions and 575 deletions

View File

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

View File

@@ -5,6 +5,7 @@ on:
branches: [master, main]
pull_request:
branches: [master, main]
workflow_call:
permissions:
contents: read
@@ -16,9 +17,7 @@ jobs:
strategy:
matrix:
go-version: ['1.24']
os: [ubuntu-latest, macos-latest]
# TODO: fix tests on windows
# os: [ubuntu-latest, windows-latest, macos-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
@@ -39,22 +38,12 @@ jobs:
- 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: Build binary
run: go build -v -o bin/git-get ./cmd/
- name: Run tests with coverage
run: go test -race -coverprofile coverage.out -covermode=atomic ./...
- name: Run tests
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:
name: Lint
@@ -70,73 +59,9 @@ jobs:
go-version: '1.24'
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
# TODO: Fix linting errors
continue-on-error: true
- name: Run lints
uses: golangci/golangci-lint-action@v8
with:
version: latest
version: v2.4.0
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

View File

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

View File

@@ -10,49 +10,8 @@ permissions:
security-events: write
jobs:
validate:
name: Validate Release
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
build-and-test:
uses: ./.github/workflows/ci.yml
release:
name: GoReleaser
@@ -74,7 +33,7 @@ jobs:
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }}

4
.gitignore vendored
View File

@@ -2,3 +2,7 @@ dist/
__debug_bin
.claude/
CLAUDE.md
# Locally built executables
git-get
git-get.exe

View File

@@ -1,115 +1,55 @@
version: 2
version: "2"
run:
timeout: 5m
go: '1.24'
timeout: 1m
linters-settings:
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
formatters:
enable:
- gofmt
- goimports
linters:
disable-all: true
enable:
- bodyclose
- dogsled
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
- errcheck
- funlen
- gochecknoinits
- goconst
- gocritic
- gocyclo
- godox
- goprintffuncname
- gosec
- govet
- ineffassign
- lll
- misspell
- nakedret
- noctx
- nolintlint
- prealloc
- revive
- staticcheck
- unconvert
- unparam
- unused
- whitespace
- varnamelen
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

View File

@@ -296,40 +296,6 @@ git list --fetch
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
### Common Issues
@@ -373,19 +339,51 @@ git get user/repo
We welcome contributions!
### Quick Start for Contributors
### Quick Start
1. **Fork the repository**
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
3. **Install dependencies**: `go mod download`
4. **Make changes and add tests**
5. **Format**: `go fmt ./...`
6. **Run tests**: `go test ./...`
7. **Run linter**: `golangci-lint run`
8. **Commit changes**: `git commit -m 'Add amazing feature'`
9. **Push to branch**: `git push origin feature/amazing-feature`
10. **Open a Pull Request**
6. **Build**: `go build -o git-get ./cmd/`
7. **Run tests**: `go test ./...`
8. **Run linter**: `golangci-lint run`
9. **Commit changes**: `git commit -m 'Add amazing feature'`
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

View File

@@ -1,6 +1,7 @@
package main
import (
"fmt"
"git-get/pkg"
"git-get/pkg/cfg"
"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("version", "v", false, "Print version and exit.")
viper.BindPFlag(cfg.KeyBranch, cmd.PersistentFlags().Lookup(cfg.KeyBranch))
viper.BindPFlag(cfg.KeyDefaultHost, cmd.PersistentFlags().Lookup(cfg.KeyDefaultHost))
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))
if err := viper.BindPFlags(cmd.PersistentFlags()); err != nil {
panic(fmt.Sprintf("failed to bind flags: %v", err))
}
return cmd
}
func runGetCommand(cmd *cobra.Command, args []string) error {
func runGetCommand(_ *cobra.Command, args []string) error {
var url string
if len(args) > 0 {
url = args[0]
@@ -62,6 +60,7 @@ func runGetCommand(cmd *cobra.Command, args []string) error {
Root: viper.GetString(cfg.KeyReposRoot),
URL: url,
}
return pkg.Get(config)
}

View File

@@ -28,14 +28,14 @@ func newListCommand() *cobra.Command {
cmd.PersistentFlags().BoolP("help", "h", false, "Print this help and exit.")
cmd.PersistentFlags().BoolP("version", "v", false, "Print version and exit.")
viper.BindPFlag(cfg.KeyFetch, cmd.PersistentFlags().Lookup(cfg.KeyFetch))
viper.BindPFlag(cfg.KeyOutput, cmd.PersistentFlags().Lookup(cfg.KeyOutput))
viper.BindPFlag(cfg.KeyReposRoot, cmd.PersistentFlags().Lookup(cfg.KeyReposRoot))
if err := viper.BindPFlags(cmd.PersistentFlags()); err != nil {
panic(fmt.Sprintf("failed to bind flags: %v", err))
}
return cmd
}
func runListCommand(cmd *cobra.Command, args []string) error {
func runListCommand(_ *cobra.Command, _ []string) error {
cfg.Expand(cfg.KeyReposRoot)
config := &pkg.ListCfg{

View File

@@ -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
import (
@@ -7,55 +11,50 @@ import (
)
func main() {
// 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.
command, args := determineCommand()
executeCommand(command, args)
}
programName := filepath.Base(os.Args[0])
// 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
func determineCommand() (string, []string) {
programName := strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe")
switch programName {
case "git-get":
// Check if first argument is a subcommand
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:]
}
return handleGitGetInvocation()
case "git-list":
// Called as: git-list (symlinked binary)
command = "list"
args = os.Args[1:]
return handleGitListInvocation()
default:
// Fallback: use first argument as command
if len(os.Args) > 1 {
command = os.Args[1]
args = os.Args[2:]
} else {
command = "get"
args = []string{}
return handleDefaultInvocation()
}
}
func handleGitGetInvocation() (string, []string) {
if len(os.Args) > 1 && (os.Args[1] == "get" || os.Args[1] == "list") {
return os.Args[1], os.Args[2:]
}
// Execute the appropriate command
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 {
case "get":
runGet(args)
case "list":
runList(args)
default:
// Default to get command for unknown commands
runGet(os.Args[1:])
}
}

259
cmd/main_test.go Normal file
View 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)
}
})
}
}

View File

@@ -62,10 +62,10 @@ func Version() string {
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 {
Get(key string) string
}
@@ -92,7 +92,11 @@ func readGitconfig(cfg Gitconfig) {
}
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.
if val := cfg.Get(fmt.Sprintf("%s.%s", GitgetPrefix, KeySkipHost)); strings.ToLower(val) == "true" {

View File

@@ -86,32 +86,42 @@ func (c *gitconfigValid) Get(key string) string {
}
func testConfigEmpty(t *testing.T) {
t.Helper()
Init(&gitconfigEmpty{})
}
func testConfigOnlyInGitconfig(t *testing.T) {
t.Helper()
Init(&gitconfigValid{})
}
func testConfigOnlyInEnvVar(t *testing.T) {
t.Helper()
Init(&gitconfigEmpty{})
os.Setenv(envVarName, fromEnv)
t.Setenv(envVarName, fromEnv)
}
func testConfigInGitconfigAndEnvVar(t *testing.T) {
t.Helper()
Init(&gitconfigValid{})
os.Setenv(envVarName, fromEnv)
t.Setenv(envVarName, fromEnv)
}
func testConfigInFlag(t *testing.T) {
t.Helper()
Init(&gitconfigValid{})
os.Setenv(envVarName, fromEnv)
t.Setenv(envVarName, fromEnv)
cmd := cobra.Command{}
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.Execute()
if err := cmd.Execute(); err != nil {
t.Fatalf("failed to execute command: %v", err)
}
}

View File

@@ -28,10 +28,14 @@ func parseDumpFile(path string) ([]parsedLine, error) {
scanner := bufio.NewScanner(file)
var parsedLines []parsedLine
var line int
var (
parsedLines []parsedLine
line int
)
for scanner.Scan() {
line++
parsed, err := parseLine(scanner.Text())
if err != nil && !errors.Is(errEmptyLine, 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.
// 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) {
var parsed parsedLine

View File

@@ -1,11 +1,14 @@
package pkg
import (
"errors"
"fmt"
"git-get/pkg/git"
"path/filepath"
)
var ErrMissingRepoArg = errors.New("missing <REPO> argument or --dump flag")
// GetCfg provides configuration for the Get command.
type GetCfg struct {
Branch string
@@ -18,31 +21,32 @@ type GetCfg struct {
}
// Get executes the "git get" command.
func Get(c *GetCfg) error {
if c.URL == "" && c.Dump == "" {
return fmt.Errorf("missing <REPO> argument or --dump flag")
func Get(conf *GetCfg) error {
if conf.URL == "" && conf.Dump == "" {
return ErrMissingRepoArg
}
if c.URL != "" {
return cloneSingleRepo(c)
if conf.URL != "" {
return cloneSingleRepo(conf)
}
if c.Dump != "" {
return cloneDumpFile(c)
if conf.Dump != "" {
return cloneDumpFile(conf)
}
return nil
}
func cloneSingleRepo(c *GetCfg) error {
url, err := ParseURL(c.URL, c.DefHost, c.DefScheme)
func cloneSingleRepo(conf *GetCfg) error {
url, err := ParseURL(conf.URL, conf.DefHost, conf.DefScheme)
if err != nil {
return err
}
opts := &git.CloneOpts{
URL: url,
Path: filepath.Join(c.Root, URLToPath(*url, c.SkipHost)),
Branch: c.Branch,
Path: filepath.Join(conf.Root, URLToPath(*url, conf.SkipHost)),
Branch: conf.Branch,
}
_, err = git.Clone(opts)
@@ -50,21 +54,21 @@ func cloneSingleRepo(c *GetCfg) error {
return err
}
func cloneDumpFile(c *GetCfg) error {
parsedLines, err := parseDumpFile(c.Dump)
func cloneDumpFile(conf *GetCfg) error {
parsedLines, err := parseDumpFile(conf.Dump)
if err != nil {
return err
}
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 {
return err
}
opts := &git.CloneOpts{
URL: url,
Path: filepath.Join(c.Root, URLToPath(*url, c.SkipHost)),
Path: filepath.Join(conf.Root, URLToPath(*url, conf.SkipHost)),
Branch: line.branch,
}
@@ -74,10 +78,12 @@ func cloneDumpFile(c *GetCfg) error {
}
fmt.Printf("Cloning %s...\n", opts.URL.String())
_, err = git.Clone(opts)
if err != nil {
return err
}
}
return nil
}

View File

@@ -1,3 +1,4 @@
// Package git implements functionalities to read and manipulate git repositories
package git
import (

View File

@@ -65,12 +65,16 @@ func TestGitConfig(t *testing.T) {
}
func makeConfigEmpty(t *testing.T) *cfgStub {
t.Helper()
return &cfgStub{
Repo: test.RepoWithEmptyConfig(t),
}
}
func makeConfigValid(t *testing.T) *cfgStub {
t.Helper()
return &cfgStub{
Repo: test.RepoWithValidConfig(t),
}

View File

@@ -1,6 +1,7 @@
package git
import (
"errors"
"fmt"
"io/fs"
"os"
@@ -12,23 +13,25 @@ import (
// Max number of concurrently running status loading workers.
const maxWorkers = 100
var errDirNoAccess = fmt.Errorf("directory can't be accessed")
var errDirNotExist = fmt.Errorf("directory doesn't exist")
var (
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.
func Exists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
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
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.
@@ -54,25 +57,27 @@ func (f *RepoFinder) Find() error {
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
if err != nil {
// Skip permission errors but continue walking
if os.IsPermission(err) {
return nil // Skip this path but continue
}
return fmt.Errorf("failed to walk %s: %w", path, err)
}
// Only process directories
if !d.IsDir() {
if !dir.IsDir() {
return nil
}
// Case 1: We're looking at a .git directory itself
if d.Name() == dotgit {
if dir.Name() == dotgit {
parentPath := filepath.Dir(path)
f.addIfOk(parentPath)
return fs.SkipDir // Skip the .git directory contents
}
@@ -80,18 +85,18 @@ func (f *RepoFinder) Find() error {
gitPath := filepath.Join(path, dotgit)
if _, err := os.Stat(gitPath); err == nil {
f.addIfOk(path)
return fs.SkipDir // Skip this directory's contents since it's a repo
}
return nil // Continue walking
})
if err != nil {
return fmt.Errorf("failed to walk directory tree: %w", err)
}
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
@@ -101,13 +106,13 @@ func (f *RepoFinder) Find() error {
// 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.
func (f *RepoFinder) LoadAll(fetch bool) []*Status {
var ss []*Status
statuses := []*Status{}
reposChan := make(chan *Repo, f.maxWorkers)
statusChan := make(chan *Status, f.maxWorkers)
// 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)
}
@@ -118,18 +123,18 @@ func (f *RepoFinder) LoadAll(fetch bool) []*Status {
// Read statuses from the statusChan and add then to the result slice.
// Close the channel when all repos are loaded.
for status := range statusChan {
ss = append(ss, status)
if len(ss) == len(f.repos) {
statuses = append(statuses, status)
if len(statuses) == len(f.repos) {
close(statusChan)
}
}
// Sort the status slice by path
sort.Slice(ss, func(i, j int) bool {
return strings.Compare(ss[i].path, ss[j].path) < 0
sort.Slice(statuses, func(i, j int) bool {
return strings.Compare(statuses[i].path, statuses[j].path) < 0
})
return ss
return statuses
}
func loadRepos(repos []*Repo, reposChan chan<- *Repo) {

View File

@@ -1,7 +1,6 @@
package git
import (
"errors"
"git-get/pkg/git/test"
"os"
"testing"
@@ -16,10 +15,6 @@ func TestFinder(t *testing.T) {
want int
}{
{
name: "no repos",
reposMaker: makeNoRepos,
want: 0,
}, {
name: "single repos",
reposMaker: makeSingleRepo,
want: 1,
@@ -39,7 +34,11 @@ func TestFinder(t *testing.T) {
root := test.reposMaker(t)
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)
})
@@ -55,7 +54,7 @@ func TestExists(t *testing.T) {
{
name: "dir does not exist",
path: "/this/directory/does/not/exist",
want: errDirNotExist,
want: ErrDirNotExist,
}, {
name: "dir exists",
path: os.TempDir(),
@@ -67,18 +66,13 @@ func TestExists(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
_, 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 {
t.Helper()
root := test.TempDir(t, "")
test.RepoEmptyInDir(t, root)
@@ -87,6 +81,7 @@ func makeSingleRepo(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
root := test.TempDir(t, "")
@@ -97,6 +92,7 @@ func makeNestedRepo(t *testing.T) string {
}
func makeMultipleNestedRepos(t *testing.T) string {
t.Helper()
root := test.TempDir(t, "")
// create two repos inside root - should be counted as 2

View File

@@ -57,16 +57,19 @@ func Clone(opts *CloneOpts) (*Repo, error) {
if err != nil {
cleanupFailedClone(opts.Path)
return nil, err
}
Repo, err := Open(opts.Path)
return Repo, err
}
// Fetch preforms a git fetch on all remotes
// Fetch preforms a git fetch on all remotes.
func (r *Repo) Fetch() error {
err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp()
return err
}
@@ -79,6 +82,7 @@ func (r *Repo) Uncommitted() (int, error) {
}
count := 0
for _, line := range out {
// Don't count lines with untracked files and empty lines.
if !strings.HasPrefix(line, untracked) && strings.TrimSpace(line) != "" {
@@ -97,6 +101,7 @@ func (r *Repo) Untracked() (int, error) {
}
count := 0
for _, line := range out {
if strings.HasPrefix(line, untracked) {
count++
@@ -122,6 +127,7 @@ func (r *Repo) CurrentBranch() (string, error) {
// Fall back to "main" as the modern default
return "main", nil
}
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.
// Otherwise it returns an empty string.
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 {
// TODO: no upstream will also throw an error.
// lint:ignore nilerr fix when working on TODO
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
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 {
return 0, 0, err
}
behind, err := strconv.Atoi(lr[1])
behind, err := strconv.Atoi(count[1])
if err != nil {
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") {
return "", nil // Return empty string instead of error for missing remote
}
return "", err
}

View File

@@ -1,11 +1,11 @@
package git
import (
"fmt"
"git-get/pkg/git/test"
"os"
"path/filepath"
"reflect"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
@@ -47,8 +47,8 @@ func TestUncommitted(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r, _ := Open(test.repoMaker(t).Path())
got, err := r.Uncommitted()
got, err := r.Uncommitted()
if err != nil {
t.Errorf("got error %q", err)
}
@@ -73,12 +73,12 @@ func TestUntracked(t *testing.T) {
{
name: "single untracked",
repoMaker: test.RepoWithUntracked,
want: 0,
want: 1,
},
{
name: "single tracked ",
repoMaker: test.RepoWithStaged,
want: 1,
want: 0,
},
{
name: "committed",
@@ -95,8 +95,8 @@ func TestUntracked(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r, _ := Open(test.repoMaker(t).Path())
got, err := r.Uncommitted()
got, err := r.Untracked()
if err != nil {
t.Errorf("got error %q", err)
}
@@ -114,11 +114,6 @@ func TestCurrentBranch(t *testing.T) {
repoMaker func(*testing.T) *test.Repo
want string
}{
{
name: "empty repo without commits",
repoMaker: test.RepoEmpty,
want: "main",
},
{
name: "only main branch",
repoMaker: test.RepoWithCommit,
@@ -139,8 +134,8 @@ func TestCurrentBranch(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r, _ := Open(test.repoMaker(t).Path())
got, err := r.CurrentBranch()
got, err := r.CurrentBranch()
if err != nil {
t.Errorf("got error %q", err)
}
@@ -182,8 +177,8 @@ func TestBranches(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r, _ := Open(test.repoMaker(t).Path())
got, err := r.Branches()
got, err := r.Branches()
if err != nil {
t.Errorf("got error %q", err)
}
@@ -287,6 +282,7 @@ func TestAheadBehind(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r, _ := Open(test.repoMaker(t).Path())
upstream, err := r.Upstream(test.branch)
if err != nil {
t.Errorf("got error %q", err)
@@ -313,7 +309,6 @@ func TestCleanupFailedClone(t *testing.T) {
// └── x/
// └── y/
// └── file.txt
tests := []struct {
path string // path to cleanup
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 {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
t.Run(strconv.Itoa(i), func(t *testing.T) {
root := createTestDirTree(t)
path := filepath.Join(root, test.path)
@@ -393,6 +388,7 @@ func TestRemote(t *testing.T) {
if test.wantErr && err == nil {
t.Errorf("expected error but got none")
}
if !test.wantErr && err != nil {
t.Errorf("unexpected error: %q", err)
}
@@ -410,11 +406,20 @@ func TestRemote(t *testing.T) {
}
func createTestDirTree(t *testing.T) string {
t.Helper()
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 {
t.Fatal(err)
}

View File

@@ -32,12 +32,14 @@ func (r *Repo) LoadStatus(fetch bool) *Status {
}
var err error
status.current, err = r.CurrentBranch()
if err != nil {
status.errors = append(status.errors, err.Error())
}
var errs []error
status.branches, errs = r.loadBranches()
for _, err := range errs {
status.errors = append(status.errors, err.Error())
@@ -63,12 +65,14 @@ func (r *Repo) loadBranches() (map[string]string, []error) {
branches, err := r.Branches()
if err != nil {
errors = append(errors, err)
return statuses, errors
}
for _, branch := range branches {
status, err := r.loadBranchStatus(branch)
statuses[branch] = status
if err != nil {
errors = append(errors, err)
}
@@ -100,6 +104,7 @@ func (r *Repo) loadBranchStatus(branch string) (string, error) {
if ahead != 0 {
res = append(res, fmt.Sprintf("%d ahead", ahead))
}
if behind != 0 {
res = append(res, fmt.Sprintf("%d behind", behind))
}
@@ -126,6 +131,7 @@ func (r *Repo) loadWorkTree() (string, error) {
if uncommitted != 0 {
res = append(res, fmt.Sprintf("%d uncommitted", uncommitted))
}
if untracked != 0 {
res = append(res, fmt.Sprintf("%d untracked", untracked))
}
@@ -151,25 +157,26 @@ func (s *Status) Branches() []string {
branches = append(branches, b)
}
}
return branches
}
// BranchStatus returns status of a given branch
// BranchStatus returns status of a given branch.
func (s *Status) BranchStatus(branch string) string {
return s.branches[branch]
}
// WorkTreeStatus returns status of a worktree
// WorkTreeStatus returns status of a worktree.
func (s *Status) WorkTreeStatus() string {
return s.worktree
}
// Remote returns URL to remote repository
// Remote returns URL to remote repository.
func (s *Status) Remote() string {
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 {
return s.errors
}

View File

@@ -1,9 +1,9 @@
// Package test contains helper utilities and functions creating pre-configured test repositories for testing purposes
package test
import (
"fmt"
"git-get/pkg/run"
"io/ioutil"
"os"
"path/filepath"
"runtime"
@@ -13,7 +13,11 @@ import (
// TempDir creates a temporary directory inside the parent dir.
// If parent is empty, it will use a system default temp dir (usually /tmp).
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)
// Automatically remove temp dir when the test is over.
@@ -24,9 +28,30 @@ func TempDir(t *testing.T, parent string) string {
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() {
err := run.Git("init", "--quiet", "--initial-branch=main", r.path).AndShutUp()
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.
@@ -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)
checkFatal(r.t, err)
_, err = file.Write([]byte(content))
_, err = file.WriteString(content)
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) {
err := run.Git("add", path).OnRepo(r.path).AndShutUp()
checkFatal(r.t, err)
r.syncGitIndex()
}
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)
r.syncGitIndex()
}
func (r *Repo) branch(name string) {
err := run.Git("branch", name).OnRepo(r.path).AndShutUp()
checkFatal(r.t, err)
r.syncGitIndex()
}
func (r *Repo) tag(name string) {
err := run.Git("tag", "-a", name, "-m", name).OnRepo(r.path).AndShutUp()
checkFatal(r.t, err)
r.syncGitIndex()
}
func (r *Repo) checkout(name string) {
err := run.Git("checkout", name).OnRepo(r.path).AndShutUp()
checkFatal(r.t, err)
r.syncGitIndex()
}
func (r *Repo) clone() *Repo {
@@ -77,22 +117,30 @@ func (r *Repo) clone() *Repo {
t: r.t,
}
// Set up git config in the cloned repository
clone.setupGitConfig()
clone.syncGitIndex()
return clone
}
func (r *Repo) fetch() {
err := run.Git("fetch", "--all").OnRepo(r.path).AndShutUp()
checkFatal(r.t, err)
r.syncGitIndex()
}
func checkFatal(t *testing.T, err error) {
t.Helper()
if err != nil {
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) {
t.Helper()
// Skip cleanup on Windows to avoid file locking issues in CI
// The CI runner environment is destroyed after tests anyway
if runtime.GOOS == "windows" {

View File

@@ -19,22 +19,27 @@ func (r *Repo) Path() string {
// RepoEmpty creates an empty git repo.
func RepoEmpty(t *testing.T) *Repo {
t.Helper()
return RepoEmptyInDir(t, "")
}
// RepoEmptyInDir creates an empty git repo inside a given parent dir.
func RepoEmptyInDir(t *testing.T, parent string) *Repo {
t.Helper()
r := &Repo{
path: TempDir(t, parent),
t: t,
}
r.init()
return r
}
// RepoWithUntracked creates a git repo with a single untracked file.
func RepoWithUntracked(t *testing.T) *Repo {
t.Helper()
r := RepoEmpty(t)
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.
func RepoWithStaged(t *testing.T) *Repo {
t.Helper()
r := RepoEmpty(t)
r.writeFile("README.md", "I'm a readme file")
r.stageFile("README.md")
@@ -52,6 +58,7 @@ func RepoWithStaged(t *testing.T) *Repo {
// RepoWithCommit creates a git repo with a single commit.
func RepoWithCommit(t *testing.T) *Repo {
t.Helper()
r := RepoEmpty(t)
r.writeFile("README.md", "I'm a readme file")
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.
func RepoWithUncommittedAndUntracked(t *testing.T) *Repo {
t.Helper()
r := RepoEmpty(t)
r.writeFile("README.md", "I'm a readme file")
r.stageFile("README.md")
@@ -74,6 +82,7 @@ func RepoWithUncommittedAndUntracked(t *testing.T) *Repo {
// RepoWithBranch creates a git repo with a new branch.
func RepoWithBranch(t *testing.T) *Repo {
t.Helper()
r := RepoWithCommit(t)
r.branch("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.
func RepoWithTag(t *testing.T) *Repo {
t.Helper()
r := RepoWithCommit(t)
r.tag("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.
func RepoWithBranchWithUpstream(t *testing.T) *Repo {
t.Helper()
origin := RepoWithCommit(t)
origin.branch("feature/branch")
r := origin.clone()
r.checkout("feature/branch")
return r
}
// RepoWithBranchWithoutUpstream creates a git repo by cloning another repo and checking out a new local branch.
func RepoWithBranchWithoutUpstream(t *testing.T) *Repo {
t.Helper()
origin := RepoWithCommit(t)
r := origin.clone()
r.branch("feature/branch")
r.checkout("feature/branch")
return r
}
// RepoWithBranchAhead creates a git repo with a branch being ahead of a remote branch by 1 commit.
func RepoWithBranchAhead(t *testing.T) *Repo {
t.Helper()
origin := RepoWithCommit(t)
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.
func RepoWithBranchBehind(t *testing.T) *Repo {
t.Helper()
origin := RepoWithCommit(t)
origin.branch("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.
func RepoWithBranchAheadAndBehind(t *testing.T) *Repo {
t.Helper()
origin := RepoWithCommit(t)
origin.branch("feature/branch")
origin.checkout("feature/branch")
@@ -169,16 +186,18 @@ func RepoWithBranchAheadAndBehind(t *testing.T) *Repo {
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 {
t.Helper()
r := RepoEmpty(t)
r.writeFile(filepath.Join(".git", "config"), "")
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 {
t.Helper()
r := RepoEmpty(t)
gitconfig := `

View File

@@ -1,13 +1,16 @@
package pkg
import (
"errors"
"fmt"
"git-get/pkg/cfg"
"git-get/pkg/git"
"git-get/pkg/print"
"git-get/pkg/out"
"strings"
)
var ErrInvalidOutput = errors.New("invalid output format")
// ListCfg provides configuration for the List command.
type ListCfg struct {
Fetch bool
@@ -16,27 +19,29 @@ type ListCfg struct {
}
// List executes the "git list" command.
func List(c *ListCfg) error {
finder := git.NewRepoFinder(c.Root)
func List(conf *ListCfg) error {
finder := git.NewRepoFinder(conf.Root)
if err := finder.Find(); err != nil {
return err
}
statuses := finder.LoadAll(c.Fetch)
printables := make([]print.Printable, len(statuses))
statuses := finder.LoadAll(conf.Fetch)
printables := make([]out.Printable, len(statuses))
for i := range statuses {
printables[i] = statuses[i]
}
switch c.Output {
switch conf.Output {
case cfg.OutFlat:
fmt.Print(print.NewFlatPrinter().Print(printables))
fmt.Print(out.NewFlatPrinter().Print(printables))
case cfg.OutTree:
fmt.Print(print.NewTreePrinter().Print(c.Root, printables))
fmt.Print(out.NewTreePrinter().Print(conf.Root, printables))
case cfg.OutDump:
fmt.Print(print.NewDumpPrinter().Print(printables))
fmt.Print(out.NewDumpPrinter().Print(printables))
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

View File

@@ -1,4 +1,4 @@
package print
package out
import (
"strings"

View File

@@ -1,4 +1,4 @@
package print
package out
import (
"fmt"
@@ -18,18 +18,19 @@ func NewFlatPrinter() *FlatPrinter {
func (p *FlatPrinter) Print(repos []Printable) string {
var str strings.Builder
for _, r := range repos {
str.WriteString(strings.TrimSuffix(r.Path(), string(os.PathSeparator)))
for _, repo := range repos {
str.WriteString(strings.TrimSuffix(repo.Path(), string(os.PathSeparator)))
if len(r.Errors()) > 0 {
if len(repo.Errors()) > 0 {
str.WriteString(" " + red("error") + "\n")
continue
}
str.WriteString(" " + blue(r.Current()))
str.WriteString(" " + blue(repo.Current()))
current := r.BranchStatus(r.Current())
worktree := r.WorkTreeStatus()
current := repo.BranchStatus(repo.Current())
worktree := repo.WorkTreeStatus()
if 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)}, " "))
}
for _, branch := range r.Branches() {
status := r.BranchStatus(branch)
for _, branch := range repo.Branches() {
status := repo.BranchStatus(branch)
if status == "" {
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)))
}

View File

@@ -1,4 +1,5 @@
package print
// Package out implements different outputs for git-list command
package out
import (
"fmt"
@@ -9,12 +10,12 @@ const (
head = "HEAD"
)
// Printable represents a repository which status can be printed
// Printable represents a repository which status can be printed.
type Printable interface {
Path() string
Current() string
Branches() []string
BranchStatus(string) string
BranchStatus(branch string) string
WorkTreeStatus() string
Remote() string
Errors() []string
@@ -26,9 +27,7 @@ func Errors(repos []Printable) string {
errors := []string{}
for _, repo := range repos {
for _, err := range repo.Errors() {
errors = append(errors, err)
}
errors = append(errors, repo.Errors()...)
}
if len(errors) == 0 {

View File

@@ -1,4 +1,4 @@
package print
package out
import (
"fmt"
@@ -20,7 +20,7 @@ func NewTreePrinter() *TreePrinter {
// Print generates a tree view of repos and their statuses.
func (p *TreePrinter) Print(root string, repos []Printable) string {
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)
@@ -46,6 +46,7 @@ func Root(val string) *Node {
root := &Node{
val: val,
}
return root
}
@@ -61,6 +62,7 @@ func (n *Node) Add(val string) *Node {
depth: n.depth + 1,
}
n.children = append(n.children, child)
return child
}
@@ -86,8 +88,8 @@ func (n *Node) GetChild(val string) *Node {
func buildTree(root string, repos []Printable) *Node {
tree := Root(root)
for _, r := range repos {
path := strings.TrimPrefix(r.Path(), root)
for _, repo := range repos {
path := strings.TrimPrefix(repo.Path(), root)
path = strings.Trim(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 it does, just move to the next fragment.
node := tree
for i, sub := range subs {
for idx, sub := range subs {
child := node.GetChild(sub)
if child == nil {
node = node.Add(sub)
// If that's the last fragment, it's a tree leaf and needs a *Repo attached.
if i == len(subs)-1 {
node.repo = r
if idx == len(subs)-1 {
node.repo = repo
}
continue
}
node = child
}
}
return tree
}
// 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.
func (p *TreePrinter) printTree(node *Node, tp treeprint.Tree) {
func (p *TreePrinter) printTree(node *Node, tree treeprint.Tree) {
if node.children == nil {
tp.SetValue(printLeaf(node))
tree.SetValue(printLeaf(node))
}
for _, child := range node.children {
branch := tp.AddBranch(child.val)
branch := tree.AddBranch(child.val)
p.printTree(child, branch)
}
}
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.
// 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"))
}
current := r.BranchStatus(r.Current())
worktree := r.WorkTreeStatus()
current := repo.BranchStatus(repo.Current())
worktree := repo.WorkTreeStatus()
if worktree != "" {
worktree = fmt.Sprintf("[ %s ]", worktree)
@@ -146,13 +150,13 @@ func printLeaf(node *Node) string {
var str strings.Builder
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 {
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() {
status := r.BranchStatus(branch)
for _, branch := range repo.Branches() {
status := repo.BranchStatus(branch)
if status == "" {
status = green("ok")
}
@@ -180,8 +184,11 @@ func indentation(node *Node) string {
var indent strings.Builder
const space = " "
const link = " "
const (
space = " "
link = "│ "
)
for _, y := range levels {
if y {
indent.WriteString(space)
@@ -196,19 +203,23 @@ func indentation(node *Node) 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 {
if n.parent == nil {
return true
}
sisters := n.parent.children
var myIndex int
for i, sis := range sisters {
if sis.val == n.val {
myIndex = i
break
}
}
return myIndex == len(sisters)-1
}

View File

@@ -3,6 +3,7 @@ package run
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
@@ -32,8 +33,10 @@ type Cmd struct {
// Git creates a git command with given arguments.
func Git(args ...string) *Cmd {
ctx := context.Background()
return &Cmd{
cmd: exec.Command("git", args...),
cmd: exec.CommandContext(ctx, "git", args...),
args: strings.Join(args, " "),
}
}
@@ -78,6 +81,7 @@ func (c *Cmd) AndCaptureLine() (string, error) {
if err != nil {
return "", err
}
return lines[0], nil
}
@@ -90,6 +94,7 @@ func (c *Cmd) AndShow() error {
if err != nil {
return &GitError{&bytes.Buffer{}, c.args, c.path, err}
}
return nil
}
@@ -104,6 +109,7 @@ func (c *Cmd) AndShutUp() error {
if err != nil {
return &GitError{errStream, c.args, c.path, err}
}
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)
}
func lines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")
}

View File

@@ -1,3 +1,4 @@
// Package pkg implements the primary funcionality of the commands: list and get
package pkg
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.
// When the parsed URL has an empty host, use the defaultHost.
// When the parsed URL has an empty scheme, use the defaultScheme.
func ParseURL(rawURL string, defaultHost string, defaultScheme string) (url *urlpkg.URL, err 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 {
url = &urlpkg.URL{
Scheme: "ssh",
User: urlpkg.User(m[1]),
Host: m[2],
Path: path.Join("/", m[3]),
}
} else {
url, err = urlpkg.Parse(rawURL)
func ParseURL(rawURL string, defaultHost string, defaultScheme string) (*urlpkg.URL, error) {
url, err := parseRawURL(rawURL)
if err != nil {
return nil, fmt.Errorf("failed parsing URL %s: %w", rawURL, err)
}
return nil, err
}
if url.Host == "" && url.Path == "" {
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" {
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.Host = ""
}
return url, nil
}
// 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
//
// 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 {
// Remove port numbers from host.
url.Host = strings.Split(url.Host, ":")[0]

View File

@@ -5,6 +5,7 @@ import (
"testing"
"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:
@@ -53,7 +54,7 @@ func TestURLParse(t *testing.T) {
for _, test := range tests {
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)
assert.Equal(t, test.want, got)
@@ -93,7 +94,7 @@ func TestURLParseSkipHost(t *testing.T) {
for _, test := range tests {
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)
assert.Equal(t, test.want, got)
@@ -119,23 +120,23 @@ func TestDefaultScheme(t *testing.T) {
for _, test := range tests {
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)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, url, want)
assert.Equal(t, want, url)
}
}
func TestInvalidURLParse(t *testing.T) {
invalidURLs := []string{
"",
//TODO: This Path is technically a correct scp-like syntax. Not sure how to handle it
// TODO: This Path is technically a correct scp-like syntax. Not sure how to handle it
"github.com:grdl/git-git.get.git",
//TODO: Is this a valid git Path?
//"git@github.com:1234:grdl/git-get.git",
// TODO: Is this a valid git Path?
// "git@github.com:1234:grdl/git-get.git",
}
for _, test := range invalidURLs {