diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index f1f648639..38deaed8c 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). 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 diff --git a/app/cli/internal/trace/attribution/shell.go b/app/cli/internal/trace/attribution/shell.go index 4f55b46da..3b28cc073 100644 --- a/app/cli/internal/trace/attribution/shell.go +++ b/app/cli/internal/trace/attribution/shell.go @@ -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 diff --git a/app/cli/internal/trace/attribution/shell_test.go b/app/cli/internal/trace/attribution/shell_test.go index 943a35e2b..e946ba295 100644 --- a/app/cli/internal/trace/attribution/shell_test.go +++ b/app/cli/internal/trace/attribution/shell_test.go @@ -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"} diff --git a/app/cli/internal/trace/git/blobs.go b/app/cli/internal/trace/git/blobs.go new file mode 100644 index 000000000..cf9d60d62 --- /dev/null +++ b/app/cli/internal/trace/git/blobs.go @@ -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") { + 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") + // " missing" or " ambiguous": the name can hold spaces. + if strings.HasSuffix(header, " missing") || strings.HasSuffix(header, " ambiguous") { + return nil, nil + } + + // " ", 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() +} diff --git a/app/cli/internal/trace/git/reflog.go b/app/cli/internal/trace/git/reflog.go new file mode 100644 index 000000000..4fccdd7d3 --- /dev/null +++ b/app/cli/internal/trace/git/reflog.go @@ -0,0 +1,290 @@ +// +// 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" + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" +) + +// ErrReflogRewritten reports that the HEAD reflog no longer holds a position +// read from it earlier: something expired or rewrote it in between, so the +// position 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 +} + +// ReflogPos marks the end of the HEAD reflog at one moment. Taken before a +// command, it marks where the entries that the command writes start. +type ReflogPos struct { + // Offset is the size in bytes of the reflog file, with the files ref + // storage. + Offset int64 `json:"offset,omitempty"` + // Top is the newest entry, as git prints it, with the reftable ref + // storage. It is empty when the reflog has no entry. + Top string `json:"top,omitempty"` +} + +// IsZeroOID reports whether oid is the all-zero object ID that a reflog +// entry holds for "no commit", as the old side of the first commit. +func IsZeroOID(oid string) bool { + return oid != "" && strings.Trim(oid, "0") == "" +} + +// headReflog locates the HEAD reflog of the checkout at repoRoot. A linked +// worktree has its own HEAD, and its own reflog. With the files ref storage +// the reflog is the file logs/HEAD in the checkout's git dir. With the +// reftable ref storage (extensions.refStorage) it lives in binary tables that +// only git reads, and reftable is true. +func headReflog(repoRoot string) (path string, reftable bool, err error) { + gitDir, _, err := FindGitDirAndRootFrom(repoRoot) + if err != nil { + return "", false, err + } + + commonDir := gitDir + if data, err := os.ReadFile(filepath.Join(gitDir, "commondir")); err == nil { + commonDir = strings.TrimSpace(string(data)) + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(gitDir, commonDir) + } + } + if fi, err := os.Stat(filepath.Join(commonDir, "reftable")); err == nil && fi.IsDir() { //nolint:gosec // the common dir that git wrote for this checkout + return "", true, nil + } + + return filepath.Join(gitDir, "logs", "HEAD"), false, nil +} + +// HeadReflogPos returns the position of the end of the HEAD reflog of the +// checkout at repoRoot. It is cheap enough for a hook that runs before every +// shell command, whatever the length of the reflog: a stat with the files ref +// storage, and one entry read through git with reftable. +func HeadReflogPos(repoRoot string) (ReflogPos, error) { + path, reftable, err := headReflog(repoRoot) + if err != nil { + return ReflogPos{}, err + } + if reftable { + lines, err := gitReflogLines(repoRoot, 1) + if err != nil || len(lines) == 0 { + return ReflogPos{}, err + } + + return ReflogPos{Top: lines[0].line}, nil + } + + fi, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return ReflogPos{}, nil + } + if err != nil { + return ReflogPos{}, fmt.Errorf("stat HEAD reflog: %w", err) + } + + return ReflogPos{Offset: fi.Size()}, nil +} + +// HeadReflogSince returns the HEAD reflog entries written after pos, a +// position that HeadReflogPos returned earlier, oldest first. It returns +// ErrReflogRewritten when the reflog no longer holds pos. +func HeadReflogSince(repoRoot string, pos ReflogPos) ([]ReflogEntry, error) { + path, reftable, err := headReflog(repoRoot) + if err != nil { + return nil, err + } + if reftable { + return gitHeadReflogSince(repoRoot, pos.Top) + } + + f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + if pos.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() < pos.Offset { + return nil, ErrReflogRewritten + } + if _, err := f.Seek(pos.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 +} + +// reflogLine is one HEAD reflog entry as git prints it. +type reflogLine struct { + // line is the whole entry: the commit, the selector with the time of the + // entry, and the message. It identifies the entry as a ReflogPos.Top. + line string + // newOID is the commit that the entry moved HEAD to. + newOID string + message string +} + +// gitReflogLines returns the newest n entries of the HEAD reflog through git, +// newest first, for a ref storage that only git reads. The selector holds the +// time of the entry, not its index, so the line of an entry stays the same +// when newer entries are added. An unborn HEAD with no reflog yet has no +// entries. +func gitReflogLines(repoRoot string, n int) ([]reflogLine, error) { + cmd := exec.Command("git", "reflog", "show", "-n", strconv.Itoa(n), "--date=unix", "--format=%H%x09%gd%x09%gs", "HEAD", "--") //nolint:gosec // a fixed git command; n is a number + cmd.Dir = repoRoot + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + if hasHead := exec.Command("git", "-C", repoRoot, "rev-parse", "--verify", "-q", "HEAD").Run() == nil; !hasHead { + return nil, nil + } + + return nil, fmt.Errorf("git reflog HEAD: %w: %s", err, strings.TrimSpace(stderr.String())) + } + + var lines []reflogLine + for line := range strings.Lines(string(out)) { + line = strings.TrimSuffix(line, "\n") + fields := strings.SplitN(line, "\t", 3) + if len(fields) != 3 { + return nil, fmt.Errorf("parse git reflog line %q", line) + } + lines = append(lines, reflogLine{line: line, newOID: fields[0], message: fields[2]}) + } + + return lines, nil +} + +// gitHeadReflogSince is HeadReflogSince for a ref storage that only git reads. +// top is the newest entry before the command, empty when there was none. It +// reads the reflog newest first, twice as many entries each time, until it +// finds top: a command that wrote few entries costs one read, whatever the +// length of the reflog. +// +// git prints the commit that each entry moved HEAD to, not the one it left. +// The old side of an entry is the new side of the entry before it, or the zero +// ID for the first entry of the reflog. +func gitHeadReflogSince(repoRoot, top string) ([]ReflogEntry, error) { + for n := 16; ; n *= 2 { + lines, err := gitReflogLines(repoRoot, n) + if err != nil { + return nil, err + } + + end := -1 + if top != "" { + end = slices.IndexFunc(lines, func(l reflogLine) bool { return l.line == top }) + } + if end < 0 { + if len(lines) == n { + continue // more entries to read + } + if top != "" { + return nil, ErrReflogRewritten + } + end = len(lines) + } + + added := make([]ReflogEntry, 0, end) + for i := end - 1; i >= 0; i-- { + old := strings.Repeat("0", len(lines[i].newOID)) + if i+1 < len(lines) { + old = lines[i+1].newOID + } + added = append(added, ReflogEntry{Old: old, New: lines[i].newOID, Message: lines[i].message}) + } + + return added, nil + } +} + +// CommitPaths returns the repo-relative paths, with forward slashes, that +// differ between the commits from and to. A zero from is the first commit of +// a branch, and every path of to then counts. +func CommitPaths(repoRoot, from, to string) (map[string]bool, error) { + args := []string{"diff-tree", "-r", "--no-renames", "--no-commit-id", "--name-only", "-z"} + if IsZeroOID(from) { + args = append(args, "--root", to) + } else { + args = append(args, from, to) + } + out, err := gitRawOutput(repoRoot, args...) + if err != nil { + return nil, fmt.Errorf("git diff-tree %s %s: %w", from, to, err) + } + + paths := make(map[string]bool) + for p := range strings.SplitSeq(string(out), "\x00") { + if p != "" { + paths[p] = true + } + } + + return paths, nil +} + +// parseReflogLine parses one reflog line: +// " <>