Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func Execute(rootCmd *cobra.Command) error {
// every command, including the ones that fail and so skip cobra's post-run hooks.
executed, err := rootCmd.ExecuteC()
reportCommand(executed, time.Since(processStart), err)
err = applyPrePushPolicy(executed, err)

if err != nil {
// The local file is pointing to the wrong organization, we remove it
Expand Down
75 changes: 38 additions & 37 deletions app/cli/cmd/trace_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@ func newTraceHookGitCommitMsgCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(cmd *cobra.Command, args []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()

return action.HandleCommitMsgHook(cmd.Context(), args[0], logger)
},
Expand All @@ -80,8 +79,7 @@ func newTraceHookGitPostCommitCmd() *cobra.Command {
Use: "post-commit",
Short: "Handle the post-commit git hook",
RunE: func(cmd *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandlePostCommitHook(cmd.Context(), logger)
},
}
Expand All @@ -95,25 +93,41 @@ func newTraceHookGitPostRewriteCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(cmd *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()

return action.HandlePostRewriteHook(cmd.Context(), logger)
},
}
}

// prePushHookAnnotation marks the pre-push hook command for applyPrePushPolicy.
const prePushHookAnnotation = "prePushHook"

// applyPrePushPolicy applies requireTrace to any error of the pre-push hook
// command: setup, run and cleanup alike. The managed pre-push script
// propagates the command's exit status to git, so only this decides whether
// the push is blocked.
func applyPrePushPolicy(executed *cobra.Command, err error) error {
if err == nil || executed == nil || executed.Annotations[prePushHookAnnotation] != trueString {
return err
}

return action.PrePushFailure(err, config.LoadRequireTraceFromYML("."), logger)
}

func newTraceHookGitPrePushCmd() *cobra.Command {
return &cobra.Command{
Use: "pre-push",
Short: "Handle the pre-push git hook",
// Any error of this command, setup included, goes through
// action.PrePushFailure in Execute.
Annotations: map[string]string{
prePushHookAnnotation: trueString,
},
RunE: func(cmd *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()

requireTrace := config.LoadRequireTraceFromYML(".")
InitHookLogger()

return action.HandlePrePushHook(cmd.Context(), requireTrace, logger, action.RunTracePushOpts{
return action.HandlePrePushHook(cmd.Context(), logger, action.RunTracePushOpts{
ActionOpts: ActionOpts,
CLIVersion: Version,
Mode: aicodingsession.ModeCoding,
Expand Down Expand Up @@ -147,8 +161,7 @@ func newTraceHookClaudeSessionStartCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentSessionStart(claude.New(), logger)
},
}
Expand All @@ -162,8 +175,7 @@ func newTraceHookClaudeUserPromptSubmitCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentPromptSubmit(claude.New(), logger)
},
}
Expand All @@ -177,8 +189,7 @@ func newTraceHookClaudeSessionEndCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentSessionEnd(claude.New(), logger)
},
}
Expand All @@ -192,8 +203,7 @@ func newTraceHookClaudePreToolUseCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentPreToolUse(claude.New(), logger)
},
}
Expand All @@ -207,8 +217,7 @@ func newTraceHookClaudePostToolUseCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentPostToolUse(claude.New(), logger)
},
}
Expand Down Expand Up @@ -237,8 +246,7 @@ func newTraceHookCursorSessionStartCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentSessionStart(cursor.New(), logger)
},
}
Expand All @@ -252,8 +260,7 @@ func newTraceHookCursorSessionEndCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentSessionEnd(cursor.New(), logger)
},
}
Expand All @@ -267,8 +274,7 @@ func newTraceHookCursorAfterFileEditCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentPostToolUse(cursor.New(), logger)
},
}
Expand Down Expand Up @@ -299,8 +305,7 @@ func newTraceHookOpenCodeSessionStartCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentSessionStart(opencode.New(), logger)
},
}
Expand All @@ -314,8 +319,7 @@ func newTraceHookOpenCodeUserPromptSubmitCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentPromptSubmit(opencode.New(), logger)
},
}
Expand All @@ -329,8 +333,7 @@ func newTraceHookOpenCodeSessionEndCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentSessionEnd(opencode.New(), logger)
},
}
Expand All @@ -344,8 +347,7 @@ func newTraceHookOpenCodePreToolUseCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentPreToolUse(opencode.New(), logger)
},
}
Expand All @@ -359,8 +361,7 @@ func newTraceHookOpenCodePostToolUseCmd() *cobra.Command {
"skipActionOptsInit": "true",
},
RunE: func(_ *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()
InitHookLogger()
return action.HandleAgentPostToolUse(opencode.New(), logger)
},
}
Expand Down
65 changes: 65 additions & 0 deletions app/cli/cmd/trace_hook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"errors"
"os"
"path/filepath"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestApplyPrePushPolicy(t *testing.T) {
failure := errors.New("setup failed")
prePush := newTraceHookGitPrePushCmd()
other := &cobra.Command{Use: "other"}

testCases := []struct {
name string
executed *cobra.Command
err error
requireTrace bool
wantErr bool
}{
{name: "success stays success", executed: prePush},
{name: "pre-push failure fails open by default", executed: prePush, err: failure},
{name: "pre-push failure blocks with requireTrace", executed: prePush, err: failure, requireTrace: true, wantErr: true},
{name: "other commands keep their error", executed: other, err: failure, wantErr: true},
{name: "unknown command keeps its error", err: failure, wantErr: true},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if tc.requireTrace {
require.NoError(t, os.WriteFile(filepath.Join(dir, ".chainloop.yml"), []byte("requireTrace: true\n"), 0600))
}
t.Chdir(dir)

err := applyPrePushPolicy(tc.executed, tc.err)
if tc.wantErr {
require.ErrorIs(t, err, failure)
return
}

assert.NoError(t, err)
})
}
}
34 changes: 25 additions & 9 deletions app/cli/cmd/trace_hooklog.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import (
"github.com/rs/zerolog"
)

// hookLogFile holds the trace log file while hook commands run, so it can be
// closed by the cleanup closure returned from InitHookLogger.
// hookLogFile holds the trace log file opened by InitHookLogger. It stays open
// until CloseHookLog runs at process exit, so the lines logged after the
// command returns (main's final error line, telemetry) still reach it.
var hookLogFile *os.File

// stderrMinLevel is the minimum zerolog.Level that hook commands write to
Expand Down Expand Up @@ -67,6 +68,12 @@ func hookStderrWriter() *levelFilterWriter {
}
}

// hookStderrLogger builds the stderr-only hook logger, used when no trace log
// file is open.
func hookStderrLogger() zerolog.Logger {
return zerolog.New(hookStderrWriter()).Level(stderrMinLevel)
}

// InitHookLogger reconfigures the root logger for git/agent hook commands:
// colorless output, all levels written to the trace state's log.txt, and
// Warn+ (or Debug, with --debug) written to stderr. Colorless matters because
Expand All @@ -75,9 +82,9 @@ func hookStderrWriter() *levelFilterWriter {
//
// If trace state cannot be located or the file cannot be opened, the logger
// falls back to colorless stderr-only output so the rest of the hook still
// runs with consistent formatting. Returns a cleanup function that closes the
// log file — callers should defer it at the top of a hook's RunE.
func InitHookLogger() func() {
// runs with consistent formatting. The log file is closed by CloseHookLog, not
// by the hook command: main logs a failed command's error after RunE returns.
func InitHookLogger() {
closeHookLogFile()

// Mirror initLogger: Info normally, Debug with --debug. Info matters —
Expand All @@ -89,19 +96,28 @@ func InitHookLogger() func() {
stderrMinLevel = zerolog.DebugLevel
}

logger = zerolog.New(hookStderrWriter()).Level(stderrMinLevel)
logger = hookStderrLogger()

store, _, err := state.Locate()
if err != nil {
return func() {}
return
}

if err := initHookLogFile(store); err != nil {
logger.Debug().Err(err).Msg("could not open hook log file")
return func() {}
}
}

// CloseHookLog closes the trace log file opened by InitHookLogger and points
// the root logger back at stderr, so a later log line never writes to a
// closed file. main calls it right before the process exits.
func CloseHookLog() {
if hookLogFile == nil {
return
}

return closeHookLogFile
closeHookLogFile()
logger = hookStderrLogger()
}

// initHookLogFile opens the trace log file and reassigns the root logger to a
Expand Down
29 changes: 29 additions & 0 deletions app/cli/cmd/trace_hooklog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,35 @@ func TestInitHookLogFileFailsOnBadPath(t *testing.T) {
assert.Error(t, err)
}

// TestCloseHookLog covers the exit path: main logs a failed hook's error after
// RunE returns, then closes the file. Nothing logged afterwards (telemetry)
// may write to the closed file.
func TestCloseHookLog(t *testing.T) {
store := state.NewGitStore(t.TempDir())
require.NoError(t, store.InitTraceDir())
resetHookLoggerState(t)

var writeErrs []error
prevHandler := zerolog.ErrorHandler
zerolog.ErrorHandler = func(err error) { writeErrs = append(writeErrs, err) }
t.Cleanup(func() { zerolog.ErrorHandler = prevHandler })

require.NoError(t, initHookLogFile(store))
l := Logger()
l.Error().Msg("final error line")

CloseHookLog()
l = Logger()
l.Debug().Msg("logged after close")
CloseHookLog()

assert.Empty(t, writeErrs)
content, err := os.ReadFile(store.LogFilePath())
require.NoError(t, err)
assert.Contains(t, string(content), "final error line")
assert.NotContains(t, string(content), "logged after close")
}

func TestCloseHookLogFileIdempotent(_ *testing.T) {
// Calling close without init should not panic
closeHookLogFile()
Expand Down
Loading
Loading