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
const OriginTracked Origin = "tracked"#OriginLocal
const OriginLocal Origin = "local"#OriginLegacy
const OriginLegacy Origin = "legacy"#HookResult
type HookResult structHookResult holds the outcome of running a single hook.
#LegacyLocationError
type LegacyLocationError structLegacyLocationError 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
type TrackedNotExecutableError structTrackedNotExecutableError 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
type LocalNotExecutableError structLocalNotExecutableError 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
type Origin stringOrigin 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
type Store structStore 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
type Location structLocation 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
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
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
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
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
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
func RunSingle(ctx context.Context, hookPath string, stdin []byte, timeoutSec int, env []string) HookResultRunSingle executes a single hook by path.
#PlanInstall
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
func TrackedDir(worktree string) stringTrackedDir 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
func LocalDir(sharedGitDir string) stringLocalDir 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
func LegacyFile(sharedGitDir string) stringLegacyFile 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
#LegacyDir
func LegacyDir(sharedGitDir string) stringLegacyDir is the directory half of the legacy location (see LegacyFile).
#Enumerate
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
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
func (e *LegacyLocationError) Error() string#TrackedNotExecutableError.Error
func (e *TrackedNotExecutableError) Error() string#LocalNotExecutableError.Error
func (e *LocalNotExecutableError) Error() string#Location.IsHookName
func (l Location) IsHookName() boolIsHookName 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.