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
17 changes: 17 additions & 0 deletions app/cli/cmd/trace_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func newTraceHookGitCmd() *cobra.Command {
cmd.AddCommand(
newTraceHookGitCommitMsgCmd(),
newTraceHookGitPostCommitCmd(),
newTraceHookGitPostRewriteCmd(),
newTraceHookGitPrePushCmd(),
)

Expand Down Expand Up @@ -86,6 +87,22 @@ func newTraceHookGitPostCommitCmd() *cobra.Command {
}
}

func newTraceHookGitPostRewriteCmd() *cobra.Command {
return &cobra.Command{
Use: "post-rewrite",
Short: "Handle the post-rewrite git hook",
Annotations: map[string]string{
"skipActionOptsInit": "true",
},
RunE: func(cmd *cobra.Command, _ []string) error {
cleanup := InitHookLogger()
defer cleanup()

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

func newTraceHookGitPrePushCmd() *cobra.Command {
return &cobra.Command{
Use: "pre-push",
Expand Down
12 changes: 12 additions & 0 deletions app/cli/internal/trace/git/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"os/exec"
"strconv"
"strings"
"time"

"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
)
Expand Down Expand Up @@ -277,6 +278,17 @@ func (c *ExecClient) CommitHeadInfo(repoRoot string) (sha, message string, err e
return sha, strings.TrimSpace(message), nil
}

// HeadAuthorTime returns the author date of HEAD.
func (c *ExecClient) HeadAuthorTime(repoRoot string) (time.Time, error) {
out := gitOutput(repoRoot, "log", "-1", "--format=%at", "HEAD")
secs, err := strconv.ParseInt(out, 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("git log HEAD author date %q: %w", out, err)
}

return time.Unix(secs, 0), nil
}

// StagedFiles returns repo-relative paths of files staged for the current commit.
func (c *ExecClient) StagedFiles(repoRoot string) ([]string, error) {
out := gitOutput(repoRoot, "diff", "--cached", "--name-only")
Expand Down
4 changes: 4 additions & 0 deletions app/cli/internal/trace/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
package git

import (
"time"

"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
)

Expand Down Expand Up @@ -48,6 +50,8 @@ type Client interface {
CodeChangesForCommits(repoRoot string, shas []string) (*aicodingsession.CodeChanges, error)
// CommitHeadInfo returns the SHA and message of HEAD.
CommitHeadInfo(repoRoot string) (sha, message string, err error)
// HeadAuthorTime returns the author date of HEAD.
HeadAuthorTime(repoRoot string) (time.Time, error)
// GeneratedMatcher returns a predicate that reports whether a repo-relative
// path is marked linguist-generated=true in the repository's .gitattributes.
// Implementations must return a no-op matcher (always false) when attributes
Expand Down
20 changes: 20 additions & 0 deletions app/cli/internal/trace/git/gogit.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"

"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
"github.com/go-git/go-billy/v6/osfs"
Expand Down Expand Up @@ -198,6 +199,25 @@ func (c *GoGitClient) CommitHeadInfo(repoRoot string) (sha, message string, err
return head.Hash().String(), strings.TrimSpace(commit.Message), nil
}

func (c *GoGitClient) HeadAuthorTime(repoRoot string) (time.Time, error) {
repo, err := openRepo(repoRoot)
if err != nil {
return time.Time{}, fmt.Errorf("open repo: %w", err)
}

head, err := repo.Head()
if err != nil {
return time.Time{}, fmt.Errorf("get HEAD: %w", err)
}

commit, err := repo.CommitObject(head.Hash())
if err != nil {
return time.Time{}, fmt.Errorf("get commit: %w", err)
}

return commit.Author.When, nil
}

// GeneratedMatcher returns a predicate that reports whether a repo-relative path
// is marked linguist-generated=true in the repository's .gitattributes, parsed
// in-process via go-git's gitattributes parser.
Expand Down
29 changes: 29 additions & 0 deletions app/cli/internal/trace/git/merge_branch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,35 @@ func setupBranchedRepo(t *testing.T) branchedRepo {
}
}

func TestHeadAuthorTime(t *testing.T) {
r := setupBranchedRepo(t)

// Give HEAD a known author date.
cmd := exec.Command("git", "commit", "-q", "--amend", "--no-edit", "--date=@1700000000 +0200")
cmd.Dir = r.dir
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))

clients := []struct {
name string
client Client
}{
{"go-git", NewGoGitClient()},
{"exec", NewExecClient()},
}

for _, tc := range clients {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.client.HeadAuthorTime(r.dir)
require.NoError(t, err)
assert.Equal(t, int64(1700000000), got.Unix())

_, err = tc.client.HeadAuthorTime(t.TempDir())
assert.Error(t, err, "a directory without a repository has no HEAD")
})
}
}

func TestGoGitClient_IsMergeCommit(t *testing.T) {
repo := setupBranchedRepo(t)
c := NewGoGitClient()
Expand Down
3 changes: 2 additions & 1 deletion app/cli/internal/trace/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
var managedHooks = []hookDef{
{"commit-msg", true},
{"post-commit", false},
{"post-rewrite", false},
{"pre-push", false},
}

Expand Down Expand Up @@ -117,7 +118,7 @@ func hookContentWithChain(hookCmd, backupPath string, passArgs bool) string {
// Hooks are always written to the *common* hooks dir, since git only reads
// hooks/ from the shared .git/ even when invoked from a linked worktree.
//
// Pass skipPrePush=true to install only commit-msg and post-commit; used
// Pass skipPrePush=true to install every hook except pre-push; used
// by trace run, which drives the attestation push itself.
func Install(gitDir string, skipPrePush bool) (string, error) {
hooksDir, err := resolveHooksDir(gitDir)
Expand Down
16 changes: 8 additions & 8 deletions app/cli/internal/trace/hooks/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func TestInstall(t *testing.T) {
_, err := Install(gitDir, false)
require.NoError(t, err)

for _, name := range []string{"commit-msg", "post-commit", "pre-push"} {
for _, name := range []string{"commit-msg", "post-commit", "post-rewrite", "pre-push"} {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
hookPath := filepath.Join(gitDir, "hooks", name)
content, err := os.ReadFile(hookPath)
require.NoError(t, err)
Expand All @@ -49,8 +49,8 @@ func TestInstall(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, string(commitMsgContent), `"$@"`)

// post-commit and pre-push must NOT forward arguments
for _, name := range []string{"post-commit", "pre-push"} {
// post-commit, post-rewrite and pre-push must NOT forward arguments
for _, name := range []string{"post-commit", "post-rewrite", "pre-push"} {
content, err := os.ReadFile(filepath.Join(gitDir, "hooks", name))
require.NoError(t, err)
assert.NotContains(t, string(content), `"$@"`)
Expand Down Expand Up @@ -122,7 +122,7 @@ func TestInstall(t *testing.T) {
assert.Equal(t, filepath.Join(commonDir, "hooks"), hooksDir)

// Hooks must land under the common dir, not the worktree-private dir.
for _, name := range []string{"commit-msg", "post-commit", "pre-push"} {
for _, name := range []string{"commit-msg", "post-commit", "post-rewrite", "pre-push"} {
content, err := os.ReadFile(filepath.Join(commonDir, "hooks", name))
require.NoError(t, err, "hook %s should exist in common .git/hooks", name)
assert.Contains(t, string(content), HookMarker)
Expand All @@ -135,13 +135,13 @@ func TestInstall(t *testing.T) {
}

func TestInstallSkipPrePush(t *testing.T) {
t.Run("installs commit-msg and post-commit only", func(t *testing.T) {
t.Run("installs every hook except pre-push", func(t *testing.T) {
gitDir := t.TempDir()

_, err := Install(gitDir, true)
require.NoError(t, err)

for _, name := range []string{"commit-msg", "post-commit"} {
for _, name := range []string{"commit-msg", "post-commit", "post-rewrite"} {
content, err := os.ReadFile(filepath.Join(gitDir, "hooks", name))
require.NoError(t, err, "hook %s should be installed", name)
assert.Contains(t, string(content), HookMarker)
Expand Down Expand Up @@ -196,7 +196,7 @@ func TestHookScriptsExitZero(t *testing.T) {
_, err := Install(gitDir, false)
require.NoError(t, err)

for _, name := range []string{"commit-msg", "post-commit", "pre-push"} {
for _, name := range []string{"commit-msg", "post-commit", "post-rewrite", "pre-push"} {
//nolint:gosec // the hook path is derived from t.TempDir(), not from user input
cmd := exec.Command("/bin/sh", filepath.Join(hooksDir, name), "msgfile")
cmd.Env = []string{"PATH="}
Expand All @@ -214,7 +214,7 @@ func TestUninstall(t *testing.T) {
_, err = Uninstall(gitDir)
require.NoError(t, err)

for _, name := range []string{"commit-msg", "post-commit", "pre-push"} {
for _, name := range []string{"commit-msg", "post-commit", "post-rewrite", "pre-push"} {
_, err := os.Stat(filepath.Join(gitDir, "hooks", name))
assert.True(t, os.IsNotExist(err))
}
Expand Down
Loading
Loading