From 757c14388a7267f6065e08d2dd64db2c959464ad Mon Sep 17 00:00:00 2001 From: Mohamed-elg Date: Thu, 11 Jun 2026 22:21:10 +0200 Subject: [PATCH 1/4] pre hook --- main.go | 73 ++++++++++++++++++++++++++++++++++++++- pkg/hook/exechook.go | 7 ++-- pkg/hook/exechook_test.go | 3 ++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 99fdf840a..ffce3431a 100644 --- a/main.go +++ b/main.go @@ -230,6 +230,16 @@ func main() { envDuration(3*time.Second, "GITSYNC_EXECHOOK_BACKOFF", "GIT_SYNC_EXECHOOK_BACKOFF"), "the time to wait before retrying a failed exechook") + flPreExechookCommand := pflag.String("pre-exechook-command", + envString("", "GITSYNC_PRE_EXECHOOK_COMMAND", "GIT_SYNC_PRE_EXECHOOK_COMMAND"), + "an optional command to be run before syncs complete (must be idempotent)") + flPreExechookTimeout := pflag.Duration("pre-exechook-timeout", + envDuration(30*time.Second, "GITSYNC_PRE_EXECHOOK_TIMEOUT", "GIT_SYNC_PRE_EXECHOOK_TIMEOUT"), + "the timeout for the pre-exechook") + flPreExechookBackoff := pflag.Duration("pre-exechook-backoff", + envDuration(3*time.Second, "GITSYNC_PRE_EXECHOOK_BACKOFF", "GIT_SYNC_PRE_EXECHOOK_BACKOFF"), + "the time to wait before retrying a failed pre-exechook") + flWebhookURL := pflag.String("webhook-url", envString("", "GITSYNC_WEBHOOK_URL", "GIT_SYNC_WEBHOOK_URL"), "a URL for optional webhook notifications when syncs complete (must be idempotent)") @@ -541,6 +551,15 @@ func main() { } } + if *flPreExechookCommand != "" { + if *flPreExechookTimeout < time.Second { + fatalConfigErrorf(log, true, "invalid flag: --pre-exechook-timeout must be at least 1s") + } + if *flPreExechookBackoff < time.Second { + fatalConfigErrorf(log, true, "invalid flag: --pre-exechook-backoff must be at least 1s") + } + } + if *flWebhookURL != "" { if *flWebhookStatusSuccess == -1 { // Back-compat: -1 and 0 mean the same things @@ -859,8 +878,10 @@ func main() { // Startup exechooks goroutine var exechookRunner *hook.HookRunner if *flExechookCommand != "" { - log := log.WithName("exechook") + logname := "exechook" + log := log.WithName(logname) exechook := hook.NewExechook( + logname, cmd.NewRunner(log), *flExechookCommand, func(hash string) string { @@ -880,6 +901,32 @@ func main() { go exechookRunner.Run(context.Background()) } + // Startup pre-exechooks goroutine + var preExechookRunner *hook.HookRunner + if *flPreExechookCommand != "" { + logname := "pre-exechook" + log := log.WithName(logname) + exechook := hook.NewExechook( + logname, + cmd.NewRunner(log), + *flPreExechookCommand, + func(hash string) string { + return git.worktreeFor(hash).Path().String() + }, + []string{}, + *flPreExechookTimeout, + log, + ) + preExechookRunner = hook.NewHookRunner( + exechook, + *flPreExechookBackoff, + hook.NewHookData(), + log, + *flOneTime, + ) + go preExechookRunner.Run(context.Background()) + } + // Setup signal notify channel sigChan := make(chan os.Signal, 1) if syncSig != 0 { @@ -947,6 +994,11 @@ func main() { for { start := time.Now() ctx, cancel := context.WithTimeout(context.Background(), *flSyncTimeout) + prevHash := "" + + if preExechookRunner != nil { + preExechookRunner.Send(prevHash) + } if changed, hash, err := git.SyncRepo(ctx, refreshCreds); err != nil { failCount++ @@ -981,6 +1033,9 @@ func main() { if exechookRunner != nil { exechookRunner.Send(hash) } + if preExechookRunner != nil { + prevHash = hash + } updateSyncMetrics(metricKeySuccess, start) } else { updateSyncMetrics(metricKeyNoOp, start) @@ -2528,6 +2583,22 @@ OPTIONS The timeout for the --exechook-command. If not specifid, this defaults to 30 seconds ("30s"). + --pre-exechook-backoff , $GITSYNC_PRE_EXECHOOK_BACKOFF + The time to wait before retrying a failed --pre-exechook-command. If + not specified, this defaults to 3 seconds ("3s"). + + --pre-exechook-command , $GITSYNC_PRE_EXECHOOK_COMMAND + An optional command to be executed before syncing a new hash of the + remote repository. This command does not take any arguments and + executes with the synced repo as its working directory. The + $GITSYNC_HASH environment variable will be set to the previous git hash that + was synced. This hook will always be invoked as it runs before any sync attempt. + + --pre-exechook-timeout , $GITSYNC_PRE_EXECHOOK_TIMEOUT + The timeout for the --pre-exechook-command. If not specifid, this + defaults to 30 seconds ("30s"). + + --filter , $GITSYNC_FILTER Use partial clone with the specified filter. This can reduce the amount of data transferred when cloning large repositories. diff --git a/pkg/hook/exechook.go b/pkg/hook/exechook.go index e31d99642..c62f15ef0 100644 --- a/pkg/hook/exechook.go +++ b/pkg/hook/exechook.go @@ -27,6 +27,8 @@ import ( // Exechook implements Hook in terms of executing a command. type Exechook struct { + // Name + name string // Runner cmdrunner cmd.Runner // Command to run @@ -42,8 +44,9 @@ type Exechook struct { } // NewExechook returns a new Exechook. -func NewExechook(cmdrunner cmd.Runner, command string, getWorktree func(string) string, args []string, timeout time.Duration, log logintf) *Exechook { +func NewExechook(name string, cmdrunner cmd.Runner, command string, getWorktree func(string) string, args []string, timeout time.Duration, log logintf) *Exechook { return &Exechook{ + name: name, cmdrunner: cmdrunner, command: command, getWorktree: getWorktree, @@ -55,7 +58,7 @@ func NewExechook(cmdrunner cmd.Runner, command string, getWorktree func(string) // Name describes hook, implements Hook.Name. func (h *Exechook) Name() string { - return "exechook" + return h.name } // Do runs exechook.command, implements Hook.Do. diff --git a/pkg/hook/exechook_test.go b/pkg/hook/exechook_test.go index 0de065088..1f6b521df 100644 --- a/pkg/hook/exechook_test.go +++ b/pkg/hook/exechook_test.go @@ -29,6 +29,7 @@ func TestNotZeroReturnExechookDo(t *testing.T) { t.Run("test not zero return code", func(t *testing.T) { l := logging.New("", "", 0) ch := NewExechook( + "exechook", cmd.NewRunner(l), "false", func(string) string { return "/tmp" }, @@ -47,6 +48,7 @@ func TestZeroReturnExechookDo(t *testing.T) { t.Run("test zero return code", func(t *testing.T) { l := logging.New("", "", 0) ch := NewExechook( + "exechook", cmd.NewRunner(l), "true", func(string) string { return "/tmp" }, @@ -65,6 +67,7 @@ func TestTimeoutExechookDo(t *testing.T) { t.Run("test timeout", func(t *testing.T) { l := logging.New("", "", 0) ch := NewExechook( + "exechook", cmd.NewRunner(l), "/bin/sh", func(string) string { return "/tmp" }, From 2d840264566e1f507292d114568d49c5d051e24b Mon Sep 17 00:00:00 2001 From: Mohamed-elg Date: Thu, 11 Jun 2026 22:38:53 +0200 Subject: [PATCH 2/4] readme + tab fix --- README.md | 16 ++++++++++++++++ main.go | 3 +-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e9a4b1223..2fcd6b8c2 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,22 @@ OPTIONS The timeout for the --exechook-command. If not specifid, this defaults to 30 seconds ("30s"). + --pre-exechook-backoff , $GITSYNC_PRE_EXECHOOK_BACKOFF + The time to wait before retrying a failed --pre-exechook-command. If + not specified, this defaults to 3 seconds ("3s"). + + --pre-exechook-command , $GITSYNC_PRE_EXECHOOK_COMMAND + An optional command to be executed before syncing a new hash of the + remote repository. This command does not take any arguments and + executes with the synced repo as its working directory. The + $GITSYNC_HASH environment variable will be set to the previous git hash that + was synced. This hook will always be invoked as it runs before any sync attempt. + + --pre-exechook-timeout , $GITSYNC_PRE_EXECHOOK_TIMEOUT + The timeout for the --pre-exechook-command. If not specifid, this + defaults to 30 seconds ("30s"). + + --filter , $GITSYNC_FILTER Use partial clone with the specified filter. This can reduce the amount of data transferred when cloning large repositories. diff --git a/main.go b/main.go index ffce3431a..5489b5572 100644 --- a/main.go +++ b/main.go @@ -2583,7 +2583,7 @@ OPTIONS The timeout for the --exechook-command. If not specifid, this defaults to 30 seconds ("30s"). - --pre-exechook-backoff , $GITSYNC_PRE_EXECHOOK_BACKOFF + --pre-exechook-backoff , $GITSYNC_PRE_EXECHOOK_BACKOFF The time to wait before retrying a failed --pre-exechook-command. If not specified, this defaults to 3 seconds ("3s"). @@ -2598,7 +2598,6 @@ OPTIONS The timeout for the --pre-exechook-command. If not specifid, this defaults to 30 seconds ("30s"). - --filter , $GITSYNC_FILTER Use partial clone with the specified filter. This can reduce the amount of data transferred when cloning large repositories. From 47f28c6eba0d042a87806477ea2f13a249c4073c Mon Sep 17 00:00:00 2001 From: Mohamed-elg Date: Thu, 11 Jun 2026 22:55:41 +0200 Subject: [PATCH 3/4] fix def --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 5489b5572..563c02afe 100644 --- a/main.go +++ b/main.go @@ -982,6 +982,7 @@ func main() { syncCount := uint64(0) initialSyncDone := false waitTime := *flInitPeriod + prevHash := "" // getMaxFailures returns the effective max-failure limit for the current // phase. During the initial sync phase, --init-max-failures (if set) // overrides --max-failures; otherwise --max-failures applies. @@ -994,7 +995,6 @@ func main() { for { start := time.Now() ctx, cancel := context.WithTimeout(context.Background(), *flSyncTimeout) - prevHash := "" if preExechookRunner != nil { preExechookRunner.Send(prevHash) From 981235356b085793190a9a55130fc6880b3a9213 Mon Sep 17 00:00:00 2001 From: Mohamed-elg Date: Mon, 15 Jun 2026 14:38:02 +0200 Subject: [PATCH 4/4] change logic around symlink --- README.md | 32 ++++++++-------- main.go | 91 +++++++++++++++++++++++++++----------------- pkg/hook/exechook.go | 4 +- 3 files changed, 75 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 2fcd6b8c2..55ea8bc1f 100644 --- a/README.md +++ b/README.md @@ -297,22 +297,6 @@ OPTIONS The timeout for the --exechook-command. If not specifid, this defaults to 30 seconds ("30s"). - --pre-exechook-backoff , $GITSYNC_PRE_EXECHOOK_BACKOFF - The time to wait before retrying a failed --pre-exechook-command. If - not specified, this defaults to 3 seconds ("3s"). - - --pre-exechook-command , $GITSYNC_PRE_EXECHOOK_COMMAND - An optional command to be executed before syncing a new hash of the - remote repository. This command does not take any arguments and - executes with the synced repo as its working directory. The - $GITSYNC_HASH environment variable will be set to the previous git hash that - was synced. This hook will always be invoked as it runs before any sync attempt. - - --pre-exechook-timeout , $GITSYNC_PRE_EXECHOOK_TIMEOUT - The timeout for the --pre-exechook-command. If not specifid, this - defaults to 30 seconds ("30s"). - - --filter , $GITSYNC_FILTER Use partial clone with the specified filter. This can reduce the amount of data transferred when cloning large repositories. @@ -461,6 +445,22 @@ OPTIONS will take precedence. If not specified, this defaults to 10 seconds ("10s"). + --pre-exechook-backoff , $GITSYNC_PRE_EXECHOOK_BACKOFF + The time to wait before retrying a failed --pre-exechook-command. If + not specified, this defaults to 3 seconds ("3s"). + + --pre-exechook-command , $GITSYNC_PRE_EXECHOOK_COMMAND + An optional command to be executed after syncing a new hash of the + remote repository but before publishing the symlink (see --link). + This command does not take any arguments and + executes with the synced repo as its working directory. The + $GITSYNC_HASH environment variable will be set to the previous git hash that + was synced. This hook will always be invoked as it runs before any sync attempt. + + --pre-exechook-timeout , $GITSYNC_PRE_EXECHOOK_TIMEOUT + The timeout for the --pre-exechook-command. If not specifid, this + defaults to 30 seconds ("30s"). + --ref , $GITSYNC_REF The git revision (branch, tag, or hash) to check out. If not specified, this defaults to "HEAD" (of the upstream repo's default diff --git a/main.go b/main.go index 563c02afe..93269ef09 100644 --- a/main.go +++ b/main.go @@ -133,6 +133,13 @@ type repoSync struct { appTokenExpiry time.Time // time when github app auth token expires } +// syncHooks manages the refresh of credentials and hooks. +type syncHooks struct { + refreshCreds func(ctx context.Context) error + beforePublish func(hash string) error + afterPublish func(hash string) error +} + func main() { // In case we come up as pid 1, act as init. if os.Getpid() == 1 { @@ -978,11 +985,28 @@ func main() { return nil } + syncHooks := syncHooks{ + refreshCreds: refreshCreds, + beforePublish: func(hash string) error { + if preExechookRunner != nil { + preExechookRunner.Send(hash) + } + return nil + }, + afterPublish: func(hash string) error { + if exechookRunner != nil { + exechookRunner.Send(hash) + } + if webhookRunner != nil { + webhookRunner.Send(hash) + } + return nil + }, + } failCount := 0 syncCount := uint64(0) initialSyncDone := false waitTime := *flInitPeriod - prevHash := "" // getMaxFailures returns the effective max-failure limit for the current // phase. During the initial sync phase, --init-max-failures (if set) // overrides --max-failures; otherwise --max-failures applies. @@ -996,11 +1020,7 @@ func main() { start := time.Now() ctx, cancel := context.WithTimeout(context.Background(), *flSyncTimeout) - if preExechookRunner != nil { - preExechookRunner.Send(prevHash) - } - - if changed, hash, err := git.SyncRepo(ctx, refreshCreds); err != nil { + if changed, hash, err := git.SyncRepo(ctx, syncHooks); err != nil { failCount++ updateSyncMetrics(metricKeyError, start) if maxFails := getMaxFailures(); maxFails >= 0 && failCount >= maxFails { @@ -1027,15 +1047,6 @@ func main() { log.V(3).Info("touched touch-file", "path", absTouchFile) } } - if webhookRunner != nil { - webhookRunner.Send(hash) - } - if exechookRunner != nil { - exechookRunner.Send(hash) - } - if preExechookRunner != nil { - prevHash = hash - } updateSyncMetrics(metricKeySuccess, start) } else { updateSyncMetrics(metricKeyNoOp, start) @@ -1809,10 +1820,10 @@ func (git *repoSync) currentWorktree() (worktree, error) { // SyncRepo syncs the repository to the desired ref, publishes it via the link, // and tries to clean up any detritus. This function returns whether the // current hash has changed and what the new hash is. -func (git *repoSync) SyncRepo(ctx context.Context, refreshCreds func(context.Context) error) (bool, string, error) { +func (git *repoSync) SyncRepo(ctx context.Context, syncHooks syncHooks) (bool, string, error) { git.log.V(3).Info("syncing", "repo", redactURL(git.repo)) - if err := refreshCreds(ctx); err != nil { + if err := syncHooks.refreshCreds(ctx); err != nil { return false, "", fmt.Errorf("credential refresh failed: %w", err) } @@ -1897,7 +1908,12 @@ func (git *repoSync) SyncRepo(ctx context.Context, refreshCreds func(context.Con // If we have a new hash, update the symlink to point to the new worktree. if changed { - err := git.publishSymlink(newWorktree) + err := syncHooks.beforePublish(newWorktree.Hash()) + if err != nil { + return false, "", err + } + + err = git.publishSymlink(newWorktree) if err != nil { return false, "", err } @@ -1910,6 +1926,11 @@ func (git *repoSync) SyncRepo(ctx context.Context, refreshCreds func(context.Con } } + err := syncHooks.afterPublish(newWorktree.Hash()) + if err != nil { + return false, "", err + } + // Mark ourselves as "ready". setRepoReady() git.syncCount++ @@ -2570,7 +2591,8 @@ OPTIONS --exechook-command , $GITSYNC_EXECHOOK_COMMAND An optional command to be executed after syncing a new hash of the - remote repository. This command does not take any arguments and + remote repository and publishing the symlink (see --link). + This command does not take any arguments and executes with the synced repo as its working directory. The $GITSYNC_HASH environment variable will be set to the git hash that was synced. If, at startup, git-sync finds that the --root already @@ -2583,21 +2605,6 @@ OPTIONS The timeout for the --exechook-command. If not specifid, this defaults to 30 seconds ("30s"). - --pre-exechook-backoff , $GITSYNC_PRE_EXECHOOK_BACKOFF - The time to wait before retrying a failed --pre-exechook-command. If - not specified, this defaults to 3 seconds ("3s"). - - --pre-exechook-command , $GITSYNC_PRE_EXECHOOK_COMMAND - An optional command to be executed before syncing a new hash of the - remote repository. This command does not take any arguments and - executes with the synced repo as its working directory. The - $GITSYNC_HASH environment variable will be set to the previous git hash that - was synced. This hook will always be invoked as it runs before any sync attempt. - - --pre-exechook-timeout , $GITSYNC_PRE_EXECHOOK_TIMEOUT - The timeout for the --pre-exechook-command. If not specifid, this - defaults to 30 seconds ("30s"). - --filter , $GITSYNC_FILTER Use partial clone with the specified filter. This can reduce the amount of data transferred when cloning large repositories. @@ -2746,6 +2753,22 @@ OPTIONS will take precedence. If not specified, this defaults to 10 seconds ("10s"). + --pre-exechook-backoff , $GITSYNC_PRE_EXECHOOK_BACKOFF + The time to wait before retrying a failed --pre-exechook-command. If + not specified, this defaults to 3 seconds ("3s"). + + --pre-exechook-command , $GITSYNC_PRE_EXECHOOK_COMMAND + An optional command to be executed after syncing a new hash of the + remote repository but before publishing the symlink (see --link). + This command does not take any arguments and + executes with the synced repo as its working directory. The + $GITSYNC_HASH environment variable will be set to the previous git hash that + was synced. This hook will always be invoked as it runs before any sync attempt. + + --pre-exechook-timeout , $GITSYNC_PRE_EXECHOOK_TIMEOUT + The timeout for the --pre-exechook-command. If not specifid, this + defaults to 30 seconds ("30s"). + --ref , $GITSYNC_REF The git revision (branch, tag, or hash) to check out. If not specified, this defaults to "HEAD" (of the upstream repo's default diff --git a/pkg/hook/exechook.go b/pkg/hook/exechook.go index c62f15ef0..b75ad6970 100644 --- a/pkg/hook/exechook.go +++ b/pkg/hook/exechook.go @@ -71,10 +71,10 @@ func (h *Exechook) Do(ctx context.Context, hash string) error { env := os.Environ() env = append(env, envKV("GITSYNC_HASH", hash)) - h.log.V(0).Info("running exechook", "hash", hash, "command", h.command, "timeout", h.timeout) + h.log.V(0).Info("running hook", "name", h.name, "hash", hash, "command", h.command, "timeout", h.timeout) stdout, stderr, err := h.cmdrunner.Run(ctx, worktreePath, env, h.command, h.args...) if err == nil { - h.log.V(1).Info("exechook succeeded", "hash", hash, "stdout", stdout, "stderr", stderr) + h.log.V(1).Info("hook succeeded", "name", h.name, "hash", hash, "stdout", stdout, "stderr", stderr) } return err }