Files
forge/modules/gitrepo/hooks.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

286 lines
9.5 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package gitrepo
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/hanzoai/git/modules/setting"
"github.com/hanzoai/git/modules/util"
)
// delegateHookName is the file we write into "<hook>.d/". It is named after the
// shipped binary (gitd) and is the script that re-execs us as "gitd hook ...".
//
// legacyDelegateHookNames are the names earlier releases used. They MUST be
// removed, because the outer "<hook>" script runs EVERY executable in
// "<hook>.d/" and rejects the push if any of them exits non-zero: a delegate
// left behind by the old binary name would invoke a path that no longer exists,
// exit 127, and break every push to that repository — permanently.
//
// Renaming delegateHookName is therefore only safe in a release that ALSO moves
// setting.AppPath, because that (and only that) is what makes
// routers.syncAppConfForGit re-run SyncRepositoryHooks over every repository on
// the first boot after the upgrade — before the web listener opens. The
// gitea->gitd rename moved both at once.
const delegateHookName = "gitd"
var legacyDelegateHookNames = []string{"gitea"}
func getHookTemplates() (hookNames, hookTpls, delegateHookTpls []string) {
hookNames = []string{"pre-receive", "update", "post-receive"}
hookTpls = []string{
// for pre-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY HANZO GIT, DO NOT MODIFY
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
// for update
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY HANZO GIT, DO NOT MODIFY
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
"${hook}" $1 $2 $3
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
// for post-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY HANZO GIT, DO NOT MODIFY
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
}
delegateHookTpls = []string{
// for pre-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY HANZO GIT, DO NOT MODIFY
%s hook --config=%s pre-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
// for update
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY HANZO GIT, DO NOT MODIFY
%s hook --config=%s update $1 $2 $3
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
// for post-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY HANZO GIT, DO NOT MODIFY
%s hook --config=%s post-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
}
// although only new git (>=2.29) supports proc-receive, it's still good to create its hook, in case the user upgrades git
hookNames = append(hookNames, "proc-receive")
hookTpls = append(hookTpls,
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY HANZO GIT, DO NOT MODIFY
%s hook --config=%s proc-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)))
delegateHookTpls = append(delegateHookTpls, "")
return hookNames, hookTpls, delegateHookTpls
}
// CreateDelegateHooks creates all the hooks scripts for the repo
func CreateDelegateHooks(_ context.Context, repo Repository) (err error) {
return createDelegateHooks(filepath.Join(repoPath(repo), "hooks"))
}
func createDelegateHooks(hookDir string) (err error) {
hookNames, hookTpls, delegateHookTpls := getHookTemplates()
for i, hookName := range hookNames {
oldHookPath := filepath.Join(hookDir, hookName)
newHookPath := filepath.Join(hookDir, hookName+".d", delegateHookName)
if err := os.MkdirAll(filepath.Join(hookDir, hookName+".d"), os.ModePerm); err != nil {
return fmt.Errorf("create hooks dir '%s': %w", filepath.Join(hookDir, hookName+".d"), err)
}
// WARNING: This will override all old server-side hooks
if err = util.Remove(oldHookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to pre-remove old hook file '%s' prior to rewriting: %w ", oldHookPath, err)
}
if err = os.WriteFile(oldHookPath, []byte(hookTpls[i]), 0o777); err != nil {
return fmt.Errorf("write old hook file '%s': %w", oldHookPath, err)
}
if err = ensureExecutable(oldHookPath); err != nil {
return fmt.Errorf("Unable to set %s executable. Error %w", oldHookPath, err)
}
if err = util.Remove(newHookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to pre-remove new hook file '%s' prior to rewriting: %w", newHookPath, err)
}
if err = os.WriteFile(newHookPath, []byte(delegateHookTpls[i]), 0o777); err != nil {
return fmt.Errorf("write new hook file '%s': %w", newHookPath, err)
}
if err = ensureExecutable(newHookPath); err != nil {
return fmt.Errorf("Unable to set %s executable. Error %w", oldHookPath, err)
}
// Drop delegates written under an earlier binary name, LAST: if we die
// midway the repo is left with two delegates and pushes fail loudly,
// which is the safe direction. Removing first and dying would leave a
// repo with no delegate at all — pushes would silently bypass every
// server-side check.
for _, legacy := range legacyDelegateHookNames {
legacyHookPath := filepath.Join(hookDir, hookName+".d", legacy)
if err = util.Remove(legacyHookPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("unable to remove legacy hook file '%s': %w", legacyHookPath, err)
}
}
}
return nil
}
func checkExecutable(filename string) bool {
// windows has no concept of a executable bit
if runtime.GOOS == "windows" {
return true
}
fileInfo, err := os.Stat(filename)
if err != nil {
return false
}
return (fileInfo.Mode() & 0o100) > 0
}
func ensureExecutable(filename string) error {
fileInfo, err := os.Stat(filename)
if err != nil {
return err
}
if (fileInfo.Mode() & 0o100) > 0 {
return nil
}
mode := fileInfo.Mode() | 0o100
return os.Chmod(filename, mode)
}
// CheckDelegateHooks checks the hooks scripts for the repo
func CheckDelegateHooks(_ context.Context, repo Repository) ([]string, error) {
return checkDelegateHooks(filepath.Join(repoPath(repo), "hooks"))
}
func checkDelegateHooks(hookDir string) ([]string, error) {
hookNames, hookTpls, delegateHookTpls := getHookTemplates()
results := make([]string, 0, 10)
for i, hookName := range hookNames {
oldHookPath := filepath.Join(hookDir, hookName)
newHookPath := filepath.Join(hookDir, hookName+".d", delegateHookName)
cont := false
// Reported before anything else, and never short-circuited by `cont`: a
// surviving legacy delegate is the one defect here that rejects pushes
// outright, and reporting it is what makes `doctor --fix` (and the repo
// settings page) call CreateDelegateHooks, which deletes it.
for _, legacy := range legacyDelegateHookNames {
legacyHookPath := filepath.Join(hookDir, hookName+".d", legacy)
isExist, err := util.IsExist(legacyHookPath)
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", legacyHookPath, err))
}
if err == nil && isExist {
results = append(results, fmt.Sprintf("legacy hook file %s still exists and would reject every push", legacyHookPath))
}
}
isExist, err := util.IsExist(oldHookPath)
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath, err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("old hook file %s does not exist", oldHookPath))
cont = true
}
isExist, err = util.IsExist(oldHookPath + ".d")
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath+".d", err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("hooks directory %s does not exist", oldHookPath+".d"))
cont = true
}
isExist, err = util.IsExist(newHookPath)
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", newHookPath, err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("new hook file %s does not exist", newHookPath))
cont = true
}
if cont {
continue
}
contents, err := os.ReadFile(oldHookPath)
if err != nil {
return results, err
}
if string(contents) != hookTpls[i] {
results = append(results, fmt.Sprintf("old hook file %s is out of date", oldHookPath))
}
if !checkExecutable(oldHookPath) {
results = append(results, fmt.Sprintf("old hook file %s is not executable", oldHookPath))
}
contents, err = os.ReadFile(newHookPath)
if err != nil {
return results, err
}
if string(contents) != delegateHookTpls[i] {
results = append(results, fmt.Sprintf("new hook file %s is out of date", newHookPath))
}
if !checkExecutable(newHookPath) {
results = append(results, fmt.Sprintf("new hook file %s is not executable", newHookPath))
}
}
return results, nil
}