From 7ed4749ef23619d4e8aeee7444179618015cfc56 Mon Sep 17 00:00:00 2001 From: Jose Ignacio Paris Prieto Date: Wed, 4 Nov 2026 00:15:07 +0100 Subject: [PATCH 1/6] fix(trace): do not count upstream lines when a shell command moves HEAD An agent shell command that moves HEAD, such as a rebase, a pull, a merge, a checkout or a reset, recorded every line of the commits that it moved to as an AI edit of the session, which inflated ai_generated_lines_added and ai_generated_lines_removed and added unrelated files to the session's ledger. The pre hook now saves the size of the HEAD reflog. The post hook reads the entries that the command added, credits only the commits that the command wrote itself, each against the HEAD it replaced, plus the uncommitted changes against the HEAD after the command. Refs: PFM-8131 Assisted-by: Claude Code Signed-off-by: Jose Ignacio Paris Prieto Chainloop-Trace-Sessions: 66252f01-6f1c-4e49-b41b-d21335c39743 --- app/cli/internal/trace/README.md | 2 +- app/cli/internal/trace/attribution/shell.go | 39 ++++- .../internal/trace/attribution/shell_test.go | 82 ++++++++++ app/cli/internal/trace/git/reflog.go | 134 +++++++++++++++++ app/cli/internal/trace/git/reflog_test.go | 120 +++++++++++++++ app/cli/internal/trace/state/snapshot.go | 42 ++++++ app/cli/internal/trace/state/snapshot_test.go | 19 +++ app/cli/pkg/action/trace_agent_hook.go | 142 ++++++++++++++---- .../pkg/action/trace_line_attribution_test.go | 133 +++++++++++++++- 9 files changed, 681 insertions(+), 32 deletions(-) create mode 100644 app/cli/internal/trace/git/reflog.go create mode 100644 app/cli/internal/trace/git/reflog_test.go diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index f1f648639..fa947575c 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -94,7 +94,7 @@ Why not Yes: - **Net contribution and line ranges, all agents:** the push finds the last writer of each line with `git blame`. A later commit that only re-indents a line takes the line, because blame sees a change. For Cursor, the limits of line attribution apply too. - **Session updates, all agents:** the update works only in the clone where the work happened, while the CLI keeps the trace state of the session (30 days), and only when the commits of the session are on the branch that the push sends. The push reads only the commits that are not on the default branch of the remote (`origin/HEAD`, or else `origin/main` or `origin/master`). After the commits of a session are pushed to that branch, a later commit does not update the session. - **Renames and rewrites, all agents:** this works only in the clone where the work happened. Git finds a rename only when the file keeps at least half of its content. Two pushed commits can rename a file one after the other. A later change to the file under its middle name does not send the session again. A rename inside an amend or a squash of the session commit loses the AI lines: the new commit creates the file under its new name, so no line of it matches the trace state (issue 3686). The limits of session updates apply too. For Cursor, the limits of line attribution apply too. -- **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The file at the `HEAD` commit from before the command stands in for the content before the command. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI. +- **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The command's changes are the commits that it wrote itself, each against the `HEAD` it replaced, plus the worktree against the `HEAD` after the command. The `HEAD` reflog tells the command's own commits apart from moves to commits that it did not write, such as a rebase, a pull, a merge, a checkout or a reset: lines that such a move brings in do not count (PFM-8131). When the reflog is off or cannot be read, no commit of the command counts. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI. ## User experience diff --git a/app/cli/internal/trace/attribution/shell.go b/app/cli/internal/trace/attribution/shell.go index 4f55b46da..23dad01d7 100644 --- a/app/cli/internal/trace/attribution/shell.go +++ b/app/cli/internal/trace/attribution/shell.go @@ -15,7 +15,44 @@ package attribution -import "sort" +import ( + "sort" + "strings" + + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" +) + +// ownCommitPrefixes are the reflog messages of a commit that git wrote from +// the index: "git commit", also with --amend, and the first commit of a +// branch. A merge commit ("commit (merge)") is left out because its content +// comes from the merged branch. +var ownCommitPrefixes = []string{"commit: ", "commit (amend): ", "commit (initial): "} + +// OwnCommits returns the HEAD moves of a shell command that are commits the +// command wrote itself, oldest first. entries are the HEAD reflog entries that +// the command wrote. Every other move, such as a rebase, a pull, a merge, a +// checkout or a reset, points HEAD at content that the command did not write. +// +// It returns nil when entries do not lead from oldHead to newHead: the reflog +// then does not tell the whole story of the command, and none of its moves can +// be trusted. +func OwnCommits(oldHead, newHead string, entries []tracegit.ReflogEntry) []tracegit.ReflogEntry { + if len(entries) == 0 || entries[0].Old != oldHead || entries[len(entries)-1].New != newHead { + return nil + } + + var own []tracegit.ReflogEntry + for _, e := range entries { + for _, prefix := range ownCommitPrefixes { + if strings.HasPrefix(e.Message, prefix) { + own = append(own, e) + break + } + } + } + + return own +} // ChangedPaths diffs two working-tree signatures (repo-relative path → content // hash) captured before and after a shell command, returning the paths the diff --git a/app/cli/internal/trace/attribution/shell_test.go b/app/cli/internal/trace/attribution/shell_test.go index 943a35e2b..7fcd72abc 100644 --- a/app/cli/internal/trace/attribution/shell_test.go +++ b/app/cli/internal/trace/attribution/shell_test.go @@ -18,9 +18,91 @@ package attribution import ( "testing" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" "github.com/stretchr/testify/assert" ) +func TestOwnCommits(t *testing.T) { + entry := func(old, new, msg string) tracegit.ReflogEntry { + return tracegit.ReflogEntry{Old: old, New: new, Message: msg} + } + + tests := []struct { + name string + oldHead string + newHead string + entries []tracegit.ReflogEntry + want []tracegit.ReflogEntry + }{ + { + name: "no move", + oldHead: "a", newHead: "a", + }, + { + name: "a commit", + oldHead: "a", newHead: "b", + entries: []tracegit.ReflogEntry{entry("a", "b", "commit: feat")}, + want: []tracegit.ReflogEntry{entry("a", "b", "commit: feat")}, + }, + { + name: "an amend", + oldHead: "a", newHead: "b", + entries: []tracegit.ReflogEntry{entry("a", "b", "commit (amend): feat")}, + want: []tracegit.ReflogEntry{entry("a", "b", "commit (amend): feat")}, + }, + { + name: "a rebase writes nothing of the command", + oldHead: "a", newHead: "d", + entries: []tracegit.ReflogEntry{ + entry("a", "c", "rebase (start): checkout main"), + entry("c", "d", "rebase (pick): feat"), + entry("d", "d", "rebase (finish): returning to refs/heads/feature"), + }, + }, + { + name: "a fast-forward pull, a checkout and a reset", + oldHead: "a", newHead: "d", + entries: []tracegit.ReflogEntry{ + entry("a", "b", "pull: Fast-forward"), + entry("b", "c", "checkout: moving from feature to main"), + entry("c", "d", "reset: moving to d"), + }, + }, + { + name: "a merge commit is not the command's own", + oldHead: "a", newHead: "m", + entries: []tracegit.ReflogEntry{entry("a", "m", "commit (merge): Merge branch 'main'")}, + }, + { + name: "a commit before and after a rebase", + oldHead: "a", newHead: "e", + entries: []tracegit.ReflogEntry{ + entry("a", "b", "commit: one"), + entry("b", "c", "rebase (start): checkout main"), + entry("c", "d", "rebase (pick): one"), + entry("d", "d", "rebase (finish): returning to refs/heads/feature"), + entry("d", "e", "commit: two"), + }, + want: []tracegit.ReflogEntry{entry("a", "b", "commit: one"), entry("d", "e", "commit: two")}, + }, + { + name: "entries that do not start at the old HEAD", + oldHead: "a", newHead: "c", + entries: []tracegit.ReflogEntry{entry("x", "c", "commit: feat")}, + }, + { + name: "entries that do not end at the new HEAD", + oldHead: "a", newHead: "z", + entries: []tracegit.ReflogEntry{entry("a", "c", "commit: feat")}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, OwnCommits(tc.oldHead, tc.newHead, tc.entries)) + }) + } +} + func TestChangedPaths(t *testing.T) { t.Run("detects created, modified, and deleted paths", func(t *testing.T) { before := map[string]string{"a": "1", "b": "2", "c": "3"} diff --git a/app/cli/internal/trace/git/reflog.go b/app/cli/internal/trace/git/reflog.go new file mode 100644 index 000000000..780f912d0 --- /dev/null +++ b/app/cli/internal/trace/git/reflog.go @@ -0,0 +1,134 @@ +// +// 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 git + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// ErrReflogRewritten reports that the HEAD reflog is shorter than an offset +// read from it earlier: something expired or rewrote it in between, so the +// offset no longer marks where the new entries start. +var ErrReflogRewritten = errors.New("HEAD reflog was rewritten") + +// ReflogEntry is one move of HEAD, as the reflog records it. +type ReflogEntry struct { + // Old and New are the commits HEAD pointed to before and after the move. + Old, New string + // Message says what moved HEAD, for example "commit: " or + // "rebase (finish): returning to refs/heads/main". + Message string +} + +// headReflogPath returns the HEAD reflog of the checkout at repoRoot. A linked +// worktree has its own HEAD, and its own reflog in its private git dir. +func headReflogPath(repoRoot string) (string, error) { + gitDir, _, err := FindGitDirAndRootFrom(repoRoot) + if err != nil { + return "", err + } + + return filepath.Join(gitDir, "logs", "HEAD"), nil +} + +// HeadReflogSize returns the size in bytes of the HEAD reflog of the checkout +// at repoRoot, zero when it has none yet. Taken before a command, it marks +// where the entries that the command writes start. It only stats the file, so +// it is cheap enough for a hook that runs before every shell command. +func HeadReflogSize(repoRoot string) (int64, error) { + path, err := headReflogPath(repoRoot) + if err != nil { + return 0, err + } + + fi, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("stat HEAD reflog: %w", err) + } + + return fi.Size(), nil +} + +// HeadReflogSince returns the HEAD reflog entries written after offset, a size +// that HeadReflogSize returned earlier, oldest first. It returns +// ErrReflogRewritten when the reflog is now shorter than offset. +func HeadReflogSince(repoRoot string, offset int64) ([]ReflogEntry, error) { + path, err := headReflogPath(repoRoot) + if err != nil { + return nil, err + } + + f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + if offset > 0 { + return nil, ErrReflogRewritten + } + + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("open HEAD reflog: %w", err) + } + defer func() { _ = f.Close() }() + + fi, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("stat HEAD reflog: %w", err) + } + if fi.Size() < offset { + return nil, ErrReflogRewritten + } + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return nil, fmt.Errorf("seek HEAD reflog: %w", err) + } + + var entries []ReflogEntry + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + entry, ok := parseReflogLine(sc.Text()) + if !ok { + return nil, fmt.Errorf("parse HEAD reflog line %q", sc.Text()) + } + entries = append(entries, entry) + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("read HEAD reflog: %w", err) + } + + return entries, nil +} + +// parseReflogLine parses one reflog line: +// " <>