6
0
mirror of https://github.com/grdl/git-get.git synced 2026-02-04 19:09:45 +00:00

Merge pull request #22 from grdl/issue-14-crash-on-empty-repo

Fix a git-list crash on empty repo or repo without a remote
This commit is contained in:
Greg Dlugoszewski
2025-08-11 23:43:16 +02:00
committed by GitHub
16 changed files with 610 additions and 153 deletions

39
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
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

@@ -1,19 +0,0 @@
name: build
on:
- pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Set up Git
run: git config --global user.email "grdl@example.com" && git config --global user.name "grdl"
- name: Run go test
run: CGO_ENABLED=0 GOOS=linux go test ./... -v

142
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,142 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
permissions:
contents: read
security-events: write
jobs:
test:
name: Test
strategy:
matrix:
go-version: ['1.24']
os: [ubuntu-latest, macos-latest]
# TODO: fix tests on windows
# os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
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 with coverage
run: go test -race -coverprofile coverage.out -covermode=atomic ./...
- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.24'
uses: codecov/codecov-action@v4
with:
file: ./coverage.out
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
# TODO: Fix linting errors
continue-on-error: true
with:
version: latest
args: --timeout=5m
security:
name: Security
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- 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@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Build binaries
run: |
go build -v -o bin/git-get ./cmd/get
go build -v -o bin/git-list ./cmd/list
- name: Test binaries
run: |
./bin/git-get --version
./bin/git-list --version
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: binaries
path: bin/
retention-days: 30

View File

@@ -1,71 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '34 21 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

46
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
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@v4
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}}"

24
.github/workflows/release-simple.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: release
on:
push:
tags:
- '*'
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }}

View File

@@ -1,24 +1,78 @@
name: release
name: Release
on:
push:
tags:
- '*'
- 'v*'
permissions:
contents: write
security-events: write
id-token: write # For SLSA provenance
jobs:
goreleaser:
validate:
name: Validate Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Run tests
run: go test -race ./...
- name: Run lints
uses: golangci/golangci-lint-action@v6
with:
version: latest
- name: Validate GoReleaser config
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: check
release:
name: GoReleaser
runs-on: ubuntu-latest
needs: validate
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --rm-dist
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GORELEASER_TOKEN }}
provenance:
name: Generate SLSA Provenance
needs: release
if: startsWith(github.ref, 'refs/tags/')
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0
with:
base64-subjects: "${{ needs.release.outputs.hashes }}"
upload-assets: true
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}

115
.golangci.yml Normal file
View File

@@ -0,0 +1,115 @@
version: 2
run:
timeout: 5m
go: '1.24'
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
linters:
disable-all: true
enable:
- bodyclose
- dogsled
- dupl
- errcheck
- 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

View File

@@ -1,3 +1,5 @@
version: 2
before:
hooks:
- go mod download
@@ -30,18 +32,16 @@ builds:
archives:
- id: archive
builds:
ids:
- git-get
- git-list
replacements:
darwin: macOS
linux: linux
windows: windows
386: i386
amd64: x86_64
name_template: "git-get_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
formats:
- tar.gz
- zip
format_overrides:
- goos: windows
format: zip
formats: [zip]
# Don't include any additional files into the archives (such as README, CHANGELOG etc).
files:
- none*
@@ -50,23 +50,30 @@ checksum:
name_template: 'checksums.txt'
changelog:
skip: true
sort: asc
use: github
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore'
- typo
release:
github:
owner: grdl
name: git-get
brews:
- name: git-get
tap:
repository:
owner: grdl
name: homebrew-tap
branch: main
commit_author:
name: Grzegorz Dlugoszewski
email: git-get@grdl.dev
folder: Formula
directory: Formula
homepage: https://github.com/grdl/git-get/
description: Better way to clone, organize and manage multiple git repositories
test: |
@@ -75,8 +82,10 @@ brews:
bin.install "git-get", "git-list"
nfpms:
- license: MIT
maintainer: grdl
- id: packages
package_name: git-get
license: MIT
maintainer: Grzegorz Dlugoszewski <git-get@grdl.dev>
homepage: https://github.com/grdl/git-get
bindir: /usr/local/bin
dependencies:
@@ -85,3 +94,19 @@ nfpms:
formats:
- deb
- rpm
scoops:
- name: git-get
repository:
owner: grdl
name: git-get
branch: master
directory: bucket
url_template: "https://github.com/grdl/git-get/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
commit_author:
name: Grzegorz Dlugoszewski
email: git-get@grdl.dev
commit_msg_template: "Scoop update for {{ .ProjectName }} version {{ .Tag }}"
homepage: "https://github.com/grdl/git-get"
description: "Better way to clone, organize and manage multiple git repositories"
license: MIT

View File

@@ -88,7 +88,8 @@ brew install grdl/tap/git-get
**Option 2: Using Scoop**
```powershell
# Coming soon
scoop bucket add git-get https://github.com/grdl/git-get
scoop install git-get
```
### Building from Source
@@ -269,6 +270,24 @@ go tool cover -html=coverage.out
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
@@ -317,12 +336,13 @@ We welcome contributions!
1. **Fork the repository**
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
3. **Install dependencies**: `go mod download`
3. **Make changes and add tests**
4. **Run tests**: `go test ./...`
5. **Run linter**: `golangci-lint run`
6. **Commit changes**: `git commit -m 'Add amazing feature'`
7. **Push to branch**: `git push origin feature/amazing-feature`
8. **Open a Pull Request**
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**
## License

View File

@@ -3,6 +3,7 @@ package git
import (
"errors"
"git-get/pkg/git/test"
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -57,7 +58,7 @@ func TestExists(t *testing.T) {
want: errDirNotExist,
}, {
name: "dir exists",
path: "/tmp/",
path: os.TempDir(),
want: nil,
},
}

View File

@@ -13,7 +13,7 @@ import (
const (
dotgit = ".git"
untracked = "??" // Untracked files are marked as "??" in git status output.
master = "master"
main = "main"
head = "HEAD"
)
@@ -108,9 +108,20 @@ func (r *Repo) Untracked() (int, error) {
// CurrentBranch returns the short name currently checked-out branch for the Repository.
// If Repo is in a detached head state, it will return "HEAD".
// If the repository has no commits yet, it returns "main" (for new repositories).
func (r *Repo) CurrentBranch() (string, error) {
out, err := run.Git("rev-parse", "--symbolic-full-name", "--abbrev-ref", "HEAD").OnRepo(r.path).AndCaptureLine()
if err != nil {
// Check if this is a new repository without commits
if strings.Contains(err.Error(), "ambiguous argument 'HEAD'") {
// Try to get the default branch name from git config
defaultBranch, branchErr := run.Git("config", "--get", "init.defaultBranch").OnRepo(r.path).AndCaptureLine()
if branchErr == nil && defaultBranch != "" {
return defaultBranch, nil
}
// Fall back to "main" as the modern default
return "main", nil
}
return "", err
}
@@ -175,6 +186,10 @@ func (r *Repo) Remote() (string, error) {
// https://stackoverflow.com/a/16880000/1085632
out, err := run.Git("ls-remote", "--get-url").OnRepo(r.path).AndCaptureLine()
if err != nil {
// Check if this is a repository without any remotes configured
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

@@ -114,16 +114,15 @@ func TestCurrentBranch(t *testing.T) {
repoMaker func(*testing.T) *test.Repo
want string
}{
// TODO: maybe add wantErr to check if error is returned correctly?
// {
// name: "empty",
// repoMaker: newTestRepo,
// want: "",
// },
{
name: "only master branch",
name: "empty repo without commits",
repoMaker: test.RepoEmpty,
want: "main",
},
{
name: "only main branch",
repoMaker: test.RepoWithCommit,
want: master,
want: main,
},
{
name: "checked out new branch",
@@ -164,19 +163,19 @@ func TestBranches(t *testing.T) {
want: []string{""},
},
{
name: "only master branch",
name: "only main branch",
repoMaker: test.RepoWithCommit,
want: []string{"master"},
want: []string{"main"},
},
{
name: "new branch",
repoMaker: test.RepoWithBranch,
want: []string{"feature/branch", "master"},
want: []string{"feature/branch", "main"},
},
{
name: "checked out new tag",
repoMaker: test.RepoWithTag,
want: []string{"master"},
want: []string{"main"},
},
}
@@ -205,7 +204,7 @@ func TestUpstream(t *testing.T) {
{
name: "empty",
repoMaker: test.RepoEmpty,
branch: "master",
branch: "main",
want: "",
},
// TODO: add wantErr
@@ -216,10 +215,10 @@ func TestUpstream(t *testing.T) {
want: "",
},
{
name: "master with upstream",
name: "main with upstream",
repoMaker: test.RepoWithBranchWithUpstream,
branch: "master",
want: "origin/master",
branch: "main",
want: "origin/main",
},
{
name: "branch with upstream",
@@ -261,7 +260,7 @@ func TestAheadBehind(t *testing.T) {
{
name: "fresh clone",
repoMaker: test.RepoWithBranchWithUpstream,
branch: "master",
branch: "main",
want: []int{0, 0},
},
{
@@ -359,6 +358,57 @@ func TestCleanupFailedClone(t *testing.T) {
}
}
func TestRemote(t *testing.T) {
tests := []struct {
name string
repoMaker func(*testing.T) *test.Repo
want string
wantErr bool
}{
{
name: "empty repo without remote",
repoMaker: test.RepoEmpty,
want: "",
wantErr: false,
},
{
name: "repo with commit but no remote",
repoMaker: test.RepoWithCommit,
want: "",
wantErr: false,
},
{
name: "repo with upstream",
repoMaker: test.RepoWithBranchWithUpstream,
want: "", // This will contain the actual remote URL but we just test it doesn't error
wantErr: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r, _ := Open(test.repoMaker(t).Path())
got, err := r.Remote()
if test.wantErr && err == nil {
t.Errorf("expected error but got none")
}
if !test.wantErr && err != nil {
t.Errorf("unexpected error: %q", err)
}
// For repos with remote, just check no error occurred
if test.name == "repo with upstream" {
if err != nil {
t.Errorf("unexpected error for repo with remote: %q", err)
}
} else if got != test.want {
t.Errorf("expected %q; got %q", test.want, got)
}
})
}
}
func createTestDirTree(t *testing.T) string {
root := test.TempDir(t, "")
err := os.MkdirAll(filepath.Join(root, "a", "b", "c"), os.ModePerm)

View File

@@ -6,6 +6,7 @@ import (
"io/ioutil"
"os"
"path/filepath"
"runtime"
"testing"
)
@@ -17,17 +18,14 @@ func TempDir(t *testing.T, parent string) string {
// Automatically remove temp dir when the test is over.
t.Cleanup(func() {
err := os.RemoveAll(dir)
if err != nil {
t.Errorf("failed removing test repo %s", dir)
}
removeTestDir(t, dir)
})
return dir
}
func (r *Repo) init() {
err := run.Git("init", "--quiet", r.path).AndShutUp()
err := run.Git("init", "--quiet", "--initial-branch=main", r.path).AndShutUp()
checkFatal(r.t, err)
}
@@ -92,3 +90,17 @@ func checkFatal(t *testing.T, err error) {
t.Fatalf("failed making test repo: %+v", err)
}
}
// removeTestDir removes a test directory
func removeTestDir(t *testing.T, dir string) {
// Skip cleanup on Windows to avoid file locking issues in CI
// The CI runner environment is destroyed after tests anyway
if runtime.GOOS == "windows" {
return
}
err := os.RemoveAll(dir)
if err != nil {
t.Logf("warning: failed removing test repo %s: %v", dir, err)
}
}

View File

@@ -16,14 +16,14 @@ import (
//
// Examples of different compositions:
//
// - run.Git("clone", <URL>).AndShow()
// means running "git clone <URL>" and printing the progress into stdout
// - run.Git("clone", <URL>).AndShow()
// means running "git clone <URL>" and printing the progress into stdout
//
// - run.Git("branch","-a").OnRepo(<REPO>).AndCaptureLines()
// means running "git branch -a" inside <REPO> and returning a slice of branch names
// - run.Git("branch","-a").OnRepo(<REPO>).AndCaptureLines()
// means running "git branch -a" inside <REPO> and returning a slice of branch names
//
// - run.Git("pull").OnRepo(<REPO>).AndShutUp()
// means running "git pull" inside <REPO> and not printing any output
// - run.Git("pull").OnRepo(<REPO>).AndShutUp()
// means running "git pull" inside <REPO> and not printing any output
type Cmd struct {
cmd *exec.Cmd
args string

View File

@@ -5,7 +5,6 @@ import (
"fmt"
urlpkg "net/url"
"path"
"path/filepath"
"regexp"
"strings"
)
@@ -70,7 +69,7 @@ func ParseURL(rawURL string, defaultHost string, defaultScheme string) (url *url
return url, nil
}
// URLToPath cleans up the URL and converts it into a path string with correct separators for the current OS.
// 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.
@@ -82,18 +81,23 @@ func URLToPath(url urlpkg.URL, skipHost bool) string {
// Remove tilde (~) char from username.
url.Path = strings.ReplaceAll(url.Path, "~", "")
// Remove leading and trailing slashes (correct separator is added by the filepath.Join() below).
// Remove leading and trailing slashes.
url.Path = strings.Trim(url.Path, "/")
// Remove trailing ".git" from repo name.
url.Path = strings.TrimSuffix(url.Path, ".git")
// Replace slashes with separator correct for the current OS.
url.Path = strings.ReplaceAll(url.Path, "/", string(filepath.Separator))
if skipHost {
url.Host = ""
return url.Path
}
return filepath.Join(url.Host, url.Path)
if url.Host == "" {
return url.Path
}
if url.Path == "" {
return url.Host
}
return url.Host + "/" + url.Path
}