mirror of
https://github.com/YuzuZensai/Git-Identity-Audit.git
synced 2026-09-13 10:49:12 +00:00
✨ feat: Initial commit
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*.*.*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26.4"
|
||||||
|
- run: go build ./...
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: go test ./...
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: amd64
|
||||||
|
- goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26.4"
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
CGO_ENABLED: "0"
|
||||||
|
run: |
|
||||||
|
ext=""
|
||||||
|
if [ "$GOOS" = "windows" ]; then ext=".exe"; fi
|
||||||
|
mkdir -p dist
|
||||||
|
go build -o "dist/git-identity-audit-${GOOS}-${GOARCH}${ext}" .
|
||||||
|
- uses: actions/upload-artifact@v5
|
||||||
|
with:
|
||||||
|
name: git-identity-audit-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
path: dist/*
|
||||||
|
|
||||||
|
release:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
- uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: dist/*
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# Build artifacts
|
||||||
|
/git-identity-audit
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Generated reports
|
||||||
|
git-identity-audit-*.html
|
||||||
|
|
||||||
|
# Go tooling
|
||||||
|
/vendor/
|
||||||
|
|
||||||
|
# Editor/OS
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Output
|
||||||
|
*.html
|
||||||
|
!template.html
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Yuzu
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 🔍 Git Identity Audit
|
||||||
|
|
||||||
|
Scans every repository a GitHub personal access token can see and extracts
|
||||||
|
every contributor name/email identity found in commit metadata and
|
||||||
|
commit-message trailers (`Co-authored-by`, `Signed-off-by`, etc.), then
|
||||||
|
writes an interactive HTML report.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . [-token <pat>] [-output <path>] [-concurrency <n>]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `-token` - GitHub personal access token. Falls back to the `GITHUB_TOKEN`
|
||||||
|
env var, then an interactive prompt.
|
||||||
|
- `-output` - path to write the HTML report (default
|
||||||
|
`git-identity-audit-<unix-time>.html`).
|
||||||
|
- `-concurrency` - max number of repos to clone/scan in parallel (default `10`).
|
||||||
|
|
||||||
|
The token needs read access to whichever repos you want scanned (owned,
|
||||||
|
collaborator, and organization-member repos are all included).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Prebuilt binaries for linux/darwin/windows (amd64+arm64) are published to
|
||||||
|
[GitHub Releases](https://github.com/YuzuZensai/Git-Identity-Audit/releases)
|
||||||
|
whenever a tag is pushed:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module github.com/YuzuZensai/Git-Identity-Audit
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/vbauerster/mpb/v8 v8.12.1
|
||||||
|
golang.org/x/term v0.45.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
|
||||||
|
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/vbauerster/mpb/v8 v8.12.1 h1:pyj3yQ2ZGQJgUXm4h17QpR+eERaNz5OQ1ftPSEE/sMM=
|
||||||
|
github.com/vbauerster/mpb/v8 v8.12.1/go.mod h1:XLXRfStkw/6i5k0aQltijDHT1Z93fD1DVwmIdcFUp6k=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const apiBase = "https://api.github.com"
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
token string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(token string) *Client {
|
||||||
|
return &Client{
|
||||||
|
token: token,
|
||||||
|
http: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Actor struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
} `json:"actor"`
|
||||||
|
Repo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"repo"`
|
||||||
|
Payload struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Head string `json:"head"`
|
||||||
|
Before string `json:"before"`
|
||||||
|
} `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CurrentUserLogin() (string, error) {
|
||||||
|
var user struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
}
|
||||||
|
if _, err := c.get(apiBase+"/user", &user); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return user.Login, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) PublicEvents(login string) ([]Event, error) {
|
||||||
|
var all []Event
|
||||||
|
url := apiBase + "/users/" + login + "/events/public?per_page=100"
|
||||||
|
for url != "" {
|
||||||
|
var page []Event
|
||||||
|
next, err := c.get(url, &page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, page...)
|
||||||
|
url = next
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListAccessibleRepos() ([]Repo, error) {
|
||||||
|
var all []Repo
|
||||||
|
url := apiBase + "/user/repos?affiliation=owner,collaborator,organization_member&per_page=100"
|
||||||
|
for url != "" {
|
||||||
|
var page []Repo
|
||||||
|
next, err := c.get(url, &page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, page...)
|
||||||
|
url = next
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(url string, out any) (string, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusForbidden && resp.Header.Get("X-RateLimit-Remaining") == "0" {
|
||||||
|
reset := resp.Header.Get("X-RateLimit-Reset")
|
||||||
|
return "", fmt.Errorf("rate limited by GitHub API, resets at unix time %s", reset)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("GitHub API request to %s failed: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||||
|
return "", fmt.Errorf("decoding response from %s: %w", url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseNextLink(resp.Header.Get("Link")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNextLink(header string) string {
|
||||||
|
if header == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(header, ",") {
|
||||||
|
segments := strings.Split(part, ";")
|
||||||
|
if len(segments) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(segments[1]) == `rel="next"` {
|
||||||
|
url := strings.TrimSpace(segments[0])
|
||||||
|
return strings.Trim(url, "<>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/YuzuZensai/Git-Identity-Audit/internal/github"
|
||||||
|
"github.com/YuzuZensai/Git-Identity-Audit/internal/scanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed template.html
|
||||||
|
var templateHTML string
|
||||||
|
|
||||||
|
var htmlTemplate = template.Must(template.New("report").Parse(templateHTML))
|
||||||
|
|
||||||
|
func WriteHTML(path string, entries []scanner.Entry, events []github.Event) error {
|
||||||
|
dataJSON, err := json.Marshal(entries)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling entries: %w", err)
|
||||||
|
}
|
||||||
|
eventsJSON, err := json.Marshal(events)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
return htmlTemplate.Execute(f, struct {
|
||||||
|
DataJSON template.JS
|
||||||
|
EventsJSON template.JS
|
||||||
|
GeneratedAt string
|
||||||
|
}{
|
||||||
|
DataJSON: template.JS(dataJSON),
|
||||||
|
EventsJSON: template.JS(eventsJSON),
|
||||||
|
GeneratedAt: time.Now().Format(time.RFC1123),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type summaryRow struct {
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Count int
|
||||||
|
RepoCounts map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrintSummary(entries []scanner.Entry) {
|
||||||
|
rows := map[string]*summaryRow{}
|
||||||
|
for _, e := range entries {
|
||||||
|
key := e.Name + "\x00" + e.Email
|
||||||
|
row, ok := rows[key]
|
||||||
|
if !ok {
|
||||||
|
row = &summaryRow{Name: e.Name, Email: e.Email, RepoCounts: map[string]int{}}
|
||||||
|
rows[key] = row
|
||||||
|
}
|
||||||
|
row.Count++
|
||||||
|
row.RepoCounts[e.Repo]++
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted := make([]*summaryRow, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
sorted = append(sorted, r)
|
||||||
|
}
|
||||||
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Count > sorted[j].Count })
|
||||||
|
|
||||||
|
fmt.Printf("\n%d unique identities found:\n\n", len(sorted))
|
||||||
|
for i, r := range sorted {
|
||||||
|
repoNames := make([]string, 0, len(r.RepoCounts))
|
||||||
|
for repo := range r.RepoCounts {
|
||||||
|
repoNames = append(repoNames, repo)
|
||||||
|
}
|
||||||
|
sort.Strings(repoNames)
|
||||||
|
|
||||||
|
fmt.Printf("%3d. %s <%s>\n", i+1, r.Name, r.Email)
|
||||||
|
fmt.Printf(" %d commits across %d repo(s):\n", r.Count, len(repoNames))
|
||||||
|
for _, repo := range repoNames {
|
||||||
|
fmt.Printf(" - %s (%d)\n", repo, r.RepoCounts[repo])
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,139 @@
|
|||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Field string // "Author", "Committer", or a trailer name like "Co-authored-by"
|
||||||
|
Repo string
|
||||||
|
CommitSHA string
|
||||||
|
Date string
|
||||||
|
PushedByLogin string `json:",omitempty"` // GitHub login that pushed this commit, per the public Events API
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
unitSep = "\x1f"
|
||||||
|
recordSep = "\x1e"
|
||||||
|
)
|
||||||
|
|
||||||
|
var logFormat = "%H" + unitSep + "%an" + unitSep + "%ae" + unitSep + "%cn" + unitSep + "%ce" + unitSep + "%aI" + unitSep + "%B" + recordSep
|
||||||
|
|
||||||
|
var trailerPattern = regexp.MustCompile(`(?m)^([A-Za-z][A-Za-z-]*-by):\s*(.+?)\s*<([^>]+)>\s*$`)
|
||||||
|
|
||||||
|
func Clone(repoFullName, repoURL, token string) (dir string, cleanup func(), err error) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "git-identity-audit-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("creating temp dir: %w", err)
|
||||||
|
}
|
||||||
|
cleanup = func() { os.RemoveAll(tmpDir) }
|
||||||
|
|
||||||
|
authURL := injectToken(repoURL, token)
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "clone", "--bare", "--filter=blob:none", "--quiet", authURL, tmpDir)
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("cloning %s: %w: %s", repoFullName, err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpDir, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CountCommits(gitDir string) (int, error) {
|
||||||
|
cmd := exec.Command("git", "--git-dir", gitDir, "rev-list", "--all", "--count")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("counting commits: %w", err)
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("parsing commit count: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func StreamLog(gitDir, repoFullName string, onCommit func(entries []Entry)) error {
|
||||||
|
cmd := exec.Command("git", "--git-dir", gitDir, "log", "--all", "--format="+logFormat)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening log stream: %w", err)
|
||||||
|
}
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("starting log stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||||
|
scanner.Split(splitOnRecordSep)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
record := strings.Trim(scanner.Text(), "\n")
|
||||||
|
if record == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
onCommit(parseRecord(record, repoFullName))
|
||||||
|
}
|
||||||
|
scanErr := scanner.Err()
|
||||||
|
|
||||||
|
waitErr := cmd.Wait()
|
||||||
|
if waitErr != nil {
|
||||||
|
return fmt.Errorf("reading log for %s: %w: %s", repoFullName, waitErr, stderr.String())
|
||||||
|
}
|
||||||
|
if scanErr != nil {
|
||||||
|
return fmt.Errorf("reading log for %s: %w", repoFullName, scanErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitOnRecordSep(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||||
|
if atEOF && len(data) == 0 {
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
if i := bytes.IndexByte(data, recordSep[0]); i >= 0 {
|
||||||
|
return i + 1, data[:i], nil
|
||||||
|
}
|
||||||
|
if atEOF {
|
||||||
|
return len(data), data, nil
|
||||||
|
}
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectToken(repoURL, token string) string {
|
||||||
|
const prefix = "https://"
|
||||||
|
if !strings.HasPrefix(repoURL, prefix) {
|
||||||
|
return repoURL
|
||||||
|
}
|
||||||
|
return prefix + "x-access-token:" + token + "@" + strings.TrimPrefix(repoURL, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRecord(record, repoFullName string) []Entry {
|
||||||
|
fields := strings.SplitN(record, unitSep, 7)
|
||||||
|
if len(fields) < 7 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sha, authorName, authorEmail, committerName, committerEmail, date, body := fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], fields[6]
|
||||||
|
|
||||||
|
entries := []Entry{
|
||||||
|
{Name: authorName, Email: authorEmail, Field: "Author", Repo: repoFullName, CommitSHA: sha, Date: date},
|
||||||
|
{Name: committerName, Email: committerEmail, Field: "Committer", Repo: repoFullName, CommitSHA: sha, Date: date},
|
||||||
|
}
|
||||||
|
for _, match := range trailerPattern.FindAllStringSubmatch(body, -1) {
|
||||||
|
entries = append(entries, Entry{Name: match[2], Email: match[3], Field: match[1], Repo: repoFullName, CommitSHA: sha, Date: date})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package scanner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runGit(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanExtractsAuthorCommitterAndTrailers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
runGit(t, dir, "config", "user.email", "author@example.com")
|
||||||
|
runGit(t, dir, "config", "user.name", "Author One")
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("hello"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "file.txt")
|
||||||
|
runGit(t, dir, "commit", "-q", "-m",
|
||||||
|
"Initial commit\n\nCo-authored-by: Helper Two <helper@example.com>\nSigned-off-by: Author One <author@example.com>")
|
||||||
|
|
||||||
|
runGit(t, dir, "-c", "user.name=Committer Three", "-c", "user.email=committer@example.com",
|
||||||
|
"commit", "-q", "--allow-empty", "-m", "second commit")
|
||||||
|
|
||||||
|
gitDir, cleanup, err := Clone("smoke/test", "file://"+dir, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Clone failed: %v", err)
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
count, err := CountCommits(gitDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountCommits failed: %v", err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("expected 2 commits, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
commitsSeen := 0
|
||||||
|
if err := StreamLog(gitDir, "smoke/test", func(e []Entry) {
|
||||||
|
commitsSeen++
|
||||||
|
entries = append(entries, e...)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("StreamLog failed: %v", err)
|
||||||
|
}
|
||||||
|
if commitsSeen != 2 {
|
||||||
|
t.Errorf("expected onCommit called 2 times, got %d", commitsSeen)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, e := range entries {
|
||||||
|
got[e.Field+"|"+e.Name+"|"+e.Email] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"Author|Author One|author@example.com",
|
||||||
|
"Committer|Author One|author@example.com",
|
||||||
|
"Co-authored-by|Helper Two|helper@example.com",
|
||||||
|
"Signed-off-by|Author One|author@example.com",
|
||||||
|
"Author|Author One|author@example.com",
|
||||||
|
"Committer|Committer Three|committer@example.com",
|
||||||
|
}
|
||||||
|
for _, w := range want {
|
||||||
|
if !got[w] {
|
||||||
|
all := make([]string, 0, len(got))
|
||||||
|
for k := range got {
|
||||||
|
all = append(all, k)
|
||||||
|
}
|
||||||
|
sort.Strings(all)
|
||||||
|
t.Errorf("expected entry %q not found; got: %v", w, all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/YuzuZensai/Git-Identity-Audit/internal/github"
|
||||||
|
"github.com/YuzuZensai/Git-Identity-Audit/internal/report"
|
||||||
|
"github.com/YuzuZensai/Git-Identity-Audit/internal/scanner"
|
||||||
|
"github.com/vbauerster/mpb/v8"
|
||||||
|
"github.com/vbauerster/mpb/v8/decor"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
var overallBarStyle = mpb.BarStyle().Lbound("[").Filler("=").Tip(">").Padding("-").Rbound("]")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
tokenFlag := flag.String("token", "", "GitHub personal access token (prefer GITHUB_TOKEN env var or the interactive prompt instead)")
|
||||||
|
output := flag.String("output", fmt.Sprintf("git-identity-audit-%d.html", time.Now().Unix()), "path to write the HTML report")
|
||||||
|
concurrency := flag.Int("concurrency", 10, "max number of repos to clone/scan in parallel")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
token, err := resolveToken(*tokenFlag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := github.NewClient(token)
|
||||||
|
|
||||||
|
fmt.Println("Discovering accessible repositories...")
|
||||||
|
repos, err := client.ListAccessibleRepos()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing repositories: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Found %d repositories.\n", len(repos))
|
||||||
|
|
||||||
|
entries, scanned, failed, warnings := scanAll(repos, token, *concurrency)
|
||||||
|
for _, w := range warnings {
|
||||||
|
fmt.Fprintln(os.Stderr, w)
|
||||||
|
}
|
||||||
|
fmt.Printf("Scanned %d/%d repos successfully (%d failed).\n", scanned, len(repos), failed)
|
||||||
|
|
||||||
|
events, err := fetchEvents(client)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning: skipping GitHub events correlation:", err)
|
||||||
|
}
|
||||||
|
correlateEvents(entries, events)
|
||||||
|
|
||||||
|
if err := report.WriteHTML(*output, entries, events); err != nil {
|
||||||
|
return fmt.Errorf("writing HTML report: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Wrote %d identity entries to %s\n", len(entries), *output)
|
||||||
|
|
||||||
|
report.PrintSummary(entries)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveToken(flagToken string) (string, error) {
|
||||||
|
if flagToken != "" {
|
||||||
|
return flagToken, nil
|
||||||
|
}
|
||||||
|
if envToken := os.Getenv("GITHUB_TOKEN"); envToken != "" {
|
||||||
|
return envToken, nil
|
||||||
|
}
|
||||||
|
fmt.Print("GitHub PAT: ")
|
||||||
|
tokenBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading token: %w", err)
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(string(tokenBytes))
|
||||||
|
if token == "" {
|
||||||
|
return "", fmt.Errorf("no token provided")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchEvents(client *github.Client) ([]github.Event, error) {
|
||||||
|
login, err := client.CurrentUserLogin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolving token owner: %w", err)
|
||||||
|
}
|
||||||
|
events, err := client.PublicEvents(login)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetching public events for %s: %w", login, err)
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func correlateEvents(entries []scanner.Entry, events []github.Event) {
|
||||||
|
heads := make(map[string]string, len(events))
|
||||||
|
for _, e := range events {
|
||||||
|
if e.Type != "PushEvent" || e.Payload.Head == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
heads[e.Repo.Name+"@"+e.Payload.Head] = e.Actor.Login
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Field != "Committer" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if login, ok := heads[entries[i].Repo+"@"+entries[i].CommitSHA]; ok {
|
||||||
|
entries[i].PushedByLogin = login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAll(repos []github.Repo, token string, concurrency int) ([]scanner.Entry, int, int, []string) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
entries []scanner.Entry
|
||||||
|
scanned int
|
||||||
|
failed int
|
||||||
|
warnings []string
|
||||||
|
wg sync.WaitGroup
|
||||||
|
sem = make(chan struct{}, concurrency)
|
||||||
|
)
|
||||||
|
|
||||||
|
progress := mpb.New(mpb.WithWidth(50))
|
||||||
|
overallBar := progress.New(int64(len(repos)), overallBarStyle,
|
||||||
|
mpb.BarPriority(math.MaxInt),
|
||||||
|
mpb.PrependDecorators(decor.Name("total repos", decor.WC{W: 14})),
|
||||||
|
mpb.AppendDecorators(decor.CountersNoUnit("%d / %d"), decor.Name(" "), decor.Percentage(decor.WC{W: 5})),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, repo := range repos {
|
||||||
|
repo := repo
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
defer overallBar.Increment()
|
||||||
|
|
||||||
|
err := scanRepo(progress, repo, token, &mu, &entries)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
warnings = append(warnings, fmt.Sprintf("warning: skipping %s: %v", repo.FullName, err))
|
||||||
|
} else {
|
||||||
|
scanned++
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
progress.Wait()
|
||||||
|
|
||||||
|
return entries, scanned, failed, warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRepo(progress *mpb.Progress, repo github.Repo, token string, mu *sync.Mutex, entries *[]scanner.Entry) error {
|
||||||
|
gitDir, cleanup, err := scanner.Clone(repo.FullName, repo.CloneURL, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
count, err := scanner.CountCommits(gitDir)
|
||||||
|
if err != nil || count == 0 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
repoBar := progress.New(int64(count), overallBarStyle,
|
||||||
|
mpb.PrependDecorators(decor.Name(repo.FullName, decor.WCSyncSpaceR)),
|
||||||
|
mpb.AppendDecorators(decor.CountersNoUnit("%d / %d commits")),
|
||||||
|
)
|
||||||
|
|
||||||
|
err = scanner.StreamLog(gitDir, repo.FullName, func(e []scanner.Entry) {
|
||||||
|
mu.Lock()
|
||||||
|
*entries = append(*entries, e...)
|
||||||
|
mu.Unlock()
|
||||||
|
repoBar.Increment()
|
||||||
|
})
|
||||||
|
repoBar.SetCurrent(int64(count))
|
||||||
|
return err
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user