diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..dca5599 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + groups: + gomod: + patterns: ["*"] + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0475ce..975219a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: - name: Coverage summary run: go tool cover -func=coverage.out | tail -n 1 + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./... + lint: name: golangci-lint runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37555ee..fef6725 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,18 +12,18 @@ jobs: goreleaser: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v5 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: - version: latest + version: v2.18.2 args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a919b3..519c345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] — planned as 1.4.0 + +### Security +- Scanning no longer runs programs configured in a repository's `.git/config` + (`core.fsmonitor`). Previously, scanning a folder containing a crafted repo + (e.g. an unzipped archive) could execute arbitrary code. +- Status checks run with `--no-optional-locks`, so git-scope no longer + rewrites `.git/index` and cannot cause `index.lock` errors in your own git + commands. git-scope is now strictly read-only during scans. +- Git never prompts for credentials during bulk fetch (`GIT_TERMINAL_PROMPT=0`). +- The install script verifies the archive's SHA-256 against the release's + `checksums.txt` and fails on HTTP errors. + +### Added +- Attention summary and default Attention sort: repos ranked into + Action / Watch / Clean tiers (#36). +- Bulk fetch across all repos with `F` (#31). +- Ahead/behind commit columns (#16); repos ahead or behind count as dirty (#15). +- Scoop installation on Windows (#22). +- Page size adapts to terminal height (#21). + +### Fixed +- `git-scope --version` reported `1.0.1` for every release: the version was + a `const`, which `-ldflags -X` cannot override. +- The star nudge now tracks the real running version. diff --git a/cmd/git-scope/main.go b/cmd/git-scope/main.go index 6b90468..c56a4dd 100644 --- a/cmd/git-scope/main.go +++ b/cmd/git-scope/main.go @@ -11,11 +11,13 @@ import ( "github.com/Bharath-code/git-scope/internal/browser" "github.com/Bharath-code/git-scope/internal/config" + "github.com/Bharath-code/git-scope/internal/nudge" "github.com/Bharath-code/git-scope/internal/scan" "github.com/Bharath-code/git-scope/internal/tui" ) -const version = "1.0.1" +// version is set at release time via -ldflags "-X main.version=…" (must be a var). +var version = "dev" type options struct { ConfigPath string @@ -55,6 +57,7 @@ func printVersion() { } func main() { + nudge.Version = version flag.Usage = usage opts := parseFlags() diff --git a/go.mod b/go.mod index b573a85..1a6e356 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/Bharath-code/git-scope go 1.26.0 +toolchain go1.26.8 + require ( github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.0 diff --git a/internal/gitops/gitops.go b/internal/gitops/gitops.go index c79d8d2..e5a4e9c 100644 --- a/internal/gitops/gitops.go +++ b/internal/gitops/gitops.go @@ -8,11 +8,11 @@ package gitops import ( "context" "fmt" - "os/exec" "strings" "sync" "time" + "github.com/Bharath-code/git-scope/internal/gitstatus" "github.com/Bharath-code/git-scope/internal/model" ) @@ -112,8 +112,7 @@ func fetchOne(repo model.Repo) ActionResult { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - cmd := exec.CommandContext(ctx, "git", "fetch", "--all", "--quiet") - cmd.Dir = repo.Path + cmd := gitstatus.Command(ctx, repo.Path, "fetch", "--all", "--quiet") out, err := cmd.CombinedOutput() if err != nil { res.Status = StatusFailed @@ -127,9 +126,7 @@ func fetchOne(repo model.Repo) ActionResult { // hasRemote reports whether the repository has at least one configured remote. func hasRemote(path string) bool { - cmd := exec.Command("git", "remote") - cmd.Dir = path - out, err := cmd.Output() + out, err := gitstatus.Command(context.Background(), path, "remote").Output() if err != nil { return false } diff --git a/internal/gitstatus/gitstatus.go b/internal/gitstatus/gitstatus.go index 6091569..748e46e 100644 --- a/internal/gitstatus/gitstatus.go +++ b/internal/gitstatus/gitstatus.go @@ -1,7 +1,9 @@ package gitstatus import ( + "context" "fmt" + "os" "os/exec" "strconv" "strings" @@ -43,12 +45,23 @@ func Status(repoPath string) (model.RepoStatus, error) { return status, nil } -// runGit is a helper that executes a git command with the given arguments -// in the specified directory and returns its stdout output -func runGit(dir string, args ...string) ([]byte, error) { - cmd := exec.Command("git", args...) +// safeArgs neutralise repo config that would make a read turn into a write or +// an exec: core.fsmonitor names a program git runs during status, and +// --no-optional-locks stops status from rewriting .git/index. +var safeArgs = []string{"-c", "core.fsmonitor=false", "--no-optional-locks"} + +// Command builds a git command that is safe to run inside repositories +// git-scope did not create. Every git invocation should go through it. +func Command(ctx context.Context, dir string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "git", append(append([]string{}, safeArgs...), args...)...) cmd.Dir = dir - return cmd.Output() + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + return cmd +} + +// runGit runs a git command in dir and returns its stdout. +func runGit(dir string, args ...string) ([]byte, error) { + return Command(context.Background(), dir, args...).Output() } // applyBranchHeader parses porcelain v2 branch metadata lines and updates diff --git a/internal/gitstatus/gitstatus_test.go b/internal/gitstatus/gitstatus_test.go index a60ca5f..b208fd5 100644 --- a/internal/gitstatus/gitstatus_test.go +++ b/internal/gitstatus/gitstatus_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "testing" "github.com/Bharath-code/git-scope/internal/model" @@ -170,3 +171,25 @@ func TestStatus_NonRepoReturnsError(t *testing.T) { t.Error("expected error for non-git directory, got nil") } } + +// A repo's own config must not be able to make git-scope execute code. +// core.fsmonitor names a program git runs during `git status`. +func TestStatus_IgnoresRepoFsmonitor(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a shell script hook") + } + dir := initRepoWithCommit(t) + marker := filepath.Join(t.TempDir(), "executed") + hook := filepath.Join(dir, "hook.sh") + if err := os.WriteFile(hook, []byte("#!/bin/sh\ntouch "+marker+"\n"), 0755); err != nil { + t.Fatal(err) + } + gitRun(t, dir, "config", "core.fsmonitor", hook) + + if _, err := Status(dir); err != nil { + t.Fatalf("Status: %v", err) + } + if _, err := os.Stat(marker); err == nil { + t.Fatal("core.fsmonitor from repo config was executed") + } +} diff --git a/internal/nudge/nudge.go b/internal/nudge/nudge.go index 926b7fc..e42959f 100644 --- a/internal/nudge/nudge.go +++ b/internal/nudge/nudge.go @@ -6,8 +6,9 @@ import ( "path/filepath" ) -// Version is the current app version - used to track per-version nudge -const Version = "1.3.0" +// Version is the running app version, set by main at startup; the nudge +// shows at most once per version. +var Version = "dev" // GitHubRepoURL is the URL to open when user presses S const GitHubRepoURL = "https://github.com/Bharath-code/git-scope" diff --git a/internal/stats/contributions.go b/internal/stats/contributions.go index 17107ef..a38ac7f 100644 --- a/internal/stats/contributions.go +++ b/internal/stats/contributions.go @@ -1,11 +1,12 @@ package stats import ( - "os/exec" + "context" "strconv" "strings" "time" + "github.com/Bharath-code/git-scope/internal/gitstatus" "github.com/Bharath-code/git-scope/internal/model" ) @@ -50,9 +51,7 @@ func GetContributions(repos []model.Repo, weeks int) (*ContributionData, error) // getRepoCommits returns a list of commit dates (YYYY-MM-DD) from a repo func getRepoCommits(repoPath, sinceDate string) ([]string, error) { - cmd := exec.Command("git", "log", "--since="+sinceDate, "--format=%ad", "--date=short") - cmd.Dir = repoPath - out, err := cmd.Output() + out, err := gitstatus.Command(context.Background(), repoPath, "log", "--since="+sinceDate, "--format=%ad", "--date=short").Output() if err != nil { return nil, err } diff --git a/internal/stats/timeline.go b/internal/stats/timeline.go index a852873..858d838 100644 --- a/internal/stats/timeline.go +++ b/internal/stats/timeline.go @@ -1,11 +1,12 @@ package stats import ( - "os/exec" + "context" "sort" "strings" "time" + "github.com/Bharath-code/git-scope/internal/gitstatus" "github.com/Bharath-code/git-scope/internal/model" ) @@ -69,9 +70,7 @@ func GetTimeline(repos []model.Repo) (*TimelineData, error) { // getLastCommitMessage gets the last commit message for a repo func getLastCommitMessage(repoPath string) string { - cmd := exec.Command("git", "log", "-1", "--format=%s") - cmd.Dir = repoPath - out, err := cmd.Output() + out, err := gitstatus.Command(context.Background(), repoPath, "log", "-1", "--format=%s").Output() if err != nil { return "" } diff --git a/scripts/install.sh b/scripts/install.sh index 7687777..b169775 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -83,10 +83,28 @@ main() { trap "rm -rf ${TMP_DIR}" EXIT # Download and extract - if ! curl -sSL "${DOWNLOAD_URL}" -o "${TMP_DIR}/archive.tar.gz"; then + if ! curl -fsSL "${DOWNLOAD_URL}" -o "${TMP_DIR}/archive.tar.gz"; then error "Failed to download. Check if the release exists for your platform." fi + CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt" + if ! curl -fsSL "${CHECKSUMS_URL}" -o "${TMP_DIR}/checksums.txt"; then + error "Failed to download checksums.txt; refusing to install an unverified binary." + fi + EXPECTED=$(grep " ${ARCHIVE_NAME}\$" "${TMP_DIR}/checksums.txt" | awk '{print $1}') + if [ -z "$EXPECTED" ]; then + error "No checksum listed for ${ARCHIVE_NAME}." + fi + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "${TMP_DIR}/archive.tar.gz" | awk '{print $1}') + else + ACTUAL=$(shasum -a 256 "${TMP_DIR}/archive.tar.gz" | awk '{print $1}') + fi + if [ "$EXPECTED" != "$ACTUAL" ]; then + error "Checksum mismatch for ${ARCHIVE_NAME} (expected ${EXPECTED}, got ${ACTUAL})." + fi + info "Checksum verified." + tar xzf "${TMP_DIR}/archive.tar.gz" -C "${TMP_DIR}" # Create install directory if it doesn't exist