diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index f7cde0b24..ea18a064d 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -70,6 +70,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 | Yes | | 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 reports the totals of the session in `ai_generated_lines_added` and `ai_generated_lines_removed`. A session with no commit sends no evidence, so it reports no totals (spec issue-3639). | Yes | Partial | Yes | Yes | No | Yes | | 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 | Partial | +| Same-call commit attribution | A commit made in the same shell tool call that changed and staged its files carries that session's trace trailer. | Partial | No | No | No | 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 | Partial | | 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 | Partial | | 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 | Partial | @@ -82,6 +83,8 @@ Why not Yes: - **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. +- **Same-call commit attribution, Claude Code:** supported for foreground Bash calls. A background Bash call can continue after its `PostToolUse` hook removes the open-call snapshot, so a later commit from that background process is not attributed. A custom clean filter that transforms file content is handled conservatively as unstaged and can requeue attribution after commit. +- **Same-call commit attribution, Cursor, OpenCode, Pi and Codex:** these providers do not tag a shell command with the per-call token that git hooks use to load its open snapshot. ## User experience diff --git a/app/cli/internal/trace/claude/hooks.go b/app/cli/internal/trace/claude/hooks.go index e30138e17..8f7368b40 100644 --- a/app/cli/internal/trace/claude/hooks.go +++ b/app/cli/internal/trace/claude/hooks.go @@ -203,11 +203,8 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { // Cursor-relayed events without a second pass. var raw struct { trace.HookInput - ToolInput struct { - FilePath string `json:"file_path"` - Command string `json:"command"` - } `json:"tool_input"` - CursorVersion string `json:"cursor_version"` + ToolInput json.RawMessage `json:"tool_input"` + CursorVersion string `json:"cursor_version"` } if err := json.Unmarshal(data, &raw); err != nil { return nil, err @@ -224,10 +221,20 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) { } input := raw.HookInput - if raw.ToolInput.FilePath != "" { - input.FilePath = raw.ToolInput.FilePath + var toolInput struct { + FilePath string `json:"file_path"` + Command string `json:"command"` } - input.Command = raw.ToolInput.Command + if len(raw.ToolInput) > 0 { + if err := json.Unmarshal(raw.ToolInput, &toolInput); err != nil { + return nil, err + } + } + if toolInput.FilePath != "" { + input.FilePath = toolInput.FilePath + } + input.Command = toolInput.Command + input.RawToolInput = append(json.RawMessage(nil), raw.ToolInput...) input.ToolFailed = input.HookEventName == eventPostToolUseFailure return &input, nil diff --git a/app/cli/internal/trace/claude/provider.go b/app/cli/internal/trace/claude/provider.go index 9ac279b18..4165c5fa9 100644 --- a/app/cli/internal/trace/claude/provider.go +++ b/app/cli/internal/trace/claude/provider.go @@ -37,6 +37,7 @@ const Name = "claude-code" // Compile-time check that Provider implements trace.Provider. var _ trace.Provider = (*Provider)(nil) +var _ trace.ShellCommandTagger = (*Provider)(nil) // Provider implements trace.Provider for Claude Code sessions. type Provider struct{} @@ -181,6 +182,37 @@ func (p *Provider) IsCommandTool(toolName string) bool { return slices.Contains(commandTools, toolName) } +// TagShellCommand returns a PreToolUse response that exports token for every +// subcommand in the Bash call. Git hooks inherit it and can load the exact +// pre-command snapshot instead of guessing among abandoned calls. +func (p *Provider) TagShellCommand(input *trace.HookInput, token string) error { + if input == nil || input.Command == "" || len(input.RawToolInput) == 0 || token == "" { + return nil + } + + var updatedInput map[string]json.RawMessage + if err := json.Unmarshal(input.RawToolInput, &updatedInput); err != nil { + return fmt.Errorf("parse Bash input: %w", err) + } + + command, err := json.Marshal("export " + state.ShellCallEnv + "=" + token + "; " + input.Command) + if err != nil { + return fmt.Errorf("encode tagged Bash command: %w", err) + } + updatedInput["command"] = command + + output := struct { + HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + UpdatedInput map[string]json.RawMessage `json:"updatedInput"` + } `json:"hookSpecificOutput"` + }{} + output.HookSpecificOutput.HookEventName = eventPreToolUse + output.HookSpecificOutput.UpdatedInput = updatedInput + + return json.NewEncoder(os.Stdout).Encode(output) +} + // SupportsSessionStartBanner is true for Claude Code: it renders the // systemMessage field of a hook response directly to the user. func (p *Provider) SupportsSessionStartBanner() bool { diff --git a/app/cli/internal/trace/claude/shell_tag_test.go b/app/cli/internal/trace/claude/shell_tag_test.go new file mode 100644 index 000000000..12e252837 --- /dev/null +++ b/app/cli/internal/trace/claude/shell_tag_test.go @@ -0,0 +1,56 @@ +// +// 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 claude + +import ( + "encoding/json" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/stretchr/testify/require" +) + +func TestTagShellCommand_PreservesInputAndExportsCallToken(t *testing.T) { + // Given + input := &trace.HookInput{ + Command: "git commit -m test", + RawToolInput: json.RawMessage(`{"command":"git commit -m test","description":"commit changes","timeout":120000}`), + } + + // When + out := captureStdout(t, func() { + require.NoError(t, New().TagShellCommand(input, "opaque-token")) + }) + + // Then + var response struct { + HookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + UpdatedInput struct { + Command string `json:"command"` + Description string `json:"description"` + Timeout int `json:"timeout"` + } `json:"updatedInput"` + } `json:"hookSpecificOutput"` + } + require.NoError(t, json.Unmarshal([]byte(out), &response)) + require.Equal(t, eventPreToolUse, response.HookSpecificOutput.HookEventName) + require.Equal(t, "export "+state.ShellCallEnv+"=opaque-token; git commit -m test", + response.HookSpecificOutput.UpdatedInput.Command) + require.Equal(t, "commit changes", response.HookSpecificOutput.UpdatedInput.Description) + require.Equal(t, 120000, response.HookSpecificOutput.UpdatedInput.Timeout) +} diff --git a/app/cli/internal/trace/git/attributes.go b/app/cli/internal/trace/git/attributes.go new file mode 100644 index 000000000..11365c509 --- /dev/null +++ b/app/cli/internal/trace/git/attributes.go @@ -0,0 +1,142 @@ +// +// 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 ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/go-git/go-billy/v6/osfs" + gogit "github.com/go-git/go-git/v6" + gogitconfig "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/plumbing/format/gitattributes" + formatindex "github.com/go-git/go-git/v6/plumbing/format/index" + filesystemstorage "github.com/go-git/go-git/v6/storage/filesystem" + "github.com/go-git/go-git/v6/x/plugin" +) + +func attributeMatcher( + repo *gogit.Repository, + repoRoot string, + indexed map[string]*formatindex.Entry, +) (gitattributes.Matcher, error) { + system, err := gitattributes.LoadSystemPatterns(osfs.Default) + if err != nil { + return nil, err + } + global, err := gitattributes.LoadGlobalPatterns(osfs.Default) + if err != nil { + return nil, err + } + configSource, err := plugin.Get(plugin.ConfigLoader()) + if err != nil { + return nil, err + } + globalStore, err := configSource.Load(gogitconfig.GlobalScope) + if err != nil { + return nil, err + } + globalConfig, err := globalStore.Config() + if err != nil { + return nil, err + } + if path := globalConfig.Raw.Section("core").Options.Get("attributesfile"); path != "" { + global, err = configuredAttributes(path, repoRoot) + if err != nil { + return nil, err + } + } + cfg, err := repo.Config() + if err != nil { + return nil, err + } + if path := cfg.Raw.Section("core").Options.Get("attributesfile"); path != "" { + global, err = configuredAttributes(path, repoRoot) + if err != nil { + return nil, err + } + } + attrs := make([]gitattributes.MatchAttribute, 0, len(system)+len(global)) + attrs = append(attrs, system...) + attrs = append(attrs, global...) + + worktreeFS := osfs.New(repoRoot) + root, err := gitattributes.ReadAttributesFile(worktreeFS, nil, ".gitattributes", true) + if err != nil { + return nil, err + } + attrs = append(attrs, root...) + + dirs := make(map[string]struct{}) + for path := range indexed { + for dir := filepath.ToSlash(filepath.Dir(path)); dir != "."; dir = filepath.ToSlash(filepath.Dir(dir)) { + dirs[dir] = struct{}{} + } + } + ordered := make([]string, 0, len(dirs)) + for dir := range dirs { + ordered = append(ordered, dir) + } + sort.Slice(ordered, func(i, j int) bool { + leftDepth, rightDepth := strings.Count(ordered[i], "/"), strings.Count(ordered[j], "/") + if leftDepth == rightDepth { + return ordered[i] < ordered[j] + } + return leftDepth < rightDepth + }) + for _, dir := range ordered { + nested, err := gitattributes.ReadAttributesFile(worktreeFS, strings.Split(dir, "/"), ".gitattributes", false) + if err != nil { + return nil, err + } + attrs = append(attrs, nested...) + } + + storage, ok := repo.Storer.(*filesystemstorage.Storage) + if !ok { + return nil, fmt.Errorf("load info attributes: unsupported repository storage %T", repo.Storer) + } + info, err := gitattributes.ReadAttributesFile(storage.Filesystem(), nil, "info/attributes", true) + if err != nil { + return nil, err + } + attrs = append(attrs, info...) + + // go-git beta.1 walks the matcher stack backwards while overwriting prior + // results, so reverse Git's low-to-high source order before constructing it. + for i, j := 0, len(attrs)-1; i < j; i, j = i+1, j-1 { + attrs[i], attrs[j] = attrs[j], attrs[i] + } + + return gitattributes.NewMatcher(attrs), nil +} + +func configuredAttributes(path, repoRoot string) ([]gitattributes.MatchAttribute, error) { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + path = filepath.Join(home, strings.TrimPrefix(path, "~/")) + } else if !filepath.IsAbs(path) { + path = filepath.Join(repoRoot, path) + } + + return gitattributes.ReadAttributesFile(osfs.Default, nil, filepath.Clean(path), true) +} diff --git a/app/cli/internal/trace/git/gogit.go b/app/cli/internal/trace/git/gogit.go index f7f41b0ee..0dd07ea9c 100644 --- a/app/cli/internal/trace/git/gogit.go +++ b/app/cli/internal/trace/git/gogit.go @@ -18,12 +18,12 @@ package git import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" iofs "io/fs" "os" "path/filepath" - "sort" "strings" "time" @@ -266,35 +266,6 @@ func (c *GoGitClient) GeneratedMatcher(repoRoot string) func(path string) bool { } } -// StagedFiles returns repo-relative paths of files staged for the current commit. -func (c *GoGitClient) StagedFiles(repoRoot string) ([]string, error) { - repo, err := openRepo(repoRoot) - if err != nil { - return nil, fmt.Errorf("open repo: %w", err) - } - - wt, err := repo.Worktree() - if err != nil { - return nil, fmt.Errorf("get worktree: %w", err) - } - - status, err := wt.Status() - if err != nil { - return nil, fmt.Errorf("get status: %w", err) - } - - var files []string - for path, s := range status { - if s.Staging != gogit.Unmodified && s.Staging != gogit.Untracked { - files = append(files, path) - } - } - - sort.Strings(files) - - return files, nil -} - // SnapshotWorktree returns a signature of the working tree (repo-relative path // → SHA-256 content hash), honoring .gitignore and skipping the .git directory. func (c *GoGitClient) SnapshotWorktree(repoRoot string) (map[string]string, error) { @@ -339,9 +310,7 @@ func snapshotWorktree(repoRoot string) (map[string]string, error) { // Ignore rules apply only to untracked files, matching git: a tracked // file that also matches a .gitignore pattern (e.g. force-added // generated code) is still part of the working tree and must be - // snapshotted so edits to it are attributed. (Tracked files nested - // inside an ignored directory are the one gap — that directory is - // pruned above for walk performance.) + // snapshotted so edits to it are attributed. if matcher.Match(parts, false) && !tracked[slashRel] { return nil } @@ -354,6 +323,25 @@ func snapshotWorktree(repoRoot string) (map[string]string, error) { return nil }) + root, rootErr := os.OpenRoot(repoRoot) + if rootErr != nil { + return sig, errors.Join(err, fmt.Errorf("open worktree root: %w", rootErr)) + } + defer func() { _ = root.Close() }() + for path := range tracked { + if _, ok := sig[path]; ok { + continue + } + f, ok := openRegularRootFile(root, path) + if !ok { + continue + } + hash, hashErr := hashFileReader(f) + _ = f.Close() + if hashErr == nil { + sig[path] = hash + } + } return sig, err } @@ -366,20 +354,44 @@ func trackedPaths(repoRoot string) map[string]bool { if err != nil { return nil } - - idx, err := repo.Storer.Index() + idx, err := loadEffectiveIndex(repo) if err != nil { return nil } tracked := make(map[string]bool, len(idx.Entries)) - for _, e := range idx.Entries { - tracked[e.Name] = true + for _, entry := range idx.Entries { + if entry.IntentToAdd { + continue + } + tracked[entry.Name] = true } return tracked } +func openRegularRootFile(root *os.Root, path string) (*os.File, bool) { + parts := strings.Split(filepath.ToSlash(path), "/") + current := "" + for i, part := range parts { + current = filepath.Join(current, filepath.FromSlash(part)) + info, err := root.Lstat(current) + if err != nil || info.Mode()&os.ModeSymlink != 0 { + return nil, false + } + if i < len(parts)-1 { + if !info.IsDir() { + return nil, false + } + } else if !info.Mode().IsRegular() { + return nil, false + } + } + + f, err := root.Open(filepath.FromSlash(path)) + return f, err == nil +} + // hashFileContent returns the hex-encoded SHA-256 of the file's content. func hashFileContent(path string) (string, error) { f, err := os.Open(path) @@ -388,8 +400,12 @@ func hashFileContent(path string) (string, error) { } defer func() { _ = f.Close() }() + return hashFileReader(f) +} + +func hashFileReader(r io.Reader) (string, error) { h := sha256.New() - if _, err := io.Copy(h, f); err != nil { + if _, err := io.Copy(h, r); err != nil { return "", err } diff --git a/app/cli/internal/trace/git/index.go b/app/cli/internal/trace/git/index.go new file mode 100644 index 000000000..b9f66feac --- /dev/null +++ b/app/cli/internal/trace/git/index.go @@ -0,0 +1,241 @@ +// +// 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 ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + gogit "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/filemode" + formatcfg "github.com/go-git/go-git/v6/plumbing/format/config" + formatindex "github.com/go-git/go-git/v6/plumbing/format/index" + gogithash "github.com/go-git/go-git/v6/plumbing/hash" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// StagedFiles returns repo-relative paths changed between HEAD and the index +// used by the current git process. Git exposes temporary indexes (for example, +// during git commit --only) through GIT_INDEX_FILE. +func (c *GoGitClient) StagedFiles(repoRoot string) ([]string, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + idx, err := loadEffectiveIndex(repo) + if err != nil { + return nil, err + } + + head, err := headEntries(repo) + if err != nil { + return nil, err + } + + staged := make(map[string]struct{}) + current := make(map[string]*formatindex.Entry) + for _, entry := range idx.Entries { + if entry.IntentToAdd { + continue + } + if entry.Stage != 0 { + staged[entry.Name] = struct{}{} + continue + } + current[entry.Name] = entry + } + + for path, entry := range current { + before, ok := head[path] + if !ok || before.hash != entry.Hash || before.mode != entry.Mode { + staged[path] = struct{}{} + } + } + for path := range head { + if _, ok := current[path]; !ok { + staged[path] = struct{}{} + } + } + + files := make([]string, 0, len(staged)) + for path := range staged { + files = append(files, path) + } + sort.Strings(files) + + return files, nil +} + +// SnapshotIndex returns the content signature currently staged in git's index. +// It excludes symlinks and submodules to match SnapshotWorktree's file scope. +func (c *GoGitClient) SnapshotIndex(repoRoot string) (map[string]string, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + idx, err := loadEffectiveIndex(repo) + if err != nil { + return nil, err + } + + parts := make(map[string][]string, len(idx.Entries)) + for _, entry := range idx.Entries { + if !entry.Mode.IsFile() || entry.Mode == filemode.Symlink { + continue + } + parts[entry.Name] = append(parts[entry.Name], fmt.Sprintf("%d:%s", entry.Stage, entry.Hash)) + } + + sig := make(map[string]string, len(parts)) + for path, stages := range parts { + sort.Strings(stages) + sig[path] = strings.Join(stages, ",") + } + + return sig, nil +} + +// IndexFile returns the exact staged content for path. +func (c *GoGitClient) IndexFile(repoRoot, path string) ([]byte, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + idx, err := loadEffectiveIndex(repo) + if err != nil { + return nil, err + } + + for _, entry := range idx.Entries { + if entry.Name != path || entry.Stage != 0 || !entry.Mode.IsFile() || entry.Mode == filemode.Symlink { + continue + } + + blob, err := repo.BlobObject(entry.Hash) + if err != nil { + return nil, fmt.Errorf("load staged blob %s: %w", path, err) + } + reader, err := blob.Reader() + if err != nil { + return nil, fmt.Errorf("read staged blob %s: %w", path, err) + } + defer func() { _ = reader.Close() }() + + content, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read staged content %s: %w", path, err) + } + + return content, nil + } + + return nil, fmt.Errorf("staged file %s not found", path) +} + +type treeEntry struct { + hash plumbing.Hash + mode filemode.FileMode +} + +func headEntries(repo *gogit.Repository) (map[string]treeEntry, error) { + entries := make(map[string]treeEntry) + ref, err := repo.Head() + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return entries, nil + } + if err != nil { + return nil, fmt.Errorf("load HEAD: %w", err) + } + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + return nil, fmt.Errorf("load HEAD commit: %w", err) + } + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("load HEAD tree: %w", err) + } + + walker := object.NewTreeWalker(tree, true, nil) + defer walker.Close() + for { + name, entry, err := walker.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("walk HEAD tree: %w", err) + } + if entry.Mode == filemode.Dir { + continue + } + entries[name] = treeEntry{hash: entry.Hash, mode: entry.Mode} + } + + return entries, nil +} + +func loadEffectiveIndex(repo *gogit.Repository) (*formatindex.Index, error) { + path := os.Getenv("GIT_INDEX_FILE") + if path == "" { + idx, err := repo.Storer.Index() + if err != nil { + return nil, fmt.Errorf("load index: %w", err) + } + + return idx, nil + } + + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, fmt.Errorf("open effective index directory: %w", err) + } + defer func() { _ = root.Close() }() + + f, err := root.Open(filepath.Base(path)) + if err != nil { + return nil, fmt.Errorf("open effective index: %w", err) + } + defer func() { _ = f.Close() }() + + cfg, err := repo.Config() + if err != nil { + return nil, fmt.Errorf("load repository config: %w", err) + } + objectFormat := cfg.Extensions.ObjectFormat + if objectFormat == formatcfg.UnsetObjectFormat { + objectFormat = formatcfg.DefaultObjectFormat + } + hasher, err := gogithash.FromObjectFormat(objectFormat) + if err != nil { + return nil, fmt.Errorf("select index hash: %w", err) + } + + idx := &formatindex.Index{} + if err := formatindex.NewDecoder(f, hasher).Decode(idx); err != nil { + return nil, fmt.Errorf("decode effective index: %w", err) + } + if stat, err := f.Stat(); err == nil { + idx.ModTime = stat.ModTime() + } + + return idx, nil +} diff --git a/app/cli/internal/trace/git/index_test.go b/app/cli/internal/trace/git/index_test.go new file mode 100644 index 000000000..7c7d69792 --- /dev/null +++ b/app/cli/internal/trace/git/index_test.go @@ -0,0 +1,120 @@ +// +// 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 ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGoGitClient_SnapshotIndex_ResolutionChangesSignature(t *testing.T) { + // Given + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + runIndexGit(t, dir, "read-tree", "--empty") + base := writeGitObject(t, dir, "base\n") + ours := writeGitObject(t, dir, "ours\n") + theirs := writeGitObject(t, dir, "theirs\n") + writeGitIndex(t, dir, fmt.Sprintf( + "100644 %s 1\tconflict.txt\n100644 %s 2\tconflict.txt\n100644 %s 3\tconflict.txt\n", + base, ours, theirs, + )) + + client := NewGoGitClient() + before, err := client.SnapshotIndex(dir) + require.NoError(t, err) + + runIndexGit(t, dir, "read-tree", "--empty") + writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tconflict.txt\n", theirs)) + + // When + after, err := client.SnapshotIndex(dir) + require.NoError(t, err) + + // Then + require.Contains(t, before, "conflict.txt") + require.Contains(t, after, "conflict.txt") + require.NotEqual(t, before["conflict.txt"], after["conflict.txt"], + "resolving to an existing conflict stage must change the index signature") +} + +func TestGoGitClient_IndexFile_IgnoresReplacementObjects(t *testing.T) { + // Given + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + runIndexGit(t, dir, "read-tree", "--empty") + indexed := writeGitObject(t, dir, "indexed content\n") + replacement := writeGitObject(t, dir, "replacement content\n") + writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tindexed.txt\n", indexed)) + runIndexGit(t, dir, "replace", indexed, replacement) + + // When + content, err := NewGoGitClient().IndexFile(dir, "indexed.txt") + require.NoError(t, err) + + // Then + require.Equal(t, []byte("indexed content\n"), content, + "staged content must come from the exact object recorded in the index") +} + +func TestGoGitClient_StagedFiles_ExcludesIntentToAdd(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.WriteFile(filepath.Join(dir, "pending.txt"), []byte("pending\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), nil, 0o600)) + runIndexGit(t, dir, "add", "-N", "pending.txt") + runIndexGit(t, dir, "add", "staged.txt") + + files, err := NewGoGitClient().StagedFiles(dir) + require.NoError(t, err) + require.Equal(t, []string{"staged.txt"}, files) +} + +func runIndexGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) + + return strings.TrimSpace(string(out)) +} + +func writeGitIndex(t *testing.T, dir, entries string) { + t.Helper() + cmd := exec.Command("git", "update-index", "--index-info") + cmd.Dir = dir + cmd.Stdin = strings.NewReader(entries) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git update-index: %s", string(out)) +} + +func writeGitObject(t *testing.T, dir, content string) string { + t.Helper() + cmd := exec.Command("git", "hash-object", "-w", "--stdin") + cmd.Dir = dir + cmd.Stdin = strings.NewReader(content) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git hash-object: %s", string(out)) + + return strings.TrimSpace(string(out)) +} diff --git a/app/cli/internal/trace/git/worktree_index.go b/app/cli/internal/trace/git/worktree_index.go new file mode 100644 index 000000000..3b3b81217 --- /dev/null +++ b/app/cli/internal/trace/git/worktree_index.go @@ -0,0 +1,199 @@ +// +// 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 ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + gogit "github.com/go-git/go-git/v6" + gogitconfig "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/plumbing/filemode" + "github.com/go-git/go-git/v6/plumbing/format/gitattributes" + formatindex "github.com/go-git/go-git/v6/plumbing/format/index" + "github.com/go-git/go-git/v6/utils/convert" +) + +// UnstagedFiles returns regular-file paths whose working-tree content differs +// from the effective index. Paths are repo-relative and slash-separated. +func (c *GoGitClient) UnstagedFiles(repoRoot string) (map[string]struct{}, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + idx, err := loadEffectiveIndex(repo) + if err != nil { + return nil, err + } + cfg, err := repo.Config() + if err != nil { + return nil, fmt.Errorf("load repository config: %w", err) + } + worktree, err := snapshotWorktree(repoRoot) + if err != nil { + return nil, fmt.Errorf("snapshot worktree: %w", err) + } + + indexed := make(map[string]*formatindex.Entry) + for _, entry := range idx.Entries { + if entry.Stage == 0 && !entry.IntentToAdd && entry.Mode.IsFile() && entry.Mode != filemode.Symlink { + indexed[entry.Name] = entry + } + } + attributes, err := attributeMatcher(repo, repoRoot, indexed) + if err != nil { + return nil, fmt.Errorf("load git attributes: %w", err) + } + + paths := make(map[string]struct{}) + for path := range worktree { + entry, ok := indexed[path] + if !ok { + paths[path] = struct{}{} + continue + } + same, err := worktreeMatchesIndex(repoRoot, path, entry, repo, cfg, attributes) + if err != nil { + return nil, err + } + if !same { + paths[path] = struct{}{} + } + } + for path := range indexed { + if _, ok := worktree[path]; !ok { + paths[path] = struct{}{} + } + } + + return paths, nil +} + +func worktreeMatchesIndex( + repoRoot, path string, + entry *formatindex.Entry, + repo *gogit.Repository, + cfg *gogitconfig.Config, + attributes gitattributes.Matcher, +) (bool, error) { + fullPath := filepath.Join(repoRoot, filepath.FromSlash(path)) + info, err := os.Stat(fullPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("stat worktree file %s: %w", path, err) + } + if cfg.Core.FileMode { + mode, err := filemode.NewFromOSFileMode(info.Mode()) + if err != nil { + return false, fmt.Errorf("read worktree mode %s: %w", path, err) + } + if mode != entry.Mode { + return false, nil + } + } + + content, err := os.ReadFile(fullPath) + if err != nil { + return false, fmt.Errorf("read worktree file %s: %w", path, err) + } + + blob, err := repo.BlobObject(entry.Hash) + if err != nil { + return false, fmt.Errorf("load index blob %s: %w", path, err) + } + reader, err := blob.Reader() + if err != nil { + return false, fmt.Errorf("read index blob %s: %w", path, err) + } + defer func() { _ = reader.Close() }() + staged, err := io.ReadAll(reader) + if err != nil { + return false, fmt.Errorf("read index content %s: %w", path, err) + } + content, canCompare := cleanWorktreeContent(path, content, cfg, attributes) + if !canCompare { + return false, nil + } + + return bytes.Equal(content, staged), nil +} + +func cleanWorktreeContent( + path string, + content []byte, + cfg *gogitconfig.Config, + matcher gitattributes.Matcher, +) ([]byte, bool) { + result, _ := matcher.Match(strings.Split(filepath.ToSlash(path), "/"), nil) + if filter, ok := result["filter"]; ok && !filter.IsUnset() && !noOpCleanFilter(filter, cfg) { + // Reproducing an arbitrary configured filter driver would execute + // repository code, so transformed content stays conservatively dirty. + return nil, false + } + + normalize := cfg.Core.AutoCRLF == "true" || cfg.Core.AutoCRLF == "input" + forceText := false + if text, ok := result["text"]; ok { + switch { + case text.IsUnset(): + normalize = false + case text.IsSet(): + normalize, forceText = true, true + case text.IsValueSet() && strings.EqualFold(text.Value(), "auto"): + normalize = true + } + } + if eol, ok := result["eol"]; ok && !eol.IsUnset() { + normalize, forceText = true, true + } + if !normalize { + return content, true + } + + stat, err := convert.GetStat(bytes.NewReader(content)) + if err != nil || stat.IsBinary() && !forceText { + return content, true + } + var cleaned bytes.Buffer + if _, err := io.Copy(convert.NewLFWriter(&cleaned), bytes.NewReader(content)); err != nil { + return nil, false + } + + return cleaned.Bytes(), true +} + +func noOpCleanFilter(filter gitattributes.Attribute, cfg *gogitconfig.Config) bool { + if !filter.IsValueSet() { + return false + } + section := cfg.Raw.Section("filter") + if !section.HasSubsection(filter.Value()) { + return false + } + driver := section.Subsection(filter.Value()) + if strings.TrimSpace(driver.Options.Get("process")) != "" { + return false + } + clean := strings.TrimSpace(driver.Options.Get("clean")) + return clean == "" || clean == "cat" +} diff --git a/app/cli/internal/trace/git/worktree_index_test.go b/app/cli/internal/trace/git/worktree_index_test.go new file mode 100644 index 000000000..034d4ca50 --- /dev/null +++ b/app/cli/internal/trace/git/worktree_index_test.go @@ -0,0 +1,162 @@ +// +// 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 ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGoGitClient_UnstagedFiles_HonorsAttributeSources(t *testing.T) { + for _, source := range []string{"global", "local", "info"} { + t.Run(source, func(t *testing.T) { + dir := t.TempDir() + home := t.TempDir() + t.Setenv("HOME", home) + runIndexGit(t, dir, "init", "-q") + + switch source { + case "global": + attributes := filepath.Join(home, "attributes") + require.NoError(t, os.WriteFile(attributes, []byte("file.txt text eol=lf\n"), 0o600)) + config := "[core]\n\tattributesFile = ~/attributes\n" + require.NoError(t, os.WriteFile(filepath.Join(home, ".gitconfig"), []byte(config), 0o600)) + case "local": + globalAttributes := filepath.Join(home, "global-attributes") + require.NoError(t, os.WriteFile(globalAttributes, []byte("file.txt -text\n"), 0o600)) + globalConfig := fmt.Sprintf("[core]\n\tattributesFile = %s\n", globalAttributes) + require.NoError(t, os.WriteFile(filepath.Join(home, ".gitconfig"), []byte(globalConfig), 0o600)) + + localAttributes := filepath.Join(dir, "local-attributes") + require.NoError(t, os.WriteFile(localAttributes, []byte("file.txt text eol=lf\n"), 0o600)) + runIndexGit(t, dir, "config", "core.attributesFile", localAttributes) + case "info": + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".git", "info", "attributes"), + []byte("file.txt text eol=lf\n"), + 0o600, + )) + } + + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("line\r\n"), 0o600)) + runIndexGit(t, dir, "add", "file.txt") + + content, err := NewGoGitClient().IndexFile(dir, "file.txt") + require.NoError(t, err) + require.Equal(t, []byte("line\n"), content) + + unstaged, err := NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.NotContains(t, unstaged, "file.txt") + }) + } +} + +func TestGoGitClient_UnstagedFiles_MissingNestedAttributes(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "nested"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "nested", "file.txt"), []byte("content\n"), 0o600)) + runIndexGit(t, dir, "add", "nested/file.txt") + + unstaged, err := NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.NotContains(t, unstaged, "nested/file.txt") +} + +func TestGoGitClient_UnstagedFiles_TrackedFileInIgnoredDirectory(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "build"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("build/\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "tracked.txt"), []byte("tracked\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "untracked.txt"), []byte("untracked\n"), 0o600)) + runIndexGit(t, dir, "add", ".gitignore") + runIndexGit(t, dir, "add", "-f", "build/tracked.txt") + + snapshot, err := NewGoGitClient().SnapshotWorktree(dir) + require.NoError(t, err) + require.Contains(t, snapshot, "build/tracked.txt") + require.NotContains(t, snapshot, "build/untracked.txt") + + unstaged, err := NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.NotContains(t, unstaged, "build/tracked.txt") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "tracked.txt"), []byte("changed\n"), 0o600)) + unstaged, err = NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.Contains(t, unstaged, "build/tracked.txt") +} + +func TestGoGitClient_SnapshotWorktree_ExcludesIgnoredIntentToAdd(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "build"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("build/\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "pending.txt"), []byte("pending\n"), 0o600)) + runIndexGit(t, dir, "add", ".gitignore") + runIndexGit(t, dir, "add", "-N", "-f", "build/pending.txt") + + snapshot, err := NewGoGitClient().SnapshotWorktree(dir) + require.NoError(t, err) + require.NotContains(t, snapshot, "build/pending.txt") +} + +func TestGoGitClient_SnapshotWorktree_DoesNotFollowEscapingSymlink(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.WriteFile(filepath.Join(outside, "outside.txt"), []byte("outside\n"), 0o600)) + require.NoError(t, os.Symlink(outside, filepath.Join(dir, "link"))) + blob := writeGitObject(t, dir, "outside\n") + writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tlink/outside.txt\n", blob)) + + snapshot, err := NewGoGitClient().SnapshotWorktree(dir) + require.NoError(t, err) + require.NotContains(t, snapshot, "link/outside.txt") +} + +func TestGoGitClient_SnapshotWorktree_DoesNotFollowInRootSymlink(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "target"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "target", "file.txt"), []byte("inside\n"), 0o600)) + require.NoError(t, os.Symlink("target", filepath.Join(dir, "link"))) + blob := writeGitObject(t, dir, "inside\n") + writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tlink/file.txt\n", blob)) + + snapshot, err := NewGoGitClient().SnapshotWorktree(dir) + require.NoError(t, err) + require.NotContains(t, snapshot, "link/file.txt") +} + +func TestGoGitClient_UnstagedFiles_ChangedFilterMarksRawEqualityDirty(t *testing.T) { + dir := t.TempDir() + runIndexGit(t, dir, "init", "-q") + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("filtered.txt filter=mutate\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "filtered.txt"), []byte("same bytes\n"), 0o600)) + runIndexGit(t, dir, "add", ".gitattributes", "filtered.txt") + runIndexGit(t, dir, "config", "filter.mutate.clean", "sed 's/^/clean:/'") + + unstaged, err := NewGoGitClient().UnstagedFiles(dir) + require.NoError(t, err) + require.Contains(t, unstaged, "filtered.txt") +} diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go index 490af29a5..73527e234 100644 --- a/app/cli/internal/trace/provider.go +++ b/app/cli/internal/trace/provider.go @@ -187,6 +187,13 @@ type SkillTracker interface { SkillRoots(repoRoot string) skill.Roots } +// ShellCommandTagger is implemented by a provider whose pre-tool hook can +// replace shell input before execution. The opaque token reaches git hooks +// through the command environment, tying a commit to one exact tool call. +type ShellCommandTagger interface { + TagShellCommand(input *HookInput, token string) error +} + // SpecCopyReplacer is implemented by a provider that can find the copies of // spec sources in its transcript (spec issue-3556). For the sessions of a // provider without it, the transcript keeps its copies. @@ -313,6 +320,9 @@ type HookInput struct { // Command is the command line of a shell command tool, set by provider's // ReadHookInput. Empty when the provider does not report it. Command string `json:"-"` + // RawToolInput is the provider's full tool input. A provider that rewrites + // shell input preserves every field and changes only Command. + RawToolInput json.RawMessage `json:"-"` // Cwd is the directory the agent session runs in, which is where the // agent files its transcripts. It can differ from the checkout that owns // FilePath, e.g. for an edit in a linked git worktree. Empty when the diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go index 5160340ff..8b3cdc79f 100644 --- a/app/cli/internal/trace/state/snapshot.go +++ b/app/cli/internal/trace/state/snapshot.go @@ -17,14 +17,18 @@ package state import ( "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" "strings" ) +const ShellCallEnv = "CHAINLOOP_TRACE_SHELL_CALL" + // snapshotPath returns the path for a file snapshot: /chainloop-trace/snapshots// func (s *Store) snapshotPath(sessionID, filePath string) string { h := sha256.Sum256([]byte(filePath)) @@ -66,6 +70,32 @@ type ShellCallKey struct { ToolUseID string } +func EncodeShellCallKey(key ShellCallKey) (string, error) { + data, err := json.Marshal(key) + if err != nil { + return "", fmt.Errorf("marshal shell call key: %w", err) + } + + return base64.RawURLEncoding.EncodeToString(data), nil +} + +func DecodeShellCallKey(encoded string) (ShellCallKey, error) { + data, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return ShellCallKey{}, fmt.Errorf("decode shell call key: %w", err) + } + + var key ShellCallKey + if err := json.Unmarshal(data, &key); err != nil { + return ShellCallKey{}, fmt.Errorf("parse shell call key: %w", err) + } + if !ValidSessionID(key.SessionID) { + return ShellCallKey{}, fmt.Errorf("invalid shell call session ID") + } + + return key, nil +} + // shellPreSignaturePath returns the path storing the pre-command working-tree // signature of a shell command, under // /chainloop-trace/snapshots//: @@ -75,12 +105,20 @@ type ShellCallKey struct { // // A tool use ID is unique within a session, so it needs no agent qualifier. func (s *Store) shellPreSignaturePath(key ShellCallKey) string { - name := "shell-pre.json" + return s.shellSignaturePath("shell-pre", key) +} + +func (s *Store) shellPreIndexSignaturePath(key ShellCallKey) string { + return s.shellSignaturePath("shell-index", key) +} + +func (s *Store) shellSignaturePath(prefix string, key ShellCallKey) string { + name := prefix + commitRecordExt switch { case key.ToolUseID != "": - name = "shell-pre-call-" + sanitizeID(key.ToolUseID) + ".json" + name = prefix + "-call-" + sanitizeID(key.ToolUseID) + commitRecordExt case key.AgentID != "": - name = "shell-pre-" + sanitizeID(key.AgentID) + ".json" + name = prefix + "-" + sanitizeID(key.AgentID) + commitRecordExt } return filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(key.SessionID), name) @@ -107,6 +145,22 @@ func (s *Store) SaveShellPreSignature(key ShellCallKey, sig WorktreeSignatures) return os.WriteFile(path, data, 0600) } +// SaveShellPreIndexSignature stores the staged index signature captured before +// an agent-run shell command. +func (s *Store) SaveShellPreIndexSignature(key ShellCallKey, sig WorktreeSignatures) error { + path := s.shellPreIndexSignaturePath(key) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create snapshot dir: %w", err) + } + + data, err := json.Marshal(sig) + if err != nil { + return fmt.Errorf("marshal shell index signature: %w", err) + } + + return os.WriteFile(path, data, 0600) +} + // LoadShellPreSignature loads the pre-command working-tree signatures of a // shell command. A file in the earlier single-checkout format (written by an // older CLI just before an upgrade) does not parse, and the caller skips it. @@ -124,10 +178,56 @@ func (s *Store) LoadShellPreSignature(key ShellCallKey) (WorktreeSignatures, err return sig, nil } +func (s *Store) LoadShellPreIndexSignature(key ShellCallKey) (WorktreeSignatures, error) { + data, err := os.ReadFile(s.shellPreIndexSignaturePath(key)) + if err != nil { + return nil, err + } + + var sig WorktreeSignatures + if err := json.Unmarshal(data, &sig); err != nil { + return nil, fmt.Errorf("parse shell index signature: %w", err) + } + + return sig, nil +} + +// DeleteShellSnapshots removes every in-progress shell snapshot for a session +// while preserving file-edit snapshots in the same directory. +func (s *Store) DeleteShellSnapshots(sessionID string) error { + dir := filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(sessionID)) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return fmt.Errorf("read shell snapshots: %w", err) + } + + var errs []error + for _, entry := range entries { + if entry.IsDir() || !isShellSnapshotName(entry.Name()) { + continue + } + if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +func isShellSnapshotName(name string) bool { + return filepath.Ext(name) == commitRecordExt && + (strings.HasPrefix(name, "shell-pre") || strings.HasPrefix(name, "shell-index")) +} + // DeleteShellPreSignature removes the pre-command signature, and the HEAD -// commits saved with it, once processed. +// commit and index signature saved with it, once processed. func (s *Store) DeleteShellPreSignature(key ShellCallKey) { _ = os.Remove(s.shellPreSignaturePath(key)) + _ = os.Remove(s.shellPreIndexSignaturePath(key)) _ = os.Remove(s.shellPreHeadsPath(key)) } diff --git a/app/cli/internal/trace/state/snapshot_test.go b/app/cli/internal/trace/state/snapshot_test.go index 69945fff4..401b16210 100644 --- a/app/cli/internal/trace/state/snapshot_test.go +++ b/app/cli/internal/trace/state/snapshot_test.go @@ -52,15 +52,21 @@ func TestShellPreSignatureRoundTrip(t *testing.T) { } require.NoError(t, store.SaveShellPreSignature(tc.key, sig)) + require.NoError(t, store.SaveShellPreIndexSignature(tc.key, sig)) loaded, err := store.LoadShellPreSignature(tc.key) require.NoError(t, err) assert.Equal(t, sig, loaded) + loadedIndex, err := store.LoadShellPreIndexSignature(tc.key) + require.NoError(t, err) + assert.Equal(t, sig, loadedIndex) store.DeleteShellPreSignature(tc.key) _, err = store.LoadShellPreSignature(tc.key) assert.Error(t, err, "signature should be gone after delete") + _, err = store.LoadShellPreIndexSignature(tc.key) + assert.Error(t, err, "index signature should be gone after delete") }) } } @@ -137,3 +143,17 @@ func TestShellPreSignatureRejectsSingleCheckoutFormat(t *testing.T) { _, err := store.LoadShellPreSignature(key) assert.Error(t, err) } + +func TestShellCallKeyTokenRoundTrip(t *testing.T) { + // Given + key := ShellCallKey{SessionID: shellSessionID, AgentID: "agent-1", ToolUseID: "tool-1"} + + // When + token, err := EncodeShellCallKey(key) + require.NoError(t, err) + decoded, err := DecodeShellCallKey(token) + + // Then + require.NoError(t, err) + require.Equal(t, key, decoded) +} diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index c4c071fb2..9ae4b8b11 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -18,6 +18,7 @@ package action import ( "context" "errors" + "fmt" "os" "path/filepath" "slices" @@ -69,7 +70,7 @@ func HandleAgentSessionEnd(provider trace.Provider, log zerolog.Logger) error { // stay after the session end, for a push that comes later. captureSkillLoads(provider, store, input, log) - setSessionActive(store, sessionID, false, log) + cleanupSessionShellState(store, sessionID, log) // The spec stays on disk for the whole session, so that every push of it // records the spec as it is at that moment. The session end is where it @@ -112,6 +113,32 @@ func setSessionActive(store *state.Store, sessionID string, active bool, log zer } } +func cleanupSessionShellState(store *state.Store, sessionID string, log zerolog.Logger) { + rec, err := store.LoadSessionRecord(sessionID) + if err != nil { + log.Debug().Err(err).Msg("load session record for shell cleanup failed") + } + + setSessionActive(store, sessionID, false, log) + if err := store.DeleteShellSnapshots(sessionID); err != nil { + log.Debug().Err(err).Msg("delete shell snapshots failed") + } + + if rec == nil { + return + } + for _, root := range rec.Checkouts { + checkoutStore, _, err := state.LocateFrom(root) + if err != nil { + continue + } + setSessionActive(checkoutStore, sessionID, false, log) + if err := checkoutStore.DeleteShellSnapshots(sessionID); err != nil { + log.Debug().Err(err).Str("root", root).Msg("delete checkout shell snapshots failed") + } + } +} + // sessionStartBanner is what the developer sees at the top of a traced // session. Its audience includes someone who cloned the repository and never // ran init, so it says what is happening in plain words and, when we know it, @@ -407,6 +434,7 @@ func handleAgentPreToolUse(provider trace.Provider, input *trace.HookInput, log // checkouts the command names first, so the snapshot covers them. registerCommandCheckouts(provider, input, repoRoot, log) captureWorktreeSnapshot(store, repoRoot, shellCallKey(input), log) + tagShellCommand(provider, input, log) case provider.IsFileWritingTool(input.ToolName): if input.FilePath == "" { log.Debug().Str("tool", input.ToolName).Msg("pre-tool-use: file-writing tool produced no file path, skipping") @@ -437,33 +465,65 @@ func shellCallKey(input *trace.HookInput) state.ShellCallKey { // run in the session's own checkout. Best-effort: failures are logged and // never block the agent. func captureWorktreeSnapshot(store *state.Store, repoRoot string, key state.ShellCallKey, log zerolog.Logger) { - client := tracegit.NewGitClient() - sigs := make(state.WorktreeSignatures) - heads := make(map[string]string) + client := tracegit.NewGoGitClient() for _, root := range shellSnapshotRoots(store, repoRoot, key.SessionID) { - sig, err := client.SnapshotWorktree(root) + worktreeSig, err := client.SnapshotWorktree(root) if err != nil { log.Debug().Err(err).Str("root", root).Msg("pre-command: worktree snapshot failed") continue } - sigs[root] = sig + indexSig, err := client.SnapshotIndex(root) + if err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: index snapshot failed") + continue + } + + rootStore := store + if !sameDir(root, repoRoot) { + s, resolvedRoot, err := state.LocateFrom(root) + if err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: no trace state for checkout") + continue + } + rootStore, root = s, resolvedRoot + if err := copySessionRecord(store, rootStore, key.SessionID); err != nil { + log.Debug().Err(err).Str("session_id", key.SessionID).Msg("pre-command: copy session record failed") + } + } + heads := make(map[string]string) // A checkout with no commit yet has no HEAD; its files count as new. if sha, _, err := client.CommitHeadInfo(root); err == nil { heads[root] = sha } - } - if len(sigs) == 0 { - return + if err := rootStore.SaveShellPreSignature(key, state.WorktreeSignatures{root: worktreeSig}); err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: save worktree signature failed") + } + if err := rootStore.SaveShellPreIndexSignature(key, state.WorktreeSignatures{root: indexSig}); err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: save index signature failed") + } + if err := rootStore.SaveShellPreHeads(key, heads); err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: save HEAD commits failed") + } } +} - if err := store.SaveShellPreSignature(key, sigs); err != nil { - log.Debug().Err(err).Msg("pre-command: save worktree signature failed") +func copySessionRecord(source, target *state.Store, sessionID string) error { + rec, err := source.LoadSessionRecord(sessionID) + if err != nil { + return fmt.Errorf("load session record: %w", err) + } + if rec == nil { + return nil } - if err := store.SaveShellPreHeads(key, heads); err != nil { - log.Debug().Err(err).Msg("pre-command: save HEAD commits failed") + + recordCopy := *rec + if err := target.SaveSessionRecord(&recordCopy); err != nil { + return fmt.Errorf("save session record: %w", err) } + + return nil } // shellSnapshotRoots returns the checkouts that a shell command of the session @@ -879,27 +939,7 @@ func recordFileEdit(store *state.Store, sessionID, relPath string, before, after // blocks the agent. func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, store *state.Store, repoRoot string, log zerolog.Logger) { key := shellCallKey(input) - before, err := store.LoadShellPreSignature(key) - if err != nil { - // No pre-command snapshot (missed pre hook, or an overlapping call - // of an agent without tool call IDs overwrote it) — skip. - log.Debug().Err(err).Msg("post-command: no pre-command worktree signature") - return - } - defer store.DeleteShellPreSignature(key) - // A pre hook from an older CLI saved no HEAD. That case, and a heads file - // that cannot be read, fall back to the current HEAD. - heads, err := store.LoadShellPreHeads(key) - if err != nil && !errors.Is(err, os.ErrNotExist) { - log.Debug().Err(err).Msg("post-command: cannot read pre-command HEAD commits; using the current HEAD") - } - - for root, rootBefore := range before { - base := heads[root] - if base == "" { - base = "HEAD" - } - + for _, root := range shellSnapshotRoots(store, repoRoot, key.SessionID) { rootStore := store if !sameDir(root, repoRoot) { s, r, err := state.LocateFrom(root) @@ -911,7 +951,29 @@ func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, st rootStore, root = s, r } - recordRootChanges(rootStore, root, base, rootBefore, key.SessionID, log) + before, err := rootStore.LoadShellPreSignature(key) + if err != nil { + log.Debug().Err(err).Str("root", root).Msg("post-command: no pre-command worktree signature") + continue + } + // A pre hook from an older CLI saved no HEAD. That case, and a heads + // file that cannot be read, fall back to the current HEAD. + heads, err := rootStore.LoadShellPreHeads(key) + if err != nil && !errors.Is(err, os.ErrNotExist) { + log.Debug().Err(err).Str("root", root).Msg("post-command: cannot read pre-command HEAD commits; using the current HEAD") + } + + for snapshotRoot, rootBefore := range before { + if sameDir(snapshotRoot, root) { + base := heads[snapshotRoot] + if base == "" { + base = "HEAD" + } + recordRootChanges(rootStore, root, base, rootBefore, key.SessionID, log) + break + } + } + rootStore.DeleteShellPreSignature(key) } } @@ -945,7 +1007,19 @@ func recordRootChanges(store *state.Store, repoRoot, base string, before map[str open := store.LoadAILineAttribution(sessionID).Open record := func(relPath string, edit attribution.Edit) error { - return store.RecordEdit(sessionID, relPath, editRecord(attribution.DropOpen(edit, open[relPath]))) + edit = attribution.DropOpen(edit, open[relPath]) + if err := store.RecordEdit(sessionID, relPath, editRecord(edit)); err != nil { + return err + } + // Fingerprints intentionally ignore whitespace. Keep a legacy touch + // marker when a real line diff therefore has counts but no fingerprints, + // so binary CRLF changes still stay pending for a later commit. + if len(edit.Added) == 0 && len(edit.Removed) == 0 && + (edit.AddedCount > 0 || edit.RemovedCount > 0) { + return store.RecordLineRanges(sessionID, relPath, nil) + } + + return nil } changed, deleted := attribution.ChangedPaths(before, after) @@ -954,10 +1028,17 @@ func recordRootChanges(store *state.Store, repoRoot, base string, before map[str if err != nil { continue } + beforeContent := atBase(relPath) + if _, existed := before[relPath]; !existed { + // The call created this path after its latest checkpoint. HEAD can + // still hold the old file when that checkpoint staged and committed + // a deletion, so it is not the content before this new edit. + beforeContent = nil + } // ChangedPaths already proved the file changed, so record it even when // the diff finds nothing (created empty, or restored to base): the // file is then AI-touched with zero lines, as a deletion-only edit. - if err := record(relPath, attribution.ComputeEdit(atBase(relPath), content)); err != nil { + if err := record(relPath, attribution.ComputeEdit(beforeContent, content)); err != nil { log.Debug().Err(err).Str("file", relPath).Msg("post-command: record line ranges failed") } } diff --git a/app/cli/pkg/action/trace_bash_cleanup_test.go b/app/cli/pkg/action/trace_bash_cleanup_test.go new file mode 100644 index 000000000..610caa9fd --- /dev/null +++ b/app/cli/pkg/action/trace_bash_cleanup_test.go @@ -0,0 +1,72 @@ +// +// 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 action + +import ( + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestCleanupSessionShellState_RemovesOnlyShellSnapshotsAcrossCheckouts(t *testing.T) { + // Given + home, homeGitDir := initGitRepo(t) + linked := filepath.Join(t.TempDir(), "linked") + runGit(t, home, "worktree", "add", "-b", "cleanup-linked", linked) + + const sessionID = "cleanup-session" + rec := &state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Cwd: home, + Checkouts: []string{linked}, + Active: true, + StartedAt: state.NowTimestamp(), + } + homeStore := state.NewGitStore(homeGitDir) + require.NoError(t, homeStore.SaveSessionRecord(rec)) + linkedStore, _, err := state.LocateFrom(linked) + require.NoError(t, err) + require.NoError(t, linkedStore.SaveSessionRecord(rec)) + + key := state.ShellCallKey{SessionID: sessionID, ToolUseID: "tool-cleanup"} + sig := state.WorktreeSignatures{home: {"kept.txt": "hash"}} + for _, store := range []*state.Store{homeStore, linkedStore} { + require.NoError(t, store.SaveShellPreSignature(key, sig)) + require.NoError(t, store.SaveShellPreIndexSignature(key, sig)) + } + require.NoError(t, homeStore.SaveFileSnapshot(sessionID, "kept.txt", []byte("keep file snapshot"))) + + // When + cleanupSessionShellState(homeStore, sessionID, zerolog.Nop()) + + // Then + for _, store := range []*state.Store{homeStore, linkedStore} { + _, err := store.LoadShellPreSignature(key) + require.Error(t, err) + _, err = store.LoadShellPreIndexSignature(key) + require.Error(t, err) + record, err := store.LoadSessionRecord(sessionID) + require.NoError(t, err) + require.False(t, record.Active) + } + content, err := homeStore.LoadFileSnapshot(sessionID, "kept.txt") + require.NoError(t, err) + require.Equal(t, []byte("keep file snapshot"), content) +} diff --git a/app/cli/pkg/action/trace_bash_commit.go b/app/cli/pkg/action/trace_bash_commit.go new file mode 100644 index 000000000..970350de0 --- /dev/null +++ b/app/cli/pkg/action/trace_bash_commit.go @@ -0,0 +1,195 @@ +// +// 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 action + +import ( + "errors" + "fmt" + "os" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" +) + +func tagShellCommand(provider trace.Provider, input *trace.HookInput, log zerolog.Logger) { + tagger, ok := provider.(trace.ShellCommandTagger) + if !ok { + return + } + + token, err := state.EncodeShellCallKey(shellCallKey(input)) + if err != nil { + log.Debug().Err(err).Msg("pre-command: encode shell call key failed") + return + } + if err := tagger.TagShellCommand(input, token); err != nil { + log.Debug().Err(err).Msg("pre-command: tag shell command failed") + } +} + +// checkpointActiveShellAttribution records index changes made by the exact +// shell call whose token git inherited. The normal PostToolUse attribution +// arrives only after the call exits, after commit-msg and pre-push already ran. +func checkpointActiveShellAttribution(store *state.Store, repoRoot string, stagedFiles []string) error { + if len(stagedFiles) == 0 { + return nil + } + + token := os.Getenv(state.ShellCallEnv) + if token == "" { + return nil + } + key, err := state.DecodeShellCallKey(token) + if err != nil { + return err + } + + beforeWorktree, err := store.LoadShellPreSignature(key) + if err != nil { + return fmt.Errorf("load pre-command worktree snapshot: %w", err) + } + beforeIndex, err := store.LoadShellPreIndexSignature(key) + if err != nil { + return fmt.Errorf("load pre-command index snapshot: %w", err) + } + beforeHeads, err := store.LoadShellPreHeads(key) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("load pre-command HEAD commits: %w", err) + } + + client := tracegit.NewGoGitClient() + afterIndex, err := client.SnapshotIndex(repoRoot) + if err != nil { + return fmt.Errorf("snapshot staged index: %w", err) + } + afterWorktree, err := client.SnapshotWorktree(repoRoot) + if err != nil { + return fmt.Errorf("snapshot worktree: %w", err) + } + unstaged, err := client.UnstagedFiles(repoRoot) + if err != nil { + return fmt.Errorf("load unstaged paths: %w", err) + } + + staged := make(map[string]struct{}, len(stagedFiles)) + for _, path := range stagedFiles { + staged[path] = struct{}{} + } + + var errs []error + open := store.LoadAILineAttribution(key.SessionID).Open + for root, indexBefore := range beforeIndex { + if !sameDir(root, repoRoot) { + continue + } + worktreeBefore, ok := beforeWorktree[root] + if !ok { + continue + } + + changed, deleted := attribution.ChangedPaths(indexBefore, afterIndex) + checkpointed := false + for _, relPath := range append(changed, deleted...) { + if _, ok := staged[relPath]; !ok { + continue + } + + base := beforeHeads[root] + if base == "" { + base = "HEAD" + } + beforeContent, _ := client.FileAt(repoRoot, base, relPath) + var afterContent []byte + if hash, exists := afterIndex[relPath]; exists { + content, err := client.IndexFile(repoRoot, relPath) + if err != nil { + errs = append(errs, fmt.Errorf("read staged file %s: %w", relPath, err)) + continue + } + afterContent = content + indexBefore[relPath] = hash + if _, hasUnstaged := unstaged[relPath]; !hasUnstaged { + if hash, exists := afterWorktree[relPath]; exists { + worktreeBefore[relPath] = hash + } else { + delete(worktreeBefore, relPath) + } + } + } else { + delete(indexBefore, relPath) + delete(worktreeBefore, relPath) + } + + edit := attribution.DropOpen(attribution.ComputeEdit(beforeContent, afterContent), open[relPath]) + if edit.Changed { + if err := store.RecordEdit(key.SessionID, relPath, editRecord(edit)); err != nil { + errs = append(errs, fmt.Errorf("record attribution for %s: %w", relPath, err)) + continue + } + } + checkpointed = true + } + + if checkpointed { + if err := store.SaveShellPreSignature(key, beforeWorktree); err != nil { + errs = append(errs, fmt.Errorf("checkpoint worktree snapshot: %w", err)) + } + if err := store.SaveShellPreIndexSignature(key, beforeIndex); err != nil { + errs = append(errs, fmt.Errorf("checkpoint index snapshot: %w", err)) + } + } + } + + return errors.Join(errs...) +} + +func advanceActiveShellHead(store *state.Store, repoRoot, sha string) error { + token := os.Getenv(state.ShellCallEnv) + if token == "" { + return nil + } + key, err := state.DecodeShellCallKey(token) + if err != nil { + return err + } + + heads, err := store.LoadShellPreHeads(key) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if heads == nil { + heads = make(map[string]string) + } + + updated := false + for root := range heads { + if sameDir(root, repoRoot) { + heads[root] = sha + updated = true + } + } + if !updated { + heads[repoRoot] = sha + } + + return store.SaveShellPreHeads(key, heads) +} diff --git a/app/cli/pkg/action/trace_bash_commit_test.go b/app/cli/pkg/action/trace_bash_commit_test.go new file mode 100644 index 000000000..53cc330c9 --- /dev/null +++ b/app/cli/pkg/action/trace_bash_commit_test.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 action + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func beginTaggedBashCall(t *testing.T, key state.ShellCallKey, command string) trace.Provider { + t.Helper() + + cwd, err := os.Getwd() + require.NoError(t, err) + payload := fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"cwd":%q,"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":%q}}`, + key.SessionID, key.ToolUseID, cwd, command, + ) + provider := claude.New() + withStdin(t, payload) + out := captureStdout(t, func() { + require.NoError(t, HandleAgentPreToolUse(provider, zerolog.Nop())) + }) + require.Contains(t, out, state.ShellCallEnv) + + token, err := state.EncodeShellCallKey(key) + require.NoError(t, err) + t.Setenv(state.ShellCallEnv, token) + + return provider +} + +func TestCommitLifecycle_BashCommitBeforePostToolUseAddsSession(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "same-call-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-same-call" + key := state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID} + provider := beginTaggedBashCall(t, key, "edit, commit, push") + + // When + target := filepath.Join(dir, "same-call.txt") + require.NoError(t, os.WriteFile(target, []byte("changed by Bash\n"), 0600)) + runGit(t, dir, "add", "same-call.txt") + + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: same-call commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.Contains(t, string(msg), "Chainloop-Trace-Sessions: "+sessionID) + + runGit(t, dir, "commit", "-F", msgPath) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + heads, err := store.LoadShellPreHeads(key) + require.NoError(t, err) + require.Equal(t, headSHA(t, dir), heads[dir], "the open Bash call must advance to the commit it just made") + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 1) + require.Equal(t, []string{sessionID}, commits[0].SessionIDs) + + postInput := fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"edit, commit, push"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, + sessionID, toolUseID, + ) + withStdin(t, postInput) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + attr := store.LoadAILineAttribution(sessionID) + require.Contains(t, attr.Files, "same-call.txt") + require.False(t, attr.Legacy["same-call.txt"], "same-call attribution must use the fingerprinted edit ledger") + require.Equal(t, 1, attr.GeneratedAdded) + require.Empty(t, attr.Pending, "the committed file must not stay pending after the Bash call ends") +} + +func TestCommitLifecycle_BashCommitDoesNotClaimPreviouslyStagedFile(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + target := filepath.Join(dir, "already-staged.txt") + require.NoError(t, os.WriteFile(target, []byte("staged before Bash\n"), 0600)) + runGit(t, dir, "add", "already-staged.txt") + require.NoError(t, os.WriteFile(target, []byte("human unstaged before Bash\n"), 0600)) + + const sessionID = "unrelated-bash-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-pre-staged" + provider := beginTaggedBashCall(t, state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, "git commit") + + require.NoError(t, os.WriteFile(target, []byte("unstaged AI change\n"), 0600)) + + // When + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: pre-staged commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.NotContains(t, string(msg), state.TrailerKey, + "a Bash call must not claim the human content staged before it began") + + runGit(t, dir, "commit", "-F", msgPath) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + withStdin(t, fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"git commit"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, + sessionID, toolUseID, + )) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + attr := store.LoadAILineAttribution(sessionID) + require.Contains(t, attr.Pending, "already-staged.txt", + "the unstaged AI change must remain pending for a later commit") +} + +func TestCommitLifecycle_BashCommitUsesStagedContentWhenWorkingFileIsGone(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "staged-content-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: "tool-staged-content"}, + "create, stage, remove, commit", + ) + + target := filepath.Join(dir, "staged-only.txt") + require.NoError(t, os.WriteFile(target, []byte("committed from the index\n"), 0600)) + runGit(t, dir, "add", "staged-only.txt") + require.NoError(t, os.Remove(target)) + + // When + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: commit staged-only content\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.Contains(t, string(msg), "Chainloop-Trace-Sessions: "+sessionID) + require.Contains(t, store.LoadAILineAttribution(sessionID).Pending, "staged-only.txt") +} + +func TestCommitLifecycle_BashCommitInLinkedCheckoutAddsSession(t *testing.T) { + // Given + home, homeGitDir := initGitRepo(t) + linked := filepath.Join(t.TempDir(), "linked") + runGit(t, home, "worktree", "add", "-b", "linked", linked) + t.Cleanup(func() { _ = os.Chdir(home) }) + + homeStore := state.NewGitStore(homeGitDir) + require.NoError(t, homeStore.InitTraceDir()) + + const sessionID = "linked-checkout-session" + require.NoError(t, homeStore.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Cwd: home, + Checkouts: []string{linked}, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-linked" + provider := beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, + "cd linked && edit, commit, push", + ) + + require.NoError(t, os.Chdir(linked)) + target := filepath.Join(linked, "linked.txt") + require.NoError(t, os.WriteFile(target, []byte("changed in linked checkout\n"), 0600)) + runGit(t, linked, "add", "linked.txt") + + // When + msgPath := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: linked same-call commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.Contains(t, string(msg), "Chainloop-Trace-Sessions: "+sessionID) + + runGit(t, linked, "commit", "-F", msgPath) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + require.NoError(t, os.Chdir(home)) + withStdin(t, fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"cwd":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"cd linked && edit, commit, push"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, + sessionID, toolUseID, home, + )) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + linkedStore, _, err := state.LocateFrom(linked) + require.NoError(t, err) + commits, err := linkedStore.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 1) + require.Equal(t, []string{sessionID}, commits[0].SessionIDs) + require.Empty(t, linkedStore.LoadAILineAttribution(sessionID).Pending) +} + +func TestCommitLifecycle_StaleBashSnapshotDoesNotClaimLaterCommit(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "abandoned-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: "tool-abandoned"}, + "abandoned", + ) + t.Setenv(state.ShellCallEnv, "") + + target := filepath.Join(dir, "later.txt") + require.NoError(t, os.WriteFile(target, []byte("later human change\n"), 0600)) + runGit(t, dir, "add", "later.txt") + + // When + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: later commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.NotContains(t, string(msg), state.TrailerKey) +} diff --git a/app/cli/pkg/action/trace_bash_filter_test.go b/app/cli/pkg/action/trace_bash_filter_test.go new file mode 100644 index 000000000..dda565ab5 --- /dev/null +++ b/app/cli/pkg/action/trace_bash_filter_test.go @@ -0,0 +1,124 @@ +// +// 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 action + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestCommitLifecycle_BashCommitWithCleanFilterDoesNotRequeueAttribution(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("filtered.txt filter=identity\n"), 0600)) + runGit(t, dir, "config", "filter.identity.clean", "cat") + runGit(t, dir, "config", "filter.identity.smudge", "cat") + runGit(t, dir, "add", ".gitattributes") + runGit(t, dir, "commit", "-m", "chore: add attributes") + + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "filtered-content-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-filtered-content" + provider := beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, + "create filtered content, commit, push", + ) + + target := filepath.Join(dir, "filtered.txt") + require.NoError(t, os.WriteFile(target, []byte("first\nsecond\n"), 0600)) + runGit(t, dir, "add", "filtered.txt") + + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: filtered same-call commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + runGit(t, dir, "commit", "-F", msgPath) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + // When + withStdin(t, fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"create filtered content, commit, push"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, + sessionID, toolUseID, + )) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + // Then + require.Empty(t, store.LoadAILineAttribution(sessionID).Pending, + "a clean filtered working file must not be recorded again after commit") +} + +func TestCommitLifecycle_BashCommitWithBinaryCRLFKeepsUnstagedAttribution(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("binary.txt -text\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "binary.txt"), []byte("before\n"), 0600)) + runGit(t, dir, "config", "core.autocrlf", "true") + runGit(t, dir, "add", ".gitattributes", "binary.txt") + runGit(t, dir, "commit", "-m", "chore: add binary content") + + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "binary-crlf-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-binary-crlf" + provider := beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, + "stage LF content, leave CRLF content unstaged, commit", + ) + + target := filepath.Join(dir, "binary.txt") + require.NoError(t, os.WriteFile(target, []byte("after\n"), 0600)) + runGit(t, dir, "add", "binary.txt") + require.NoError(t, os.WriteFile(target, []byte("after\r\n"), 0600)) + + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: binary same-call commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + runGit(t, dir, "commit", "-F", msgPath) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + // When + withStdin(t, fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"stage LF content, leave CRLF content unstaged, commit"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, + sessionID, toolUseID, + )) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + // Then + require.Contains(t, store.LoadAILineAttribution(sessionID).Pending, "binary.txt", + "real unstaged CRLF content must remain pending after the staged LF content is committed") +} diff --git a/app/cli/pkg/action/trace_bash_review_test.go b/app/cli/pkg/action/trace_bash_review_test.go new file mode 100644 index 000000000..05d1ca7b0 --- /dev/null +++ b/app/cli/pkg/action/trace_bash_review_test.go @@ -0,0 +1,104 @@ +// +// 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 action + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestCommitLifecycle_BashCommitOnlyHonorsGitIndexFile(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "commit-only-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: "tool-commit-only"}, + "edit and commit --only", + ) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("changed through temporary index\n"), 0600)) + t.Setenv("GIT_INDEX_FILE", filepath.Join(t.TempDir(), "index")) + runGit(t, dir, "read-tree", "HEAD") + runGit(t, dir, "add", "init.txt") + + // When + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: commit only\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.Contains(t, string(msg), "Chainloop-Trace-Sessions: "+sessionID) + require.Contains(t, store.LoadAILineAttribution(sessionID).Pending, "init.txt") +} + +func TestCommitLifecycle_BashCommitWithRecreatedDeletedFileKeepsPending(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "recreated-deletion-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-recreated-deletion" + provider := beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, + "delete, recreate, commit", + ) + + runGit(t, dir, "rm", "init.txt") + require.NoError(t, os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0600)) + + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: delete tracked file\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + runGit(t, dir, "commit", "-F", msgPath) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + // When + withStdin(t, fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"delete, recreate, commit"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, + sessionID, toolUseID, + )) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + // Then + require.Contains(t, store.LoadAILineAttribution(sessionID).Pending, "init.txt", + "recreated content must remain pending after the staged deletion is committed") +} diff --git a/app/cli/pkg/action/trace_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index 720a80c29..79a032cb6 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -90,16 +90,21 @@ func handleCommitMsg(msgFilePath string, log zerolog.Logger) error { log.Debug().Strs("session_ids", sessionIDs).Msg("amending HEAD, keeping its trace sessions") } - attrs, err := state.NewRepoStore(gitDir, repoRoot).LoadAllAILineAttributions() + store := state.NewRepoStore(gitDir, repoRoot) + stagedFiles, err := client.StagedFiles(repoRoot) + if err != nil { + return fmt.Errorf("get staged files: %w", err) + } + + if err := checkpointActiveShellAttribution(store, repoRoot, stagedFiles); err != nil { + log.Debug().Err(err).Msg("commit-msg: checkpoint active shell attribution failed") + } + + attrs, err := store.LoadAllAILineAttributions() if err != nil { return fmt.Errorf("load AI line attributions: %w", err) } if len(attrs) > 0 { - stagedFiles, err := client.StagedFiles(repoRoot) - if err != nil { - return fmt.Errorf("get staged files: %w", err) - } - sessionIDs = mergeSessionIDs(sessionIDs, matchSessionsToFiles(attrs, stagedFiles)) } @@ -254,6 +259,10 @@ func handlePostCommit(_ context.Context, log zerolog.Logger) error { if err != nil { return fmt.Errorf("get commit info: %w", err) } + store := state.NewRepoStore(gitDir, repoRoot) + if err := advanceActiveShellHead(store, repoRoot, sha); err != nil { + log.Debug().Err(err).Msg("post-commit: advance active shell HEAD failed") + } // Skip merge commits: their content is already attributed in the // merged-in branch's commits, so recording them would double-count. @@ -269,8 +278,6 @@ func handlePostCommit(_ context.Context, log zerolog.Logger) error { // Prefer the trailer's session IDs when present — they survive rebase // and cherry-pick verbatim. Fall back to file-based derivation otherwise. - store := state.NewRepoStore(gitDir, repoRoot) - sessionIDs := state.ParseSessionIDsFromTrailer(message) if len(sessionIDs) == 0 { sessionIDs = deriveSessionIDsForCommit(client, store, repoRoot, sha)