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
2 changes: 1 addition & 1 deletion app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). Both the files and the reftable ref storage are read. When the reflog is off, rewritten, or does not lead from the old `HEAD` to the new one, 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

Expand Down
50 changes: 49 additions & 1 deletion app/cli/internal/trace/attribution/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,55 @@

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.
//
// An empty oldHead is a checkout with no commit before the command, which the
// reflog shows as the zero ID.
//
// It returns nil when entries do not lead from oldHead to newHead, each entry
// starting where the one before it ended: 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[len(entries)-1].New != newHead {
return nil
}
if first := entries[0].Old; first != oldHead && (oldHead != "" || !tracegit.IsZeroOID(first)) {
return nil
}
for i := 1; i < len(entries); i++ {
if entries[i].Old != entries[i-1].New {
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
Expand Down
102 changes: 102 additions & 0 deletions app/cli/internal/trace/attribution/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,111 @@ 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(oldOID, newOID, msg string) tracegit.ReflogEntry {
return tracegit.ReflogEntry{Old: oldOID, New: newOID, Message: msg}
}
zero := "0000000000000000000000000000000000000000"

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")},
},
{
name: "entries with a gap between them",
oldHead: "a", newHead: "d",
entries: []tracegit.ReflogEntry{
entry("a", "b", "commit: one"),
entry("c", "d", "commit: two"),
},
},
{
name: "the first commit of a checkout with no commit",
oldHead: "", newHead: "b",
entries: []tracegit.ReflogEntry{entry(zero, "b", "commit (initial): first")},
want: []tracegit.ReflogEntry{entry(zero, "b", "commit (initial): first")},
},
{
name: "no old HEAD and a first entry that replaces a commit",
oldHead: "", newHead: "b",
entries: []tracegit.ReflogEntry{entry("a", "b", "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"}
Expand Down
123 changes: 123 additions & 0 deletions app/cli/internal/trace/git/blobs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//
// 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/exec"
"strconv"
"strings"
)

// ErrUnbatchableName reports a name that a BlobReader cannot ask git for,
// because it holds a newline: read it in another way.
var ErrUnbatchableName = errors.New("name holds a newline")

// BlobReader reads files at commits through one long-running
// "git cat-file --batch" process, so that many reads cost one process, not
// several processes each. It is not safe for concurrent use.
type BlobReader struct {
cmd *exec.Cmd
in io.WriteCloser
out *bufio.Reader
// err is the first failure to talk to git. The output may then be out of
// step with the requests, so every later read fails too.
err error
}

// NewBlobReader starts a BlobReader for the repository at repoRoot. Close it
// when done.
func NewBlobReader(repoRoot string) (*BlobReader, error) {
cmd := exec.Command("git", "cat-file", "--batch")
cmd.Dir = repoRoot
in, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("git cat-file stdin: %w", err)
}
out, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("git cat-file stdout: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start git cat-file: %w", err)
}

return &BlobReader{cmd: cmd, in: in, out: bufio.NewReader(out)}, nil
}

// FileAt returns the content of path, repo-relative with forward slashes, at
// rev. It returns nil when rev does not resolve or does not hold path as a
// file, and ErrUnbatchableName when rev or path holds a newline.
func (r *BlobReader) FileAt(rev, path string) ([]byte, error) {
if r.err != nil {
return nil, r.err
}
if strings.ContainsAny(rev+path, "\n") {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
return nil, fmt.Errorf("read %q at %q: %w", path, rev, ErrUnbatchableName)
}

content, err := r.read(rev + ":" + path)
if err != nil {
r.err = err
}

return content, err
}

func (r *BlobReader) read(name string) ([]byte, error) {
if _, err := fmt.Fprintf(r.in, "%s\n", name); err != nil {
return nil, fmt.Errorf("write git cat-file request: %w", err)
}

header, err := r.out.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("read git cat-file header: %w", err)
}
header = strings.TrimSuffix(header, "\n")
// "<name> missing" or "<name> ambiguous": the name can hold spaces.
if strings.HasSuffix(header, " missing") || strings.HasSuffix(header, " ambiguous") {
return nil, nil
}

// "<oid> <type> <size>", then the content and a newline.
fields := strings.Fields(header)
if len(fields) != 3 {
return nil, fmt.Errorf("unexpected git cat-file header %q", header)
}
size, err := strconv.Atoi(fields[2])
if err != nil {
return nil, fmt.Errorf("unexpected git cat-file header %q: %w", header, err)
}
content := make([]byte, size+1)
if _, err := io.ReadFull(r.out, content); err != nil {
return nil, fmt.Errorf("read git cat-file content: %w", err)
}
if fields[1] != "blob" {
return nil, nil
}

return content[:size], nil
}

// Close stops the git process.
func (r *BlobReader) Close() error {
_ = r.in.Close()

return r.cmd.Wait()
}
Loading
Loading