From 0b7e8723e1e6257198d6454db3ca561ccdb73b4d Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Fri, 16 Oct 2026 01:26:06 +0530 Subject: [PATCH 1/8] fix(trace): attribute commits made inside Bash calls Tag the exact Claude Bash call so commit-msg can attribute staged content before PostToolUse runs. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/claude/hooks.go | 23 +- app/cli/internal/trace/claude/provider.go | 32 ++ .../internal/trace/claude/shell_tag_test.go | 56 ++++ app/cli/internal/trace/git/index.go | 137 +++++++++ app/cli/internal/trace/provider.go | 10 + app/cli/internal/trace/state/snapshot.go | 107 ++++++- app/cli/internal/trace/state/snapshot_test.go | 20 ++ app/cli/pkg/action/trace_agent_hook.go | 101 +++++-- app/cli/pkg/action/trace_bash_cleanup_test.go | 72 +++++ app/cli/pkg/action/trace_bash_commit.go | 147 +++++++++ app/cli/pkg/action/trace_bash_commit_test.go | 283 ++++++++++++++++++ app/cli/pkg/action/trace_bash_filter_test.go | 122 ++++++++ app/cli/pkg/action/trace_hook_handler.go | 17 +- 13 files changed, 1091 insertions(+), 36 deletions(-) create mode 100644 app/cli/internal/trace/claude/shell_tag_test.go create mode 100644 app/cli/internal/trace/git/index.go create mode 100644 app/cli/pkg/action/trace_bash_cleanup_test.go create mode 100644 app/cli/pkg/action/trace_bash_commit.go create mode 100644 app/cli/pkg/action/trace_bash_commit_test.go create mode 100644 app/cli/pkg/action/trace_bash_filter_test.go 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/index.go b/app/cli/internal/trace/git/index.go new file mode 100644 index 000000000..f26b9f5f4 --- /dev/null +++ b/app/cli/internal/trace/git/index.go @@ -0,0 +1,137 @@ +// +// 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" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os/exec" + + "github.com/go-git/go-git/v6/plumbing/filemode" + "github.com/go-git/go-git/v6/plumbing/object" +) + +// 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) + } + + index, err := repo.Storer.Index() + if err != nil { + return nil, fmt.Errorf("load index: %w", err) + } + + sig := make(map[string]string, len(index.Entries)) + for _, entry := range index.Entries { + if !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", entry.Name, err) + } + + hash, err := hashBlob(blob) + if err != nil { + return nil, fmt.Errorf("hash staged blob %s: %w", entry.Name, err) + } + sig[entry.Name] = hash + } + + 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) + } + + index, err := repo.Storer.Index() + if err != nil { + return nil, fmt.Errorf("load index: %w", err) + } + + for _, entry := range index.Entries { + if entry.Name != path { + 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) +} + +// UnstagedFiles returns paths whose working-tree content differs from the +// index. Paths are repo-relative and slash-separated. +func (c *GoGitClient) UnstagedFiles(repoRoot string) (map[string]struct{}, error) { + cmd := exec.Command("git", "-C", repoRoot, "diff", + "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", "--") + var stderr bytes.Buffer + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("list unstaged paths: %w: %s", err, bytes.TrimSpace(stderr.Bytes())) + } + + paths := make(map[string]struct{}) + for _, path := range bytes.Split(output, []byte{0}) { + if len(path) > 0 { + paths[string(path)] = struct{}{} + } + } + + return paths, nil +} + +func hashBlob(blob *object.Blob) (string, error) { + reader, err := blob.Reader() + if err != nil { + return "", err + } + defer func() { _ = reader.Close() }() + + hash := sha256.New() + if _, err := io.Copy(hash, reader); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/app/cli/internal/trace/provider.go b/app/cli/internal/trace/provider.go index fba5c355b..703908753 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. @@ -310,6 +317,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 2be3411d6..cfe4debda 100644 --- a/app/cli/internal/trace/state/snapshot.go +++ b/app/cli/internal/trace/state/snapshot.go @@ -17,13 +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)) @@ -65,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//: @@ -74,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) @@ -106,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. @@ -123,7 +178,53 @@ 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 once processed. func (s *Store) DeleteShellPreSignature(key ShellCallKey) { _ = os.Remove(s.shellPreSignaturePath(key)) + _ = os.Remove(s.shellPreIndexSignaturePath(key)) } diff --git a/app/cli/internal/trace/state/snapshot_test.go b/app/cli/internal/trace/state/snapshot_test.go index ef262d656..25e082937 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") }) } } @@ -112,3 +118,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 7b37713ae..784390066 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, @@ -324,6 +351,7 @@ func HandleAgentPreToolUse(provider trace.Provider, log zerolog.Logger) error { // 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") @@ -355,23 +383,55 @@ func shellCallKey(input *trace.HookInput) state.ShellCallKey { // never block the agent. func captureWorktreeSnapshot(store *state.Store, repoRoot string, key state.ShellCallKey, log zerolog.Logger) { client := tracegit.NewGoGitClient() - sigs := make(state.WorktreeSignatures) 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") + } + } + + 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 len(sigs) == 0 { - return +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.SaveShellPreSignature(key, sigs); err != nil { - log.Debug().Err(err).Msg("pre-command: save worktree signature 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 @@ -751,16 +811,7 @@ func HandleAgentPostToolUse(provider trace.Provider, log zerolog.Logger) error { // 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) - - for root, rootBefore := range before { + for _, root := range shellSnapshotRoots(store, repoRoot, key.SessionID) { rootStore := store if !sameDir(root, repoRoot) { s, r, err := state.LocateFrom(root) @@ -772,7 +823,19 @@ func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, st rootStore, root = s, r } - recordRootChanges(rootStore, root, 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 + } + + for snapshotRoot, rootBefore := range before { + if sameDir(snapshotRoot, root) { + recordRootChanges(rootStore, root, rootBefore, key.SessionID, log) + break + } + } + rootStore.DeleteShellPreSignature(key) } } 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..679cc5f68 --- /dev/null +++ b/app/cli/pkg/action/trace_bash_commit.go @@ -0,0 +1,147 @@ +// +// 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/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" + "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) + } + + 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 + 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 + } + + var ranges []aicodingsession.LineRange + 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 + } + ranges, _ = attribution.ComputeLineRanges(nil, content) + indexBefore[relPath] = hash + } else { + delete(indexBefore, relPath) + } + if _, hasUnstaged := unstaged[relPath]; !hasUnstaged { + if hash, exists := afterWorktree[relPath]; exists { + worktreeBefore[relPath] = hash + } else { + delete(worktreeBefore, relPath) + } + } + + if err := store.RecordLineRanges(key.SessionID, relPath, ranges); 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...) +} 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..70ed807f0 --- /dev/null +++ b/app/cli/pkg/action/trace_bash_commit_test.go @@ -0,0 +1,283 @@ +// +// 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" + provider := beginTaggedBashCall(t, state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, "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())) + + 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.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..48fbcaa52 --- /dev/null +++ b/app/cli/pkg/action/trace_bash_filter_test.go @@ -0,0 +1,122 @@ +// +// 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 text eol=lf\n"), 0600)) + 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\r\nsecond\r\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_hook_handler.go b/app/cli/pkg/action/trace_hook_handler.go index fe34ed875..fa1c2cacb 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -87,16 +87,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)) } From 8badc8f1361e1763b732b6b90cf1e3f6f91cffb8 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Fri, 16 Oct 2026 02:13:08 +0530 Subject: [PATCH 2/8] fix(trace): address Bash attribution edge cases Honor the effective Git index, preserve recreated paths after staged deletions, and distinguish conflict-stage signatures. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/git/gogit.go | 37 ++--- app/cli/internal/trace/git/index.go | 147 +++++++++++-------- app/cli/internal/trace/git/index_test.go | 84 +++++++++++ app/cli/pkg/action/trace_bash_commit.go | 15 +- app/cli/pkg/action/trace_bash_review_test.go | 104 +++++++++++++ 5 files changed, 287 insertions(+), 100 deletions(-) create mode 100644 app/cli/internal/trace/git/index_test.go create mode 100644 app/cli/pkg/action/trace_bash_review_test.go diff --git a/app/cli/internal/trace/git/gogit.go b/app/cli/internal/trace/git/gogit.go index f7f41b0ee..d0ad0ba0e 100644 --- a/app/cli/internal/trace/git/gogit.go +++ b/app/cli/internal/trace/git/gogit.go @@ -22,6 +22,7 @@ import ( "io" iofs "io/fs" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -268,28 +269,13 @@ 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() + output, err := commandOutput(exec.Command("git", "-C", repoRoot, "diff", "--cached", + "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", "--")) 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) - } + return nil, fmt.Errorf("list staged files: %w", err) } + files := nullDelimitedPaths(output) sort.Strings(files) return files, nil @@ -362,19 +348,14 @@ func snapshotWorktree(repoRoot string) (map[string]string, error) { // recorded in the git index. Best-effort: returns nil when the repository or // its index cannot be read, in which case ignore rules apply to every file. func trackedPaths(repoRoot string) map[string]bool { - repo, err := openRepo(repoRoot) - if err != nil { - return nil - } - - idx, err := repo.Storer.Index() + entries, err := loadIndexEntries(repoRoot) if err != nil { return nil } - tracked := make(map[string]bool, len(idx.Entries)) - for _, e := range idx.Entries { - tracked[e.Name] = true + tracked := make(map[string]bool, len(entries)) + for _, entry := range entries { + tracked[entry.path] = true } return tracked diff --git a/app/cli/internal/trace/git/index.go b/app/cli/internal/trace/git/index.go index f26b9f5f4..b1944195d 100644 --- a/app/cli/internal/trace/git/index.go +++ b/app/cli/internal/trace/git/index.go @@ -17,45 +17,40 @@ package git import ( "bytes" - "crypto/sha256" - "encoding/hex" "fmt" - "io" "os/exec" - - "github.com/go-git/go-git/v6/plumbing/filemode" - "github.com/go-git/go-git/v6/plumbing/object" + "sort" + "strconv" + "strings" ) +type indexEntry struct { + mode string + object string + path string + stage int +} + // 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) - } - - index, err := repo.Storer.Index() + entries, err := loadIndexEntries(repoRoot) if err != nil { - return nil, fmt.Errorf("load index: %w", err) + return nil, err } - sig := make(map[string]string, len(index.Entries)) - for _, entry := range index.Entries { - if !entry.Mode.IsFile() || entry.Mode == filemode.Symlink { + parts := make(map[string][]string, len(entries)) + for _, entry := range entries { + if !entry.isRegular() { continue } + parts[entry.path] = append(parts[entry.path], fmt.Sprintf("%d:%s", entry.stage, entry.object)) + } - blob, err := repo.BlobObject(entry.Hash) - if err != nil { - return nil, fmt.Errorf("load staged blob %s: %w", entry.Name, err) - } - - hash, err := hashBlob(blob) - if err != nil { - return nil, fmt.Errorf("hash staged blob %s: %w", entry.Name, err) - } - sig[entry.Name] = hash + sig := make(map[string]string, len(parts)) + for path, stages := range parts { + sort.Strings(stages) + sig[path] = strings.Join(stages, ",") } return sig, nil @@ -63,34 +58,19 @@ func (c *GoGitClient) SnapshotIndex(repoRoot string) (map[string]string, error) // IndexFile returns the exact staged content for path. func (c *GoGitClient) IndexFile(repoRoot, path string) ([]byte, error) { - repo, err := openRepo(repoRoot) + entries, err := loadIndexEntries(repoRoot) if err != nil { - return nil, fmt.Errorf("open repository: %w", err) + return nil, err } - index, err := repo.Storer.Index() - if err != nil { - return nil, fmt.Errorf("load index: %w", err) - } - - for _, entry := range index.Entries { - if entry.Name != path { + for _, entry := range entries { + if entry.path != path || entry.stage != 0 || !entry.isRegular() { 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) + content, err := commandOutput(exec.Command("git", "-C", repoRoot, "cat-file", "blob", entry.object)) if err != nil { - return nil, fmt.Errorf("read staged content %s: %w", path, err) + return nil, fmt.Errorf("read staged file %s: %w", path, err) } return content, nil @@ -102,36 +82,73 @@ func (c *GoGitClient) IndexFile(repoRoot, path string) ([]byte, error) { // UnstagedFiles returns paths whose working-tree content differs from the // index. Paths are repo-relative and slash-separated. func (c *GoGitClient) UnstagedFiles(repoRoot string) (map[string]struct{}, error) { - cmd := exec.Command("git", "-C", repoRoot, "diff", - "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", "--") - var stderr bytes.Buffer - cmd.Stderr = &stderr - output, err := cmd.Output() + output, err := commandOutput(exec.Command("git", "-C", repoRoot, "diff", + "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", "--")) if err != nil { - return nil, fmt.Errorf("list unstaged paths: %w: %s", err, bytes.TrimSpace(stderr.Bytes())) + return nil, fmt.Errorf("list unstaged paths: %w", err) } paths := make(map[string]struct{}) - for _, path := range bytes.Split(output, []byte{0}) { - if len(path) > 0 { - paths[string(path)] = struct{}{} - } + for _, path := range nullDelimitedPaths(output) { + paths[path] = struct{}{} } return paths, nil } -func hashBlob(blob *object.Blob) (string, error) { - reader, err := blob.Reader() +func loadIndexEntries(repoRoot string) ([]indexEntry, error) { + output, err := commandOutput(exec.Command("git", "-C", repoRoot, "ls-files", "--stage", "-z", "--")) if err != nil { - return "", err + return nil, fmt.Errorf("list index entries: %w", err) + } + + var entries []indexEntry + for _, record := range bytes.Split(output, []byte{0}) { + if len(record) == 0 { + continue + } + metadata, path, ok := bytes.Cut(record, []byte{'\t'}) + fields := bytes.Fields(metadata) + if !ok || len(fields) != 3 { + return nil, fmt.Errorf("parse index entry %q", record) + } + stage, err := strconv.Atoi(string(fields[2])) + if err != nil { + return nil, fmt.Errorf("parse index stage %q: %w", fields[2], err) + } + entries = append(entries, indexEntry{ + mode: string(fields[0]), + object: string(fields[1]), + path: string(path), + stage: stage, + }) + } + + return entries, nil +} + +func (e indexEntry) isRegular() bool { + return strings.HasPrefix(e.mode, "100") +} + +func nullDelimitedPaths(output []byte) []string { + var paths []string + for _, path := range bytes.Split(output, []byte{0}) { + if len(path) > 0 { + paths = append(paths, string(path)) + } } - defer func() { _ = reader.Close() }() - hash := sha256.New() - if _, err := io.Copy(hash, reader); err != nil { - return "", err + return paths +} + +func commandOutput(cmd *exec.Cmd) ([]byte, error) { + var stderr bytes.Buffer + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git command: %w: %s", err, bytes.TrimSpace(stderr.Bytes())) } - return hex.EncodeToString(hash.Sum(nil)), nil + return output, 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..69688fce3 --- /dev/null +++ b/app/cli/internal/trace/git/index_test.go @@ -0,0 +1,84 @@ +// +// 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/exec" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGoGitClient_SnapshotIndex_ResolutionChangesSignature(t *testing.T) { + // Given + dir := t.TempDir() + run := func(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)) + } + writeIndex := func(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)) + } + writeObject := func(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)) + } + + run("init", "-q") + run("read-tree", "--empty") + base := writeObject("base\n") + ours := writeObject("ours\n") + theirs := writeObject("theirs\n") + writeIndex(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) + + run("read-tree", "--empty") + writeIndex(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") +} diff --git a/app/cli/pkg/action/trace_bash_commit.go b/app/cli/pkg/action/trace_bash_commit.go index 679cc5f68..27fcb8653 100644 --- a/app/cli/pkg/action/trace_bash_commit.go +++ b/app/cli/pkg/action/trace_bash_commit.go @@ -115,15 +115,16 @@ func checkpointActiveShellAttribution(store *state.Store, repoRoot string, stage } ranges, _ = attribution.ComputeLineRanges(nil, 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) - } - if _, hasUnstaged := unstaged[relPath]; !hasUnstaged { - if hash, exists := afterWorktree[relPath]; exists { - worktreeBefore[relPath] = hash - } else { - delete(worktreeBefore, relPath) - } + delete(worktreeBefore, relPath) } if err := store.RecordLineRanges(key.SessionID, relPath, ranges); err != nil { 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") +} From fe95c9261560d5f36adca53e74d08b058f071b19 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Fri, 16 Oct 2026 12:46:52 +0530 Subject: [PATCH 3/8] fix(trace): ignore Git replacement objects Read staged blob content from the exact object recorded in the index, even when refs/replace maps it to another object. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/git/index.go | 2 +- app/cli/internal/trace/git/index_test.go | 93 +++++++++++++++--------- 2 files changed, 58 insertions(+), 37 deletions(-) diff --git a/app/cli/internal/trace/git/index.go b/app/cli/internal/trace/git/index.go index b1944195d..09e05f3dd 100644 --- a/app/cli/internal/trace/git/index.go +++ b/app/cli/internal/trace/git/index.go @@ -68,7 +68,7 @@ func (c *GoGitClient) IndexFile(repoRoot, path string) ([]byte, error) { continue } - content, err := commandOutput(exec.Command("git", "-C", repoRoot, "cat-file", "blob", entry.object)) + content, err := commandOutput(exec.Command("git", "--no-replace-objects", "-C", repoRoot, "cat-file", "blob", entry.object)) if err != nil { return nil, fmt.Errorf("read staged file %s: %w", path, err) } diff --git a/app/cli/internal/trace/git/index_test.go b/app/cli/internal/trace/git/index_test.go index 69688fce3..98ea021f6 100644 --- a/app/cli/internal/trace/git/index_test.go +++ b/app/cli/internal/trace/git/index_test.go @@ -27,40 +27,12 @@ import ( func TestGoGitClient_SnapshotIndex_ResolutionChangesSignature(t *testing.T) { // Given dir := t.TempDir() - run := func(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)) - } - writeIndex := func(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)) - } - writeObject := func(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)) - } - - run("init", "-q") - run("read-tree", "--empty") - base := writeObject("base\n") - ours := writeObject("ours\n") - theirs := writeObject("theirs\n") - writeIndex(fmt.Sprintf( + 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, )) @@ -69,8 +41,8 @@ func TestGoGitClient_SnapshotIndex_ResolutionChangesSignature(t *testing.T) { before, err := client.SnapshotIndex(dir) require.NoError(t, err) - run("read-tree", "--empty") - writeIndex(fmt.Sprintf("100644 %s 0\tconflict.txt\n", theirs)) + runIndexGit(t, dir, "read-tree", "--empty") + writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tconflict.txt\n", theirs)) // When after, err := client.SnapshotIndex(dir) @@ -82,3 +54,52 @@ func TestGoGitClient_SnapshotIndex_ResolutionChangesSignature(t *testing.T) { 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 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)) +} From b3c16574327b15c4198a0a840c01d0271ad0aafb Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 00:30:42 +0530 Subject: [PATCH 4/8] fix(trace): use GoGit for effective index Decode GIT_INDEX_FILE with go-git and keep staged, index, blob, and worktree comparisons inside GoGitClient without native git subprocesses. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/git/gogit.go | 28 +-- app/cli/internal/trace/git/index.go | 222 +++++++++++++------ app/cli/internal/trace/git/worktree_index.go | 222 +++++++++++++++++++ 3 files changed, 383 insertions(+), 89 deletions(-) create mode 100644 app/cli/internal/trace/git/worktree_index.go diff --git a/app/cli/internal/trace/git/gogit.go b/app/cli/internal/trace/git/gogit.go index d0ad0ba0e..48ff1122d 100644 --- a/app/cli/internal/trace/git/gogit.go +++ b/app/cli/internal/trace/git/gogit.go @@ -22,9 +22,7 @@ import ( "io" iofs "io/fs" "os" - "os/exec" "path/filepath" - "sort" "strings" "time" @@ -267,20 +265,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) { - output, err := commandOutput(exec.Command("git", "-C", repoRoot, "diff", "--cached", - "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", "--")) - if err != nil { - return nil, fmt.Errorf("list staged files: %w", err) - } - - files := nullDelimitedPaths(output) - 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) { @@ -348,14 +332,18 @@ func snapshotWorktree(repoRoot string) (map[string]string, error) { // recorded in the git index. Best-effort: returns nil when the repository or // its index cannot be read, in which case ignore rules apply to every file. func trackedPaths(repoRoot string) map[string]bool { - entries, err := loadIndexEntries(repoRoot) + repo, err := openRepo(repoRoot) + if err != nil { + return nil + } + idx, err := loadEffectiveIndex(repo) if err != nil { return nil } - tracked := make(map[string]bool, len(entries)) - for _, entry := range entries { - tracked[entry.path] = true + tracked := make(map[string]bool, len(idx.Entries)) + for _, entry := range idx.Entries { + tracked[entry.Name] = true } return tracked diff --git a/app/cli/internal/trace/git/index.go b/app/cli/internal/trace/git/index.go index 09e05f3dd..c60e66c66 100644 --- a/app/cli/internal/trace/git/index.go +++ b/app/cli/internal/trace/git/index.go @@ -16,35 +16,90 @@ package git import ( - "bytes" + "errors" "fmt" - "os/exec" + "io" + "os" + "path/filepath" "sort" - "strconv" "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" ) -type indexEntry struct { - mode string - object string - path string - stage int +// 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.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) { - entries, err := loadIndexEntries(repoRoot) + 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(entries)) - for _, entry := range entries { - if !entry.isRegular() { + parts := make(map[string][]string, len(idx.Entries)) + for _, entry := range idx.Entries { + if !entry.Mode.IsFile() || entry.Mode == filemode.Symlink { continue } - parts[entry.path] = append(parts[entry.path], fmt.Sprintf("%d:%s", entry.stage, entry.object)) + parts[entry.Name] = append(parts[entry.Name], fmt.Sprintf("%d:%s", entry.Stage, entry.Hash)) } sig := make(map[string]string, len(parts)) @@ -58,19 +113,33 @@ func (c *GoGitClient) SnapshotIndex(repoRoot string) (map[string]string, error) // IndexFile returns the exact staged content for path. func (c *GoGitClient) IndexFile(repoRoot, path string) ([]byte, error) { - entries, err := loadIndexEntries(repoRoot) + 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 entries { - if entry.path != path || entry.stage != 0 || !entry.isRegular() { + for _, entry := range idx.Entries { + if entry.Name != path || entry.Stage != 0 || !entry.Mode.IsFile() || entry.Mode == filemode.Symlink { continue } - content, err := commandOutput(exec.Command("git", "--no-replace-objects", "-C", repoRoot, "cat-file", "blob", entry.object)) + blob, err := repo.BlobObject(entry.Hash) if err != nil { - return nil, fmt.Errorf("read staged file %s: %w", path, err) + 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 @@ -79,76 +148,91 @@ func (c *GoGitClient) IndexFile(repoRoot, path string) ([]byte, error) { return nil, fmt.Errorf("staged file %s not found", path) } -// UnstagedFiles returns paths whose working-tree content differs from the -// index. Paths are repo-relative and slash-separated. -func (c *GoGitClient) UnstagedFiles(repoRoot string) (map[string]struct{}, error) { - output, err := commandOutput(exec.Command("git", "-C", repoRoot, "diff", - "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z", "--")) +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("list unstaged paths: %w", err) + return nil, fmt.Errorf("load HEAD: %w", err) } - - paths := make(map[string]struct{}) - for _, path := range nullDelimitedPaths(output) { - paths[path] = struct{}{} + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + return nil, fmt.Errorf("load HEAD commit: %w", err) } - - return paths, nil -} - -func loadIndexEntries(repoRoot string) ([]indexEntry, error) { - output, err := commandOutput(exec.Command("git", "-C", repoRoot, "ls-files", "--stage", "-z", "--")) + tree, err := commit.Tree() if err != nil { - return nil, fmt.Errorf("list index entries: %w", err) + return nil, fmt.Errorf("load HEAD tree: %w", err) } - var entries []indexEntry - for _, record := range bytes.Split(output, []byte{0}) { - if len(record) == 0 { - continue + walker := object.NewTreeWalker(tree, true, nil) + defer walker.Close() + for { + name, entry, err := walker.Next() + if errors.Is(err, io.EOF) { + break } - metadata, path, ok := bytes.Cut(record, []byte{'\t'}) - fields := bytes.Fields(metadata) - if !ok || len(fields) != 3 { - return nil, fmt.Errorf("parse index entry %q", record) - } - stage, err := strconv.Atoi(string(fields[2])) if err != nil { - return nil, fmt.Errorf("parse index stage %q: %w", fields[2], err) + return nil, fmt.Errorf("walk HEAD tree: %w", err) } - entries = append(entries, indexEntry{ - mode: string(fields[0]), - object: string(fields[1]), - path: string(path), - stage: stage, - }) + if entry.Mode == filemode.Dir { + continue + } + entries[name] = treeEntry{hash: entry.Hash, mode: entry.Mode} } return entries, nil } -func (e indexEntry) isRegular() bool { - return strings.HasPrefix(e.mode, "100") -} - -func nullDelimitedPaths(output []byte) []string { - var paths []string - for _, path := range bytes.Split(output, []byte{0}) { - if len(path) > 0 { - paths = append(paths, string(path)) +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 } - return paths -} + 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() }() -func commandOutput(cmd *exec.Cmd) ([]byte, error) { - var stderr bytes.Buffer - cmd.Stderr = &stderr - output, err := cmd.Output() + 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("git command: %w: %s", err, bytes.TrimSpace(stderr.Bytes())) + 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 output, nil + return idx, nil } 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..2770448a9 --- /dev/null +++ b/app/cli/internal/trace/git/worktree_index.go @@ -0,0 +1,222 @@ +// +// 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" + "sort" + "strings" + + "github.com/go-git/go-billy/v6/osfs" + gogit "github.com/go-git/go-git/v6" + "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.Mode.IsFile() && entry.Mode != filemode.Symlink { + indexed[entry.Name] = entry + } + } + attributes, err := attributeMatcher(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.Core.AutoCRLF, cfg.Core.FileMode, 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 attributeMatcher(repoRoot string, indexed map[string]*formatindex.Entry) (gitattributes.Matcher, error) { + fs := osfs.New(repoRoot) + attrs, err := gitattributes.ReadAttributesFile(fs, nil, ".gitattributes", true) + if err != nil { + return nil, err + } + + 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(fs, strings.Split(dir, "/"), ".gitattributes", false) + if err != nil { + return nil, err + } + attrs = append(attrs, nested...) + } + + 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 worktreeMatchesIndex( + repoRoot, path string, + entry *formatindex.Entry, + repo *gogit.Repository, + autoCRLF string, + fileMode bool, + 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 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) + } + content, canCompare := cleanWorktreeContent(path, content, autoCRLF, attributes) + if !canCompare { + return false, nil + } + + 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) + } + + return bytes.Equal(content, staged), nil +} + +func cleanWorktreeContent( + path string, + content []byte, + autoCRLF string, + matcher gitattributes.Matcher, +) ([]byte, bool) { + result, _ := matcher.Match(strings.Split(filepath.ToSlash(path), "/"), nil) + if filter, ok := result["filter"]; ok && !filter.IsUnset() { + return nil, false + } + + normalize := autoCRLF == "true" || 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 +} From 687e98716e922415a4970f4383472e22da96c7b7 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 00:31:16 +0530 Subject: [PATCH 5/8] docs(trace): document same-call attribution Mark same-call commit attribution as Claude-only and document the foreground Bash limitation. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 8277af1da..1ff83e5f5 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -35,11 +35,14 @@ Which lines of a commit the AI wrote. |---------|---------------|-------------|--------|--------------|------------| | Line attribution | Each changed line of a commit is attributed to the AI or to a human, from a snapshot of each file before and after the agent edits it. | Yes | Partial | Yes | 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. | Yes | Partial | Yes | Yes | +| 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. | Yes | No | No | No | Why not Yes: - **Line attribution, Cursor:** Cursor has only the `afterFileEdit` hook, which gives the edits but no snapshot before the edit. The provider rebuilds the content before the edit from the edits. - **Shell changes attribution, Cursor:** the provider installs the `preToolUse` and `postToolUse` hooks for the `Shell` tool. These hooks are verified for the Cursor IDE only. It is not verified that the Cursor CLI sends them. +- **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. +- **Same-call commit attribution, Cursor and OpenCode:** these providers do not tag a shell command with the per-call token that git hooks use to load its open snapshot. ## User experience From b0b57e6c89719b962d9a39e7540f4a688094ea8b Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 01:21:40 +0530 Subject: [PATCH 6/8] fix(trace): handle GoGit index edge cases Exclude intent-to-add entries, honor effective attribute sources, retain tracked files below ignored directories, and avoid requeueing no-op filtered content. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/README.md | 4 +- app/cli/internal/trace/git/attributes.go | 142 ++++++++++++++++++ app/cli/internal/trace/git/gogit.go | 18 ++- app/cli/internal/trace/git/index.go | 3 + app/cli/internal/trace/git/index_test.go | 15 ++ app/cli/internal/trace/git/worktree_index.go | 60 ++------ .../internal/trace/git/worktree_index_test.go | 107 +++++++++++++ app/cli/pkg/action/trace_bash_filter_test.go | 6 +- 8 files changed, 301 insertions(+), 54 deletions(-) create mode 100644 app/cli/internal/trace/git/attributes.go create mode 100644 app/cli/internal/trace/git/worktree_index_test.go diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 1ff83e5f5..e5b512d2f 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -35,13 +35,13 @@ Which lines of a commit the AI wrote. |---------|---------------|-------------|--------|--------------|------------| | Line attribution | Each changed line of a commit is attributed to the AI or to a human, from a snapshot of each file before and after the agent edits it. | Yes | Partial | Yes | 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. | Yes | Partial | Yes | Yes | -| 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. | Yes | No | No | No | +| 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 | Why not Yes: - **Line attribution, Cursor:** Cursor has only the `afterFileEdit` hook, which gives the edits but no snapshot before the edit. The provider rebuilds the content before the edit from the edits. - **Shell changes attribution, Cursor:** the provider installs the `preToolUse` and `postToolUse` hooks for the `Shell` tool. These hooks are verified for the Cursor IDE only. It is not verified that the Cursor CLI sends them. -- **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. +- **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 and OpenCode:** 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/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 48ff1122d..b5ddcfa4b 100644 --- a/app/cli/internal/trace/git/gogit.go +++ b/app/cli/internal/trace/git/gogit.go @@ -309,9 +309,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 } @@ -324,6 +322,20 @@ func snapshotWorktree(repoRoot string) (map[string]string, error) { return nil }) + for path := range tracked { + if _, ok := sig[path]; ok { + continue + } + fullPath := filepath.Join(repoRoot, filepath.FromSlash(path)) + info, statErr := os.Lstat(fullPath) + if statErr != nil || !info.Mode().IsRegular() { + continue + } + hash, hashErr := hashFileContent(fullPath) + if hashErr == nil { + sig[path] = hash + } + } return sig, err } diff --git a/app/cli/internal/trace/git/index.go b/app/cli/internal/trace/git/index.go index c60e66c66..b9f66feac 100644 --- a/app/cli/internal/trace/git/index.go +++ b/app/cli/internal/trace/git/index.go @@ -54,6 +54,9 @@ func (c *GoGitClient) StagedFiles(repoRoot string) ([]string, error) { 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 diff --git a/app/cli/internal/trace/git/index_test.go b/app/cli/internal/trace/git/index_test.go index 98ea021f6..7c7d69792 100644 --- a/app/cli/internal/trace/git/index_test.go +++ b/app/cli/internal/trace/git/index_test.go @@ -17,7 +17,9 @@ package git import ( "fmt" + "os" "os/exec" + "path/filepath" "strings" "testing" @@ -74,6 +76,19 @@ func TestGoGitClient_IndexFile_IgnoresReplacementObjects(t *testing.T) { "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...) diff --git a/app/cli/internal/trace/git/worktree_index.go b/app/cli/internal/trace/git/worktree_index.go index 2770448a9..bd0c9bfa3 100644 --- a/app/cli/internal/trace/git/worktree_index.go +++ b/app/cli/internal/trace/git/worktree_index.go @@ -22,10 +22,8 @@ import ( "io" "os" "path/filepath" - "sort" "strings" - "github.com/go-git/go-billy/v6/osfs" gogit "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing/filemode" "github.com/go-git/go-git/v6/plumbing/format/gitattributes" @@ -55,11 +53,11 @@ func (c *GoGitClient) UnstagedFiles(repoRoot string) (map[string]struct{}, error indexed := make(map[string]*formatindex.Entry) for _, entry := range idx.Entries { - if entry.Stage == 0 && entry.Mode.IsFile() && entry.Mode != filemode.Symlink { + if entry.Stage == 0 && !entry.IntentToAdd && entry.Mode.IsFile() && entry.Mode != filemode.Symlink { indexed[entry.Name] = entry } } - attributes, err := attributeMatcher(repoRoot, indexed) + attributes, err := attributeMatcher(repo, repoRoot, indexed) if err != nil { return nil, fmt.Errorf("load git attributes: %w", err) } @@ -88,45 +86,6 @@ func (c *GoGitClient) UnstagedFiles(repoRoot string) (map[string]struct{}, error return paths, nil } -func attributeMatcher(repoRoot string, indexed map[string]*formatindex.Entry) (gitattributes.Matcher, error) { - fs := osfs.New(repoRoot) - attrs, err := gitattributes.ReadAttributesFile(fs, nil, ".gitattributes", true) - if err != nil { - return nil, err - } - - 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(fs, strings.Split(dir, "/"), ".gitattributes", false) - if err != nil { - return nil, err - } - attrs = append(attrs, nested...) - } - - 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 worktreeMatchesIndex( repoRoot, path string, entry *formatindex.Entry, @@ -157,10 +116,6 @@ func worktreeMatchesIndex( if err != nil { return false, fmt.Errorf("read worktree file %s: %w", path, err) } - content, canCompare := cleanWorktreeContent(path, content, autoCRLF, attributes) - if !canCompare { - return false, nil - } blob, err := repo.BlobObject(entry.Hash) if err != nil { @@ -175,6 +130,14 @@ func worktreeMatchesIndex( if err != nil { return false, fmt.Errorf("read index content %s: %w", path, err) } + if bytes.Equal(content, staged) { + return true, nil + } + + content, canCompare := cleanWorktreeContent(path, content, autoCRLF, attributes) + if !canCompare { + return false, nil + } return bytes.Equal(content, staged), nil } @@ -187,6 +150,9 @@ func cleanWorktreeContent( ) ([]byte, bool) { result, _ := matcher.Match(strings.Split(filepath.ToSlash(path), "/"), nil) if filter, ok := result["filter"]; ok && !filter.IsUnset() { + // Raw equality is handled before this function. Reproducing an + // arbitrary configured filter driver would execute repository code, so + // transformed content stays conservatively dirty. return nil, false } 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..a8ce9ade0 --- /dev/null +++ b/app/cli/internal/trace/git/worktree_index_test.go @@ -0,0 +1,107 @@ +// +// 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") +} diff --git a/app/cli/pkg/action/trace_bash_filter_test.go b/app/cli/pkg/action/trace_bash_filter_test.go index 48fbcaa52..dda565ab5 100644 --- a/app/cli/pkg/action/trace_bash_filter_test.go +++ b/app/cli/pkg/action/trace_bash_filter_test.go @@ -29,7 +29,9 @@ import ( func TestCommitLifecycle_BashCommitWithCleanFilterDoesNotRequeueAttribution(t *testing.T) { // Given dir, gitDir := initGitRepo(t) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("filtered.txt text eol=lf\n"), 0600)) + 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") @@ -51,7 +53,7 @@ func TestCommitLifecycle_BashCommitWithCleanFilterDoesNotRequeueAttribution(t *t ) target := filepath.Join(dir, "filtered.txt") - require.NoError(t, os.WriteFile(target, []byte("first\r\nsecond\r\n"), 0600)) + require.NoError(t, os.WriteFile(target, []byte("first\nsecond\n"), 0600)) runGit(t, dir, "add", "filtered.txt") msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") From 57fa8893645a43b94879cc6292715588ea17d470 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 01:41:55 +0530 Subject: [PATCH 7/8] fix(trace): harden worktree comparison Guard transforming filters, exclude intent-to-add paths from tracked snapshots, and confine fallback reads to the checkout root. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/git/gogit.go | 26 ++++++++++-- app/cli/internal/trace/git/worktree_index.go | 41 ++++++++++++------- .../internal/trace/git/worktree_index_test.go | 41 +++++++++++++++++++ 3 files changed, 89 insertions(+), 19 deletions(-) diff --git a/app/cli/internal/trace/git/gogit.go b/app/cli/internal/trace/git/gogit.go index b5ddcfa4b..bf8b551f6 100644 --- a/app/cli/internal/trace/git/gogit.go +++ b/app/cli/internal/trace/git/gogit.go @@ -18,6 +18,7 @@ package git import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" iofs "io/fs" @@ -322,16 +323,26 @@ 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 } - fullPath := filepath.Join(repoRoot, filepath.FromSlash(path)) - info, statErr := os.Lstat(fullPath) + f, openErr := root.Open(filepath.FromSlash(path)) + if openErr != nil { + continue + } + info, statErr := f.Stat() if statErr != nil || !info.Mode().IsRegular() { + _ = f.Close() continue } - hash, hashErr := hashFileContent(fullPath) + hash, hashErr := hashFileReader(f) + _ = f.Close() if hashErr == nil { sig[path] = hash } @@ -355,6 +366,9 @@ func trackedPaths(repoRoot string) map[string]bool { tracked := make(map[string]bool, len(idx.Entries)) for _, entry := range idx.Entries { + if entry.IntentToAdd { + continue + } tracked[entry.Name] = true } @@ -369,8 +383,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/worktree_index.go b/app/cli/internal/trace/git/worktree_index.go index bd0c9bfa3..3b3b81217 100644 --- a/app/cli/internal/trace/git/worktree_index.go +++ b/app/cli/internal/trace/git/worktree_index.go @@ -25,6 +25,7 @@ import ( "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" @@ -69,7 +70,7 @@ func (c *GoGitClient) UnstagedFiles(repoRoot string) (map[string]struct{}, error paths[path] = struct{}{} continue } - same, err := worktreeMatchesIndex(repoRoot, path, entry, repo, cfg.Core.AutoCRLF, cfg.Core.FileMode, attributes) + same, err := worktreeMatchesIndex(repoRoot, path, entry, repo, cfg, attributes) if err != nil { return nil, err } @@ -90,8 +91,7 @@ func worktreeMatchesIndex( repoRoot, path string, entry *formatindex.Entry, repo *gogit.Repository, - autoCRLF string, - fileMode bool, + cfg *gogitconfig.Config, attributes gitattributes.Matcher, ) (bool, error) { fullPath := filepath.Join(repoRoot, filepath.FromSlash(path)) @@ -102,7 +102,7 @@ func worktreeMatchesIndex( } return false, fmt.Errorf("stat worktree file %s: %w", path, err) } - if fileMode { + if cfg.Core.FileMode { mode, err := filemode.NewFromOSFileMode(info.Mode()) if err != nil { return false, fmt.Errorf("read worktree mode %s: %w", path, err) @@ -130,11 +130,7 @@ func worktreeMatchesIndex( if err != nil { return false, fmt.Errorf("read index content %s: %w", path, err) } - if bytes.Equal(content, staged) { - return true, nil - } - - content, canCompare := cleanWorktreeContent(path, content, autoCRLF, attributes) + content, canCompare := cleanWorktreeContent(path, content, cfg, attributes) if !canCompare { return false, nil } @@ -145,18 +141,17 @@ func worktreeMatchesIndex( func cleanWorktreeContent( path string, content []byte, - autoCRLF string, + 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() { - // Raw equality is handled before this function. Reproducing an - // arbitrary configured filter driver would execute repository code, so - // transformed content stays conservatively dirty. + 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 := autoCRLF == "true" || autoCRLF == "input" + normalize := cfg.Core.AutoCRLF == "true" || cfg.Core.AutoCRLF == "input" forceText := false if text, ok := result["text"]; ok { switch { @@ -186,3 +181,19 @@ func cleanWorktreeContent( 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 index a8ce9ade0..af287e194 100644 --- a/app/cli/internal/trace/git/worktree_index_test.go +++ b/app/cli/internal/trace/git/worktree_index_test.go @@ -105,3 +105,44 @@ func TestGoGitClient_UnstagedFiles_TrackedFileInIgnoredDirectory(t *testing.T) { 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_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") +} From b008a4afbf0d3f85e9bb5ed90b272f97ea3f3968 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 01:56:12 +0530 Subject: [PATCH 8/8] fix(trace): reject symlinked tracked paths Validate every fallback path component before opening tracked files so symlinks and special files stay outside worktree snapshots. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/git/gogit.go | 31 ++++++++++++++----- .../internal/trace/git/worktree_index_test.go | 14 +++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/app/cli/internal/trace/git/gogit.go b/app/cli/internal/trace/git/gogit.go index bf8b551f6..0dd07ea9c 100644 --- a/app/cli/internal/trace/git/gogit.go +++ b/app/cli/internal/trace/git/gogit.go @@ -332,13 +332,8 @@ func snapshotWorktree(repoRoot string) (map[string]string, error) { if _, ok := sig[path]; ok { continue } - f, openErr := root.Open(filepath.FromSlash(path)) - if openErr != nil { - continue - } - info, statErr := f.Stat() - if statErr != nil || !info.Mode().IsRegular() { - _ = f.Close() + f, ok := openRegularRootFile(root, path) + if !ok { continue } hash, hashErr := hashFileReader(f) @@ -375,6 +370,28 @@ func trackedPaths(repoRoot string) map[string]bool { 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) diff --git a/app/cli/internal/trace/git/worktree_index_test.go b/app/cli/internal/trace/git/worktree_index_test.go index af287e194..034d4ca50 100644 --- a/app/cli/internal/trace/git/worktree_index_test.go +++ b/app/cli/internal/trace/git/worktree_index_test.go @@ -134,6 +134,20 @@ func TestGoGitClient_SnapshotWorktree_DoesNotFollowEscapingSymlink(t *testing.T) 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")