diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 9b4e3ebfa..034a7f810 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -58,7 +58,7 @@ Which lines of a commit the AI wrote. | Partial commits | A commit that holds only part of the AI lines of a file credits the session with those lines. The other AI lines stay pending, and the next commit with them is credited to the session too (spec issue-3639). | Yes | Partial | Yes | Yes | No | No | | Generated line counts | The trace state keeps the lines that each agent edit added and removed, also the lines that never reach a commit. The evidence does not report them yet (spec issue-3639). | Yes | Partial | Yes | Yes | No | No | | Shell changes attribution | Changes that the agent makes with shell commands (for example `sed`) are attributed to the AI too, not only changes made with the file tools. | Partial | Partial | Partial | Partial | No | No | -| Net contribution | The added lines of a session are the lines that its commits added and that are still in the commit that the push sends. A line that a later commit replaced does not count, and each line counts one time. The removed lines are the sum of the removals of the session's commits (spec issue-3639). | Partial | Partial | Partial | Partial | No | No | +| Net contribution | The added lines of a session are the lines that its commits added and that are still in the commit that the push sends. A line that a later commit replaced does not count, and each line counts one time. The removed lines are the removals of the session's commits, less the lines that a commit of the session wrote. The line counts of each file are the sum of its AI and human lines, and a file that the session created stays created when a later commit of the session changes it (spec issue-3639, issue 3674). | Partial | Partial | Partial | Partial | No | No | | Session updates | A later commit that replaces lines of a session sends the evidence of the session again at the next push, with the new numbers. A commit in no session that replaces lines of a session joins it: the commit list of the session holds the commit, and its replacing lines count as human (spec issue-3639). | Partial | Partial | Partial | Partial | No | No | | Line ranges | The line ranges of each file are positions in the file at the commit that the push sends. They cover the lines of the session that are still there, and each range says if its lines are AI or human. The AI ranges hold as many lines as the net AI lines added, and the human ranges as many lines as the net human lines added. A file that the pushed commit no longer holds has no ranges. A range with no attribution is an AI range at edit time. Older CLIs write only these ranges. This CLI writes them for a file that it cannot blame at the pushed commit, and for a file with edits from before line fingerprints (spec issue-3639, line ranges). | Partial | Partial | Partial | Partial | No | No | | Renames and rewrites | The AI/human split survives a file rename, an amend, a rebase, a squash, and a cherry-pick. The lines match the trace state by content, and blame follows a renamed file to its lines from before the rename (spec issue-3639). | Partial | Partial | Partial | Partial | No | No | diff --git a/app/cli/internal/trace/attribution/enrich.go b/app/cli/internal/trace/attribution/enrich.go index 03dcc04eb..15f713c18 100644 --- a/app/cli/internal/trace/attribution/enrich.go +++ b/app/cli/internal/trace/attribution/enrich.go @@ -57,8 +57,11 @@ type CommitLines struct { // contribution of the session (R-003): the lines that its commits added and // that no later commit replaced, each one counted once. A file that tip holds // with no lines is gone, and adds nothing. A file that tip does not hold falls -// back to the sum of the added lines of each commit. Removed lines are always -// the sum over the session's own commits. +// back to the sum of the added lines of each commit. Removed lines are the sum +// over the session's own commits; each commit holds only the removed lines +// that no earlier commit of the session wrote. The line counts of a file with +// a split are the sum of the split, so the AI and human numbers add up to them +// (issue 3674). // // The line ranges of a file in tip are the positions of the session's net // added lines in the pushed commit, each range AI or human (spec issue-3639, @@ -101,6 +104,7 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi } } + changes.LinesAdded, changes.LinesRemoved = 0, 0 changes.AILinesAdded, changes.AILinesRemoved = 0, 0 changes.HumanLinesAdded, changes.HumanLinesRemoved = 0, 0 for i := range changes.Files { @@ -127,6 +131,10 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi case ok: f.AILinesAdded, f.AILinesRemoved = s.aiAdded, s.aiRemoved f.HumanLinesAdded, f.HumanLinesRemoved = s.humanAdded, s.humanRemoved + // The line counts of the file are the net numbers of the + // split, not the sum of the diff of each commit. + f.LinesAdded = s.aiAdded + s.humanAdded + f.LinesRemoved = s.aiRemoved + s.humanRemoved case f.Attribution == trace.AttributionHuman: // No line content for the file (a binary file, or a commit that // could not be read): the diff totals are human, as before. @@ -135,6 +143,8 @@ func Enrich(sessionID string, ledgers map[string]*state.AILineAttribution, commi legacySplit(f, self.Files[f.Path]) } + changes.LinesAdded += f.LinesAdded + changes.LinesRemoved += f.LinesRemoved changes.AILinesAdded += f.AILinesAdded changes.AILinesRemoved += f.AILinesRemoved changes.HumanLinesAdded += f.HumanLinesAdded diff --git a/app/cli/internal/trace/attribution/enrich_test.go b/app/cli/internal/trace/attribution/enrich_test.go index f53c38daf..7c48dc1c9 100644 --- a/app/cli/internal/trace/attribution/enrich_test.go +++ b/app/cli/internal/trace/attribution/enrich_test.go @@ -331,15 +331,17 @@ func TestEnrich_Net(t *testing.T) { }{ { // Case 3 of the spec: a later commit of the session replaces an - // AI line by hand. Each line counts once (R-003). + // AI line by hand. Each line counts once (R-003). The replaced + // line is the session's own, so c2 does not hold it as removed + // (sessionCommitLines leaves it out). name: "a replaced line of the session counts once", ledgers: map[string]*state.AILineAttribution{testSession: ledger(testSession, testFile, five, nil)}, commits: []CommitLines{ ownCommit(c1, five, nil), - ownCommit(c2, []string{"C"}, []string{"c"}), + ownCommit(c2, []string{"C"}, nil), }, tip: map[string][]tracegit.BlameLine{testFile: tipOf(c1+":a", c1+":b", c2+":C", c1+":d", c1+":e")}, - want: split{aiAdded: 4, humanAdded: 1, humanRemoved: 1}, + want: split{aiAdded: 4, humanAdded: 1}, ranges: []aicodingsession.LineRange{ai(1, 2), human(3, 3), ai(4, 5)}, }, { @@ -450,6 +452,13 @@ func TestEnrich_Net(t *testing.T) { assert.Equal(t, tc.want, split{changes.AILinesAdded, changes.AILinesRemoved, changes.HumanLinesAdded, changes.HumanLinesRemoved}) assert.Equal(t, tc.ranges, f.LineRanges) + // The line counts of the file and of the session are the sum of + // the split (issue 3674). + assert.Equal(t, tc.want.aiAdded+tc.want.humanAdded, f.LinesAdded, "lines added of the file") + assert.Equal(t, tc.want.aiRemoved+tc.want.humanRemoved, f.LinesRemoved, "lines removed of the file") + assert.Equal(t, f.LinesAdded, changes.LinesAdded, "lines added of the session") + assert.Equal(t, f.LinesRemoved, changes.LinesRemoved, "lines removed of the session") + // R-003: the ranges hold as many lines as the net added lines. covered := map[string]int{} for _, r := range f.LineRanges { diff --git a/app/cli/internal/trace/git/aggregate.go b/app/cli/internal/trace/git/aggregate.go index e617802ca..d7a2d2f3e 100644 --- a/app/cli/internal/trace/git/aggregate.go +++ b/app/cli/internal/trace/git/aggregate.go @@ -23,7 +23,8 @@ import ( // fileAgg is the running per-path total used while accumulating diffs from a // set of commits. Backends share it so the aggregation rule (sum lines, -// last-wins status) lives in one place. +// last-wins status, except that a created file stays created) lives in one +// place. type fileAgg struct { linesAdded int linesRemoved int @@ -43,7 +44,9 @@ func addCommitStats(out *aicodingsession.CodeChanges, byPath map[string]*fileAgg } agg.linesAdded += f.LinesAdded agg.linesRemoved += f.LinesRemoved - if f.Status != "" { + // A file that an earlier commit created stays created, unless a + // later commit deletes it. + if f.Status != "" && (agg.status != StatusCreated || f.Status == StatusDeleted) { agg.status = f.Status } } diff --git a/app/cli/internal/trace/git/aggregate_test.go b/app/cli/internal/trace/git/aggregate_test.go new file mode 100644 index 000000000..0f7d9effd --- /dev/null +++ b/app/cli/internal/trace/git/aggregate_test.go @@ -0,0 +1,49 @@ +// +// 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 ( + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "github.com/stretchr/testify/assert" +) + +func TestAddCommitStats_Status(t *testing.T) { + testCases := []struct { + name string + statuses []string + want string + }{ + {name: "one commit", statuses: []string{StatusModified}, want: StatusModified}, + {name: "a file that the commits create stays created", statuses: []string{StatusCreated, StatusModified, StatusModified}, want: StatusCreated}, + {name: "a created file that a later commit deletes is deleted", statuses: []string{StatusCreated, StatusDeleted}, want: StatusDeleted}, + {name: "the last status wins otherwise", statuses: []string{StatusModified, StatusDeleted}, want: StatusDeleted}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := &aicodingsession.CodeChanges{} + byPath := make(map[string]*fileAgg) + for _, s := range tc.statuses { + addCommitStats(out, byPath, &aicodingsession.CodeChanges{Files: []aicodingsession.FileChange{{Path: "a.txt", Status: s}}}) + } + finalizeAggregatedFiles(out, byPath) + + assert.Equal(t, tc.want, out.Files[0].Status) + }) + } +} diff --git a/app/cli/internal/trace/git/exec_test.go b/app/cli/internal/trace/git/exec_test.go index f21b01e27..e8200a285 100644 --- a/app/cli/internal/trace/git/exec_test.go +++ b/app/cli/internal/trace/git/exec_test.go @@ -151,6 +151,9 @@ func TestExecClient_CodeChangesForCommits(t *testing.T) { assert.Equal(t, "a.txt", changes.Files[0].Path) assert.Equal(t, 3, changes.Files[0].LinesAdded, "1 line from A + 2 lines from C") assert.Equal(t, 3, changes.LinesAdded) + assert.Equal(t, StatusCreated, changes.Files[0].Status, "commit-A created the file") + assert.Equal(t, 1, changes.FilesCreated) + assert.Equal(t, 0, changes.FilesModified) }) t.Run("empty input returns empty result", func(t *testing.T) { diff --git a/app/cli/internal/trace/git/gogit_test.go b/app/cli/internal/trace/git/gogit_test.go index 08cbf3439..2408be242 100644 --- a/app/cli/internal/trace/git/gogit_test.go +++ b/app/cli/internal/trace/git/gogit_test.go @@ -190,6 +190,9 @@ func TestGoGitClient_CodeChangesForCommits(t *testing.T) { assert.Equal(t, 3, changes.Files[0].LinesAdded, "1 line from A + 2 lines from C") assert.Equal(t, 0, changes.Files[0].LinesRemoved) assert.Equal(t, 3, changes.LinesAdded) + assert.Equal(t, StatusCreated, changes.Files[0].Status, "commit-A created the file") + assert.Equal(t, 1, changes.FilesCreated) + assert.Equal(t, 0, changes.FilesModified) }) t.Run("empty input returns empty result", func(t *testing.T) { diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index 15ed9c321..720a80c29 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -1083,12 +1083,31 @@ func sessionCodeChanges(gitClient tracegit.Client, repoRoot, sessionID string, c if l := ledgers[sessionID]; l != nil && l.Legacy[f.Path] { continue } - if lines, ok := cache.blame(cache.headPath(shas, f.Path)); ok { + if lines, ok := cache.blame("HEAD", cache.headPath(shas, f.Path)); ok { tip[f.Path] = lines } } - attribution.Enrich(sessionID, ledgers, sessionCommitLines(sessionID, commits, cache), tip, codeChanges) + lines, complete := sessionCommitLines(sessionID, commits, cache) + // A commit of the session that cannot be read has no lines in the split. + // The line counts then keep the sum of the diff of each commit, so that + // the lines of that commit still count. + var diffCounts [][2]int + diffAdded, diffRemoved := codeChanges.LinesAdded, codeChanges.LinesRemoved + if !complete { + for _, f := range codeChanges.Files { + diffCounts = append(diffCounts, [2]int{f.LinesAdded, f.LinesRemoved}) + } + } + + attribution.Enrich(sessionID, ledgers, lines, tip, codeChanges) + + if !complete { + for i := range codeChanges.Files { + codeChanges.Files[i].LinesAdded, codeChanges.Files[i].LinesRemoved = diffCounts[i][0], diffCounts[i][1] + } + codeChanges.LinesAdded, codeChanges.LinesRemoved = diffAdded, diffRemoved + } return codeChanges } @@ -1097,8 +1116,43 @@ func sessionCodeChanges(gitClient tracegit.Client, repoRoot, sessionID string, c // oldest first, with the sessions that the commit's record names. A commit // that joined the session carries the lines that its replacing hunks added. A // commit that cannot be read is left out. -func sessionCommitLines(sessionID string, commits []*state.CommitRecord, cache *lineCache) []attribution.CommitLines { - out := make([]attribution.CommitLines, 0, len(commits)) +// +// A commit of the session holds only the removed lines that no earlier commit +// of the session wrote, so that the removed lines are the net removals of the +// session (issue 3674). complete is false when a commit of the session cannot +// be read. +func sessionCommitLines(sessionID string, commits []*state.CommitRecord, cache *lineCache) (out []attribution.CommitLines, complete bool) { + // changedBy counts, for each file, the commits of the session that + // changed it. A file is keyed by its path at HEAD, so that a rename + // between two commits keeps one key, and by its old path when a commit + // renamed it. Commits can have the same timestamp, so their order is not + // used. + own := make(map[string]bool, len(commits)) + changedBy := make(map[string]int) + complete = true + for _, c := range commits { + if !slices.Contains(c.SessionIDs, sessionID) { + continue + } + own[c.SHA] = true + lines, ok := cache.commit(c.SHA) + if !ok { + complete = false + continue + } + keys := make(map[string]bool) + for _, fl := range lines { + keys[cache.pathAtHead(c.SHA, fl.Path)] = true + if fl.OldPath != "" { + keys[fl.OldPath] = true + } + } + for k := range keys { + changedBy[k]++ + } + } + + out = make([]attribution.CommitLines, 0, len(commits)) for _, c := range commits { lines, ok := cache.commit(c.SHA) if !ok { @@ -1106,7 +1160,9 @@ func sessionCommitLines(sessionID string, commits []*state.CommitRecord, cache * } cl := attribution.CommitLines{SHA: c.SHA, SessionIDs: c.SessionIDs, Files: lines} - if !slices.Contains(c.SessionIDs, sessionID) { + if own[c.SHA] { + cl.Files = cache.withoutOwnRemovals(c.SHA, lines, own, changedBy) + } else { // A joined commit with no added line still has to read as joined. cl.Joined = c.Joined[sessionID] if cl.Joined == nil { @@ -1116,7 +1172,7 @@ func sessionCommitLines(sessionID string, commits []*state.CommitRecord, cache * out = append(out, cl) } - return out + return out, complete } // lineCache reads the lines of commits and the blame of files at HEAD, the @@ -1126,7 +1182,7 @@ type lineCache struct { repoRoot string log zerolog.Logger commits map[string][]tracegit.FileLines - blames map[string][]tracegit.BlameLine + blames map[[2]string][]tracegit.BlameLine renames map[string]map[string]string } @@ -1136,7 +1192,7 @@ func newLineCache(client tracegit.Client, repoRoot string, log zerolog.Logger) * repoRoot: repoRoot, log: log, commits: make(map[string][]tracegit.FileLines), - blames: make(map[string][]tracegit.BlameLine), + blames: make(map[[2]string][]tracegit.BlameLine), renames: make(map[string]map[string]string), } } @@ -1151,19 +1207,26 @@ func (c *lineCache) headPath(shas []string, path string) string { continue } - renames, ok := c.renames[sha] - if !ok { - var err error - if renames, err = c.client.Renames(c.repoRoot, sha, "HEAD"); err != nil { - c.log.Debug().Err(err).Str("sha", sha).Msg("could not read the renames since a commit") - } - c.renames[sha] = renames - } - if to, ok := renames[path]; ok { - return to - } + return c.pathAtHead(sha, path) + } + + return path +} - return path +// pathAtHead returns the path at HEAD of a file that commit sha holds at path. +// A later commit can have renamed the file. It returns path when the renames +// cannot be read. +func (c *lineCache) pathAtHead(sha, path string) string { + renames, ok := c.renames[sha] + if !ok { + var err error + if renames, err = c.client.Renames(c.repoRoot, sha, "HEAD"); err != nil { + c.log.Debug().Err(err).Str("sha", sha).Msg("could not read the renames since a commit") + } + c.renames[sha] = renames + } + if to, ok := renames[path]; ok { + return to } return path @@ -1187,25 +1250,65 @@ func (c *lineCache) commit(sha string) ([]tracegit.FileLines, bool) { return lines, true } -// blame returns the lines of a file at HEAD with the commit that last wrote -// each, nil for a file that HEAD does not hold, and false when the blame -// fails. -func (c *lineCache) blame(path string) ([]tracegit.BlameLine, bool) { - if lines, ok := c.blames[path]; ok { +// blame returns the lines of a file at a revision with the commit that last +// wrote each, nil for a file that the revision does not hold, and false when +// the blame fails. +func (c *lineCache) blame(rev, path string) ([]tracegit.BlameLine, bool) { + key := [2]string{rev, path} + if lines, ok := c.blames[key]; ok { return lines, true } - lines, err := c.client.Blame(c.repoRoot, "HEAD", path) + lines, err := c.client.Blame(c.repoRoot, rev, path) if err != nil { - c.log.Debug().Err(err).Str("file", path).Msg("could not blame a file at HEAD; its net numbers fall back to the sum of its commits") + c.log.Debug().Err(err).Str("rev", rev).Str("file", path).Msg("could not blame a file; its net numbers fall back to the sum of its commits") return nil, false } - c.blames[path] = lines + c.blames[key] = lines return lines, true } +// withoutOwnRemovals returns the lines of commit sha without the removed lines +// that a commit in own last wrote. The parent's blame names the commit that +// last wrote each removed line. Only a file that another commit in own +// changed (changedBy, see sessionCommitLines) can hold such lines, so only +// that file is blamed. A +// file whose parent cannot be blamed keeps all its removed lines. lines is +// not changed. +func (c *lineCache) withoutOwnRemovals(sha string, lines []tracegit.FileLines, own map[string]bool, changedBy map[string]int) []tracegit.FileLines { + out := slices.Clone(lines) + for i, fl := range out { + // The parent holds the file under its old path. + parentPath := fl.Path + if fl.OldPath != "" { + parentPath = fl.OldPath + } + // This commit counts once for each key of the file. + if len(fl.Removed) == 0 || (changedBy[c.pathAtHead(sha, fl.Path)] < 2 && (fl.OldPath == "" || changedBy[fl.OldPath] < 2)) { + continue + } + parent, ok := c.blame(sha+"^", parentPath) + if !ok { + continue + } + + var removed []string + for _, h := range fl.Hunks { + for j, text := range h.Removed { + if idx := h.OldStart - 1 + j; idx >= 0 && idx < len(parent) && own[parent[idx].SHA] { + continue + } + removed = append(removed, text) + } + } + out[i].Removed = removed + } + + return out +} + // attachSessionEvidence adds the materials of each session to the attestation: // its spec and skill materials first, so that the session material can record // their digests, then the session itself. It returns the sessions that were diff --git a/app/cli/pkg/action/trace_line_attribution_test.go b/app/cli/pkg/action/trace_line_attribution_test.go index 0f2133bc9..12ea3922a 100644 --- a/app/cli/pkg/action/trace_line_attribution_test.go +++ b/app/cli/pkg/action/trace_line_attribution_test.go @@ -224,8 +224,8 @@ func TestLineAttribution_HumanChangeBetweenAgentEdits(t *testing.T) { } // Case 3 of the spec: a later commit of the same session replaces AI lines by -// hand. Each line counts once (R-003), and the removed AI lines are human -// removals (R-005). +// hand. Each line counts once (R-003). The replaced line is the session's own, +// so its removal is not a net removal of the session. func TestLineAttribution_Case3SameSessionLaterCommit(t *testing.T) { r := newLineRepo(t) @@ -238,7 +238,105 @@ func TestLineAttribution_Case3SameSessionLaterCommit(t *testing.T) { r.agentWrite(lineSession, "b.go", "package b\n") assert.Contains(t, r.commit("feat: b", "a.go", "b.go"), "Chainloop-Trace-Sessions: "+lineSession) - assert.Equal(t, lineSplit{aiAdded: 4, humanAdded: 1, humanRemoved: 1}, fileSplitOf(t, r.sessionChanges(lineSession), "a.go")) + assert.Equal(t, lineSplit{aiAdded: 4, humanAdded: 1}, fileSplitOf(t, r.sessionChanges(lineSession), "a.go")) +} + +// Issue 3674: two commits of a session change the same file. The line counts +// and the status of the file are the net change of the session, not the sum +// of the diff of each commit. A removed line that the session did not write +// still counts. +func TestLineAttribution_NetCountsOfSessionCommits(t *testing.T) { + r := newLineRepo(t) + + r.humanWrite("base.go", content(numbered("base", 3))) + r.commit("chore: base", "base.go") + + lines := numbered("ai", 10) + r.agentWrite(lineSession, "a.go", content(lines)) + r.commit("feat: a", "a.go") + + // The second commit replaces two lines of the first one and removes a + // line that was there before the session. + lines[2], lines[7] = "ai edit one", "ai edit two" + r.agentWrite(lineSession, "a.go", content(lines)) + r.agentWrite(lineSession, "base.go", content(numbered("base", 2))) + assert.Contains(t, r.commit("feat: a again", "a.go", "base.go"), "Chainloop-Trace-Sessions: "+lineSession) + + cc := r.sessionChanges(lineSession) + byPath := make(map[string]aicodingsession.FileChange, len(cc.Files)) + for _, f := range cc.Files { + byPath[f.Path] = f + } + + a := byPath["a.go"] + assert.Equal(t, tracegit.StatusCreated, a.Status, "the session created the file") + assert.Equal(t, 10, a.LinesAdded, "the net lines of the file") + assert.Equal(t, 0, a.LinesRemoved, "the replaced lines were the session's own") + assert.Equal(t, a.LinesAdded, a.AILinesAdded+a.HumanLinesAdded) + + base := byPath["base.go"] + assert.Equal(t, tracegit.StatusModified, base.Status) + assert.Equal(t, 0, base.LinesAdded) + assert.Equal(t, 1, base.LinesRemoved, "a line from before the session counts as removed") + assert.Equal(t, base.LinesRemoved, base.AILinesRemoved+base.HumanLinesRemoved) + + assert.Equal(t, 10, cc.LinesAdded) + assert.Equal(t, 1, cc.LinesRemoved) + assert.Equal(t, 1, cc.FilesCreated) + assert.Equal(t, 1, cc.FilesModified) +} + +// Issue 3674: a commit outside the session renames a file between two +// commits of the session. The lines that the first commit wrote are still the +// session's own when the second commit replaces them under the new path. +func TestLineAttribution_NetRemovalsAcrossRename(t *testing.T) { + r := newLineRepo(t) + + lines := numbered("ai", 5) + r.agentWrite(lineSession, "old.go", content(lines)) + r.commit("feat: a", "old.go") + + runGit(t, r.dir, "mv", "old.go", "new.go") + r.commit("chore: rename") + + lines[1] = "ai edit" + r.agentWrite(lineSession, "new.go", content(lines)) + assert.Contains(t, r.commit("feat: a again", "new.go"), "Chainloop-Trace-Sessions: "+lineSession) + + for _, f := range r.sessionChanges(lineSession).Files { + if f.Path == "new.go" { + assert.Equal(t, 0, f.LinesRemoved, "the replaced line was the session's own") + return + } + } + require.Fail(t, "new.go is not in the code changes") +} + +// A commit of the session that cannot be read has no lines in the split. The +// line counts then keep the sum of the diff of each commit, as before net +// counts, so that the lines of that commit are not lost. +func TestSessionCodeChanges_UnreadableCommitKeepsDiffCounts(t *testing.T) { + r := newLineRepo(t) + + lines := numbered("ai", 5) + r.agentWrite(lineSession, "a.go", content(lines)) + r.commit("feat: a", "a.go") + first := headSHA(t, r.dir) + + r.agentWrite(lineSession, "a.go", content(append(lines, "ai six"))) + r.commit("feat: a again", "a.go") + + records, err := r.store.LoadAllCommitRecords() + require.NoError(t, err) + commits := sessionCommitGroups(records, nil, false)[lineSession] + require.Len(t, commits, 2) + + client := failingLinesClient{Client: tracegit.NewGitClient(), sha: first} + cc := sessionCodeChanges(client, r.dir, lineSession, commits, loadLedgers(r.store, zerolog.Nop()), newLineCache(client, r.dir, zerolog.Nop()), func(string) bool { return false }, zerolog.Nop()) + require.NotNil(t, cc) + require.Len(t, cc.Files, 1) + assert.Equal(t, 6, cc.Files[0].LinesAdded, "the lines of the unreadable commit still count") + assert.Equal(t, 6, cc.LinesAdded) } // Cases 2 and 5 of the spec: a human commit in no session replaces AI lines