Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

389 lines
13 KiB
Go

// Package githooks installs local git hooks that re-run gortex
// commands after specified events. The implementation is read-only on
// git itself — it shells out only for `git rev-parse` and
// `git config --get core.hooksPath`. Hook files are managed by
// markers so we can install and uninstall idempotently without
// destroying any user-authored content.
package githooks
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Begin and end markers wrap the gortex-managed block inside a hook
// file. The MARKER_BEGIN / MARKER_END convention is checked by every
// install/uninstall pass and never re-written verbatim by the user.
//
// These exported constants preserve the post-commit form for callers
// that pre-date multi-hook support; new code goes through markerBegin
// / markerEnd which derive the strings from the hook name (so
// post-merge gets its own pair).
const (
MarkerBegin = "# gortex-managed:post-commit:begin"
MarkerEnd = "# gortex-managed:post-commit:end"
)
// SupportedHooks enumerates the hook names that InstallHook accepts.
// Anything else returns an error so we don't silently scatter our
// markers into hooks we haven't audited.
var SupportedHooks = []string{"post-commit", "post-merge", "post-checkout"}
func isSupportedHook(name string) bool {
for _, h := range SupportedHooks {
if h == name {
return true
}
}
return false
}
func markerBegin(hook string) string { return "# gortex-managed:" + hook + ":begin" }
func markerEnd(hook string) string { return "# gortex-managed:" + hook + ":end" }
// InstallOpts controls what the installed hook runs.
type InstallOpts struct {
// Binary is the gortex executable path. Defaults to "gortex"
// (found via $PATH at runtime).
Binary string
// RegenMermaid toggles `gortex export --format mermaid --scope all`.
RegenMermaid bool
// MermaidOutDir is where the mermaid exporter writes its files.
// Defaults to "docs/architecture/".
MermaidOutDir string
// RegenWiki toggles a `gortex wiki .` run.
RegenWiki bool
// WikiOutDir is where the wiki is written. Defaults to "wiki".
WikiOutDir string
// RegenDocs toggles a `gortex docs . --out CHANGELOG_AUTO.md` run.
RegenDocs bool
// DocsOutPath is the docs bundle output path. Defaults to
// "CHANGELOG_AUTO.md".
DocsOutPath string
// RegenChurn toggles a `gortex enrich churn` run. The companion
// MCP tool get_churn_rate reads the data this enrich pass writes,
// so wiring this into post-commit / post-merge keeps the signal
// fresh without the agent paying the recompute cost at read time.
RegenChurn bool
// ChurnBranch overrides the branch the enricher pins to. Empty
// means "let `gortex enrich churn` resolve the default branch
// at run time" — the right default for shared repos where the
// branch name varies per checkout.
ChurnBranch string
// RegenReleases toggles a `gortex enrich releases` run. Same
// motivation as RegenChurn: keeps `analyze kind=releases` answers
// fresh without paying the per-call tag walk.
RegenReleases bool
// ReleasesBranch is the rev whose reachable tags bound the
// timeline. Empty means "resolve at hook run time".
ReleasesBranch string
}
func (o InstallOpts) withDefaults() InstallOpts {
if strings.TrimSpace(o.Binary) == "" {
o.Binary = "gortex"
}
if o.MermaidOutDir == "" {
o.MermaidOutDir = "docs/architecture/"
}
if o.WikiOutDir == "" {
o.WikiOutDir = "wiki"
}
if o.DocsOutPath == "" {
o.DocsOutPath = "CHANGELOG_AUTO.md"
}
return o
}
// hookCommands builds the body the installer writes inside the
// marker block. The body is a `#!/bin/sh` snippet that runs every
// enabled action and tolerates failures so the hook always completes.
func hookCommands(hook string, opts InstallOpts) []string {
if hook == "post-checkout" {
// post-checkout fires on branch switch / clone / file checkout.
// Touch the notify file so a running gortex daemon reconciles the
// new working-tree state immediately (sub-second) instead of waiting
// out its poll interval. Harmless when no daemon is running.
return []string{
"# Force a running gortex daemon to reconcile after a checkout.",
"mkdir -p .gortex 2>/dev/null || true",
"touch .gortex/reindex.notify 2>/dev/null || true",
}
}
var cmds []string
cmds = append(cmds, fmt.Sprintf("# Auto-regenerate gortex artefacts on %s.", hook))
cmds = append(cmds, "# Failures are tolerated so the hook always completes.")
if opts.RegenMermaid {
cmds = append(cmds, fmt.Sprintf("(%s export --format mermaid --scope all --out-dir %q --on-commit) >/dev/null 2>&1 || true",
opts.Binary, opts.MermaidOutDir))
}
if opts.RegenWiki {
cmds = append(cmds, fmt.Sprintf("(%s wiki . --output %q) >/dev/null 2>&1 || true",
opts.Binary, opts.WikiOutDir))
}
if opts.RegenDocs {
cmds = append(cmds, fmt.Sprintf("(%s docs . --out %q) >/dev/null 2>&1 || true",
opts.Binary, opts.DocsOutPath))
}
if opts.RegenChurn {
if strings.TrimSpace(opts.ChurnBranch) == "" {
cmds = append(cmds, fmt.Sprintf("(%s enrich churn) >/dev/null 2>&1 || true", opts.Binary))
} else {
cmds = append(cmds, fmt.Sprintf("(%s enrich churn --branch=%q) >/dev/null 2>&1 || true",
opts.Binary, opts.ChurnBranch))
}
}
if opts.RegenReleases {
if strings.TrimSpace(opts.ReleasesBranch) == "" {
cmds = append(cmds, fmt.Sprintf("(%s enrich releases) >/dev/null 2>&1 || true", opts.Binary))
} else {
cmds = append(cmds, fmt.Sprintf("(%s enrich releases --branch=%q) >/dev/null 2>&1 || true",
opts.Binary, opts.ReleasesBranch))
}
}
if len(cmds) == 2 {
// No actions selected — note it explicitly.
cmds = append(cmds, "# (no regeneration actions enabled)")
}
return cmds
}
// HookPath resolves the absolute path of the post-commit hook for the
// repository rooted at repoRoot. Honours core.hooksPath when set.
// Thin wrapper over HookPathFor — preserved for backwards compatibility.
func HookPath(repoRoot string) (string, error) {
return HookPathFor(repoRoot, "post-commit")
}
// HookPathFor resolves the absolute path of the named hook file in
// the repository rooted at repoRoot. Honours core.hooksPath when set.
// hook is a bare hook name from SupportedHooks ("post-commit",
// "post-merge", …).
func HookPathFor(repoRoot, hook string) (string, error) {
if repoRoot == "" {
return "", fmt.Errorf("githooks: repoRoot is empty")
}
if !isSupportedHook(hook) {
return "", fmt.Errorf("githooks: unsupported hook %q (supported: %s)", hook, strings.Join(SupportedHooks, ", "))
}
gitDir, err := runGit(repoRoot, "rev-parse", "--git-dir")
if err != nil {
return "", fmt.Errorf("githooks: not a git repository at %q: %w", repoRoot, err)
}
gitDir = strings.TrimSpace(gitDir)
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(repoRoot, gitDir)
}
customPath, _ := runGit(repoRoot, "config", "--get", "core.hooksPath")
hooksDir := filepath.Join(gitDir, "hooks")
if cp := strings.TrimSpace(customPath); cp != "" {
if !filepath.IsAbs(cp) {
cp = filepath.Join(repoRoot, cp)
}
hooksDir = cp
}
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return "", fmt.Errorf("githooks: create hooks dir %q: %w", hooksDir, err)
}
return filepath.Join(hooksDir, hook), nil
}
// StatusReport describes the current state of the post-commit hook.
type StatusReport struct {
HookPath string `json:"hook_path"`
Exists bool `json:"exists"`
Managed bool `json:"managed"` // true iff our marker block is present
Body string `json:"body,omitempty"`
}
// Status reports the current state of the post-commit hook. Never
// modifies anything.
func Status(repoRoot string) (StatusReport, error) {
path, err := HookPath(repoRoot)
if err != nil {
return StatusReport{}, err
}
body, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return StatusReport{HookPath: path}, nil
}
return StatusReport{}, fmt.Errorf("githooks: read %q: %w", path, err)
}
rep := StatusReport{
HookPath: path,
Exists: true,
Body: string(body),
}
if bytes.Contains(body, []byte(MarkerBegin)) && bytes.Contains(body, []byte(MarkerEnd)) {
rep.Managed = true
}
return rep, nil
}
// InstallPostCommit is a backwards-compatible wrapper over InstallHook
// that installs the post-commit hook. New callers should reach for
// InstallHook directly so they can install post-merge too.
func InstallPostCommit(repoRoot string, opts InstallOpts) (string, error) {
return InstallHook(repoRoot, "post-commit", opts)
}
// InstallHook writes the named hook with the configured commands
// inside a hook-specific marker block. Idempotent: re-running replaces
// just the gortex block, leaving any other content intact.
//
// Returns the absolute path of the hook so callers can show it to the
// user. `hook` must be one of SupportedHooks.
func InstallHook(repoRoot, hook string, opts InstallOpts) (string, error) {
opts = opts.withDefaults()
hookPath, err := HookPathFor(repoRoot, hook)
if err != nil {
return "", err
}
cmds := hookCommands(hook, opts)
mBegin := markerBegin(hook)
mEnd := markerEnd(hook)
var newBlock bytes.Buffer
newBlock.WriteString(mBegin)
newBlock.WriteString("\n")
for _, line := range cmds {
newBlock.WriteString(line)
newBlock.WriteString("\n")
}
newBlock.WriteString(mEnd)
newBlock.WriteString("\n")
existing, _ := os.ReadFile(hookPath) // nil bytes when file doesn't exist
var out bytes.Buffer
if len(existing) == 0 {
out.WriteString("#!/bin/sh\n")
fmt.Fprintf(&out, "# Installed by `gortex githook install %s`.\n", hook)
out.WriteString("# Marker block below is regenerated on each install/uninstall;\n")
out.WriteString("# add your own commands outside the markers and they will be preserved.\n\n")
out.Write(newBlock.Bytes())
} else {
body := string(existing)
// Ensure the shebang is present so the hook is executable.
if !strings.HasPrefix(body, "#!") {
out.WriteString("#!/bin/sh\n")
}
if strings.Contains(body, mBegin) && strings.Contains(body, mEnd) {
// Replace existing block.
before, rest, _ := strings.Cut(body, mBegin)
_, after, _ := strings.Cut(rest, mEnd)
after = strings.TrimLeft(after, "\n")
out.WriteString(before)
out.Write(newBlock.Bytes())
out.WriteString(after)
} else {
// Append a new block.
out.WriteString(body)
if !strings.HasSuffix(body, "\n") {
out.WriteString("\n")
}
out.WriteString("\n")
out.Write(newBlock.Bytes())
}
}
if err := os.WriteFile(hookPath, out.Bytes(), 0o755); err != nil {
return "", fmt.Errorf("githooks: write %q: %w", hookPath, err)
}
// Make sure the bit is set even if the file already existed.
_ = os.Chmod(hookPath, 0o755)
return hookPath, nil
}
// UninstallPostCommit is a backwards-compatible wrapper.
func UninstallPostCommit(repoRoot string) (string, bool, error) {
return UninstallHook(repoRoot, "post-commit")
}
// UninstallHook removes the gortex-managed block from the named hook.
// If the file then contains nothing but the shebang and our installer
// comment, the file is deleted entirely. Otherwise we leave the
// residual (user-authored) content in place.
//
// Returns the path of the hook (whether it now exists or was deleted)
// and a bool indicating "block was found and removed".
func UninstallHook(repoRoot, hook string) (string, bool, error) {
hookPath, err := HookPathFor(repoRoot, hook)
if err != nil {
return "", false, err
}
mBegin := markerBegin(hook)
mEnd := markerEnd(hook)
body, err := os.ReadFile(hookPath)
if err != nil {
if os.IsNotExist(err) {
return hookPath, false, nil
}
return "", false, err
}
b := string(body)
if !strings.Contains(b, mBegin) || !strings.Contains(b, mEnd) {
return hookPath, false, nil
}
before, rest, _ := strings.Cut(b, mBegin)
_, after, _ := strings.Cut(rest, mEnd)
after = strings.TrimLeft(after, "\n")
cleaned := strings.TrimRight(before, "\n") + "\n" + after
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" || isInstallerStub(cleaned) {
// Only the shebang + comments left — drop the file.
if err := os.Remove(hookPath); err != nil {
return "", false, err
}
return hookPath, true, nil
}
if !strings.HasSuffix(cleaned, "\n") {
cleaned += "\n"
}
if err := os.WriteFile(hookPath, []byte(cleaned), 0o755); err != nil {
return "", false, fmt.Errorf("githooks: write %q: %w", hookPath, err)
}
return hookPath, true, nil
}
// isInstallerStub returns true when the residual content is just the
// shebang and the installer-comment header we inserted on first
// install — i.e. nothing the user added themselves.
func isInstallerStub(s string) bool {
for line := range strings.SplitSeq(s, "\n") {
l := strings.TrimSpace(line)
if l == "" {
continue
}
if strings.HasPrefix(l, "#") {
continue
}
// Found a non-comment, non-blank line — keep the file.
return false
}
return true
}
// runGit invokes `git` from inside repoRoot and returns trimmed
// stdout. Errors carry stderr context.
func runGit(repoRoot string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = repoRoot
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
return "", err
}
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg)
}
return stdout.String(), nil
}