Skip to content

Commit 5e21be3

Browse files
committed
Implement Git plumbing approach for robust change detection
Use Git plumbing commands (temporary index + write-tree + diff-tree) for detecting changes since prompt submission. This is more reliable than the previous approach because: - FAST: Git is optimized for tree comparisons - SAFE: Uses temporary index, doesn't touch actual staging area - COMPLETE: Handles all cases (modified, new, deleted, staged, unstaged) - ACCURATE: git diff-tree is Git's native tree comparison Changes: - capture_prompt_work_tree.sh: Creates tree object at prompt time - git_utils.py: Add _create_tree_from_working_dir() and _diff_trees() helpers - CompareToPrompt: Uses tree-based comparison as primary method with fallback to file-list comparison for backwards compatibility - install.py: Add .last_tree_hash to .gitignore config - test_git_utils.py: Add tests for tree-based functions and update existing tests to cover both primary and fallback paths
1 parent 0fa699a commit 5e21be3

4 files changed

Lines changed: 546 additions & 85 deletions

File tree

src/deepwork/cli/install.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def _create_deepwork_gitignore(deepwork_dir: Path) -> None:
115115
gitignore_path = deepwork_dir / ".gitignore"
116116
gitignore_content = """# DeepWork runtime artifacts
117117
# These files are generated during sessions and should not be committed
118+
.last_tree_hash
118119
.last_work_tree
119120
.last_head_ref
120121

src/deepwork/core/git_utils.py

Lines changed: 224 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,57 @@
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

1862
from __future__ import annotations
1963

64+
import os
2065
import subprocess
66+
import tempfile
2167
from abc import ABC, abstractmethod
2268
from 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+
79227
def _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:
213361
class 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

Comments
 (0)