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
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,35 @@ So this tool snapshots those endpoints, archives every raw response, and derives
trends from the archive. Everything stays on your machine — there is no service,
no account and no telemetry.

## Quick start
## Install

```sh
go build -o bin/git-stats ./cmd/git-stats
curl -fsSL https://raw.githubusercontent.com/dejo1307/git-stats/main/install.sh | sh
```

Installs the latest release into `$HOME/.local/bin`, verifying the published
SHA-256 checksum before it does. Set `GIT_STATS_INSTALL_DIR` to install elsewhere,
or `GIT_STATS_VERSION` to pin a version. Linux, macOS (Intel and Apple Silicon) and
Windows via Git Bash / MSYS2 are all covered.

Prefer to build it yourself, or want a platform the releases don't cover:

```sh
go install github.com/dejo1307/git-stats/cmd/git-stats@latest
```

Binaries for every platform are also attached to each
[release](https://github.com/dejo1307/git-stats/releases) as
`git-stats-<version>-<os>-<arch>.tar.gz`, each with a `.sha256` beside it.

## Quick start

```sh
cp .env.example .env && chmod 600 .env
$EDITOR .env # set GIT_STATS_REPO=owner/name
$EDITOR .env # set GIT_STATS_REPO=owner/name

./bin/git-stats collect # take a snapshot
./bin/git-stats report # see where things stand
git-stats collect # take a snapshot
git-stats report # see where things stand
```

`collect` works with no token at all for release counters. To get traffic data you
Expand Down Expand Up @@ -279,7 +298,15 @@ architecture with `file` before publishing, so a silent fallback to the host
architecture fails the build instead of shipping a mislabelled binary.

Release assets follow the same `<prefix>-<version>-<os>-<arch>.<ext>` scheme
git-stats parses, so it can track its own releases with no configuration.
git-stats parses, so it can track its own releases with no configuration. And
because [install.sh](install.sh) fetches each artifact's `.sha256` alongside it,
git-stats' own "scripted installs" figure means what it claims to.

[install.sh](install.sh) is POSIX `sh`, deliberately — no bashisms and no
`set -o pipefail`. A piped script never honours its own shebang; the interpreter
on the left of the pipe runs it, and on Debian and Ubuntu that is dash, which
rejects `pipefail` outright. A bash-only installer dies on `| sh` before it does
any real work, on the most common Linux systems there are.

CI on every pull request runs tests with the race detector, the same six-target
cross-build, `golangci-lint`, `govulncheck`, and an architectural regression gate
Expand Down
2 changes: 1 addition & 1 deletion cmd/git-stats/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func run(args []string) error {
case "rebuild":
return runRebuild(args[1:])
case "version", "-version", "--version":
fmt.Println("git-stats", version.Version)
fmt.Println("git-stats", version.String())
return nil
case "help", "-h", "--help":
usage()
Expand Down
119 changes: 119 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/bin/sh
# install.sh — Install the latest git-stats release.
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/dejo1307/git-stats/main/install.sh | sh
#
# Environment:
# GIT_STATS_VERSION install this version instead of the latest (e.g. 0.1.0)
# GIT_STATS_INSTALL_DIR install here instead of $HOME/.local/bin
#
# This script is deliberately POSIX sh, with no bashisms and no `set -o pipefail`.
# A piped script never gets to honour its own shebang — the interpreter on the
# left of the pipe runs it — and on Debian and Ubuntu /bin/sh is dash, which
# rejects `set -o pipefail` outright. A bash-only installer therefore dies before
# its first line of real work on the most common Linux systems. Keep it POSIX.

set -eu

REPO="dejo1307/git-stats"

fail() {
echo "Error: $*" >&2
exit 1
}

# --- Detect OS ---
OS="$(uname -s)"
case "$OS" in
Linux) OS=linux ;;
Darwin) OS=darwin ;;
# Git Bash, MSYS2 and Cygwin all report a decorated name; git-stats ships
# Windows binaries, so install rather than refuse.
MINGW*|MSYS*|CYGWIN*) OS=windows ;;
*) fail "unsupported OS: $OS — see https://github.com/$REPO/releases for a manual download" ;;
esac

# --- Detect architecture ---
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) fail "unsupported architecture: $ARCH — see https://github.com/$REPO/releases" ;;
esac

# --- Resolve version ---
VERSION="${GIT_STATS_VERSION:-}"
if [ -z "$VERSION" ]; then
VERSION="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \
| grep -m1 '"tag_name"' \
| sed -e 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}//' -e 's/".*//')"
fi
[ -n "$VERSION" ] || fail "could not determine the latest version"
VERSION="${VERSION#v}"

BASE="git-stats-${VERSION}-${OS}-${ARCH}"
ASSET="${BASE}.tar.gz"
SHASUM="${BASE}.sha256"
DL="https://github.com/$REPO/releases/download/v${VERSION}"

echo "==> Downloading git-stats v${VERSION} for ${OS}/${ARCH} ..."

TMP="$(mktemp -d)"
# shellcheck disable=SC2064 # expand TMP now, while it is still set
trap "rm -rf \"$TMP\"" EXIT INT TERM

curl -fsSL -o "$TMP/$ASSET" "$DL/$ASSET" \
|| fail "no release asset $ASSET — check https://github.com/$REPO/releases"
curl -fsSL -o "$TMP/$SHASUM" "$DL/$SHASUM" \
|| fail "no checksum $SHASUM for $ASSET"

# --- Verify ---
# The checksum is fetched and checked, never skipped: a truncated or tampered
# download that still extracts is exactly what this catches. sha256sum is the
# GNU/Linux name, shasum the one macOS ships.
echo "==> Verifying checksum ..."
if command -v sha256sum >/dev/null 2>&1; then
(cd "$TMP" && sha256sum -c "$SHASUM") || fail "checksum verification failed"
elif command -v shasum >/dev/null 2>&1; then
(cd "$TMP" && shasum -a 256 -c "$SHASUM") || fail "checksum verification failed"
else
fail "neither sha256sum nor shasum found — cannot verify the download"
fi

echo "==> Extracting ..."
tar xzf "$TMP/$ASSET" -C "$TMP"

BIN="$BASE"
[ "$OS" = "windows" ] && BIN="${BIN}.exe"
[ -f "$TMP/$BIN" ] || fail "archive did not contain $BIN"

# --- Install ---
INSTALL_DIR="${GIT_STATS_INSTALL_DIR:-$HOME/.local/bin}"
mkdir -p "$INSTALL_DIR"

TARGET="git-stats"
[ "$OS" = "windows" ] && TARGET="git-stats.exe"

if ! install -m 755 "$TMP/$BIN" "$INSTALL_DIR/$TARGET" 2>/dev/null; then
# BusyBox and some minimal images ship no install(1).
cp "$TMP/$BIN" "$INSTALL_DIR/$TARGET" && chmod 755 "$INSTALL_DIR/$TARGET"
fi

echo "==> git-stats v${VERSION} installed to $INSTALL_DIR/$TARGET"

# --- Point the user at the next step ---
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*)
echo ""
echo "$INSTALL_DIR is not in your PATH. Add it:"
echo " export PATH=\"$INSTALL_DIR:\$PATH\""
;;
esac

echo ""
echo "Next:"
echo " git-stats version"
echo " echo 'GIT_STATS_REPO=owner/name' > .env # the repository to track"
echo " git-stats collect"
35 changes: 32 additions & 3 deletions internal/version/version.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
// Package version carries the build's version string.
// Package version reports the build's version string.
package version

import (
"runtime/debug"
"strings"
)

// Version is the release this binary was built from, without a leading "v".
// The release workflow injects the tag with
//
// -ldflags "-X github.com/dejo1307/git-stats/internal/version.Version=1.2.3"
//
// so an unstamped local build reports "dev" rather than claiming a release.
var Version = "dev"
// It is empty in any build that did not go through that workflow; call String
// rather than reading it directly.
var Version = ""

// String reports the version, falling back to the module version the Go
// toolchain records and finally to "dev".
//
// The fallback is what makes `go install github.com/dejo1307/git-stats/...@v1.2.3`
// honest: that path applies no ldflags, so without it every proxy-installed
// binary would claim to be "dev" while being a tagged release. It also gives a
// build from a git working tree something better than "dev" — the toolchain
// derives a version from the nearest tag and marks uncommitted changes, so a
// local build reports e.g. "1.2.3+dirty".
func String() string {
if Version != "" {
return Version
}
if info, ok := debug.ReadBuildInfo(); ok {
// "(devel)" is what the toolchain records when it has no version to
// report at all — no tag, or no VCS information stamped in.
if v := info.Main.Version; v != "" && v != "(devel)" {
return strings.TrimPrefix(v, "v")
}
}
return "dev"
}
31 changes: 31 additions & 0 deletions internal/version/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package version

import "testing"

// An injected version wins over anything the toolchain recorded — that is the
// path every published release takes.
func TestStringPrefersInjectedVersion(t *testing.T) {
prev := Version
t.Cleanup(func() { Version = prev })

Version = "1.2.3"
if got := String(); got != "1.2.3" {
t.Errorf("String() = %q, want 1.2.3", got)
}
}

// With nothing injected, String must still name something — never an empty
// string, and never a leading "v" that would not match a release tag's assets.
func TestStringFallsBack(t *testing.T) {
prev := Version
t.Cleanup(func() { Version = prev })

Version = ""
got := String()
if got == "" {
t.Fatal("String() = empty, want a version or \"dev\"")
}
if got[0] == 'v' {
t.Errorf("String() = %q, want no leading \"v\"", got)
}
}
Loading