Skip to content
internal/hooks
Edit
On this page

Package hooks discovers and executes pre-pre-push hooks that run before any network I/O, solving the SSH timeout problem when checks are long-running.

#internal/hooks

#internal/hooks

Package hooks discovers and executes pre-pre-push hooks that run before any network I/O, solving the SSH timeout problem when checks are long-running.

#OriginTracked

Go go
const OriginTracked Origin = "tracked"

#OriginLocal

Go go
const OriginLocal   Origin = "local"

#OriginLegacy

Go go
const OriginLegacy  Origin = "legacy"

#HookResult

Go go
type HookResult struct

HookResult holds the outcome of running a single hook.

#LegacyLocationError

Go go
type LegacyLocationError struct

LegacyLocationError reports hooks still sitting in the pre-migration location, git's own .git/hooks. Discovery refuses rather than running them: running from both places would make the store safegit executes from depend on where a file happened to be left, and silently skipping them would stop an operator's checks without saying so.

#TrackedNotExecutableError

Go go
type TrackedNotExecutableError struct

TrackedNotExecutableError reports a repository-provided hook whose mode says it cannot run. It is a refusal, not a skip: a hook in the checkout's .safegit/hooks is disabled by REMOVING it (and committing that), so a mode that silently disabled one would turn an accidentally lost executable bit -- a checkout on a filesystem without modes, a patch applied by a tool that drops them -- into checks that quietly stopped running.

#LocalNotExecutableError

Go go
type LocalNotExecutableError struct

LocalNotExecutableError reports a hook in the tool-owned live store whose mode says it cannot run. It is the live store's half of the same rule the checkout-provided store has always been held to: a hook is disabled by REMOVING it, so a mode that silently disabled one would turn an accident -- an editor that rewrote the file, a patch tool that dropped the bit, a copy across a filesystem without modes -- into checks that quietly stopped running. It used to be a skip with a warning on stderr, which is git's own stance for its own hooks; the two stores answering the same accident differently is what that stance cost, so it is a refusal now.

#Origin

Go go
type Origin string

Origin says which store a hook location came from.

The three are not interchangeable. A TRACKED hook is repository-provided: it sits in the checkout's .safegit/hooks, so everyone who clones the repository gets it and disabling one means committing its deletion. A LOCAL hook lives in the tool-owned directory under the common git dir and belongs to this repository's own state, shared by every worktree of it. A LEGACY hook is one still sitting where safegit used to keep them, in git's own .git/hooks -- discovery refuses to run from there, and safegit hook migrate relocates it.

#Tracked membership is the directory, not git

A location is TRACKED because it is IN .safegit/hooks on disk, never because git tracks it: an uncommitted -- even gitignored -- executable file in that directory runs on the next push exactly like a committed one. Probing git-tracked-ness instead would make a hook an operator just wrote silently invisible to hook list and to discovery while it kept running, which is a worse failure than the one it would prevent.

#The execution boundary

safegit push executes the scripts in the checkout's .safegit/hooks, so cloning a repository and pushing from that checkout runs the repository's committed code. Execution happens only on push and on safegit hook run -- an operator action with push intent -- never on clone, fetch, checkout or any inspection command, and hook list names every location with its origin precisely so the set can be read before anything is pushed.

#Store

Go go
type Store struct

Store names one repository's hook stores: its work tree (which holds the tracked store) and its COMMON git directory (which holds the live store and the legacy location).

Worktree is empty for a repository that has none -- a bare repository -- in which case there is no tracked store to read.

#Location

Go go
type Location struct

Location is one file found in a hook store. It is a LOCATION and nothing more: whether the file is eligible to run is Discover's question, not this type's, so a non-executable file, an editor backup and a dot-file are all enumerated exactly like any other entry.

#SetOutput

Go go
func SetOutput(out, err io.Writer) func()

SetOutput overrides the package-level stdout and stderr writers. Returns a restore function that resets them to their previous values.

#Discover

Go go
func Discover(s Store) ([]string, error)

Discover returns the hooks to execute, in execution order: the tracked store first, then the local one, each in Rel order. It is the execution-eligibility layer over Enumerate, and the only place that decides what "eligible" means.

Two states are refusals rather than filters -- a hook left in the legacy location, and a discovered hook that is not executable -- and each comes back as a typed error so a caller can map it to an exit code. The non-executable refusal is store-independent: the live store and the checkout-provided one answer a missing execute bit the same way, in their own typed errors because the remedies differ by a commit, and never as a silent skip.

What this returns is a list of scripts the caller will EXECUTE, drawn partly from the checkout's own content -- see Origin for the boundary that governs when that content runs.

#DiscoverMulti

Go go
func DiscoverMulti(stores []Store) ([]string, error)

DiscoverMulti discovers hooks across several repositories, concatenating the results in order: the first store's hooks run first. It is what a push from a submodule uses to run the parent's hooks before its own.

The parameter is a store per repository rather than a git directory, because each repository's tracked hooks live in its WORK TREE -- a cascade keyed on git directories alone could never see them.

#Run

Go go
func Run(ctx context.Context, s Store, stdin []byte, timeoutSec int, env []string) ([]HookResult, error)

Run executes all discovered hooks sequentially with the given stdin. On non-zero exit, remaining hooks are skipped. On timeout: SIGTERM, 5s grace, SIGKILL.

#RunAll

Go go
func RunAll(ctx context.Context, hookPaths []string, stdin []byte, timeoutSec int, env []string) ([]HookResult, error)

RunAll executes the given hook paths sequentially with the given stdin. On non-zero exit, remaining hooks are skipped. On timeout: SIGTERM, 5s grace, SIGKILL.

#RunSingle

Go go
func RunSingle(ctx context.Context, hookPath string, stdin []byte, timeoutSec int, env []string) HookResult

RunSingle executes a single hook by path.

#PlanInstall

Go go
func PlanInstall(sharedGitDir, srcPath string) (data []byte, dest string, err error)

PlanInstall reads the hook source and resolves the destination inside the tool-owned live store under the shared git dir, without mutating anything. Callers mint the mkdir/write/chmod themselves so a dry run can record the install instead of performing it.

An existing destination is REFUSED rather than overwritten: an install that silently replaced a hook could destroy the operator's own script (and, back when the store was git's own .git/hooks, a native git hook safegit itself runs). Upgrading a hook is safegit hook remove <name> followed by an install, which says out loud that the old one is going away.

#TrackedDir

Go go
func TrackedDir(worktree string) string

TrackedDir is the repository-provided hook store inside the work tree. Its membership is the directory itself: a file there runs whether or not git tracks it (see Origin).

#LocalDir

Go go
func LocalDir(sharedGitDir string) string

LocalDir is the tool-owned live hook store under the COMMON git directory. It is where hook install writes and where discovery runs hooks from.

The argument is the shared git dir (repo.SharedGitDir), never a linked worktree's own: the store is one repository-wide answer, alongside the ref locks in the same .git/safegit.

#LegacyFile

Go go
func LegacyFile(sharedGitDir string) string

LegacyFile and LegacyDir are the two names safegit used to keep its hooks under in git's own hook directory, before the tool-owned store existed. They are the ONLY safegit-owned names there -- everything else in .git/hooks is git's own -- which is what lets hook migrate relocate them unconditionally, with no content sniffing.

The argument is the shared git dir for the same reason git's own hook directory is common in a linked worktree: there is one such location per repository, and every worktree must reach the same one. It is deliberately NOT git's resolved hook directory (core.hooksPath): safegit only ever wrote these two names into /hooks, so a repository that redirects core.hooksPath still has its legacy hooks here.

#LegacyDir

Go go
func LegacyDir(sharedGitDir string) string

LegacyDir is the directory half of the legacy location (see LegacyFile).

#Enumerate

Go go
func Enumerate(s Store) ([]Location, error)

Enumerate is the single authority for where hooks live.

It walks all three stores recursively and returns every FILE it finds, with no executability, naming or extension filter of any kind: the answer is the set of locations, and every consumer that needs a narrower set derives it here rather than re-deriving the directory layout. hook list shows non-executable entries because it reads this; Discover runs a subset of it; scan sweeps all of it; doctor's permission check reads it.

Order is store-major -- tracked, then local, then legacy -- and sorted by Rel within each store, which is the execution order Discover inherits.

A store that does not exist contributes nothing and is not an error.

#Legacy

Go go
func Legacy(sharedGitDir string) ([]Location, error)

Legacy enumerates the pre-migration location alone: the pre-pre-push file and everything under pre-pre-push.d/ in git's own hook directory, which is the COMMON one (see LegacyFile). Rel is relative to that directory, so it reads the same as the corresponding entry in a live store.

#LegacyLocationError.Error

Go go
func (e *LegacyLocationError) Error() string

#TrackedNotExecutableError.Error

Go go
func (e *TrackedNotExecutableError) Error() string

#LocalNotExecutableError.Error

Go go
func (e *LocalNotExecutableError) Error() string

#Location.IsHookName

Go go
func (l Location) IsHookName() bool

IsHookName reports whether a location is a hook at all, by NAME alone: dot-prefixed files are hidden and tilde-suffixed ones are editor backups, and neither has ever been a hook. Executability is a separate question with a different answer per store, so it is not asked here.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search