From 1b3d7a70d6d69fdf2646db3221b5db3058173c28 Mon Sep 17 00:00:00 2001 From: yasunogithub Date: Wed, 18 Feb 2026 18:56:06 +0900 Subject: [PATCH 1/3] perf: optimize stop hook - unify transcript parsing and incremental tree updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two main optimizations to reduce stop hook latency: 1. Transcript parsing 3x → 1x: commitWithMetadata() was parsing the transcript file three separate times (prompt extraction, modified files, token usage). Added FromLines variants (ExtractAllModifiedFilesFromLines, CalculateTotalTokenUsageFromLines) that accept pre-parsed lines, eliminating redundant file I/O and JSON parsing. 2. Incremental git tree updates: buildTreeWithChanges() and addTaskMetadataToTree() previously flattened the entire git tree into a map (O(N) where N=total files), modified entries, then rebuilt from scratch. Replaced with applyChangesToTree() which only traverses directories containing changes - unchanged subtrees are reused by hash reference. Complexity is now O(M*D) where M=changed files and D=tree depth. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: ce934f044ff2 --- cmd/entire/cli/agent/claudecode/transcript.go | 15 ++ cmd/entire/cli/checkpoint/temporary.go | 90 +++---- cmd/entire/cli/checkpoint/tree_incremental.go | 230 ++++++++++++++++++ 3 files changed, 273 insertions(+), 62 deletions(-) create mode 100644 cmd/entire/cli/checkpoint/tree_incremental.go diff --git a/cmd/entire/cli/agent/claudecode/transcript.go b/cmd/entire/cli/agent/claudecode/transcript.go index 6248a9383..b0571e2d8 100644 --- a/cmd/entire/cli/agent/claudecode/transcript.go +++ b/cmd/entire/cli/agent/claudecode/transcript.go @@ -339,6 +339,13 @@ func CalculateTotalTokenUsage(transcriptPath string, startLine int, subagentsDir return nil, fmt.Errorf("failed to parse transcript: %w", err) } + return CalculateTotalTokenUsageFromLines(parsed, subagentsDir) +} + +// CalculateTotalTokenUsageFromLines calculates token usage from pre-parsed transcript lines, +// including subagents. This avoids re-parsing the transcript file when lines have already been parsed. +// Subagent transcripts are still read from subagentsDir. +func CalculateTotalTokenUsageFromLines(parsed []TranscriptLine, subagentsDir string) (*agent.TokenUsage, error) { // Calculate token usage from parsed transcript mainUsage := CalculateTokenUsage(parsed) @@ -385,6 +392,14 @@ func ExtractAllModifiedFiles(transcriptPath string, startLine int, subagentsDir return nil, fmt.Errorf("failed to parse transcript: %w", err) } + return ExtractAllModifiedFilesFromLines(parsed, subagentsDir) +} + +// ExtractAllModifiedFilesFromLines extracts files modified by both the main agent and +// any subagents spawned via the Task tool, using pre-parsed transcript lines. +// This avoids re-parsing the transcript file when lines have already been parsed. +// Subagent transcripts are still read from subagentsDir. +func ExtractAllModifiedFilesFromLines(parsed []TranscriptLine, subagentsDir string) ([]string, error) { // Collect modified files from main agent fileSet := make(map[string]bool) var files []string diff --git a/cmd/entire/cli/checkpoint/temporary.go b/cmd/entire/cli/checkpoint/temporary.go index 1fc1b64c0..29a72d1a4 100644 --- a/cmd/entire/cli/checkpoint/temporary.go +++ b/cmd/entire/cli/checkpoint/temporary.go @@ -286,28 +286,22 @@ func (s *GitStore) WriteTemporaryTask(ctx context.Context, opts WriteTemporaryTa return commitHash, nil } -// addTaskMetadataToTree adds task checkpoint metadata to a git tree. +// addTaskMetadataToTree adds task checkpoint metadata to a git tree using incremental updates. +// Only the metadata paths are modified; unchanged subtrees are reused by hash reference. // When IsIncremental is true, only adds the incremental checkpoint file. func (s *GitStore) addTaskMetadataToTree(baseTreeHash plumbing.Hash, opts WriteTemporaryTaskOptions) (plumbing.Hash, error) { - // Get base tree and flatten it - baseTree, err := s.repo.TreeObject(baseTreeHash) - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get base tree: %w", err) - } - - entries := make(map[string]object.TreeEntry) - if err := FlattenTree(s.repo, baseTree, "", entries); err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to flatten tree: %w", err) - } - // Compute metadata paths sessionMetadataDir := paths.EntireMetadataDir + "/" + opts.SessionID taskMetadataDir := sessionMetadataDir + "/tasks/" + opts.ToolUseID + // Build change tree with metadata additions + changes := newChangeTree() + if opts.IsIncremental { // Incremental checkpoint: only add the checkpoint file // Use proper JSON marshaling to handle nil/empty IncrementalData correctly var incData []byte + var err error if opts.IncrementalData != nil { incData, err = redact.JSONLBytes(opts.IncrementalData) if err != nil { @@ -336,11 +330,7 @@ func (s *GitStore) addTaskMetadataToTree(baseTreeHash plumbing.Hash, opts WriteT } cpFilename := fmt.Sprintf("%03d-%s.json", opts.IncrementalSequence, opts.ToolUseID) cpPath := taskMetadataDir + "/checkpoints/" + cpFilename - entries[cpPath] = object.TreeEntry{ - Name: cpPath, - Mode: filemode.Regular, - Hash: cpBlobHash, - } + changes.addFile(cpPath, cpBlobHash, filemode.Regular) } else { // Final checkpoint: add transcripts and checkpoint.json @@ -369,11 +359,7 @@ func (s *GitStore) addTaskMetadataToTree(baseTreeHash plumbing.Hash, opts WriteT ) continue } - entries[chunkPath] = object.TreeEntry{ - Name: chunkPath, - Mode: filemode.Regular, - Hash: blobHash, - } + changes.addFile(chunkPath, blobHash, filemode.Regular) } } } @@ -393,11 +379,7 @@ func (s *GitStore) addTaskMetadataToTree(baseTreeHash plumbing.Hash, opts WriteT agentContent = redacted if blobHash, blobErr := CreateBlobFromContent(s.repo, agentContent); blobErr == nil { agentPath := taskMetadataDir + "/agent-" + opts.AgentID + ".jsonl" - entries[agentPath] = object.TreeEntry{ - Name: agentPath, - Mode: filemode.Regular, - Hash: blobHash, - } + changes.addFile(agentPath, blobHash, filemode.Regular) } } } @@ -415,15 +397,11 @@ func (s *GitStore) addTaskMetadataToTree(baseTreeHash plumbing.Hash, opts WriteT return plumbing.ZeroHash, fmt.Errorf("failed to create checkpoint blob: %w", err) } checkpointPath := taskMetadataDir + "/checkpoint.json" - entries[checkpointPath] = object.TreeEntry{ - Name: checkpointPath, - Mode: filemode.Regular, - Hash: blobHash, - } + changes.addFile(checkpointPath, blobHash, filemode.Regular) } - // Build new tree from entries - return BuildTreeFromEntries(s.repo, entries) + // Apply changes incrementally to the base tree + return applyChangesToTree(s.repo, baseTreeHash, changes) } // ListTemporaryCheckpoints lists all checkpoint commits on a shadow branch. @@ -693,7 +671,9 @@ func (s *GitStore) getOrCreateShadowBranch(branchName string) (plumbing.Hash, pl return plumbing.ZeroHash, headCommit.TreeHash, nil } -// buildTreeWithChanges builds a git tree with the given changes. +// buildTreeWithChanges builds a git tree with the given changes using incremental tree updates. +// Only directories containing changes are rebuilt; unchanged subtrees are reused by hash reference. +// This is O(changed_files * tree_depth) instead of O(total_files_in_repo). // metadataDir is the relative path for git tree entries, metadataDirAbs is the absolute path // for filesystem operations (needed when CLI is run from a subdirectory). func (s *GitStore) buildTreeWithChanges( @@ -710,54 +690,41 @@ func (s *GitStore) buildTreeWithChanges( return plumbing.ZeroHash, fmt.Errorf("failed to get repo root: %w", err) } - // Get the base tree - baseTree, err := s.repo.TreeObject(baseTreeHash) - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get base tree: %w", err) - } - - // Flatten existing tree - entries := make(map[string]object.TreeEntry) - if err := FlattenTree(s.repo, baseTree, "", entries); err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to flatten base tree: %w", err) - } + // Build change tree with all modifications + changes := newChangeTree() - // Remove deleted files + // Record deleted files for _, file := range deletedFiles { - delete(entries, file) + changes.deleteFile(file) } - // Add/update modified files + // Record modified/new files (read content and create blobs) for _, file := range modifiedFiles { - // Resolve path relative to repo root for filesystem operations absPath := filepath.Join(repoRoot, file) if !fileExists(absPath) { - delete(entries, file) + // File no longer exists, treat as deletion + changes.deleteFile(file) continue } - blobHash, mode, err := createBlobFromFile(s.repo, absPath) - if err != nil { + blobHash, mode, blobErr := createBlobFromFile(s.repo, absPath) + if blobErr != nil { // Skip files that can't be staged (may have been deleted since detection) continue } - entries[file] = object.TreeEntry{ - Name: file, - Mode: mode, - Hash: blobHash, - } + changes.addFile(file, blobHash, mode) } // Add metadata directory files if metadataDir != "" && metadataDirAbs != "" { - if err := addDirectoryToEntriesWithAbsPath(s.repo, metadataDirAbs, metadataDir, entries); err != nil { + if err := addDirectoryToChangeTree(s.repo, metadataDirAbs, metadataDir, changes); err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to add metadata directory: %w", err) } } - // Build tree - return BuildTreeFromEntries(s.repo, entries) + // Apply changes incrementally to the base tree + return applyChangesToTree(s.repo, baseTreeHash, changes) } // createCommit creates a commit object. @@ -938,7 +905,6 @@ func addDirectoryToEntriesWithAbsPath(repo *git.Repository, dirPathAbs, dirPathR } return nil } - // treeNode represents a node in our tree structure. type treeNode struct { entries map[string]*treeNode // subdirectories diff --git a/cmd/entire/cli/checkpoint/tree_incremental.go b/cmd/entire/cli/checkpoint/tree_incremental.go new file mode 100644 index 000000000..59ea7847f --- /dev/null +++ b/cmd/entire/cli/checkpoint/tree_incremental.go @@ -0,0 +1,230 @@ +package checkpoint + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// changeTree represents pending changes to apply to a git tree incrementally. +// Only changed paths are traversed; unchanged subtrees are reused by hash reference. +type changeTree struct { + dirs map[string]*changeTree // subdirectory changes + files map[string]*fileChange // file changes in this directory +} + +// fileChange represents a single file addition, update, or deletion. +type fileChange struct { + hash plumbing.Hash + mode filemode.FileMode + delete bool +} + +func newChangeTree() *changeTree { + return &changeTree{ + dirs: make(map[string]*changeTree), + files: make(map[string]*fileChange), + } +} + +// addFile records a file addition or update in the change tree. +// The path uses forward slashes as separators (git convention). +func (ct *changeTree) addFile(path string, hash plumbing.Hash, mode filemode.FileMode) { + parts := strings.Split(path, "/") + node := ct + for _, part := range parts[:len(parts)-1] { + if _, ok := node.dirs[part]; !ok { + node.dirs[part] = newChangeTree() + } + node = node.dirs[part] + } + node.files[parts[len(parts)-1]] = &fileChange{hash: hash, mode: mode} +} + +// deleteFile records a file deletion in the change tree. +func (ct *changeTree) deleteFile(path string) { + parts := strings.Split(path, "/") + node := ct + for _, part := range parts[:len(parts)-1] { + if _, ok := node.dirs[part]; !ok { + node.dirs[part] = newChangeTree() + } + node = node.dirs[part] + } + node.files[parts[len(parts)-1]] = &fileChange{delete: true} +} + +// isEmpty returns true if the change tree has no pending changes. +func (ct *changeTree) isEmpty() bool { + return len(ct.dirs) == 0 && len(ct.files) == 0 +} + +// applyChangesToTree merges a changeTree into a base git tree, returning the new root tree hash. +// Only directories containing changes are rebuilt; unchanged subtrees are reused by hash reference. +// This is O(changed_files * tree_depth) instead of O(total_files_in_repo). +func applyChangesToTree(repo *git.Repository, baseTreeHash plumbing.Hash, changes *changeTree) (plumbing.Hash, error) { + if changes.isEmpty() { + return baseTreeHash, nil + } + + baseTree, err := repo.TreeObject(baseTreeHash) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to get tree: %w", err) + } + + // Track which change entries we've processed (matched to existing tree entries) + processedDirs := make(map[string]bool) + processedFiles := make(map[string]bool) + + var newEntries []object.TreeEntry + + // Process existing entries + for _, entry := range baseTree.Entries { + if entry.Mode == filemode.Dir { + if dirChanges, ok := changes.dirs[entry.Name]; ok { + // Recurse into subdirectory with changes + newSubHash, subErr := applyChangesToTree(repo, entry.Hash, dirChanges) + if subErr != nil { + return plumbing.ZeroHash, subErr + } + newEntries = append(newEntries, object.TreeEntry{ + Name: entry.Name, + Mode: filemode.Dir, + Hash: newSubHash, + }) + processedDirs[entry.Name] = true + } else { + // No changes in this directory, keep as-is + newEntries = append(newEntries, entry) + } + } else { + // File entry + if change, ok := changes.files[entry.Name]; ok { + if !change.delete { + newEntries = append(newEntries, object.TreeEntry{ + Name: entry.Name, + Mode: change.mode, + Hash: change.hash, + }) + } + // If delete: don't add to newEntries + processedFiles[entry.Name] = true + } else { + // No change, keep as-is + newEntries = append(newEntries, entry) + } + } + } + + // Add new files (not in base tree) + for name, change := range changes.files { + if !processedFiles[name] && !change.delete { + newEntries = append(newEntries, object.TreeEntry{ + Name: name, + Mode: change.mode, + Hash: change.hash, + }) + } + } + + // Add new directories (not in base tree) + for name, dirChanges := range changes.dirs { + if !processedDirs[name] { + newSubHash, subErr := createTreeFromChanges(repo, dirChanges) + if subErr != nil { + return plumbing.ZeroHash, subErr + } + newEntries = append(newEntries, object.TreeEntry{ + Name: name, + Mode: filemode.Dir, + Hash: newSubHash, + }) + } + } + + sortTreeEntries(newEntries) + return storeTree(repo, newEntries) +} + +// createTreeFromChanges creates a new tree from a changeTree with no base tree. +// Used for new directories that don't exist in the base tree. +func createTreeFromChanges(repo *git.Repository, changes *changeTree) (plumbing.Hash, error) { + var entries []object.TreeEntry + + for name, change := range changes.files { + if !change.delete { + entries = append(entries, object.TreeEntry{ + Name: name, + Mode: change.mode, + Hash: change.hash, + }) + } + } + + for name, dirChanges := range changes.dirs { + subHash, err := createTreeFromChanges(repo, dirChanges) + if err != nil { + return plumbing.ZeroHash, err + } + entries = append(entries, object.TreeEntry{ + Name: name, + Mode: filemode.Dir, + Hash: subHash, + }) + } + + sortTreeEntries(entries) + return storeTree(repo, entries) +} + +// storeTree creates and stores a git tree object from entries. +func storeTree(repo *git.Repository, entries []object.TreeEntry) (plumbing.Hash, error) { + tree := &object.Tree{Entries: entries} + obj := repo.Storer.NewEncodedObject() + if err := tree.Encode(obj); err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to encode tree: %w", err) + } + hash, err := repo.Storer.SetEncodedObject(obj) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to store tree: %w", err) + } + return hash, nil +} + +// addDirectoryToChangeTree walks a filesystem directory and adds all files to a changeTree. +// dirPathAbs is the absolute path for reading files, dirPathRel is the git tree path prefix. +func addDirectoryToChangeTree(repo *git.Repository, dirPathAbs, dirPathRel string, ct *changeTree) error { + err := filepath.Walk(dirPathAbs, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() { + return nil + } + + relWithinDir, relErr := filepath.Rel(dirPathAbs, path) + if relErr != nil { + return fmt.Errorf("failed to get relative path for %s: %w", path, relErr) + } + + blobHash, mode, blobErr := createBlobFromFile(repo, path) + if blobErr != nil { + return fmt.Errorf("failed to create blob for %s: %w", path, blobErr) + } + + // Use forward slashes for git tree paths + treePath := dirPathRel + "/" + filepath.ToSlash(relWithinDir) + ct.addFile(treePath, blobHash, mode) + return nil + }) + if err != nil { + return fmt.Errorf("failed to walk directory %s: %w", dirPathAbs, err) + } + return nil +} From be1a780b9e2394038c2e8acc3dfd77065d14b17a Mon Sep 17 00:00:00 2001 From: yasunogithub Date: Wed, 18 Feb 2026 20:53:07 +0900 Subject: [PATCH 2/3] perf: eliminate transcript dependency from stop hook - Remove waitForTranscriptFlush (0-3s savings per stop hook call) - Use PrePromptState.UserPrompt instead of parsing transcript for commit messages - Use git status (DetectFileChanges) instead of transcript for modified file detection - Remove extractLastAssistantMessage, extractKeyActions, createContextFileMinimal (dead code) - Add transcript.CountLines for fast line counting without JSON parsing - Keep lightweight transcript file copy for rewind support - Fallback to transcript parsing only when UserPrompt unavailable (backward compat) Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 54b3d488ad4d --- cmd/entire/cli/lifecycle.go | 2 +- cmd/entire/cli/rewind.go | 18 ++++++++++--- cmd/entire/cli/state.go | 14 ++++++++-- cmd/entire/cli/state_test.go | 6 ++--- cmd/entire/cli/strategy/auto_commit.go | 7 +++-- cmd/entire/cli/strategy/manual_commit_git.go | 7 +++-- cmd/entire/cli/transcript.go | 1 + cmd/entire/cli/transcript/parse.go | 28 ++++++++++++++++++++ 8 files changed, 69 insertions(+), 14 deletions(-) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 9c34d9041..9ee83cce1 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -126,7 +126,7 @@ func handleLifecycleTurnStart(ag agent.Agent, event *agent.Event) error { } // Capture pre-prompt state (including transcript position via TranscriptAnalyzer) - if err := CapturePrePromptState(ag, sessionID, event.SessionRef); err != nil { + if err := CapturePrePromptState(ag, sessionID, event.SessionRef, event.Prompt); err != nil { return err } diff --git a/cmd/entire/cli/rewind.go b/cmd/entire/cli/rewind.go index 0a6a9ff24..bbf923813 100644 --- a/cmd/entire/cli/rewind.go +++ b/cmd/entire/cli/rewind.go @@ -317,11 +317,21 @@ func runRewindInteractive() error { //nolint:maintidx // already present in code } if !restored { - // Fall back to local file + // Fall back to local file: try the metadata dir copy first, then the live transcript if err := restoreSessionTranscript(transcriptFile, sessionID, agent); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to restore session transcript: %v\n", err) - fmt.Fprintf(os.Stderr, " Source: %s\n", transcriptFile) - fmt.Fprintf(os.Stderr, " Session ID: %s\n", sessionID) + // Metadata dir may not have a transcript (optimized stop hook skips copy). + // Try the live transcript path from session state. + liveTranscriptPath := strategy.ResolveSessionFilePath(sessionID, agent, "") + if liveTranscriptPath != "" && liveTranscriptPath != transcriptFile { + if liveErr := restoreSessionTranscript(liveTranscriptPath, sessionID, agent); liveErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to restore session transcript: %v\n", liveErr) + } else { + restored = true + } + } + if !restored { + fmt.Fprintf(os.Stderr, "Warning: failed to restore session transcript: %v\n", err) + } } } diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 7a6f94783..92b4aaf0c 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -23,6 +23,10 @@ type PrePromptState struct { Timestamp string `json:"timestamp"` UntrackedFiles []string `json:"untracked_files"` + // UserPrompt is the user's prompt text, stored at prompt submit time. + // Used by the stop hook to generate commit messages without transcript parsing. + UserPrompt string `json:"user_prompt,omitempty"` + // TranscriptOffset is the unified transcript position when this state was captured. // For Claude Code (JSONL), this is the line count. // For Gemini CLI (JSON), this is the message count. @@ -89,7 +93,7 @@ func (s *PrePromptState) normalizePrePromptState() { // The sessionRef parameter is optional — if empty, transcript position won't be captured. // // Works correctly from any subdirectory within the repository. -func CapturePrePromptState(ag agent.Agent, sessionID, sessionRef string) error { +func CapturePrePromptState(ag agent.Agent, sessionID, sessionRef, userPrompt string) error { if sessionID == "" { sessionID = unknownSessionID } @@ -128,6 +132,7 @@ func CapturePrePromptState(ag agent.Agent, sessionID, sessionRef string) error { SessionID: sessionID, Timestamp: time.Now().UTC().Format(time.RFC3339), UntrackedFiles: untrackedFiles, + UserPrompt: userPrompt, TranscriptOffset: transcriptOffset, } @@ -228,7 +233,12 @@ func DetectFileChanges(previouslyUntracked []string) (*FileChanges, error) { switch { case st.Worktree == git.Untracked: if preExisting != nil { - if !preExisting[file] { + if preExisting[file] { + // Pre-existing untracked file still present: treat as modified. + // Git status can't distinguish content changes in untracked files, + // but the strategy's tree hash dedup will skip if nothing actually changed. + changes.Modified = append(changes.Modified, file) + } else { changes.New = append(changes.New, file) } } else { diff --git a/cmd/entire/cli/state_test.go b/cmd/entire/cli/state_test.go index 45dac5bda..466f4ef77 100644 --- a/cmd/entire/cli/state_test.go +++ b/cmd/entire/cli/state_test.go @@ -335,7 +335,7 @@ func TestPrePromptState_WithTranscriptPosition(t *testing.T) { ag := claudecode.NewClaudeCodeAgent() // Capture state with transcript path using Claude agent (JSONL format) - if err := CapturePrePromptState(ag, sessionID, transcriptPath); err != nil { + if err := CapturePrePromptState(ag, sessionID, transcriptPath, ""); err != nil { t.Fatalf("CapturePrePromptState() error = %v", err) } @@ -367,7 +367,7 @@ func TestPrePromptState_WithEmptyTranscriptPath(t *testing.T) { ag := claudecode.NewClaudeCodeAgent() // Capture state with empty transcript path - if err := CapturePrePromptState(ag, sessionID, ""); err != nil { + if err := CapturePrePromptState(ag, sessionID, "", ""); err != nil { t.Fatalf("CapturePrePromptState() error = %v", err) } @@ -403,7 +403,7 @@ func TestPrePromptState_WithSummaryOnlyTranscript(t *testing.T) { ag := claudecode.NewClaudeCodeAgent() // Capture state - if err := CapturePrePromptState(ag, sessionID, transcriptPath); err != nil { + if err := CapturePrePromptState(ag, sessionID, transcriptPath, ""); err != nil { t.Fatalf("CapturePrePromptState() error = %v", err) } diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index 5ee5ced3e..60cbe2b63 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -240,8 +240,11 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository return plumbing.ZeroHash, fmt.Errorf("failed to get checkpoint store: %w", err) } - // Extract session ID from metadata dir - sessionID := filepath.Base(ctx.MetadataDir) + // Extract session ID: prefer explicit SessionID, fall back to metadata dir basename + sessionID := ctx.SessionID + if sessionID == "" { + sessionID = filepath.Base(ctx.MetadataDir) + } // Get current branch name branchName := GetCurrentBranchName(repo) diff --git a/cmd/entire/cli/strategy/manual_commit_git.go b/cmd/entire/cli/strategy/manual_commit_git.go index 37e252d50..dfe2cf792 100644 --- a/cmd/entire/cli/strategy/manual_commit_git.go +++ b/cmd/entire/cli/strategy/manual_commit_git.go @@ -27,8 +27,11 @@ func (s *ManualCommitStrategy) SaveStep(ctx StepContext) error { return fmt.Errorf("failed to open git repository: %w", err) } - // Extract session ID from metadata dir - sessionID := filepath.Base(ctx.MetadataDir) + // Extract session ID: prefer explicit SessionID, fall back to metadata dir basename + sessionID := ctx.SessionID + if sessionID == "" { + sessionID = filepath.Base(ctx.MetadataDir) + } // Load or initialize session state state, err := s.loadSessionState(sessionID) diff --git a/cmd/entire/cli/transcript.go b/cmd/entire/cli/transcript.go index 79f6f658e..c6e9d6d70 100644 --- a/cmd/entire/cli/transcript.go +++ b/cmd/entire/cli/transcript.go @@ -273,6 +273,7 @@ func resolveTranscriptPath(sessionID string, agent agentpkg.Agent) (string, erro return agent.ResolveSessionFile(sessionDir, sessionID), nil } + // AgentTranscriptPath returns the path to a subagent's transcript file. // Subagent transcripts are stored as agent-{agentId}.jsonl in the same directory // as the main transcript. diff --git a/cmd/entire/cli/transcript/parse.go b/cmd/entire/cli/transcript/parse.go index 154529c96..37cc5848d 100644 --- a/cmd/entire/cli/transcript/parse.go +++ b/cmd/entire/cli/transcript/parse.go @@ -96,6 +96,34 @@ func ParseFromFileAtLine(path string, startLine int) ([]Line, int, error) { return lines, totalLines, nil } +// CountLines counts the number of lines in a transcript file without parsing JSON. +// This is much faster than ParseFromFileAtLine when only the line count is needed. +func CountLines(path string) (int, error) { + file, err := os.Open(path) //nolint:gosec // path is a controlled transcript file path + if err != nil { + return 0, fmt.Errorf("failed to open transcript: %w", err) + } + defer func() { _ = file.Close() }() + + count := 0 + buf := make([]byte, 32*1024) + for { + n, readErr := file.Read(buf) + for i := range n { + if buf[i] == '\n' { + count++ + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return 0, fmt.Errorf("failed to read transcript: %w", readErr) + } + } + return count, nil +} + // SliceFromLine returns the content starting from line number `startLine` (0-indexed). // This is used to extract only the checkpoint-specific portion of a cumulative transcript. // For example, if startLine is 2, lines 0 and 1 are skipped and the result starts at line 2. From 91e941d5c11ca4ac0230f995a437402ee12548d8 Mon Sep 17 00:00:00 2001 From: yasunogithub Date: Thu, 19 Feb 2026 15:14:44 +0900 Subject: [PATCH 3/3] fix: handle edge cases in incremental tree builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Handle file/dir type conflicts in applyChangesToTree (dir→file and file→dir) - Skip empty trees from delete-only changes in createTreeFromChanges - Normalize dirPathRel with filepath.ToSlash for Windows compatibility Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 6dc290b19fdd --- cmd/entire/cli/checkpoint/tree_incremental.go | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/checkpoint/tree_incremental.go b/cmd/entire/cli/checkpoint/tree_incremental.go index 59ea7847f..941063fad 100644 --- a/cmd/entire/cli/checkpoint/tree_incremental.go +++ b/cmd/entire/cli/checkpoint/tree_incremental.go @@ -99,6 +99,16 @@ func applyChangesToTree(repo *git.Repository, baseTreeHash plumbing.Hash, change Hash: newSubHash, }) processedDirs[entry.Name] = true + } else if fileChange, ok := changes.files[entry.Name]; ok { + // Type conflict: base has dir, changes has file → replace dir with file + if !fileChange.delete { + newEntries = append(newEntries, object.TreeEntry{ + Name: entry.Name, + Mode: fileChange.mode, + Hash: fileChange.hash, + }) + } + processedFiles[entry.Name] = true } else { // No changes in this directory, keep as-is newEntries = append(newEntries, entry) @@ -115,6 +125,20 @@ func applyChangesToTree(repo *git.Repository, baseTreeHash plumbing.Hash, change } // If delete: don't add to newEntries processedFiles[entry.Name] = true + } else if dirChanges, ok := changes.dirs[entry.Name]; ok { + // Type conflict: base has file, changes has dir → replace file with dir + newSubHash, subErr := createTreeFromChanges(repo, dirChanges) + if subErr != nil { + return plumbing.ZeroHash, subErr + } + if newSubHash != plumbing.ZeroHash { + newEntries = append(newEntries, object.TreeEntry{ + Name: entry.Name, + Mode: filemode.Dir, + Hash: newSubHash, + }) + } + processedDirs[entry.Name] = true } else { // No change, keep as-is newEntries = append(newEntries, entry) @@ -140,6 +164,10 @@ func applyChangesToTree(repo *git.Repository, baseTreeHash plumbing.Hash, change if subErr != nil { return plumbing.ZeroHash, subErr } + // Skip empty trees (e.g., all changes were deletions for non-existent paths) + if newSubHash == plumbing.ZeroHash { + continue + } newEntries = append(newEntries, object.TreeEntry{ Name: name, Mode: filemode.Dir, @@ -172,6 +200,10 @@ func createTreeFromChanges(repo *git.Repository, changes *changeTree) (plumbing. if err != nil { return plumbing.ZeroHash, err } + // Skip empty subtrees (all files were deletions) + if subHash == plumbing.ZeroHash { + continue + } entries = append(entries, object.TreeEntry{ Name: name, Mode: filemode.Dir, @@ -179,6 +211,11 @@ func createTreeFromChanges(repo *git.Repository, changes *changeTree) (plumbing. }) } + // If all entries were deletions, don't create an empty tree + if len(entries) == 0 { + return plumbing.ZeroHash, nil + } + sortTreeEntries(entries) return storeTree(repo, entries) } @@ -219,7 +256,7 @@ func addDirectoryToChangeTree(repo *git.Repository, dirPathAbs, dirPathRel strin } // Use forward slashes for git tree paths - treePath := dirPathRel + "/" + filepath.ToSlash(relWithinDir) + treePath := filepath.ToSlash(dirPathRel) + "/" + filepath.ToSlash(relWithinDir) ct.addFile(treePath, blobHash, mode) return nil })