From 18775fee18528b7581e4305d1e49fbadd3785096 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Fri, 16 Oct 2026 01:26:06 +0530 Subject: [PATCH 1/9] 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 | 108 ++++++- app/cli/internal/trace/state/snapshot_test.go | 20 ++ app/cli/pkg/action/trace_agent_hook.go | 155 +++++++--- app/cli/pkg/action/trace_bash_cleanup_test.go | 72 +++++ app/cli/pkg/action/trace_bash_commit.go | 194 ++++++++++++ app/cli/pkg/action/trace_bash_commit_test.go | 290 ++++++++++++++++++ app/cli/pkg/action/trace_bash_filter_test.go | 122 ++++++++ app/cli/pkg/action/trace_hook_handler.go | 23 +- 13 files changed, 1185 insertions(+), 57 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 490af29a5..73527e234 100644 --- a/app/cli/internal/trace/provider.go +++ b/app/cli/internal/trace/provider.go @@ -187,6 +187,13 @@ type SkillTracker interface { SkillRoots(repoRoot string) skill.Roots } +// ShellCommandTagger is implemented by a provider whose pre-tool hook can +// replace shell input before execution. The opaque token reaches git hooks +// through the command environment, tying a commit to one exact tool call. +type ShellCommandTagger interface { + TagShellCommand(input *HookInput, token string) error +} + // SpecCopyReplacer is implemented by a provider that can find the copies of // spec sources in its transcript (spec issue-3556). For the sessions of a // provider without it, the transcript keeps its copies. @@ -313,6 +320,9 @@ type HookInput struct { // Command is the command line of a shell command tool, set by provider's // ReadHookInput. Empty when the provider does not report it. Command string `json:"-"` + // RawToolInput is the provider's full tool input. A provider that rewrites + // shell input preserves every field and changes only Command. + RawToolInput json.RawMessage `json:"-"` // Cwd is the directory the agent session runs in, which is where the // agent files its transcripts. It can differ from the checkout that owns // FilePath, e.g. for an edit in a linked git worktree. Empty when the diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go index 5160340ff..8b3cdc79f 100644 --- a/app/cli/internal/trace/state/snapshot.go +++ b/app/cli/internal/trace/state/snapshot.go @@ -17,14 +17,18 @@ package state import ( "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" "strings" ) +const ShellCallEnv = "CHAINLOOP_TRACE_SHELL_CALL" + // snapshotPath returns the path for a file snapshot: /chainloop-trace/snapshots// func (s *Store) snapshotPath(sessionID, filePath string) string { h := sha256.Sum256([]byte(filePath)) @@ -66,6 +70,32 @@ type ShellCallKey struct { ToolUseID string } +func EncodeShellCallKey(key ShellCallKey) (string, error) { + data, err := json.Marshal(key) + if err != nil { + return "", fmt.Errorf("marshal shell call key: %w", err) + } + + return base64.RawURLEncoding.EncodeToString(data), nil +} + +func DecodeShellCallKey(encoded string) (ShellCallKey, error) { + data, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return ShellCallKey{}, fmt.Errorf("decode shell call key: %w", err) + } + + var key ShellCallKey + if err := json.Unmarshal(data, &key); err != nil { + return ShellCallKey{}, fmt.Errorf("parse shell call key: %w", err) + } + if !ValidSessionID(key.SessionID) { + return ShellCallKey{}, fmt.Errorf("invalid shell call session ID") + } + + return key, nil +} + // shellPreSignaturePath returns the path storing the pre-command working-tree // signature of a shell command, under // /chainloop-trace/snapshots//: @@ -75,12 +105,20 @@ type ShellCallKey struct { // // A tool use ID is unique within a session, so it needs no agent qualifier. func (s *Store) shellPreSignaturePath(key ShellCallKey) string { - name := "shell-pre.json" + return s.shellSignaturePath("shell-pre", key) +} + +func (s *Store) shellPreIndexSignaturePath(key ShellCallKey) string { + return s.shellSignaturePath("shell-index", key) +} + +func (s *Store) shellSignaturePath(prefix string, key ShellCallKey) string { + name := prefix + commitRecordExt switch { case key.ToolUseID != "": - name = "shell-pre-call-" + sanitizeID(key.ToolUseID) + ".json" + name = prefix + "-call-" + sanitizeID(key.ToolUseID) + commitRecordExt case key.AgentID != "": - name = "shell-pre-" + sanitizeID(key.AgentID) + ".json" + name = prefix + "-" + sanitizeID(key.AgentID) + commitRecordExt } return filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(key.SessionID), name) @@ -107,6 +145,22 @@ func (s *Store) SaveShellPreSignature(key ShellCallKey, sig WorktreeSignatures) return os.WriteFile(path, data, 0600) } +// SaveShellPreIndexSignature stores the staged index signature captured before +// an agent-run shell command. +func (s *Store) SaveShellPreIndexSignature(key ShellCallKey, sig WorktreeSignatures) error { + path := s.shellPreIndexSignaturePath(key) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create snapshot dir: %w", err) + } + + data, err := json.Marshal(sig) + if err != nil { + return fmt.Errorf("marshal shell index signature: %w", err) + } + + return os.WriteFile(path, data, 0600) +} + // LoadShellPreSignature loads the pre-command working-tree signatures of a // shell command. A file in the earlier single-checkout format (written by an // older CLI just before an upgrade) does not parse, and the caller skips it. @@ -124,10 +178,56 @@ func (s *Store) LoadShellPreSignature(key ShellCallKey) (WorktreeSignatures, err return sig, nil } +func (s *Store) LoadShellPreIndexSignature(key ShellCallKey) (WorktreeSignatures, error) { + data, err := os.ReadFile(s.shellPreIndexSignaturePath(key)) + if err != nil { + return nil, err + } + + var sig WorktreeSignatures + if err := json.Unmarshal(data, &sig); err != nil { + return nil, fmt.Errorf("parse shell index signature: %w", err) + } + + return sig, nil +} + +// DeleteShellSnapshots removes every in-progress shell snapshot for a session +// while preserving file-edit snapshots in the same directory. +func (s *Store) DeleteShellSnapshots(sessionID string) error { + dir := filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(sessionID)) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + + return fmt.Errorf("read shell snapshots: %w", err) + } + + var errs []error + for _, entry := range entries { + if entry.IsDir() || !isShellSnapshotName(entry.Name()) { + continue + } + if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +func isShellSnapshotName(name string) bool { + return filepath.Ext(name) == commitRecordExt && + (strings.HasPrefix(name, "shell-pre") || strings.HasPrefix(name, "shell-index")) +} + // DeleteShellPreSignature removes the pre-command signature, and the HEAD -// commits saved with it, once processed. +// commit and index signature saved with it, once processed. func (s *Store) DeleteShellPreSignature(key ShellCallKey) { _ = os.Remove(s.shellPreSignaturePath(key)) + _ = os.Remove(s.shellPreIndexSignaturePath(key)) _ = os.Remove(s.shellPreHeadsPath(key)) } diff --git a/app/cli/internal/trace/state/snapshot_test.go b/app/cli/internal/trace/state/snapshot_test.go index 69945fff4..401b16210 100644 --- a/app/cli/internal/trace/state/snapshot_test.go +++ b/app/cli/internal/trace/state/snapshot_test.go @@ -52,15 +52,21 @@ func TestShellPreSignatureRoundTrip(t *testing.T) { } require.NoError(t, store.SaveShellPreSignature(tc.key, sig)) + require.NoError(t, store.SaveShellPreIndexSignature(tc.key, sig)) loaded, err := store.LoadShellPreSignature(tc.key) require.NoError(t, err) assert.Equal(t, sig, loaded) + loadedIndex, err := store.LoadShellPreIndexSignature(tc.key) + require.NoError(t, err) + assert.Equal(t, sig, loadedIndex) store.DeleteShellPreSignature(tc.key) _, err = store.LoadShellPreSignature(tc.key) assert.Error(t, err, "signature should be gone after delete") + _, err = store.LoadShellPreIndexSignature(tc.key) + assert.Error(t, err, "index signature should be gone after delete") }) } } @@ -137,3 +143,17 @@ func TestShellPreSignatureRejectsSingleCheckoutFormat(t *testing.T) { _, err := store.LoadShellPreSignature(key) assert.Error(t, err) } + +func TestShellCallKeyTokenRoundTrip(t *testing.T) { + // Given + key := ShellCallKey{SessionID: shellSessionID, AgentID: "agent-1", ToolUseID: "tool-1"} + + // When + token, err := EncodeShellCallKey(key) + require.NoError(t, err) + decoded, err := DecodeShellCallKey(token) + + // Then + require.NoError(t, err) + require.Equal(t, key, decoded) +} diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index c4c071fb2..9ae4b8b11 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -18,6 +18,7 @@ package action import ( "context" "errors" + "fmt" "os" "path/filepath" "slices" @@ -69,7 +70,7 @@ func HandleAgentSessionEnd(provider trace.Provider, log zerolog.Logger) error { // stay after the session end, for a push that comes later. captureSkillLoads(provider, store, input, log) - setSessionActive(store, sessionID, false, log) + cleanupSessionShellState(store, sessionID, log) // The spec stays on disk for the whole session, so that every push of it // records the spec as it is at that moment. The session end is where it @@ -112,6 +113,32 @@ func setSessionActive(store *state.Store, sessionID string, active bool, log zer } } +func cleanupSessionShellState(store *state.Store, sessionID string, log zerolog.Logger) { + rec, err := store.LoadSessionRecord(sessionID) + if err != nil { + log.Debug().Err(err).Msg("load session record for shell cleanup failed") + } + + setSessionActive(store, sessionID, false, log) + if err := store.DeleteShellSnapshots(sessionID); err != nil { + log.Debug().Err(err).Msg("delete shell snapshots failed") + } + + if rec == nil { + return + } + for _, root := range rec.Checkouts { + checkoutStore, _, err := state.LocateFrom(root) + if err != nil { + continue + } + setSessionActive(checkoutStore, sessionID, false, log) + if err := checkoutStore.DeleteShellSnapshots(sessionID); err != nil { + log.Debug().Err(err).Str("root", root).Msg("delete checkout shell snapshots failed") + } + } +} + // sessionStartBanner is what the developer sees at the top of a traced // session. Its audience includes someone who cloned the repository and never // ran init, so it says what is happening in plain words and, when we know it, @@ -407,6 +434,7 @@ func handleAgentPreToolUse(provider trace.Provider, input *trace.HookInput, log // checkouts the command names first, so the snapshot covers them. registerCommandCheckouts(provider, input, repoRoot, log) captureWorktreeSnapshot(store, repoRoot, shellCallKey(input), log) + tagShellCommand(provider, input, log) case provider.IsFileWritingTool(input.ToolName): if input.FilePath == "" { log.Debug().Str("tool", input.ToolName).Msg("pre-tool-use: file-writing tool produced no file path, skipping") @@ -437,33 +465,65 @@ func shellCallKey(input *trace.HookInput) state.ShellCallKey { // run in the session's own checkout. Best-effort: failures are logged and // never block the agent. func captureWorktreeSnapshot(store *state.Store, repoRoot string, key state.ShellCallKey, log zerolog.Logger) { - client := tracegit.NewGitClient() - sigs := make(state.WorktreeSignatures) - heads := make(map[string]string) + client := tracegit.NewGoGitClient() for _, root := range shellSnapshotRoots(store, repoRoot, key.SessionID) { - sig, err := client.SnapshotWorktree(root) + worktreeSig, err := client.SnapshotWorktree(root) if err != nil { log.Debug().Err(err).Str("root", root).Msg("pre-command: worktree snapshot failed") continue } - sigs[root] = sig + indexSig, err := client.SnapshotIndex(root) + if err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: index snapshot failed") + continue + } + + rootStore := store + if !sameDir(root, repoRoot) { + s, resolvedRoot, err := state.LocateFrom(root) + if err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: no trace state for checkout") + continue + } + rootStore, root = s, resolvedRoot + if err := copySessionRecord(store, rootStore, key.SessionID); err != nil { + log.Debug().Err(err).Str("session_id", key.SessionID).Msg("pre-command: copy session record failed") + } + } + heads := make(map[string]string) // A checkout with no commit yet has no HEAD; its files count as new. if sha, _, err := client.CommitHeadInfo(root); err == nil { heads[root] = sha } - } - if len(sigs) == 0 { - return + if err := rootStore.SaveShellPreSignature(key, state.WorktreeSignatures{root: worktreeSig}); err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: save worktree signature failed") + } + if err := rootStore.SaveShellPreIndexSignature(key, state.WorktreeSignatures{root: indexSig}); err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: save index signature failed") + } + if err := rootStore.SaveShellPreHeads(key, heads); err != nil { + log.Debug().Err(err).Str("root", root).Msg("pre-command: save HEAD commits failed") + } } +} - if err := store.SaveShellPreSignature(key, sigs); err != nil { - log.Debug().Err(err).Msg("pre-command: save worktree signature failed") +func copySessionRecord(source, target *state.Store, sessionID string) error { + rec, err := source.LoadSessionRecord(sessionID) + if err != nil { + return fmt.Errorf("load session record: %w", err) + } + if rec == nil { + return nil } - if err := store.SaveShellPreHeads(key, heads); err != nil { - log.Debug().Err(err).Msg("pre-command: save HEAD commits failed") + + recordCopy := *rec + if err := target.SaveSessionRecord(&recordCopy); err != nil { + return fmt.Errorf("save session record: %w", err) } + + return nil } // shellSnapshotRoots returns the checkouts that a shell command of the session @@ -879,27 +939,7 @@ func recordFileEdit(store *state.Store, sessionID, relPath string, before, after // blocks the agent. func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, store *state.Store, repoRoot string, log zerolog.Logger) { key := shellCallKey(input) - before, err := store.LoadShellPreSignature(key) - if err != nil { - // No pre-command snapshot (missed pre hook, or an overlapping call - // of an agent without tool call IDs overwrote it) — skip. - log.Debug().Err(err).Msg("post-command: no pre-command worktree signature") - return - } - defer store.DeleteShellPreSignature(key) - // A pre hook from an older CLI saved no HEAD. That case, and a heads file - // that cannot be read, fall back to the current HEAD. - heads, err := store.LoadShellPreHeads(key) - if err != nil && !errors.Is(err, os.ErrNotExist) { - log.Debug().Err(err).Msg("post-command: cannot read pre-command HEAD commits; using the current HEAD") - } - - for root, rootBefore := range before { - base := heads[root] - if base == "" { - base = "HEAD" - } - + for _, root := range shellSnapshotRoots(store, repoRoot, key.SessionID) { rootStore := store if !sameDir(root, repoRoot) { s, r, err := state.LocateFrom(root) @@ -911,7 +951,29 @@ func recordCommandLineRanges(provider trace.Provider, input *trace.HookInput, st rootStore, root = s, r } - recordRootChanges(rootStore, root, base, rootBefore, key.SessionID, log) + before, err := rootStore.LoadShellPreSignature(key) + if err != nil { + log.Debug().Err(err).Str("root", root).Msg("post-command: no pre-command worktree signature") + continue + } + // A pre hook from an older CLI saved no HEAD. That case, and a heads + // file that cannot be read, fall back to the current HEAD. + heads, err := rootStore.LoadShellPreHeads(key) + if err != nil && !errors.Is(err, os.ErrNotExist) { + log.Debug().Err(err).Str("root", root).Msg("post-command: cannot read pre-command HEAD commits; using the current HEAD") + } + + for snapshotRoot, rootBefore := range before { + if sameDir(snapshotRoot, root) { + base := heads[snapshotRoot] + if base == "" { + base = "HEAD" + } + recordRootChanges(rootStore, root, base, rootBefore, key.SessionID, log) + break + } + } + rootStore.DeleteShellPreSignature(key) } } @@ -945,7 +1007,19 @@ func recordRootChanges(store *state.Store, repoRoot, base string, before map[str open := store.LoadAILineAttribution(sessionID).Open record := func(relPath string, edit attribution.Edit) error { - return store.RecordEdit(sessionID, relPath, editRecord(attribution.DropOpen(edit, open[relPath]))) + edit = attribution.DropOpen(edit, open[relPath]) + if err := store.RecordEdit(sessionID, relPath, editRecord(edit)); err != nil { + return err + } + // Fingerprints intentionally ignore whitespace. Keep a legacy touch + // marker when a real line diff therefore has counts but no fingerprints, + // so binary CRLF changes still stay pending for a later commit. + if len(edit.Added) == 0 && len(edit.Removed) == 0 && + (edit.AddedCount > 0 || edit.RemovedCount > 0) { + return store.RecordLineRanges(sessionID, relPath, nil) + } + + return nil } changed, deleted := attribution.ChangedPaths(before, after) @@ -954,10 +1028,17 @@ func recordRootChanges(store *state.Store, repoRoot, base string, before map[str if err != nil { continue } + beforeContent := atBase(relPath) + if _, existed := before[relPath]; !existed { + // The call created this path after its latest checkpoint. HEAD can + // still hold the old file when that checkpoint staged and committed + // a deletion, so it is not the content before this new edit. + beforeContent = nil + } // ChangedPaths already proved the file changed, so record it even when // the diff finds nothing (created empty, or restored to base): the // file is then AI-touched with zero lines, as a deletion-only edit. - if err := record(relPath, attribution.ComputeEdit(atBase(relPath), content)); err != nil { + if err := record(relPath, attribution.ComputeEdit(beforeContent, content)); err != nil { log.Debug().Err(err).Str("file", relPath).Msg("post-command: record line ranges failed") } } diff --git a/app/cli/pkg/action/trace_bash_cleanup_test.go b/app/cli/pkg/action/trace_bash_cleanup_test.go new file mode 100644 index 000000000..610caa9fd --- /dev/null +++ b/app/cli/pkg/action/trace_bash_cleanup_test.go @@ -0,0 +1,72 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestCleanupSessionShellState_RemovesOnlyShellSnapshotsAcrossCheckouts(t *testing.T) { + // Given + home, homeGitDir := initGitRepo(t) + linked := filepath.Join(t.TempDir(), "linked") + runGit(t, home, "worktree", "add", "-b", "cleanup-linked", linked) + + const sessionID = "cleanup-session" + rec := &state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Cwd: home, + Checkouts: []string{linked}, + Active: true, + StartedAt: state.NowTimestamp(), + } + homeStore := state.NewGitStore(homeGitDir) + require.NoError(t, homeStore.SaveSessionRecord(rec)) + linkedStore, _, err := state.LocateFrom(linked) + require.NoError(t, err) + require.NoError(t, linkedStore.SaveSessionRecord(rec)) + + key := state.ShellCallKey{SessionID: sessionID, ToolUseID: "tool-cleanup"} + sig := state.WorktreeSignatures{home: {"kept.txt": "hash"}} + for _, store := range []*state.Store{homeStore, linkedStore} { + require.NoError(t, store.SaveShellPreSignature(key, sig)) + require.NoError(t, store.SaveShellPreIndexSignature(key, sig)) + } + require.NoError(t, homeStore.SaveFileSnapshot(sessionID, "kept.txt", []byte("keep file snapshot"))) + + // When + cleanupSessionShellState(homeStore, sessionID, zerolog.Nop()) + + // Then + for _, store := range []*state.Store{homeStore, linkedStore} { + _, err := store.LoadShellPreSignature(key) + require.Error(t, err) + _, err = store.LoadShellPreIndexSignature(key) + require.Error(t, err) + record, err := store.LoadSessionRecord(sessionID) + require.NoError(t, err) + require.False(t, record.Active) + } + content, err := homeStore.LoadFileSnapshot(sessionID, "kept.txt") + require.NoError(t, err) + require.Equal(t, []byte("keep file snapshot"), content) +} diff --git a/app/cli/pkg/action/trace_bash_commit.go b/app/cli/pkg/action/trace_bash_commit.go new file mode 100644 index 000000000..f459e1ba0 --- /dev/null +++ b/app/cli/pkg/action/trace_bash_commit.go @@ -0,0 +1,194 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "errors" + "fmt" + "os" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/attribution" + tracegit "github.com/chainloop-dev/chainloop/app/cli/internal/trace/git" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" +) + +func tagShellCommand(provider trace.Provider, input *trace.HookInput, log zerolog.Logger) { + tagger, ok := provider.(trace.ShellCommandTagger) + if !ok { + return + } + + token, err := state.EncodeShellCallKey(shellCallKey(input)) + if err != nil { + log.Debug().Err(err).Msg("pre-command: encode shell call key failed") + return + } + if err := tagger.TagShellCommand(input, token); err != nil { + log.Debug().Err(err).Msg("pre-command: tag shell command failed") + } +} + +// checkpointActiveShellAttribution records index changes made by the exact +// shell call whose token git inherited. The normal PostToolUse attribution +// arrives only after the call exits, after commit-msg and pre-push already ran. +func checkpointActiveShellAttribution(store *state.Store, repoRoot string, stagedFiles []string) error { + if len(stagedFiles) == 0 { + return nil + } + + token := os.Getenv(state.ShellCallEnv) + if token == "" { + return nil + } + key, err := state.DecodeShellCallKey(token) + if err != nil { + return err + } + + beforeWorktree, err := store.LoadShellPreSignature(key) + if err != nil { + return fmt.Errorf("load pre-command worktree snapshot: %w", err) + } + beforeIndex, err := store.LoadShellPreIndexSignature(key) + if err != nil { + return fmt.Errorf("load pre-command index snapshot: %w", err) + } + beforeHeads, err := store.LoadShellPreHeads(key) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("load pre-command HEAD commits: %w", err) + } + + client := tracegit.NewGoGitClient() + afterIndex, err := client.SnapshotIndex(repoRoot) + if err != nil { + return fmt.Errorf("snapshot staged index: %w", err) + } + afterWorktree, err := client.SnapshotWorktree(repoRoot) + if err != nil { + return fmt.Errorf("snapshot worktree: %w", err) + } + unstaged, err := client.UnstagedFiles(repoRoot) + if err != nil { + return fmt.Errorf("load unstaged paths: %w", err) + } + + staged := make(map[string]struct{}, len(stagedFiles)) + for _, path := range stagedFiles { + staged[path] = struct{}{} + } + + var errs []error + open := store.LoadAILineAttribution(key.SessionID).Open + for root, indexBefore := range beforeIndex { + if !sameDir(root, repoRoot) { + continue + } + worktreeBefore, ok := beforeWorktree[root] + if !ok { + continue + } + + changed, deleted := attribution.ChangedPaths(indexBefore, afterIndex) + checkpointed := false + for _, relPath := range append(changed, deleted...) { + if _, ok := staged[relPath]; !ok { + continue + } + + base := beforeHeads[root] + if base == "" { + base = "HEAD" + } + beforeContent, _ := client.FileAt(repoRoot, base, relPath) + var afterContent []byte + if hash, exists := afterIndex[relPath]; exists { + content, err := client.IndexFile(repoRoot, relPath) + if err != nil { + errs = append(errs, fmt.Errorf("read staged file %s: %w", relPath, err)) + continue + } + afterContent = content + indexBefore[relPath] = hash + } else { + delete(indexBefore, relPath) + } + if _, hasUnstaged := unstaged[relPath]; !hasUnstaged { + if hash, exists := afterWorktree[relPath]; exists { + worktreeBefore[relPath] = hash + } else { + delete(worktreeBefore, relPath) + } + } + + edit := attribution.DropOpen(attribution.ComputeEdit(beforeContent, afterContent), open[relPath]) + if edit.Changed { + if err := store.RecordEdit(key.SessionID, relPath, editRecord(edit)); err != nil { + errs = append(errs, fmt.Errorf("record attribution for %s: %w", relPath, err)) + continue + } + } + checkpointed = true + } + + if checkpointed { + if err := store.SaveShellPreSignature(key, beforeWorktree); err != nil { + errs = append(errs, fmt.Errorf("checkpoint worktree snapshot: %w", err)) + } + if err := store.SaveShellPreIndexSignature(key, beforeIndex); err != nil { + errs = append(errs, fmt.Errorf("checkpoint index snapshot: %w", err)) + } + } + } + + return errors.Join(errs...) +} + +func advanceActiveShellHead(store *state.Store, repoRoot, sha string) error { + token := os.Getenv(state.ShellCallEnv) + if token == "" { + return nil + } + key, err := state.DecodeShellCallKey(token) + if err != nil { + return err + } + + heads, err := store.LoadShellPreHeads(key) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if heads == nil { + heads = make(map[string]string) + } + + updated := false + for root := range heads { + if sameDir(root, repoRoot) { + heads[root] = sha + updated = true + } + } + if !updated { + heads[repoRoot] = sha + } + + return store.SaveShellPreHeads(key, heads) +} diff --git a/app/cli/pkg/action/trace_bash_commit_test.go b/app/cli/pkg/action/trace_bash_commit_test.go new file mode 100644 index 000000000..53cc330c9 --- /dev/null +++ b/app/cli/pkg/action/trace_bash_commit_test.go @@ -0,0 +1,290 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/chainloop-dev/chainloop/app/cli/internal/trace" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func beginTaggedBashCall(t *testing.T, key state.ShellCallKey, command string) trace.Provider { + t.Helper() + + cwd, err := os.Getwd() + require.NoError(t, err) + payload := fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"cwd":%q,"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":%q}}`, + key.SessionID, key.ToolUseID, cwd, command, + ) + provider := claude.New() + withStdin(t, payload) + out := captureStdout(t, func() { + require.NoError(t, HandleAgentPreToolUse(provider, zerolog.Nop())) + }) + require.Contains(t, out, state.ShellCallEnv) + + token, err := state.EncodeShellCallKey(key) + require.NoError(t, err) + t.Setenv(state.ShellCallEnv, token) + + return provider +} + +func TestCommitLifecycle_BashCommitBeforePostToolUseAddsSession(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "same-call-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-same-call" + key := state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID} + provider := beginTaggedBashCall(t, key, "edit, commit, push") + + // When + target := filepath.Join(dir, "same-call.txt") + require.NoError(t, os.WriteFile(target, []byte("changed by Bash\n"), 0600)) + runGit(t, dir, "add", "same-call.txt") + + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: same-call commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.Contains(t, string(msg), "Chainloop-Trace-Sessions: "+sessionID) + + runGit(t, dir, "commit", "-F", msgPath) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + heads, err := store.LoadShellPreHeads(key) + require.NoError(t, err) + require.Equal(t, headSHA(t, dir), heads[dir], "the open Bash call must advance to the commit it just made") + + commits, err := store.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 1) + require.Equal(t, []string{sessionID}, commits[0].SessionIDs) + + postInput := fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"edit, commit, push"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, + sessionID, toolUseID, + ) + withStdin(t, postInput) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + attr := store.LoadAILineAttribution(sessionID) + require.Contains(t, attr.Files, "same-call.txt") + require.False(t, attr.Legacy["same-call.txt"], "same-call attribution must use the fingerprinted edit ledger") + require.Equal(t, 1, attr.GeneratedAdded) + require.Empty(t, attr.Pending, "the committed file must not stay pending after the Bash call ends") +} + +func TestCommitLifecycle_BashCommitDoesNotClaimPreviouslyStagedFile(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + target := filepath.Join(dir, "already-staged.txt") + require.NoError(t, os.WriteFile(target, []byte("staged before Bash\n"), 0600)) + runGit(t, dir, "add", "already-staged.txt") + require.NoError(t, os.WriteFile(target, []byte("human unstaged before Bash\n"), 0600)) + + const sessionID = "unrelated-bash-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-pre-staged" + provider := beginTaggedBashCall(t, state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, "git commit") + + require.NoError(t, os.WriteFile(target, []byte("unstaged AI change\n"), 0600)) + + // When + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: pre-staged commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.NotContains(t, string(msg), state.TrailerKey, + "a Bash call must not claim the human content staged before it began") + + runGit(t, dir, "commit", "-F", msgPath) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + withStdin(t, fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"git commit"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, + sessionID, toolUseID, + )) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + attr := store.LoadAILineAttribution(sessionID) + require.Contains(t, attr.Pending, "already-staged.txt", + "the unstaged AI change must remain pending for a later commit") +} + +func TestCommitLifecycle_BashCommitUsesStagedContentWhenWorkingFileIsGone(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "staged-content-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: "tool-staged-content"}, + "create, stage, remove, commit", + ) + + target := filepath.Join(dir, "staged-only.txt") + require.NoError(t, os.WriteFile(target, []byte("committed from the index\n"), 0600)) + runGit(t, dir, "add", "staged-only.txt") + require.NoError(t, os.Remove(target)) + + // When + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: commit staged-only content\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.Contains(t, string(msg), "Chainloop-Trace-Sessions: "+sessionID) + require.Contains(t, store.LoadAILineAttribution(sessionID).Pending, "staged-only.txt") +} + +func TestCommitLifecycle_BashCommitInLinkedCheckoutAddsSession(t *testing.T) { + // Given + home, homeGitDir := initGitRepo(t) + linked := filepath.Join(t.TempDir(), "linked") + runGit(t, home, "worktree", "add", "-b", "linked", linked) + t.Cleanup(func() { _ = os.Chdir(home) }) + + homeStore := state.NewGitStore(homeGitDir) + require.NoError(t, homeStore.InitTraceDir()) + + const sessionID = "linked-checkout-session" + require.NoError(t, homeStore.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Cwd: home, + Checkouts: []string{linked}, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-linked" + provider := beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, + "cd linked && edit, commit, push", + ) + + require.NoError(t, os.Chdir(linked)) + target := filepath.Join(linked, "linked.txt") + require.NoError(t, os.WriteFile(target, []byte("changed in linked checkout\n"), 0600)) + runGit(t, linked, "add", "linked.txt") + + // When + msgPath := filepath.Join(t.TempDir(), "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: linked same-call commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.Contains(t, string(msg), "Chainloop-Trace-Sessions: "+sessionID) + + runGit(t, linked, "commit", "-F", msgPath) + require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) + + require.NoError(t, os.Chdir(home)) + withStdin(t, fmt.Sprintf( + `{"session_id":%q,"tool_use_id":%q,"cwd":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"cd linked && edit, commit, push"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, + sessionID, toolUseID, home, + )) + require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) + + linkedStore, _, err := state.LocateFrom(linked) + require.NoError(t, err) + commits, err := linkedStore.LoadAllCommitRecords() + require.NoError(t, err) + require.Len(t, commits, 1) + require.Equal(t, []string{sessionID}, commits[0].SessionIDs) + require.Empty(t, linkedStore.LoadAILineAttribution(sessionID).Pending) +} + +func TestCommitLifecycle_StaleBashSnapshotDoesNotClaimLaterCommit(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "abandoned-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: "tool-abandoned"}, + "abandoned", + ) + t.Setenv(state.ShellCallEnv, "") + + target := filepath.Join(dir, "later.txt") + require.NoError(t, os.WriteFile(target, []byte("later human change\n"), 0600)) + runGit(t, dir, "add", "later.txt") + + // When + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: later commit\n"), 0600)) + require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) + + // Then + msg, err := os.ReadFile(msgPath) + require.NoError(t, err) + require.NotContains(t, string(msg), state.TrailerKey) +} diff --git a/app/cli/pkg/action/trace_bash_filter_test.go b/app/cli/pkg/action/trace_bash_filter_test.go new file mode 100644 index 000000000..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 720a80c29..79a032cb6 100644 --- a/app/cli/pkg/action/trace_hook_handler.go +++ b/app/cli/pkg/action/trace_hook_handler.go @@ -90,16 +90,21 @@ func handleCommitMsg(msgFilePath string, log zerolog.Logger) error { log.Debug().Strs("session_ids", sessionIDs).Msg("amending HEAD, keeping its trace sessions") } - attrs, err := state.NewRepoStore(gitDir, repoRoot).LoadAllAILineAttributions() + store := state.NewRepoStore(gitDir, repoRoot) + stagedFiles, err := client.StagedFiles(repoRoot) + if err != nil { + return fmt.Errorf("get staged files: %w", err) + } + + if err := checkpointActiveShellAttribution(store, repoRoot, stagedFiles); err != nil { + log.Debug().Err(err).Msg("commit-msg: checkpoint active shell attribution failed") + } + + attrs, err := store.LoadAllAILineAttributions() if err != nil { return fmt.Errorf("load AI line attributions: %w", err) } if len(attrs) > 0 { - stagedFiles, err := client.StagedFiles(repoRoot) - if err != nil { - return fmt.Errorf("get staged files: %w", err) - } - sessionIDs = mergeSessionIDs(sessionIDs, matchSessionsToFiles(attrs, stagedFiles)) } @@ -254,6 +259,10 @@ func handlePostCommit(_ context.Context, log zerolog.Logger) error { if err != nil { return fmt.Errorf("get commit info: %w", err) } + store := state.NewRepoStore(gitDir, repoRoot) + if err := advanceActiveShellHead(store, repoRoot, sha); err != nil { + log.Debug().Err(err).Msg("post-commit: advance active shell HEAD failed") + } // Skip merge commits: their content is already attributed in the // merged-in branch's commits, so recording them would double-count. @@ -269,8 +278,6 @@ func handlePostCommit(_ context.Context, log zerolog.Logger) error { // Prefer the trailer's session IDs when present — they survive rebase // and cherry-pick verbatim. Fall back to file-based derivation otherwise. - store := state.NewRepoStore(gitDir, repoRoot) - sessionIDs := state.ParseSessionIDsFromTrailer(message) if len(sessionIDs) == 0 { sessionIDs = deriveSessionIDsForCommit(client, store, repoRoot, sha) From 65f4468fed9f665ea6676ec7529aa177cc866765 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Fri, 16 Oct 2026 02:13:08 +0530 Subject: [PATCH 2/9] 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 f459e1ba0..970350de0 100644 --- a/app/cli/pkg/action/trace_bash_commit.go +++ b/app/cli/pkg/action/trace_bash_commit.go @@ -124,15 +124,16 @@ func checkpointActiveShellAttribution(store *state.Store, repoRoot string, stage } afterContent = content indexBefore[relPath] = hash + if _, hasUnstaged := unstaged[relPath]; !hasUnstaged { + if hash, exists := afterWorktree[relPath]; exists { + worktreeBefore[relPath] = hash + } else { + delete(worktreeBefore, relPath) + } + } } else { delete(indexBefore, relPath) - } - if _, hasUnstaged := unstaged[relPath]; !hasUnstaged { - if hash, exists := afterWorktree[relPath]; exists { - worktreeBefore[relPath] = hash - } else { - delete(worktreeBefore, relPath) - } + delete(worktreeBefore, relPath) } edit := attribution.DropOpen(attribution.ComputeEdit(beforeContent, afterContent), open[relPath]) 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 72219416a539ffe88ebd4ad0d9358cc416edf319 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Fri, 16 Oct 2026 12:46:52 +0530 Subject: [PATCH 3/9] 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 018d761bb206a5f2f1e233301cc88611e27c4035 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 00:30:42 +0530 Subject: [PATCH 4/9] 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 26737fd822af1e3c2c1bcfe9cd58c15be7f03a08 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 00:31:16 +0530 Subject: [PATCH 5/9] 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 f7cde0b24..3340e9a88 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -70,6 +70,7 @@ Which lines of a commit the AI wrote. | Partial commits | A commit that holds only part of the AI lines of a file credits the session with those lines. The other AI lines stay pending, and the next commit with them is credited to the session too (spec issue-3639). | Yes | Partial | Yes | Yes | No | Yes | | Generated line counts | The trace state keeps the lines that each agent edit added and removed, also the lines that never reach a commit. The evidence reports the totals of the session in `ai_generated_lines_added` and `ai_generated_lines_removed`. A session with no commit sends no evidence, so it reports no totals (spec issue-3639). | Yes | Partial | Yes | Yes | No | Yes | | Shell changes attribution | Changes that the agent makes with shell commands (for example `sed`) are attributed to the AI too, not only changes made with the file tools. | Partial | Partial | Partial | Partial | No | Partial | +| Same-call commit attribution | A commit made in the same shell tool call that changed and staged its files carries that session's trace trailer. | Yes | No | No | No | No | No | | Net contribution | The added lines of a session are the lines that its commits added and that are still in the commit that the push sends. A line that a later commit replaced does not count, and each line counts one time. The removed lines are the removals of the session's commits, less the lines that a commit of the session wrote. The line counts of each file are the sum of its AI and human lines, and a file that the session created stays created when a later commit of the session changes it (spec issue-3639, issue 3674). | Partial | Partial | Partial | Partial | No | Partial | | Session updates | A later commit that replaces lines of a session sends the evidence of the session again at the next push, with the new numbers. A commit in no session that replaces lines of a session joins it: the commit list of the session holds the commit, and its replacing lines count as human (spec issue-3639). | Partial | Partial | Partial | Partial | No | Partial | | Line ranges | The line ranges of each file are positions in the file at the commit that the push sends. They cover the lines of the session that are still there, and each range says if its lines are AI or human. The AI ranges hold as many lines as the net AI lines added, and the human ranges as many lines as the net human lines added. A file that the pushed commit no longer holds has no ranges. A range with no attribution is an AI range at edit time. Older CLIs write only these ranges. This CLI writes them for a file that it cannot blame at the pushed commit, and for a file with edits from before line fingerprints (spec issue-3639, line ranges). | Partial | Partial | Partial | Partial | No | Partial | @@ -82,6 +83,8 @@ Why not Yes: - **Session updates, all agents:** the update works only in the clone where the work happened, while the CLI keeps the trace state of the session (30 days), and only when the commits of the session are on the branch that the push sends. The push reads only the commits that are not on the default branch of the remote (`origin/HEAD`, or else `origin/main` or `origin/master`). After the commits of a session are pushed to that branch, a later commit does not update the session. - **Renames and rewrites, all agents:** this works only in the clone where the work happened. Git finds a rename only when the file keeps at least half of its content. Two pushed commits can rename a file one after the other. A later change to the file under its middle name does not send the session again. A rename inside an amend or a squash of the session commit loses the AI lines: the new commit creates the file under its new name, so no line of it matches the trace state (issue 3686). The limits of session updates apply too. For Cursor, the limits of line attribution apply too. - **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The file at the `HEAD` commit from before the command stands in for the content before the command. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI. +- **Same-call commit attribution, Claude Code:** supported for foreground Bash calls. A background Bash call can continue after its `PostToolUse` hook removes the open-call snapshot, so a later commit from that background process is not attributed. +- **Same-call commit attribution, Cursor, OpenCode, Pi and Codex:** these providers do not tag a shell command with the per-call token that git hooks use to load its open snapshot. ## User experience From 5be09a6479769cb02eb4e48576c86dbf81a71e7c Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 01:21:40 +0530 Subject: [PATCH 6/9] 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 3340e9a88..ea18a064d 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -70,7 +70,7 @@ Which lines of a commit the AI wrote. | Partial commits | A commit that holds only part of the AI lines of a file credits the session with those lines. The other AI lines stay pending, and the next commit with them is credited to the session too (spec issue-3639). | Yes | Partial | Yes | Yes | No | Yes | | Generated line counts | The trace state keeps the lines that each agent edit added and removed, also the lines that never reach a commit. The evidence reports the totals of the session in `ai_generated_lines_added` and `ai_generated_lines_removed`. A session with no commit sends no evidence, so it reports no totals (spec issue-3639). | Yes | Partial | Yes | Yes | No | Yes | | Shell changes attribution | Changes that the agent makes with shell commands (for example `sed`) are attributed to the AI too, not only changes made with the file tools. | Partial | Partial | Partial | Partial | No | Partial | -| Same-call commit attribution | A commit made in the same shell tool call that changed and staged its files carries that session's trace trailer. | Yes | No | No | 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 | No | No | | Net contribution | The added lines of a session are the lines that its commits added and that are still in the commit that the push sends. A line that a later commit replaced does not count, and each line counts one time. The removed lines are the removals of the session's commits, less the lines that a commit of the session wrote. The line counts of each file are the sum of its AI and human lines, and a file that the session created stays created when a later commit of the session changes it (spec issue-3639, issue 3674). | Partial | Partial | Partial | Partial | No | Partial | | Session updates | A later commit that replaces lines of a session sends the evidence of the session again at the next push, with the new numbers. A commit in no session that replaces lines of a session joins it: the commit list of the session holds the commit, and its replacing lines count as human (spec issue-3639). | Partial | Partial | Partial | Partial | No | Partial | | Line ranges | The line ranges of each file are positions in the file at the commit that the push sends. They cover the lines of the session that are still there, and each range says if its lines are AI or human. The AI ranges hold as many lines as the net AI lines added, and the human ranges as many lines as the net human lines added. A file that the pushed commit no longer holds has no ranges. A range with no attribution is an AI range at edit time. Older CLIs write only these ranges. This CLI writes them for a file that it cannot blame at the pushed commit, and for a file with edits from before line fingerprints (spec issue-3639, line ranges). | Partial | Partial | Partial | Partial | No | Partial | @@ -83,7 +83,7 @@ Why not Yes: - **Session updates, all agents:** the update works only in the clone where the work happened, while the CLI keeps the trace state of the session (30 days), and only when the commits of the session are on the branch that the push sends. The push reads only the commits that are not on the default branch of the remote (`origin/HEAD`, or else `origin/main` or `origin/master`). After the commits of a session are pushed to that branch, a later commit does not update the session. - **Renames and rewrites, all agents:** this works only in the clone where the work happened. Git finds a rename only when the file keeps at least half of its content. Two pushed commits can rename a file one after the other. A later change to the file under its middle name does not send the session again. A rename inside an amend or a squash of the session commit loses the AI lines: the new commit creates the file under its new name, so no line of it matches the trace state (issue 3686). The limits of session updates apply too. For Cursor, the limits of line attribution apply too. - **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The file at the `HEAD` commit from before the command stands in for the content before the command. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI. -- **Same-call commit attribution, Claude Code:** supported for foreground Bash calls. A background Bash call can continue after its `PostToolUse` hook removes the open-call snapshot, so a later commit from that background process is not attributed. +- **Same-call commit attribution, Claude Code:** supported for foreground Bash calls. A background Bash call can continue after its `PostToolUse` hook removes the open-call snapshot, so a later commit from that background process is not attributed. A custom clean filter that transforms file content is handled conservatively as unstaged and can requeue attribution after commit. - **Same-call commit attribution, Cursor, OpenCode, Pi and Codex:** these providers do not tag a shell command with the per-call token that git hooks use to load its open snapshot. ## User experience diff --git a/app/cli/internal/trace/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 d7c8b99c66be359e6fea23b65fea6d1bca3848ea Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 01:41:55 +0530 Subject: [PATCH 7/9] 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 f40bb26222acb1550c5a8b85b16f7d1f9cf1ba15 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 18 Oct 2026 01:56:12 +0530 Subject: [PATCH 8/9] 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") From 05779be1bd87fb9377202d48046cf81b103c578a Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Thu, 22 Oct 2026 17:43:31 +0530 Subject: [PATCH 9/9] fix(trace): narrow same-call attribution scope 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 | 92 ++++---- app/cli/internal/trace/git/index.go | 172 +-------------- app/cli/internal/trace/git/index_test.go | 81 +------ app/cli/internal/trace/git/worktree_index.go | 199 ------------------ .../internal/trace/git/worktree_index_test.go | 162 -------------- app/cli/internal/trace/state/snapshot.go | 32 --- app/cli/pkg/action/trace_agent_hook.go | 28 +-- app/cli/pkg/action/trace_bash_cleanup_test.go | 72 ------- app/cli/pkg/action/trace_bash_commit.go | 25 +-- app/cli/pkg/action/trace_bash_filter_test.go | 124 ----------- app/cli/pkg/action/trace_bash_pending_test.go | 86 ++++++++ app/cli/pkg/action/trace_bash_review_test.go | 36 ---- 14 files changed, 151 insertions(+), 1104 deletions(-) delete mode 100644 app/cli/internal/trace/git/attributes.go delete mode 100644 app/cli/internal/trace/git/worktree_index.go delete mode 100644 app/cli/internal/trace/git/worktree_index_test.go delete mode 100644 app/cli/pkg/action/trace_bash_cleanup_test.go delete mode 100644 app/cli/pkg/action/trace_bash_filter_test.go create mode 100644 app/cli/pkg/action/trace_bash_pending_test.go diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index ea18a064d..0e4fe79fe 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -82,8 +82,8 @@ Why not Yes: - **Net contribution and line ranges, all agents:** the push finds the last writer of each line with `git blame`. A later commit that only re-indents a line takes the line, because blame sees a change. For Cursor, the limits of line attribution apply too. - **Session updates, all agents:** the update works only in the clone where the work happened, while the CLI keeps the trace state of the session (30 days), and only when the commits of the session are on the branch that the push sends. The push reads only the commits that are not on the default branch of the remote (`origin/HEAD`, or else `origin/main` or `origin/master`). After the commits of a session are pushed to that branch, a later commit does not update the session. - **Renames and rewrites, all agents:** this works only in the clone where the work happened. Git finds a rename only when the file keeps at least half of its content. Two pushed commits can rename a file one after the other. A later change to the file under its middle name does not send the session again. A rename inside an amend or a squash of the session commit loses the AI lines: the new commit creates the file under its new name, so no line of it matches the trace state (issue 3686). The limits of session updates apply too. For Cursor, the limits of line attribution apply too. -- **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The file at the `HEAD` commit from before the command stands in for the content before the command. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI. -- **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. +- **Shell changes attribution, all agents:** the hook before a shell command records only a hash of each file, not its content. The file at the `HEAD` commit from before the command stands in for the content before the command. Uncommitted lines that an earlier agent edit recorded are not recorded again, but uncommitted human lines of a file that the command changes count as AI. Tracked files below ignored directories can be omitted from the snapshot ([issue 3700](https://github.com/chainloop-dev/chainloop/issues/3700)). +- **Same-call commit attribution, Claude Code:** supported for foreground Bash calls that use the repository's normal index. Temporary indexes such as `GIT_INDEX_FILE` and `git commit --only` are tracked in [issue 3698](https://github.com/chainloop-dev/chainloop/issues/3698). Worktree content is compared with the staged blob as raw bytes; Git attributes, line-ending normalization and clean filters are tracked in [issue 3699](https://github.com/chainloop-dev/chainloop/issues/3699). Background calls, competing input-rewriting hooks, narrow permissions and other providers are tracked in [issue 3701](https://github.com/chainloop-dev/chainloop/issues/3701). - **Same-call commit attribution, Cursor, OpenCode, Pi and Codex:** these providers do not tag a shell command with the per-call token that git hooks use to load its open snapshot. ## User experience diff --git a/app/cli/internal/trace/git/attributes.go b/app/cli/internal/trace/git/attributes.go deleted file mode 100644 index 11365c509..000000000 --- a/app/cli/internal/trace/git/attributes.go +++ /dev/null @@ -1,142 +0,0 @@ -// -// 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 0dd07ea9c..f7f41b0ee 100644 --- a/app/cli/internal/trace/git/gogit.go +++ b/app/cli/internal/trace/git/gogit.go @@ -18,12 +18,12 @@ package git import ( "crypto/sha256" "encoding/hex" - "errors" "fmt" "io" iofs "io/fs" "os" "path/filepath" + "sort" "strings" "time" @@ -266,6 +266,35 @@ func (c *GoGitClient) GeneratedMatcher(repoRoot string) func(path string) bool { } } +// StagedFiles returns repo-relative paths of files staged for the current commit. +func (c *GoGitClient) StagedFiles(repoRoot string) ([]string, error) { + repo, err := openRepo(repoRoot) + if err != nil { + return nil, fmt.Errorf("open repo: %w", err) + } + + wt, err := repo.Worktree() + if err != nil { + return nil, fmt.Errorf("get worktree: %w", err) + } + + status, err := wt.Status() + if err != nil { + return nil, fmt.Errorf("get status: %w", err) + } + + var files []string + for path, s := range status { + if s.Staging != gogit.Unmodified && s.Staging != gogit.Untracked { + files = append(files, path) + } + } + + sort.Strings(files) + + return files, nil +} + // SnapshotWorktree returns a signature of the working tree (repo-relative path // → SHA-256 content hash), honoring .gitignore and skipping the .git directory. func (c *GoGitClient) SnapshotWorktree(repoRoot string) (map[string]string, error) { @@ -310,7 +339,9 @@ 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. + // 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.) if matcher.Match(parts, false) && !tracked[slashRel] { return nil } @@ -323,25 +354,6 @@ func snapshotWorktree(repoRoot string) (map[string]string, error) { return nil }) - root, rootErr := os.OpenRoot(repoRoot) - if rootErr != nil { - return sig, errors.Join(err, fmt.Errorf("open worktree root: %w", rootErr)) - } - defer func() { _ = root.Close() }() - for path := range tracked { - if _, ok := sig[path]; ok { - continue - } - f, ok := openRegularRootFile(root, path) - if !ok { - continue - } - hash, hashErr := hashFileReader(f) - _ = f.Close() - if hashErr == nil { - sig[path] = hash - } - } return sig, err } @@ -354,44 +366,20 @@ func trackedPaths(repoRoot string) map[string]bool { if err != nil { return nil } - idx, err := loadEffectiveIndex(repo) + + idx, err := repo.Storer.Index() if err != nil { return nil } tracked := make(map[string]bool, len(idx.Entries)) - for _, entry := range idx.Entries { - if entry.IntentToAdd { - continue - } - tracked[entry.Name] = true + for _, e := range idx.Entries { + tracked[e.Name] = true } return tracked } -func openRegularRootFile(root *os.Root, path string) (*os.File, bool) { - parts := strings.Split(filepath.ToSlash(path), "/") - current := "" - for i, part := range parts { - current = filepath.Join(current, filepath.FromSlash(part)) - info, err := root.Lstat(current) - if err != nil || info.Mode()&os.ModeSymlink != 0 { - return nil, false - } - if i < len(parts)-1 { - if !info.IsDir() { - return nil, false - } - } else if !info.Mode().IsRegular() { - return nil, false - } - } - - f, err := root.Open(filepath.FromSlash(path)) - return f, err == nil -} - // hashFileContent returns the hex-encoded SHA-256 of the file's content. func hashFileContent(path string) (string, error) { f, err := os.Open(path) @@ -400,12 +388,8 @@ 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, r); err != nil { + if _, err := io.Copy(h, f); err != nil { return "", err } diff --git a/app/cli/internal/trace/git/index.go b/app/cli/internal/trace/git/index.go index b9f66feac..c8a452874 100644 --- a/app/cli/internal/trace/git/index.go +++ b/app/cli/internal/trace/git/index.go @@ -16,75 +16,12 @@ package git import ( - "errors" "fmt" "io" - "os" - "path/filepath" - "sort" - "strings" - gogit "github.com/go-git/go-git/v6" - "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/filemode" - formatcfg "github.com/go-git/go-git/v6/plumbing/format/config" - formatindex "github.com/go-git/go-git/v6/plumbing/format/index" - gogithash "github.com/go-git/go-git/v6/plumbing/hash" - "github.com/go-git/go-git/v6/plumbing/object" ) -// StagedFiles returns repo-relative paths changed between HEAD and the index -// used by the current git process. Git exposes temporary indexes (for example, -// during git commit --only) through GIT_INDEX_FILE. -func (c *GoGitClient) StagedFiles(repoRoot string) ([]string, error) { - repo, err := openRepo(repoRoot) - if err != nil { - return nil, fmt.Errorf("open repository: %w", err) - } - idx, err := loadEffectiveIndex(repo) - if err != nil { - return nil, err - } - - head, err := headEntries(repo) - if err != nil { - return nil, err - } - - staged := make(map[string]struct{}) - current := make(map[string]*formatindex.Entry) - for _, entry := range idx.Entries { - if entry.IntentToAdd { - continue - } - if entry.Stage != 0 { - staged[entry.Name] = struct{}{} - continue - } - current[entry.Name] = entry - } - - for path, entry := range current { - before, ok := head[path] - if !ok || before.hash != entry.Hash || before.mode != entry.Mode { - staged[path] = struct{}{} - } - } - for path := range head { - if _, ok := current[path]; !ok { - staged[path] = struct{}{} - } - } - - files := make([]string, 0, len(staged)) - for path := range staged { - files = append(files, path) - } - sort.Strings(files) - - return files, nil -} - // SnapshotIndex returns the content signature currently staged in git's index. // It excludes symlinks and submodules to match SnapshotWorktree's file scope. func (c *GoGitClient) SnapshotIndex(repoRoot string) (map[string]string, error) { @@ -92,23 +29,17 @@ func (c *GoGitClient) SnapshotIndex(repoRoot string) (map[string]string, error) if err != nil { return nil, fmt.Errorf("open repository: %w", err) } - idx, err := loadEffectiveIndex(repo) + idx, err := repo.Storer.Index() if err != nil { - return nil, err + return nil, fmt.Errorf("load index: %w", err) } - parts := make(map[string][]string, len(idx.Entries)) + sig := make(map[string]string, len(idx.Entries)) for _, entry := range idx.Entries { - if !entry.Mode.IsFile() || entry.Mode == filemode.Symlink { + if entry.Stage != 0 || !entry.Mode.IsFile() || entry.Mode == filemode.Symlink { continue } - parts[entry.Name] = append(parts[entry.Name], fmt.Sprintf("%d:%s", entry.Stage, entry.Hash)) - } - - sig := make(map[string]string, len(parts)) - for path, stages := range parts { - sort.Strings(stages) - sig[path] = strings.Join(stages, ",") + sig[entry.Name] = entry.Hash.String() } return sig, nil @@ -120,9 +51,9 @@ func (c *GoGitClient) IndexFile(repoRoot, path string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("open repository: %w", err) } - idx, err := loadEffectiveIndex(repo) + idx, err := repo.Storer.Index() if err != nil { - return nil, err + return nil, fmt.Errorf("load index: %w", err) } for _, entry := range idx.Entries { @@ -150,92 +81,3 @@ func (c *GoGitClient) IndexFile(repoRoot, path string) ([]byte, error) { return nil, fmt.Errorf("staged file %s not found", path) } - -type treeEntry struct { - hash plumbing.Hash - mode filemode.FileMode -} - -func headEntries(repo *gogit.Repository) (map[string]treeEntry, error) { - entries := make(map[string]treeEntry) - ref, err := repo.Head() - if errors.Is(err, plumbing.ErrReferenceNotFound) { - return entries, nil - } - if err != nil { - return nil, fmt.Errorf("load HEAD: %w", err) - } - commit, err := repo.CommitObject(ref.Hash()) - if err != nil { - return nil, fmt.Errorf("load HEAD commit: %w", err) - } - tree, err := commit.Tree() - if err != nil { - return nil, fmt.Errorf("load HEAD tree: %w", err) - } - - walker := object.NewTreeWalker(tree, true, nil) - defer walker.Close() - for { - name, entry, err := walker.Next() - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return nil, fmt.Errorf("walk HEAD tree: %w", err) - } - if entry.Mode == filemode.Dir { - continue - } - entries[name] = treeEntry{hash: entry.Hash, mode: entry.Mode} - } - - return entries, nil -} - -func loadEffectiveIndex(repo *gogit.Repository) (*formatindex.Index, error) { - path := os.Getenv("GIT_INDEX_FILE") - if path == "" { - idx, err := repo.Storer.Index() - if err != nil { - return nil, fmt.Errorf("load index: %w", err) - } - - return idx, nil - } - - root, err := os.OpenRoot(filepath.Dir(path)) - if err != nil { - return nil, fmt.Errorf("open effective index directory: %w", err) - } - defer func() { _ = root.Close() }() - - f, err := root.Open(filepath.Base(path)) - if err != nil { - return nil, fmt.Errorf("open effective index: %w", err) - } - defer func() { _ = f.Close() }() - - cfg, err := repo.Config() - if err != nil { - return nil, fmt.Errorf("load repository config: %w", err) - } - objectFormat := cfg.Extensions.ObjectFormat - if objectFormat == formatcfg.UnsetObjectFormat { - objectFormat = formatcfg.DefaultObjectFormat - } - hasher, err := gogithash.FromObjectFormat(objectFormat) - if err != nil { - return nil, fmt.Errorf("select index hash: %w", err) - } - - idx := &formatindex.Index{} - if err := formatindex.NewDecoder(f, hasher).Decode(idx); err != nil { - return nil, fmt.Errorf("decode effective index: %w", err) - } - if stat, err := f.Stat(); err == nil { - idx.ModTime = stat.ModTime() - } - - return idx, nil -} diff --git a/app/cli/internal/trace/git/index_test.go b/app/cli/internal/trace/git/index_test.go index 7c7d69792..dccfaffbf 100644 --- a/app/cli/internal/trace/git/index_test.go +++ b/app/cli/internal/trace/git/index_test.go @@ -16,7 +16,6 @@ package git import ( - "fmt" "os" "os/exec" "path/filepath" @@ -26,67 +25,23 @@ import ( "github.com/stretchr/testify/require" ) -func TestGoGitClient_SnapshotIndex_ResolutionChangesSignature(t *testing.T) { +func TestGoGitClient_SnapshotIndexAndIndexFile_ReadStagedContent(t *testing.T) { // Given dir := t.TempDir() runIndexGit(t, dir, "init", "-q") - runIndexGit(t, dir, "read-tree", "--empty") - base := writeGitObject(t, dir, "base\n") - ours := writeGitObject(t, dir, "ours\n") - theirs := writeGitObject(t, dir, "theirs\n") - writeGitIndex(t, dir, fmt.Sprintf( - "100644 %s 1\tconflict.txt\n100644 %s 2\tconflict.txt\n100644 %s 3\tconflict.txt\n", - base, ours, theirs, - )) + require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("staged content\n"), 0o600)) + runIndexGit(t, dir, "add", "staged.txt") + // When client := NewGoGitClient() - before, err := client.SnapshotIndex(dir) + snapshot, err := client.SnapshotIndex(dir) require.NoError(t, err) - - runIndexGit(t, dir, "read-tree", "--empty") - writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tconflict.txt\n", theirs)) - - // When - after, err := client.SnapshotIndex(dir) + content, err := client.IndexFile(dir, "staged.txt") require.NoError(t, err) // Then - require.Contains(t, before, "conflict.txt") - require.Contains(t, after, "conflict.txt") - require.NotEqual(t, before["conflict.txt"], after["conflict.txt"], - "resolving to an existing conflict stage must change the index signature") -} - -func TestGoGitClient_IndexFile_IgnoresReplacementObjects(t *testing.T) { - // Given - dir := t.TempDir() - runIndexGit(t, dir, "init", "-q") - runIndexGit(t, dir, "read-tree", "--empty") - indexed := writeGitObject(t, dir, "indexed content\n") - replacement := writeGitObject(t, dir, "replacement content\n") - writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tindexed.txt\n", indexed)) - runIndexGit(t, dir, "replace", indexed, replacement) - - // When - content, err := NewGoGitClient().IndexFile(dir, "indexed.txt") - require.NoError(t, err) - - // Then - require.Equal(t, []byte("indexed content\n"), content, - "staged content must come from the exact object recorded in the index") -} - -func TestGoGitClient_StagedFiles_ExcludesIntentToAdd(t *testing.T) { - dir := t.TempDir() - runIndexGit(t, dir, "init", "-q") - require.NoError(t, os.WriteFile(filepath.Join(dir, "pending.txt"), []byte("pending\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "staged.txt"), nil, 0o600)) - runIndexGit(t, dir, "add", "-N", "pending.txt") - runIndexGit(t, dir, "add", "staged.txt") - - files, err := NewGoGitClient().StagedFiles(dir) - require.NoError(t, err) - require.Equal(t, []string{"staged.txt"}, files) + require.Contains(t, snapshot, "staged.txt") + require.Equal(t, []byte("staged content\n"), content) } func runIndexGit(t *testing.T, dir string, args ...string) string { @@ -98,23 +53,3 @@ func runIndexGit(t *testing.T, dir string, args ...string) string { return strings.TrimSpace(string(out)) } - -func writeGitIndex(t *testing.T, dir, entries string) { - t.Helper() - cmd := exec.Command("git", "update-index", "--index-info") - cmd.Dir = dir - cmd.Stdin = strings.NewReader(entries) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git update-index: %s", string(out)) -} - -func writeGitObject(t *testing.T, dir, content string) string { - t.Helper() - cmd := exec.Command("git", "hash-object", "-w", "--stdin") - cmd.Dir = dir - cmd.Stdin = strings.NewReader(content) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git hash-object: %s", string(out)) - - return strings.TrimSpace(string(out)) -} diff --git a/app/cli/internal/trace/git/worktree_index.go b/app/cli/internal/trace/git/worktree_index.go deleted file mode 100644 index 3b3b81217..000000000 --- a/app/cli/internal/trace/git/worktree_index.go +++ /dev/null @@ -1,199 +0,0 @@ -// -// Copyright 2026 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package git - -import ( - "bytes" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - gogit "github.com/go-git/go-git/v6" - gogitconfig "github.com/go-git/go-git/v6/config" - "github.com/go-git/go-git/v6/plumbing/filemode" - "github.com/go-git/go-git/v6/plumbing/format/gitattributes" - formatindex "github.com/go-git/go-git/v6/plumbing/format/index" - "github.com/go-git/go-git/v6/utils/convert" -) - -// UnstagedFiles returns regular-file paths whose working-tree content differs -// from the effective index. Paths are repo-relative and slash-separated. -func (c *GoGitClient) UnstagedFiles(repoRoot string) (map[string]struct{}, error) { - repo, err := openRepo(repoRoot) - if err != nil { - return nil, fmt.Errorf("open repository: %w", err) - } - idx, err := loadEffectiveIndex(repo) - if err != nil { - return nil, err - } - cfg, err := repo.Config() - if err != nil { - return nil, fmt.Errorf("load repository config: %w", err) - } - worktree, err := snapshotWorktree(repoRoot) - if err != nil { - return nil, fmt.Errorf("snapshot worktree: %w", err) - } - - indexed := make(map[string]*formatindex.Entry) - for _, entry := range idx.Entries { - if entry.Stage == 0 && !entry.IntentToAdd && entry.Mode.IsFile() && entry.Mode != filemode.Symlink { - indexed[entry.Name] = entry - } - } - attributes, err := attributeMatcher(repo, repoRoot, indexed) - if err != nil { - return nil, fmt.Errorf("load git attributes: %w", err) - } - - paths := make(map[string]struct{}) - for path := range worktree { - entry, ok := indexed[path] - if !ok { - paths[path] = struct{}{} - continue - } - same, err := worktreeMatchesIndex(repoRoot, path, entry, repo, cfg, attributes) - if err != nil { - return nil, err - } - if !same { - paths[path] = struct{}{} - } - } - for path := range indexed { - if _, ok := worktree[path]; !ok { - paths[path] = struct{}{} - } - } - - return paths, nil -} - -func worktreeMatchesIndex( - repoRoot, path string, - entry *formatindex.Entry, - repo *gogit.Repository, - cfg *gogitconfig.Config, - attributes gitattributes.Matcher, -) (bool, error) { - fullPath := filepath.Join(repoRoot, filepath.FromSlash(path)) - info, err := os.Stat(fullPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - return false, fmt.Errorf("stat worktree file %s: %w", path, err) - } - if cfg.Core.FileMode { - mode, err := filemode.NewFromOSFileMode(info.Mode()) - if err != nil { - return false, fmt.Errorf("read worktree mode %s: %w", path, err) - } - if mode != entry.Mode { - return false, nil - } - } - - content, err := os.ReadFile(fullPath) - if err != nil { - return false, fmt.Errorf("read worktree file %s: %w", path, err) - } - - blob, err := repo.BlobObject(entry.Hash) - if err != nil { - return false, fmt.Errorf("load index blob %s: %w", path, err) - } - reader, err := blob.Reader() - if err != nil { - return false, fmt.Errorf("read index blob %s: %w", path, err) - } - defer func() { _ = reader.Close() }() - staged, err := io.ReadAll(reader) - if err != nil { - return false, fmt.Errorf("read index content %s: %w", path, err) - } - content, canCompare := cleanWorktreeContent(path, content, cfg, attributes) - if !canCompare { - return false, nil - } - - return bytes.Equal(content, staged), nil -} - -func cleanWorktreeContent( - path string, - content []byte, - cfg *gogitconfig.Config, - matcher gitattributes.Matcher, -) ([]byte, bool) { - result, _ := matcher.Match(strings.Split(filepath.ToSlash(path), "/"), nil) - if filter, ok := result["filter"]; ok && !filter.IsUnset() && !noOpCleanFilter(filter, cfg) { - // Reproducing an arbitrary configured filter driver would execute - // repository code, so transformed content stays conservatively dirty. - return nil, false - } - - normalize := cfg.Core.AutoCRLF == "true" || cfg.Core.AutoCRLF == "input" - forceText := false - if text, ok := result["text"]; ok { - switch { - case text.IsUnset(): - normalize = false - case text.IsSet(): - normalize, forceText = true, true - case text.IsValueSet() && strings.EqualFold(text.Value(), "auto"): - normalize = true - } - } - if eol, ok := result["eol"]; ok && !eol.IsUnset() { - normalize, forceText = true, true - } - if !normalize { - return content, true - } - - stat, err := convert.GetStat(bytes.NewReader(content)) - if err != nil || stat.IsBinary() && !forceText { - return content, true - } - var cleaned bytes.Buffer - if _, err := io.Copy(convert.NewLFWriter(&cleaned), bytes.NewReader(content)); err != nil { - return nil, false - } - - return cleaned.Bytes(), true -} - -func noOpCleanFilter(filter gitattributes.Attribute, cfg *gogitconfig.Config) bool { - if !filter.IsValueSet() { - return false - } - section := cfg.Raw.Section("filter") - if !section.HasSubsection(filter.Value()) { - return false - } - driver := section.Subsection(filter.Value()) - if strings.TrimSpace(driver.Options.Get("process")) != "" { - return false - } - clean := strings.TrimSpace(driver.Options.Get("clean")) - return clean == "" || clean == "cat" -} diff --git a/app/cli/internal/trace/git/worktree_index_test.go b/app/cli/internal/trace/git/worktree_index_test.go deleted file mode 100644 index 034d4ca50..000000000 --- a/app/cli/internal/trace/git/worktree_index_test.go +++ /dev/null @@ -1,162 +0,0 @@ -// -// Copyright 2026 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package git - -import ( - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestGoGitClient_UnstagedFiles_HonorsAttributeSources(t *testing.T) { - for _, source := range []string{"global", "local", "info"} { - t.Run(source, func(t *testing.T) { - dir := t.TempDir() - home := t.TempDir() - t.Setenv("HOME", home) - runIndexGit(t, dir, "init", "-q") - - switch source { - case "global": - attributes := filepath.Join(home, "attributes") - require.NoError(t, os.WriteFile(attributes, []byte("file.txt text eol=lf\n"), 0o600)) - config := "[core]\n\tattributesFile = ~/attributes\n" - require.NoError(t, os.WriteFile(filepath.Join(home, ".gitconfig"), []byte(config), 0o600)) - case "local": - globalAttributes := filepath.Join(home, "global-attributes") - require.NoError(t, os.WriteFile(globalAttributes, []byte("file.txt -text\n"), 0o600)) - globalConfig := fmt.Sprintf("[core]\n\tattributesFile = %s\n", globalAttributes) - require.NoError(t, os.WriteFile(filepath.Join(home, ".gitconfig"), []byte(globalConfig), 0o600)) - - localAttributes := filepath.Join(dir, "local-attributes") - require.NoError(t, os.WriteFile(localAttributes, []byte("file.txt text eol=lf\n"), 0o600)) - runIndexGit(t, dir, "config", "core.attributesFile", localAttributes) - case "info": - require.NoError(t, os.WriteFile( - filepath.Join(dir, ".git", "info", "attributes"), - []byte("file.txt text eol=lf\n"), - 0o600, - )) - } - - require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("line\r\n"), 0o600)) - runIndexGit(t, dir, "add", "file.txt") - - content, err := NewGoGitClient().IndexFile(dir, "file.txt") - require.NoError(t, err) - require.Equal(t, []byte("line\n"), content) - - unstaged, err := NewGoGitClient().UnstagedFiles(dir) - require.NoError(t, err) - require.NotContains(t, unstaged, "file.txt") - }) - } -} - -func TestGoGitClient_UnstagedFiles_MissingNestedAttributes(t *testing.T) { - dir := t.TempDir() - runIndexGit(t, dir, "init", "-q") - require.NoError(t, os.MkdirAll(filepath.Join(dir, "nested"), 0o700)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "nested", "file.txt"), []byte("content\n"), 0o600)) - runIndexGit(t, dir, "add", "nested/file.txt") - - unstaged, err := NewGoGitClient().UnstagedFiles(dir) - require.NoError(t, err) - require.NotContains(t, unstaged, "nested/file.txt") -} - -func TestGoGitClient_UnstagedFiles_TrackedFileInIgnoredDirectory(t *testing.T) { - dir := t.TempDir() - runIndexGit(t, dir, "init", "-q") - require.NoError(t, os.MkdirAll(filepath.Join(dir, "build"), 0o700)) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("build/\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "tracked.txt"), []byte("tracked\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "untracked.txt"), []byte("untracked\n"), 0o600)) - runIndexGit(t, dir, "add", ".gitignore") - runIndexGit(t, dir, "add", "-f", "build/tracked.txt") - - snapshot, err := NewGoGitClient().SnapshotWorktree(dir) - require.NoError(t, err) - require.Contains(t, snapshot, "build/tracked.txt") - require.NotContains(t, snapshot, "build/untracked.txt") - - unstaged, err := NewGoGitClient().UnstagedFiles(dir) - require.NoError(t, err) - require.NotContains(t, unstaged, "build/tracked.txt") - - require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "tracked.txt"), []byte("changed\n"), 0o600)) - unstaged, err = NewGoGitClient().UnstagedFiles(dir) - require.NoError(t, err) - require.Contains(t, unstaged, "build/tracked.txt") -} - -func TestGoGitClient_SnapshotWorktree_ExcludesIgnoredIntentToAdd(t *testing.T) { - dir := t.TempDir() - runIndexGit(t, dir, "init", "-q") - require.NoError(t, os.MkdirAll(filepath.Join(dir, "build"), 0o700)) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("build/\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "build", "pending.txt"), []byte("pending\n"), 0o600)) - runIndexGit(t, dir, "add", ".gitignore") - runIndexGit(t, dir, "add", "-N", "-f", "build/pending.txt") - - snapshot, err := NewGoGitClient().SnapshotWorktree(dir) - require.NoError(t, err) - require.NotContains(t, snapshot, "build/pending.txt") -} - -func TestGoGitClient_SnapshotWorktree_DoesNotFollowEscapingSymlink(t *testing.T) { - dir := t.TempDir() - outside := t.TempDir() - runIndexGit(t, dir, "init", "-q") - require.NoError(t, os.WriteFile(filepath.Join(outside, "outside.txt"), []byte("outside\n"), 0o600)) - require.NoError(t, os.Symlink(outside, filepath.Join(dir, "link"))) - blob := writeGitObject(t, dir, "outside\n") - writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tlink/outside.txt\n", blob)) - - snapshot, err := NewGoGitClient().SnapshotWorktree(dir) - require.NoError(t, err) - require.NotContains(t, snapshot, "link/outside.txt") -} - -func TestGoGitClient_SnapshotWorktree_DoesNotFollowInRootSymlink(t *testing.T) { - dir := t.TempDir() - runIndexGit(t, dir, "init", "-q") - require.NoError(t, os.MkdirAll(filepath.Join(dir, "target"), 0o700)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "target", "file.txt"), []byte("inside\n"), 0o600)) - require.NoError(t, os.Symlink("target", filepath.Join(dir, "link"))) - blob := writeGitObject(t, dir, "inside\n") - writeGitIndex(t, dir, fmt.Sprintf("100644 %s 0\tlink/file.txt\n", blob)) - - snapshot, err := NewGoGitClient().SnapshotWorktree(dir) - require.NoError(t, err) - require.NotContains(t, snapshot, "link/file.txt") -} - -func TestGoGitClient_UnstagedFiles_ChangedFilterMarksRawEqualityDirty(t *testing.T) { - dir := t.TempDir() - runIndexGit(t, dir, "init", "-q") - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("filtered.txt filter=mutate\n"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "filtered.txt"), []byte("same bytes\n"), 0o600)) - runIndexGit(t, dir, "add", ".gitattributes", "filtered.txt") - runIndexGit(t, dir, "config", "filter.mutate.clean", "sed 's/^/clean:/'") - - unstaged, err := NewGoGitClient().UnstagedFiles(dir) - require.NoError(t, err) - require.Contains(t, unstaged, "filtered.txt") -} diff --git a/app/cli/internal/trace/state/snapshot.go b/app/cli/internal/trace/state/snapshot.go index 8b3cdc79f..a5de7bc1f 100644 --- a/app/cli/internal/trace/state/snapshot.go +++ b/app/cli/internal/trace/state/snapshot.go @@ -20,7 +20,6 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -192,37 +191,6 @@ func (s *Store) LoadShellPreIndexSignature(key ShellCallKey) (WorktreeSignatures return sig, nil } -// DeleteShellSnapshots removes every in-progress shell snapshot for a session -// while preserving file-edit snapshots in the same directory. -func (s *Store) DeleteShellSnapshots(sessionID string) error { - dir := filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(sessionID)) - entries, err := os.ReadDir(dir) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - - return fmt.Errorf("read shell snapshots: %w", err) - } - - var errs []error - for _, entry := range entries { - if entry.IsDir() || !isShellSnapshotName(entry.Name()) { - continue - } - if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} - -func isShellSnapshotName(name string) bool { - return filepath.Ext(name) == commitRecordExt && - (strings.HasPrefix(name, "shell-pre") || strings.HasPrefix(name, "shell-index")) -} - // DeleteShellPreSignature removes the pre-command signature, and the HEAD // commit and index signature saved with it, once processed. func (s *Store) DeleteShellPreSignature(key ShellCallKey) { diff --git a/app/cli/pkg/action/trace_agent_hook.go b/app/cli/pkg/action/trace_agent_hook.go index 9ae4b8b11..6a316c141 100644 --- a/app/cli/pkg/action/trace_agent_hook.go +++ b/app/cli/pkg/action/trace_agent_hook.go @@ -70,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) - cleanupSessionShellState(store, sessionID, log) + setSessionActive(store, sessionID, false, 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 @@ -113,32 +113,6 @@ 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, diff --git a/app/cli/pkg/action/trace_bash_cleanup_test.go b/app/cli/pkg/action/trace_bash_cleanup_test.go deleted file mode 100644 index 610caa9fd..000000000 --- a/app/cli/pkg/action/trace_bash_cleanup_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// -// 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 index 970350de0..cdf0ed109 100644 --- a/app/cli/pkg/action/trace_bash_commit.go +++ b/app/cli/pkg/action/trace_bash_commit.go @@ -16,6 +16,7 @@ package action import ( + "crypto/sha256" "errors" "fmt" "os" @@ -78,15 +79,6 @@ func checkpointActiveShellAttribution(store *state.Store, repoRoot string, stage 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{}{} @@ -124,15 +116,16 @@ func checkpointActiveShellAttribution(store *state.Store, repoRoot string, stage } afterContent = content indexBefore[relPath] = hash - if _, hasUnstaged := unstaged[relPath]; !hasUnstaged { - if hash, exists := afterWorktree[relPath]; exists { - worktreeBefore[relPath] = hash - } else { - delete(worktreeBefore, relPath) - } - } + // SnapshotWorktree also hashes raw bytes with SHA-256. + // Checkpointing to the staged blob makes PostToolUse retain + // any different or missing worktree content, including when + // the call restored the exact bytes from before it started. + worktreeBefore[relPath] = fmt.Sprintf("%x", sha256.Sum256(afterContent)) } else { delete(indexBefore, relPath) + // The staged content is absence. Checkpoint the worktree to the + // same state so PostToolUse records any recreated path, even + // when its bytes equal the file from before this call. delete(worktreeBefore, relPath) } diff --git a/app/cli/pkg/action/trace_bash_filter_test.go b/app/cli/pkg/action/trace_bash_filter_test.go deleted file mode 100644 index dda565ab5..000000000 --- a/app/cli/pkg/action/trace_bash_filter_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// -// Copyright 2026 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package action - -import ( - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/chainloop-dev/chainloop/app/cli/internal/trace/state" - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" -) - -func TestCommitLifecycle_BashCommitWithCleanFilterDoesNotRequeueAttribution(t *testing.T) { - // Given - dir, gitDir := initGitRepo(t) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("filtered.txt filter=identity\n"), 0600)) - runGit(t, dir, "config", "filter.identity.clean", "cat") - runGit(t, dir, "config", "filter.identity.smudge", "cat") - runGit(t, dir, "add", ".gitattributes") - runGit(t, dir, "commit", "-m", "chore: add attributes") - - store := state.NewGitStore(gitDir) - require.NoError(t, store.InitTraceDir()) - - const sessionID = "filtered-content-session" - require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ - SessionID: sessionID, - Provider: providerClaudeCode, - Active: true, - StartedAt: state.NowTimestamp(), - })) - - const toolUseID = "tool-filtered-content" - provider := beginTaggedBashCall(t, - state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, - "create filtered content, commit, push", - ) - - target := filepath.Join(dir, "filtered.txt") - require.NoError(t, os.WriteFile(target, []byte("first\nsecond\n"), 0600)) - runGit(t, dir, "add", "filtered.txt") - - msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") - require.NoError(t, os.WriteFile(msgPath, []byte("feat: filtered same-call commit\n"), 0600)) - require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) - runGit(t, dir, "commit", "-F", msgPath) - require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) - - // When - withStdin(t, fmt.Sprintf( - `{"session_id":%q,"tool_use_id":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"create filtered content, commit, push"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, - sessionID, toolUseID, - )) - require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) - - // Then - require.Empty(t, store.LoadAILineAttribution(sessionID).Pending, - "a clean filtered working file must not be recorded again after commit") -} - -func TestCommitLifecycle_BashCommitWithBinaryCRLFKeepsUnstagedAttribution(t *testing.T) { - // Given - dir, gitDir := initGitRepo(t) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitattributes"), []byte("binary.txt -text\n"), 0600)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "binary.txt"), []byte("before\n"), 0600)) - runGit(t, dir, "config", "core.autocrlf", "true") - runGit(t, dir, "add", ".gitattributes", "binary.txt") - runGit(t, dir, "commit", "-m", "chore: add binary content") - - store := state.NewGitStore(gitDir) - require.NoError(t, store.InitTraceDir()) - - const sessionID = "binary-crlf-session" - require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ - SessionID: sessionID, - Provider: providerClaudeCode, - Active: true, - StartedAt: state.NowTimestamp(), - })) - - const toolUseID = "tool-binary-crlf" - provider := beginTaggedBashCall(t, - state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, - "stage LF content, leave CRLF content unstaged, commit", - ) - - target := filepath.Join(dir, "binary.txt") - require.NoError(t, os.WriteFile(target, []byte("after\n"), 0600)) - runGit(t, dir, "add", "binary.txt") - require.NoError(t, os.WriteFile(target, []byte("after\r\n"), 0600)) - - msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") - require.NoError(t, os.WriteFile(msgPath, []byte("feat: binary same-call commit\n"), 0600)) - require.NoError(t, handleCommitMsg(msgPath, zerolog.Nop())) - runGit(t, dir, "commit", "-F", msgPath) - require.NoError(t, handlePostCommit(t.Context(), zerolog.Nop())) - - // When - withStdin(t, fmt.Sprintf( - `{"session_id":%q,"tool_use_id":%q,"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"stage LF content, leave CRLF content unstaged, commit"},"tool_response":{"stdout":"","stderr":"","interrupted":false}}`, - sessionID, toolUseID, - )) - require.NoError(t, HandleAgentPostToolUse(provider, zerolog.Nop())) - - // Then - require.Contains(t, store.LoadAILineAttribution(sessionID).Pending, "binary.txt", - "real unstaged CRLF content must remain pending after the staged LF content is committed") -} diff --git a/app/cli/pkg/action/trace_bash_pending_test.go b/app/cli/pkg/action/trace_bash_pending_test.go new file mode 100644 index 000000000..67216cbdd --- /dev/null +++ b/app/cli/pkg/action/trace_bash_pending_test.go @@ -0,0 +1,86 @@ +// +// 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_BashCommitKeepsDifferentWorktreeContentPending(t *testing.T) { + tests := []struct { + name string + worktreeContent string + }{ + {name: "different content", worktreeContent: "unstaged\n"}, + {name: "restored pre-call content", worktreeContent: "before\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Given + dir, gitDir := initGitRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pending.txt"), []byte("before\n"), 0600)) + runGit(t, dir, "add", "pending.txt") + runGit(t, dir, "commit", "-m", "chore: add pending fixture") + + store := state.NewGitStore(gitDir) + require.NoError(t, store.InitTraceDir()) + + const sessionID = "different-worktree-session" + require.NoError(t, store.SaveSessionRecord(&state.SessionRecord{ + SessionID: sessionID, + Provider: providerClaudeCode, + Active: true, + StartedAt: state.NowTimestamp(), + })) + + const toolUseID = "tool-different-worktree" + provider := beginTaggedBashCall(t, + state.ShellCallKey{SessionID: sessionID, ToolUseID: toolUseID}, + "stage content, leave different content unstaged, commit", + ) + + target := filepath.Join(dir, "pending.txt") + require.NoError(t, os.WriteFile(target, []byte("staged\n"), 0600)) + runGit(t, dir, "add", "pending.txt") + require.NoError(t, os.WriteFile(target, []byte(tt.worktreeContent), 0600)) + + msgPath := filepath.Join(gitDir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgPath, []byte("feat: same-call staged content\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 content, leave different 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, "pending.txt", + "different worktree content must remain pending after staged content is committed") + }) + } +} diff --git a/app/cli/pkg/action/trace_bash_review_test.go b/app/cli/pkg/action/trace_bash_review_test.go index 05d1ca7b0..f1a24c4c1 100644 --- a/app/cli/pkg/action/trace_bash_review_test.go +++ b/app/cli/pkg/action/trace_bash_review_test.go @@ -26,42 +26,6 @@ import ( "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)