Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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: ["*"]
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion cmd/git-scope/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,6 +57,7 @@ func printVersion() {
}

func main() {
nudge.Version = version
flag.Usage = usage

opts := parseFlags()
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions internal/gitops/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
23 changes: 18 additions & 5 deletions internal/gitstatus/gitstatus.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package gitstatus

import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions internal/gitstatus/gitstatus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"

"github.com/Bharath-code/git-scope/internal/model"
Expand Down Expand Up @@ -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")
}
}
5 changes: 3 additions & 2 deletions internal/nudge/nudge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 3 additions & 4 deletions internal/stats/contributions.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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
}
Expand Down
7 changes: 3 additions & 4 deletions internal/stats/timeline.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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 ""
}
Expand Down
20 changes: 19 additions & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading