1313 changed_files = comparator.get_changed_files()
1414 created_files = comparator.get_created_files()
1515 baseline_ref = comparator.get_baseline_ref()
16+
17+ =============================================================================
18+ GIT PLUMBING APPROACH FOR "COMPARE TO PROMPT"
19+ =============================================================================
20+
21+ The CompareToPrompt comparator uses Git "plumbing" commands with a temporary
22+ index to safely capture and compare working directory snapshots.
23+
24+ HOW IT WORKS:
25+
26+ 1. At prompt submission time (capture_prompt_work_tree.sh):
27+ - Create a temporary index file (GIT_INDEX_FILE env var)
28+ - Stage all files to this temp index (git add -A)
29+ - Write the index to a tree object (git write-tree) -> returns SHA hash
30+ - Save the tree hash to .deepwork/.last_tree_hash
31+
32+ 2. At comparison time (CompareToPrompt class):
33+ - Create another temporary index for the current state
34+ - Stage all current files and write to a tree object
35+ - Compare the two trees using "git diff-tree"
36+
37+ WHY THIS IS ROBUST:
38+ - FAST: Git is optimized for tree comparisons
39+ - SAFE: Does not touch HEAD, current Index, or Stashes
40+ - COMPLETE: Handles modified, new (untracked), and deleted files
41+ - CLEAN: Respects .gitignore automatically
42+
43+ WHAT WE CAN DETECT:
44+ | Scenario | Handled? | Explanation |
45+ |-----------------------|----------|-------------------------------------------|
46+ | Modified files | ✅ Yes | Git detects content hash changed |
47+ | New untracked files | ✅ Yes | git add -A captures them in temp index |
48+ | Deleted files | ✅ Yes | Tree comparison shows them as missing |
49+ | Staged vs Unstaged | ✅ Yes | We look at disk state, ignore staging |
50+ | Ignored files | ❌ No | git add respects .gitignore (by design) |
51+
52+ KEY GIT PLUMBING CONCEPTS:
53+ - GIT_INDEX_FILE: By setting this env var, Git uses a different index file.
54+ This lets us stage files without affecting the user's actual staging area.
55+ - git write-tree: Plumbing command that writes the current index state to
56+ Git's object database as a tree object. Returns the SHA hash.
57+ - git diff-tree: Compares two tree objects and reports differences.
58+ Much more reliable than comparing file lists manually.
59+ =============================================================================
1660"""
1761
1862from __future__ import annotations
1963
64+ import os
2065import subprocess
66+ import tempfile
2167from abc import ABC , abstractmethod
2268from pathlib import Path
2369
@@ -76,6 +122,108 @@ def _stage_all_changes() -> None:
76122 _run_git ("add" , "-A" )
77123
78124
125+ # =============================================================================
126+ # GIT PLUMBING HELPERS FOR TREE-BASED COMPARISON
127+ # =============================================================================
128+
129+
130+ def _create_tree_from_working_dir () -> str | None :
131+ """Create a tree object representing the current working directory state.
132+
133+ This function uses Git plumbing commands with a temporary index to create
134+ a tree object without affecting the actual staging area.
135+
136+ HOW IT WORKS:
137+ 1. Create a temporary file to act as a separate git index
138+ 2. Set GIT_INDEX_FILE to use this temp index instead of .git/index
139+ 3. Stage all files (git add -A) to the temp index
140+ 4. Write the temp index to a tree object (git write-tree)
141+ 5. Clean up the temp index file
142+
143+ WHY A TEMPORARY INDEX:
144+ - We need to capture the ENTIRE working directory state (including untracked)
145+ - git add -A stages everything, but we don't want to mess with the user's
146+ actual staging area
147+ - By setting GIT_INDEX_FILE, Git uses our temp file instead of .git/index
148+
149+ Returns:
150+ The SHA hash of the tree object, or None if creation failed.
151+ """
152+ temp_index = None
153+ original_env = os .environ .get ("GIT_INDEX_FILE" )
154+
155+ try :
156+ # Create a temporary file for the index
157+ fd , temp_index = tempfile .mkstemp (prefix = "deepwork_index_" )
158+ os .close (fd )
159+
160+ # Tell Git to use our temp index instead of .git/index
161+ os .environ ["GIT_INDEX_FILE" ] = temp_index
162+
163+ # Stage everything to the temp index
164+ # -A handles new files, deletions, and modifications
165+ # Respects .gitignore automatically
166+ subprocess .run (
167+ ["git" , "add" , "-A" ],
168+ capture_output = True ,
169+ text = True ,
170+ check = False , # Don't fail if no files to add
171+ )
172+
173+ # Write the index to a tree object and get the SHA hash
174+ result = subprocess .run (
175+ ["git" , "write-tree" ],
176+ capture_output = True ,
177+ text = True ,
178+ check = True ,
179+ )
180+
181+ return result .stdout .strip () or None
182+
183+ except subprocess .CalledProcessError :
184+ return None
185+
186+ finally :
187+ # Restore the original GIT_INDEX_FILE environment
188+ if original_env is None :
189+ os .environ .pop ("GIT_INDEX_FILE" , None )
190+ else :
191+ os .environ ["GIT_INDEX_FILE" ] = original_env
192+
193+ # Clean up the temp index file
194+ if temp_index and os .path .exists (temp_index ):
195+ os .unlink (temp_index )
196+
197+
198+ def _diff_trees (
199+ tree_a : str , tree_b : str , diff_filter : str | None = None
200+ ) -> set [str ]:
201+ """Compare two tree objects and return the files that differ.
202+
203+ Uses git diff-tree to compare tree objects. This is Git's native way to
204+ compare directory snapshots and is highly optimized.
205+
206+ Args:
207+ tree_a: SHA hash of the first tree (baseline/before)
208+ tree_b: SHA hash of the second tree (current/after)
209+ diff_filter: Optional filter for diff types:
210+ - "A" = Added files only (new in tree_b)
211+ - "D" = Deleted files only (removed from tree_b)
212+ - "M" = Modified files only
213+ - None = All changed files
214+
215+ Returns:
216+ Set of file paths that differ between the trees.
217+ """
218+ args = ["diff-tree" , "--name-only" , "-r" ]
219+ if diff_filter :
220+ args .append (f"--diff-filter={ diff_filter } " )
221+ args .extend ([tree_a , tree_b ])
222+
223+ result = _run_git (* args )
224+ return _parse_file_list (result .stdout )
225+
226+
79227def _get_all_changes_vs_ref (ref : str , diff_filter : str | None = None ) -> set [str ]:
80228 """Get all files that differ between the index and a ref.
81229
@@ -213,30 +361,83 @@ def _get_fallback_name(self) -> str:
213361class CompareToPrompt (GitComparator ):
214362 """Compare changes against the state when a prompt was submitted.
215363
216- Uses baseline files captured at prompt submission time to detect
217- what changed during the agent's response.
364+ ==========================================================================
365+ GIT PLUMBING APPROACH FOR ACCURATE CHANGE DETECTION
366+ ==========================================================================
367+
368+ This comparator uses Git plumbing commands with temporary indexes to create
369+ and compare tree objects. This is the most robust way to detect what changed
370+ during an agent response because:
371+
372+ 1. COMPLETE: Captures ALL changes including untracked files
373+ 2. SAFE: Uses temporary index, doesn't touch actual staging area
374+ 3. ACCURATE: git diff-tree is Git's native tree comparison
375+ 4. HANDLES COMMITS: Works even if changes were committed during response
376+
377+ HOW IT WORKS:
378+
379+ At prompt submission (capture_prompt_work_tree.sh):
380+ 1. Create temporary index file
381+ 2. Set GIT_INDEX_FILE to temp index
382+ 3. git add -A (stage everything to temp index)
383+ 4. git write-tree -> returns tree SHA hash
384+ 5. Save hash to .deepwork/.last_tree_hash
385+
386+ At comparison time (this class):
387+ 1. Create another tree for current state (_create_tree_from_working_dir)
388+ 2. Compare trees with git diff-tree (_diff_trees)
389+ 3. Return the differences
390+
391+ FALLBACK BEHAVIOR:
392+ If .last_tree_hash is missing (e.g., old capture script), falls back to:
393+ - .last_head_ref for get_changed_files() (compares commits)
394+ - .last_work_tree for get_created_files() (compares file lists)
395+ ==========================================================================
218396 """
219397
398+ # Primary: Tree hash for robust git-plumbing comparison
399+ BASELINE_TREE_PATH = Path (".deepwork/.last_tree_hash" )
400+ # Legacy fallbacks for backwards compatibility
220401 BASELINE_REF_PATH = Path (".deepwork/.last_head_ref" )
221402 BASELINE_WORK_TREE_PATH = Path (".deepwork/.last_work_tree" )
222403
223404 def get_baseline_ref (self ) -> str :
405+ """Return the baseline tree hash or fallback identifier."""
406+ if self .BASELINE_TREE_PATH .exists ():
407+ tree_hash = self .BASELINE_TREE_PATH .read_text ().strip ()
408+ if tree_hash :
409+ return tree_hash [:12 ] # Short hash for display
224410 if self .BASELINE_WORK_TREE_PATH .exists ():
225411 return str (int (self .BASELINE_WORK_TREE_PATH .stat ().st_mtime ))
226412 return "prompt"
227413
228414 def get_changed_files (self ) -> list [str ]:
415+ """Get files that changed since the prompt was submitted.
416+
417+ Uses git diff-tree to compare the baseline tree (captured at prompt time)
418+ against the current working directory tree. This accurately captures:
419+ - Modified files
420+ - New files (including previously untracked)
421+ - Deleted files
422+ - Files that were committed during the response
423+ """
229424 try :
425+ # Try tree-based comparison first (most robust)
426+ if self .BASELINE_TREE_PATH .exists ():
427+ baseline_tree = self .BASELINE_TREE_PATH .read_text ().strip ()
428+ if baseline_tree :
429+ current_tree = _create_tree_from_working_dir ()
430+ if current_tree :
431+ return sorted (_diff_trees (baseline_tree , current_tree ))
432+
433+ # Fallback to ref-based comparison
230434 _stage_all_changes ()
231-
232435 if self .BASELINE_REF_PATH .exists ():
233436 baseline_ref = self .BASELINE_REF_PATH .read_text ().strip ()
234437 if baseline_ref :
235- # Use simplified approach: after staging, index vs ref captures all changes
236438 return sorted (_get_all_changes_vs_ref (baseline_ref ))
237439
238- # No baseline ref - return files that differ from HEAD plus any untracked.
239- # The _get_untracked_files() call is defensive in case staging failed.
440+ # Last resort: compare against HEAD
240441 return sorted (_get_all_changes_vs_ref ("HEAD" ) | _get_untracked_files ())
241442
242443 except (subprocess .CalledProcessError , OSError ):
@@ -245,25 +446,32 @@ def get_changed_files(self) -> list[str]:
245446 def get_created_files (self ) -> list [str ]:
246447 """Get files created since the prompt was submitted.
247448
248- Unlike get_changed_files(), this method always uses .last_work_tree
249- for comparison (not .last_head_ref) because .last_work_tree contains
250- the actual list of files that existed at prompt time, including
251- uncommitted files. Using git-based detection would incorrectly flag
252- uncommitted files from before the prompt as "created".
449+ Uses git diff-tree with --diff-filter=A to find files that were added
450+ (exist in current tree but not in baseline tree). This accurately
451+ detects truly new files even if:
452+ - They were untracked before and are now tracked
453+ - They were committed during the response
454+ - The staging area was in an unusual state
253455 """
254456 try :
457+ # Try tree-based comparison first (most robust)
458+ if self .BASELINE_TREE_PATH .exists ():
459+ baseline_tree = self .BASELINE_TREE_PATH .read_text ().strip ()
460+ if baseline_tree :
461+ current_tree = _create_tree_from_working_dir ()
462+ if current_tree :
463+ # diff-filter=A returns files Added in current tree
464+ return sorted (_diff_trees (baseline_tree , current_tree , diff_filter = "A" ))
465+
466+ # Fallback to file-list comparison for backwards compatibility
467+ # This handles cases where .last_tree_hash doesn't exist yet
255468 _stage_all_changes ()
256-
257- # Get files that differ from HEAD (modified/added/deleted) plus any untracked.
258- # The _get_untracked_files() call is defensive in case staging failed.
259469 current_files = _get_all_changes_vs_ref ("HEAD" ) | _get_untracked_files ()
260470
261471 if self .BASELINE_WORK_TREE_PATH .exists ():
262- # Compare against the file list captured at prompt time
263472 baseline_files = _parse_file_list (self .BASELINE_WORK_TREE_PATH .read_text ())
264473 return sorted (current_files - baseline_files )
265474 else :
266- # No baseline means all current files are "new" to this prompt
267475 return sorted (current_files )
268476
269477 except (subprocess .CalledProcessError , OSError ):
0 commit comments