diff --git a/.claude/skills/manual_tests.run_fire_tests/SKILL.md b/.claude/skills/manual_tests.run_fire_tests/SKILL.md index 307f035c..25da8136 100644 --- a/.claude/skills/manual_tests.run_fire_tests/SKILL.md +++ b/.claude/skills/manual_tests.run_fire_tests/SKILL.md @@ -86,6 +86,8 @@ For EACH test below, follow this cycle: **IMPORTANT**: Only launch ONE sub-agent at a time. Wait for it to complete and reset before launching the next. +**IMPORTANT**: Always check the queue file before reverting to verify the rule was queued. + ### Test Cases (run serially) **Test 1: Trigger/Safety** diff --git a/.deepwork/jobs/manual_tests/steps/run_fire_tests.md b/.deepwork/jobs/manual_tests/steps/run_fire_tests.md index 787dc3ef..2d09db38 100644 --- a/.deepwork/jobs/manual_tests/steps/run_fire_tests.md +++ b/.deepwork/jobs/manual_tests/steps/run_fire_tests.md @@ -62,6 +62,8 @@ For EACH test below, follow this cycle: **IMPORTANT**: Only launch ONE sub-agent at a time. Wait for it to complete and reset before launching the next. +**IMPORTANT**: Always check the queue file before reverting to verify the rule was queued. + ### Test Cases (run serially) **Test 1: Trigger/Safety** diff --git a/.gemini/skills/manual_tests/run_fire_tests.toml b/.gemini/skills/manual_tests/run_fire_tests.toml index 1f471b83..e170144e 100644 --- a/.gemini/skills/manual_tests/run_fire_tests.toml +++ b/.gemini/skills/manual_tests/run_fire_tests.toml @@ -86,6 +86,8 @@ For EACH test below, follow this cycle: **IMPORTANT**: Only launch ONE sub-agent at a time. Wait for it to complete and reset before launching the next. +**IMPORTANT**: Always check the queue file before reverting to verify the rule was queued. + ### Test Cases (run serially) **Test 1: Trigger/Safety** diff --git a/src/deepwork/cli/install.py b/src/deepwork/cli/install.py index 19bec4f8..6df49c2f 100644 --- a/src/deepwork/cli/install.py +++ b/src/deepwork/cli/install.py @@ -115,6 +115,7 @@ def _create_deepwork_gitignore(deepwork_dir: Path) -> None: gitignore_path = deepwork_dir / ".gitignore" gitignore_content = """# DeepWork runtime artifacts # These files are generated during sessions and should not be committed +.last_tree_hash .last_work_tree .last_head_ref diff --git a/src/deepwork/core/git_utils.py b/src/deepwork/core/git_utils.py new file mode 100644 index 00000000..afd62b99 --- /dev/null +++ b/src/deepwork/core/git_utils.py @@ -0,0 +1,489 @@ +""" +Git utilities for DeepWork rules system. + +This module provides abstractions for comparing file changes against different +baselines. The main interface is GitComparator with implementations for: + +- CompareToBase: Compare against merge-base with origin's default branch +- CompareToDefaultTip: Compare against the tip of origin's default branch +- CompareToPrompt: Compare against the state captured when a prompt was submitted + +Usage: + comparator = get_comparator("base") # or "default_tip" or "prompt" + changed_files = comparator.get_changed_files() + created_files = comparator.get_created_files() + baseline_ref = comparator.get_baseline_ref() + +============================================================================= +GIT PLUMBING APPROACH FOR "COMPARE TO PROMPT" +============================================================================= + +The CompareToPrompt comparator uses Git "plumbing" commands with a temporary +index to safely capture and compare working directory snapshots. + +HOW IT WORKS: + +1. At prompt submission time (capture_prompt_work_tree.sh): + - Create a temporary index file (GIT_INDEX_FILE env var) + - Stage all files to this temp index (git add -A) + - Write the index to a tree object (git write-tree) -> returns SHA hash + - Save the tree hash to .deepwork/.last_tree_hash + +2. At comparison time (CompareToPrompt class): + - Create another temporary index for the current state + - Stage all current files and write to a tree object + - Compare the two trees using "git diff-tree" + +WHY THIS IS ROBUST: +- FAST: Git is optimized for tree comparisons +- SAFE: Does not touch HEAD, current Index, or Stashes +- COMPLETE: Handles modified, new (untracked), and deleted files +- CLEAN: Respects .gitignore automatically + +WHAT WE CAN DETECT: +| Scenario | Detected? | Explanation | +|-----------------------|-----------|-------------------------------------------| +| Modified files | ✅ Yes | Git detects content hash changed | +| New untracked files | ✅ Yes | git add -A captures them in temp index | +| Deleted files | ✅ Yes | Tree comparison shows them as missing | +| Staged vs Unstaged | ✅ Yes | We look at disk state, ignore staging | +| Ignored files (.gitignore) | ✅ Excluded | Correctly excluded - rules don't trigger | + +KEY GIT PLUMBING CONCEPTS: +- GIT_INDEX_FILE: By setting this env var, Git uses a different index file. + This lets us stage files without affecting the user's actual staging area. +- git write-tree: Plumbing command that writes the current index state to + Git's object database as a tree object. Returns the SHA hash. +- git diff-tree: Compares two tree objects and reports differences. + Much more reliable than comparing file lists manually. +============================================================================= +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from abc import ABC, abstractmethod +from pathlib import Path + + +def get_default_branch() -> str: + """Get the default branch name (main or master).""" + try: + result = subprocess.run( + ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip().split("/")[-1] + except subprocess.CalledProcessError: + pass + + for branch in ["main", "master"]: + try: + subprocess.run( + ["git", "rev-parse", "--verify", f"origin/{branch}"], + capture_output=True, + check=True, + ) + return branch + except subprocess.CalledProcessError: + continue + + return "main" + + +def _parse_file_list(output: str) -> set[str]: + """Parse newline-separated git output into a set of non-empty file paths.""" + if not output.strip(): + return set() + return {f for f in output.strip().split("\n") if f} + + +def _run_git(*args: str, check: bool = False) -> subprocess.CompletedProcess[str]: + """Run a git command and return the result.""" + return subprocess.run( + ["git", *args], + capture_output=True, + text=True, + check=check, + ) + + +def _get_untracked_files() -> set[str]: + """Get untracked files (excluding ignored).""" + return _parse_file_list(_run_git("ls-files", "--others", "--exclude-standard").stdout) + + +def _stage_all_changes() -> None: + """Stage all changes including untracked files.""" + _run_git("add", "-A") + + +# ============================================================================= +# GIT PLUMBING HELPERS FOR TREE-BASED COMPARISON +# ============================================================================= + + +def _create_tree_from_working_dir() -> str | None: + """Create a tree object representing the current working directory state. + + This function uses Git plumbing commands with a temporary index to create + a tree object without affecting the actual staging area. + + HOW IT WORKS: + 1. Create a temporary file to act as a separate git index + 2. Set GIT_INDEX_FILE to use this temp index instead of .git/index + 3. Stage all files (git add -A) to the temp index + 4. Write the temp index to a tree object (git write-tree) + 5. Clean up the temp index file + + WHY A TEMPORARY INDEX: + - We need to capture the ENTIRE working directory state (including untracked) + - git add -A stages everything, but we don't want to mess with the user's + actual staging area + - By setting GIT_INDEX_FILE, Git uses our temp file instead of .git/index + + Returns: + The SHA hash of the tree object, or None if creation failed. + """ + temp_index = None + original_env = os.environ.get("GIT_INDEX_FILE") + + try: + # Create a temporary file for the index + fd, temp_index = tempfile.mkstemp(prefix="deepwork_index_") + os.close(fd) + + # Tell Git to use our temp index instead of .git/index + os.environ["GIT_INDEX_FILE"] = temp_index + + # Stage everything to the temp index + # -A handles new files, deletions, and modifications + # Respects .gitignore automatically + subprocess.run( + ["git", "add", "-A"], + capture_output=True, + text=True, + check=False, # Don't fail if no files to add + ) + + # Write the index to a tree object and get the SHA hash + result = subprocess.run( + ["git", "write-tree"], + capture_output=True, + text=True, + check=True, + ) + + return result.stdout.strip() or None + + except subprocess.CalledProcessError: + return None + + finally: + # Restore the original GIT_INDEX_FILE environment + if original_env is None: + os.environ.pop("GIT_INDEX_FILE", None) + else: + os.environ["GIT_INDEX_FILE"] = original_env + + # Clean up the temp index file + if temp_index and os.path.exists(temp_index): + os.unlink(temp_index) + + +def _diff_trees( + tree_a: str, tree_b: str, diff_filter: str | None = None +) -> set[str]: + """Compare two tree objects and return the files that differ. + + Uses git diff-tree to compare tree objects. This is Git's native way to + compare directory snapshots and is highly optimized. + + Args: + tree_a: SHA hash of the first tree (baseline/before) + tree_b: SHA hash of the second tree (current/after) + diff_filter: Optional filter for diff types: + - "A" = Added files only (new in tree_b) + - "D" = Deleted files only (removed from tree_b) + - "M" = Modified files only + - None = All changed files + + Returns: + Set of file paths that differ between the trees. + """ + args = ["diff-tree", "--name-only", "-r"] + if diff_filter: + args.append(f"--diff-filter={diff_filter}") + args.extend([tree_a, tree_b]) + + result = _run_git(*args) + return _parse_file_list(result.stdout) + + +def _get_all_changes_vs_ref(ref: str, diff_filter: str | None = None) -> set[str]: + """Get all files that differ between the index and a ref. + + After staging all changes (git add -A), the index contains the complete + current state. Comparing the index to a ref captures: + - Files changed in commits since ref + - Files staged but not yet committed + - Files that were untracked (now staged) + + Args: + ref: The git ref to compare against + diff_filter: Optional diff filter (e.g., "A" for added files only) + + Returns: + Set of file paths that differ from the ref + """ + args = ["diff", "--name-only", "--cached", ref] + if diff_filter: + args.insert(2, f"--diff-filter={diff_filter}") + return _parse_file_list(_run_git(*args).stdout) + + +class GitComparator(ABC): + """Abstract base class for comparing git changes against a baseline.""" + + @abstractmethod + def get_changed_files(self) -> list[str]: + """Get list of files that changed relative to the baseline.""" + pass + + @abstractmethod + def get_created_files(self) -> list[str]: + """Get list of files that were newly created relative to the baseline.""" + pass + + @abstractmethod + def get_baseline_ref(self) -> str: + """Get the git reference or identifier for the baseline.""" + pass + + +class RefBasedComparator(GitComparator): + """Base class for comparators that compare against a git ref. + + Subclasses only need to implement _get_ref() and _get_fallback_name(). + """ + + def __init__(self) -> None: + self._cached_ref: str | None = None + + @abstractmethod + def _get_ref(self) -> str | None: + """Get the git ref to compare against. Returns None if unavailable.""" + pass + + @abstractmethod + def _get_fallback_name(self) -> str: + """Get the fallback name to use when ref is unavailable.""" + pass + + def _resolve_ref(self) -> str | None: + """Get and cache the ref.""" + if self._cached_ref is None: + ref = self._get_ref() + self._cached_ref = ref if ref else "" + return self._cached_ref if self._cached_ref else None + + def get_baseline_ref(self) -> str: + ref = self._resolve_ref() + return ref if ref else self._get_fallback_name() + + def get_changed_files(self) -> list[str]: + ref = self._resolve_ref() + if not ref: + return [] + + try: + _stage_all_changes() + # After staging, comparing index to ref captures all changes + changed = _get_all_changes_vs_ref(ref) + return sorted(changed) + except subprocess.CalledProcessError: + return [] + + def get_created_files(self) -> list[str]: + ref = self._resolve_ref() + if not ref: + return [] + + try: + _stage_all_changes() + # After staging, comparing index to ref with --diff-filter=A captures all new files + created = _get_all_changes_vs_ref(ref, diff_filter="A") + return sorted(created) + except subprocess.CalledProcessError: + return [] + + +class CompareToBase(RefBasedComparator): + """Compare changes against merge-base with the default branch.""" + + def __init__(self) -> None: + super().__init__() + self._default_branch = get_default_branch() + + def _get_ref(self) -> str | None: + try: + result = _run_git("merge-base", "HEAD", f"origin/{self._default_branch}", check=True) + return result.stdout.strip() or None + except subprocess.CalledProcessError: + return None + + def _get_fallback_name(self) -> str: + return "base" + + +class CompareToDefaultTip(RefBasedComparator): + """Compare changes against the tip of the default branch.""" + + def __init__(self) -> None: + super().__init__() + self._default_branch = get_default_branch() + + def _get_ref(self) -> str | None: + try: + result = _run_git("rev-parse", f"origin/{self._default_branch}", check=True) + return result.stdout.strip() or None + except subprocess.CalledProcessError: + return None + + def _get_fallback_name(self) -> str: + return "default_tip" + + +class CompareToPrompt(GitComparator): + """Compare changes against the state when a prompt was submitted. + + ========================================================================== + GIT PLUMBING APPROACH FOR ACCURATE CHANGE DETECTION + ========================================================================== + + This comparator uses Git plumbing commands with temporary indexes to create + and compare tree objects. This is the most robust way to detect what changed + during an agent response because: + + 1. COMPLETE: Captures ALL changes including untracked files + 2. SAFE: Uses temporary index, doesn't touch actual staging area + 3. ACCURATE: git diff-tree is Git's native tree comparison + 4. HANDLES COMMITS: Works even if changes were committed during response + + HOW IT WORKS: + + At prompt submission (capture_prompt_work_tree.sh): + 1. Create temporary index file + 2. Set GIT_INDEX_FILE to temp index + 3. git add -A (stage everything to temp index) + 4. git write-tree -> returns tree SHA hash + 5. Save hash to .deepwork/.last_tree_hash + + At comparison time (this class): + 1. Create another tree for current state (_create_tree_from_working_dir) + 2. Compare trees with git diff-tree (_diff_trees) + 3. Return the differences + + FALLBACK BEHAVIOR: + If .last_tree_hash is missing (e.g., old capture script), falls back to: + - .last_head_ref for get_changed_files() (compares commits) + - .last_work_tree for get_created_files() (compares file lists) + ========================================================================== + """ + + # Primary: Tree hash for robust git-plumbing comparison + BASELINE_TREE_PATH = Path(".deepwork/.last_tree_hash") + # Legacy fallbacks for backwards compatibility + BASELINE_REF_PATH = Path(".deepwork/.last_head_ref") + BASELINE_WORK_TREE_PATH = Path(".deepwork/.last_work_tree") + + def get_baseline_ref(self) -> str: + """Return the baseline tree hash or fallback identifier.""" + if self.BASELINE_TREE_PATH.exists(): + tree_hash = self.BASELINE_TREE_PATH.read_text().strip() + if tree_hash: + return tree_hash[:12] # Short hash for display + if self.BASELINE_WORK_TREE_PATH.exists(): + return str(int(self.BASELINE_WORK_TREE_PATH.stat().st_mtime)) + return "prompt" + + def get_changed_files(self) -> list[str]: + """Get files that changed since the prompt was submitted. + + Uses git diff-tree to compare the baseline tree (captured at prompt time) + against the current working directory tree. This accurately captures: + - Modified files + - New files (including previously untracked) + - Deleted files + - Files that were committed during the response + """ + try: + # Try tree-based comparison first (most robust) + if self.BASELINE_TREE_PATH.exists(): + baseline_tree = self.BASELINE_TREE_PATH.read_text().strip() + if baseline_tree: + current_tree = _create_tree_from_working_dir() + if current_tree: + return sorted(_diff_trees(baseline_tree, current_tree)) + + # Fallback to ref-based comparison + _stage_all_changes() + if self.BASELINE_REF_PATH.exists(): + baseline_ref = self.BASELINE_REF_PATH.read_text().strip() + if baseline_ref: + return sorted(_get_all_changes_vs_ref(baseline_ref)) + + # Last resort: compare against HEAD + return sorted(_get_all_changes_vs_ref("HEAD") | _get_untracked_files()) + + except (subprocess.CalledProcessError, OSError): + return [] + + def get_created_files(self) -> list[str]: + """Get files created since the prompt was submitted. + + Uses git diff-tree with --diff-filter=A to find files that were added + (exist in current tree but not in baseline tree). This accurately + detects truly new files even if: + - They were untracked before and are now tracked + - They were committed during the response + - The staging area was in an unusual state + """ + try: + # Try tree-based comparison first (most robust) + if self.BASELINE_TREE_PATH.exists(): + baseline_tree = self.BASELINE_TREE_PATH.read_text().strip() + if baseline_tree: + current_tree = _create_tree_from_working_dir() + if current_tree: + # diff-filter=A returns files Added in current tree + return sorted(_diff_trees(baseline_tree, current_tree, diff_filter="A")) + + # Fallback to file-list comparison for backwards compatibility + # This handles cases where .last_tree_hash doesn't exist yet + _stage_all_changes() + current_files = _get_all_changes_vs_ref("HEAD") | _get_untracked_files() + + if self.BASELINE_WORK_TREE_PATH.exists(): + baseline_files = _parse_file_list(self.BASELINE_WORK_TREE_PATH.read_text()) + return sorted(current_files - baseline_files) + else: + return sorted(current_files) + + except (subprocess.CalledProcessError, OSError): + return [] + + +def get_comparator(mode: str) -> GitComparator: + """Factory function to get the appropriate comparator for a mode.""" + comparators: dict[str, type[GitComparator]] = { + "base": CompareToBase, + "default_tip": CompareToDefaultTip, + "prompt": CompareToPrompt, + } + comparator_class = comparators.get(mode, CompareToBase) + return comparator_class() diff --git a/src/deepwork/hooks/rules_check.py b/src/deepwork/hooks/rules_check.py index 6ac2d652..73b2f4b7 100644 --- a/src/deepwork/hooks/rules_check.py +++ b/src/deepwork/hooks/rules_check.py @@ -15,6 +15,28 @@ Or with platform environment variable: DEEPWORK_HOOK_PLATFORM=claude deepwork hook rules_check + +CALL SITES +---------- +This module is invoked as a hook by the AI coding assistant platforms: + +1. Claude Code (via claude_hook.sh): + - Configured in .claude/settings.local.json under "hooks.StopResearch" and + "hooks.Stop" events + - Runs after each agent response to evaluate rules + +2. Gemini CLI (via gemini_hook.sh): + - Configured in GEMINI_SETTINGS_DIR/settings.json under hooks + - Runs after each agent response to evaluate rules + +RELATED FILES +------------- +- src/deepwork/hooks/wrapper.py: Platform abstraction for hook I/O +- src/deepwork/core/git_utils.py: Git comparison operations used here +- src/deepwork/core/rules_parser.py: Rule file parsing and evaluation +- src/deepwork/core/rules_queue.py: Rule triggering state management +- src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh: + Captures baseline state at prompt submission for compare_to: prompt rules """ from __future__ import annotations @@ -22,7 +44,6 @@ import json import os import re -import subprocess import sys from pathlib import Path @@ -31,6 +52,7 @@ format_command_errors, run_command_action, ) +from deepwork.core.git_utils import get_comparator from deepwork.core.rules_parser import ( ActionType, DetectionMode, @@ -55,375 +77,6 @@ ) -def get_default_branch() -> str: - """Get the default branch name (main or master).""" - try: - result = subprocess.run( - ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip().split("/")[-1] - except subprocess.CalledProcessError: - pass - - for branch in ["main", "master"]: - try: - subprocess.run( - ["git", "rev-parse", "--verify", f"origin/{branch}"], - capture_output=True, - check=True, - ) - return branch - except subprocess.CalledProcessError: - continue - - return "main" - - -def get_baseline_ref(mode: str) -> str: - """Get the baseline reference for a compare_to mode.""" - if mode == "base": - try: - default_branch = get_default_branch() - result = subprocess.run( - ["git", "merge-base", "HEAD", f"origin/{default_branch}"], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() - except subprocess.CalledProcessError: - return "base" - elif mode == "default_tip": - try: - default_branch = get_default_branch() - result = subprocess.run( - ["git", "rev-parse", f"origin/{default_branch}"], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() - except subprocess.CalledProcessError: - return "default_tip" - elif mode == "prompt": - baseline_path = Path(".deepwork/.last_work_tree") - if baseline_path.exists(): - # Use file modification time as reference - return str(int(baseline_path.stat().st_mtime)) - return "prompt" - return mode - - -def get_changed_files_base() -> list[str]: - """Get files changed relative to branch base.""" - default_branch = get_default_branch() - - try: - result = subprocess.run( - ["git", "merge-base", "HEAD", f"origin/{default_branch}"], - capture_output=True, - text=True, - check=True, - ) - merge_base = result.stdout.strip() - - subprocess.run(["git", "add", "-A"], capture_output=True, check=False) - - result = subprocess.run( - ["git", "diff", "--name-only", merge_base, "HEAD"], - capture_output=True, - text=True, - check=True, - ) - committed_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - result = subprocess.run( - ["git", "diff", "--name-only", "--cached"], - capture_output=True, - text=True, - check=False, - ) - staged_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - result = subprocess.run( - ["git", "ls-files", "--others", "--exclude-standard"], - capture_output=True, - text=True, - check=False, - ) - untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - all_files = committed_files | staged_files | untracked_files - return sorted([f for f in all_files if f]) - - except subprocess.CalledProcessError: - return [] - - -def get_changed_files_default_tip() -> list[str]: - """Get files changed compared to default branch tip.""" - default_branch = get_default_branch() - - try: - subprocess.run(["git", "add", "-A"], capture_output=True, check=False) - - result = subprocess.run( - ["git", "diff", "--name-only", f"origin/{default_branch}..HEAD"], - capture_output=True, - text=True, - check=True, - ) - committed_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - result = subprocess.run( - ["git", "diff", "--name-only", "--cached"], - capture_output=True, - text=True, - check=False, - ) - staged_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - result = subprocess.run( - ["git", "ls-files", "--others", "--exclude-standard"], - capture_output=True, - text=True, - check=False, - ) - untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - all_files = committed_files | staged_files | untracked_files - return sorted([f for f in all_files if f]) - - except subprocess.CalledProcessError: - return [] - - -def get_changed_files_prompt() -> list[str]: - """Get files changed since prompt was submitted. - - Returns files that changed since the prompt was submitted, including: - - Committed changes (compared to captured HEAD ref) - - Staged changes (not yet committed) - - Untracked files - - This is used by trigger/safety, set, and pair mode rules to detect - file modifications during the agent response. - """ - baseline_ref_path = Path(".deepwork/.last_head_ref") - changed_files: set[str] = set() - - try: - # Stage all changes first - subprocess.run(["git", "add", "-A"], capture_output=True, check=False) - - # If we have a captured HEAD ref, compare committed changes against it - if baseline_ref_path.exists(): - baseline_ref = baseline_ref_path.read_text().strip() - if baseline_ref: - # Get files changed in commits since the baseline - result = subprocess.run( - ["git", "diff", "--name-only", baseline_ref, "HEAD"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0 and result.stdout.strip(): - committed_files = set(result.stdout.strip().split("\n")) - changed_files.update(f for f in committed_files if f) - - # Also get currently staged changes (in case not everything is committed) - result = subprocess.run( - ["git", "diff", "--name-only", "--cached"], - capture_output=True, - text=True, - check=False, - ) - if result.stdout.strip(): - staged_files = set(result.stdout.strip().split("\n")) - changed_files.update(f for f in staged_files if f) - - # Include untracked files - result = subprocess.run( - ["git", "ls-files", "--others", "--exclude-standard"], - capture_output=True, - text=True, - check=False, - ) - if result.stdout.strip(): - untracked_files = set(result.stdout.strip().split("\n")) - changed_files.update(f for f in untracked_files if f) - - return sorted(changed_files) - - except (subprocess.CalledProcessError, OSError): - return [] - - -def get_changed_files_for_mode(mode: str) -> list[str]: - """Get changed files for a specific compare_to mode.""" - if mode == "base": - return get_changed_files_base() - elif mode == "default_tip": - return get_changed_files_default_tip() - elif mode == "prompt": - return get_changed_files_prompt() - else: - return get_changed_files_base() - - -def get_created_files_base() -> list[str]: - """Get files created (added) relative to branch base.""" - default_branch = get_default_branch() - - try: - result = subprocess.run( - ["git", "merge-base", "HEAD", f"origin/{default_branch}"], - capture_output=True, - text=True, - check=True, - ) - merge_base = result.stdout.strip() - - subprocess.run(["git", "add", "-A"], capture_output=True, check=False) - - # Get only added files (not modified) using --diff-filter=A - result = subprocess.run( - ["git", "diff", "--name-only", "--diff-filter=A", merge_base, "HEAD"], - capture_output=True, - text=True, - check=True, - ) - committed_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - # Staged new files that don't exist in merge_base - result = subprocess.run( - ["git", "diff", "--name-only", "--diff-filter=A", "--cached", merge_base], - capture_output=True, - text=True, - check=False, - ) - staged_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - # Untracked files are by definition "created" - result = subprocess.run( - ["git", "ls-files", "--others", "--exclude-standard"], - capture_output=True, - text=True, - check=False, - ) - untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - all_created = committed_added | staged_added | untracked_files - return sorted([f for f in all_created if f]) - - except subprocess.CalledProcessError: - return [] - - -def get_created_files_default_tip() -> list[str]: - """Get files created compared to default branch tip.""" - default_branch = get_default_branch() - - try: - subprocess.run(["git", "add", "-A"], capture_output=True, check=False) - - # Get only added files using --diff-filter=A - result = subprocess.run( - ["git", "diff", "--name-only", "--diff-filter=A", f"origin/{default_branch}..HEAD"], - capture_output=True, - text=True, - check=True, - ) - committed_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - result = subprocess.run( - [ - "git", - "diff", - "--name-only", - "--diff-filter=A", - "--cached", - f"origin/{default_branch}", - ], - capture_output=True, - text=True, - check=False, - ) - staged_added = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - # Untracked files are by definition "created" - result = subprocess.run( - ["git", "ls-files", "--others", "--exclude-standard"], - capture_output=True, - text=True, - check=False, - ) - untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - - all_created = committed_added | staged_added | untracked_files - return sorted([f for f in all_created if f]) - - except subprocess.CalledProcessError: - return [] - - -def get_created_files_prompt() -> list[str]: - """Get files created since prompt was submitted.""" - baseline_path = Path(".deepwork/.last_work_tree") - - try: - subprocess.run(["git", "add", "-A"], capture_output=True, check=False) - - result = subprocess.run( - ["git", "diff", "--name-only", "--cached"], - capture_output=True, - text=True, - check=False, - ) - current_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - current_files = {f for f in current_files if f} - - # Untracked files - result = subprocess.run( - ["git", "ls-files", "--others", "--exclude-standard"], - capture_output=True, - text=True, - check=False, - ) - untracked_files = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set() - untracked_files = {f for f in untracked_files if f} - - all_current = current_files | untracked_files - - if baseline_path.exists(): - baseline_files = set(baseline_path.read_text().strip().split("\n")) - baseline_files = {f for f in baseline_files if f} - # Created files are those that didn't exist at baseline - created_files = all_current - baseline_files - return sorted(created_files) - else: - # No baseline means all current files are "new" to this prompt - return sorted(all_current) - - except (subprocess.CalledProcessError, OSError): - return [] - - -def get_created_files_for_mode(mode: str) -> list[str]: - """Get created files for a specific compare_to mode.""" - if mode == "base": - return get_created_files_base() - elif mode == "default_tip": - return get_created_files_default_tip() - elif mode == "prompt": - return get_created_files_prompt() - else: - return get_created_files_base() - - def extract_promise_tags(text: str) -> set[str]: """ Extract rule names from tags in text. @@ -584,14 +237,16 @@ def rules_check_hook(hook_input: HookInput) -> HookOutput: command_errors: list[str] = [] for mode, mode_rules in rules_by_mode.items(): - changed_files = get_changed_files_for_mode(mode) - created_files = get_created_files_for_mode(mode) + # Get the appropriate comparator for this mode + comparator = get_comparator(mode) + changed_files = comparator.get_changed_files() + created_files = comparator.get_created_files() # Skip if no changed or created files if not changed_files and not created_files: continue - baseline_ref = get_baseline_ref(mode) + baseline_ref = comparator.get_baseline_ref() # Evaluate which rules fire results = evaluate_rules(mode_rules, changed_files, promised_rules, created_files) diff --git a/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh b/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh index c9cedd82..719d69c9 100755 --- a/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh +++ b/src/deepwork/standard_jobs/deepwork_rules/hooks/capture_prompt_work_tree.sh @@ -1,38 +1,90 @@ #!/bin/bash # capture_prompt_work_tree.sh - Captures the git work tree state at prompt submission # -# This script creates a snapshot of ALL tracked files at the time the prompt -# is submitted. This baseline is used for rules with compare_to: prompt and -# created: mode to detect truly NEW files (not modifications to existing ones). +# ============================================================================= +# HOW THIS WORKS: GIT PLUMBING WITH TEMPORARY INDEX +# ============================================================================= # -# The baseline contains ALL tracked files (not just changed files) so that -# the rules_check hook can determine which files are genuinely new vs which -# files existed before and were just modified. +# This script uses Git "plumbing" commands to create a snapshot of the entire +# working directory (including untracked files) WITHOUT touching your actual +# staging area or commit history. # -# It also captures the HEAD commit ref so that committed changes can be detected -# by comparing HEAD at Stop time to the captured ref. +# THE APPROACH: +# 1. Create a temporary file to act as a separate git index +# - By setting GIT_INDEX_FILE, Git uses this temp file instead of .git/index +# - This ensures we don't interfere with any actual staged changes +# +# 2. Stage everything to this temporary index (git add -A) +# - Captures modified files, new files, and deleted files +# - Respects .gitignore automatically +# +# 3. Create a "Tree Object" from the temporary index (git write-tree) +# - A tree object is a snapshot stored in Git's object database +# - Returns a SHA hash (e.g., "d8329fc1...") representing the exact state +# - This is like a lightweight commit with no message/parent/author +# +# 4. Save the tree hash for later comparison +# - At "stop" time, we create another tree object for the current state +# - Then use "git diff-tree" to compare the two trees +# +# WHY THIS IS ROBUST: +# - FAST: Git is optimized for tree comparisons +# - SAFE: Does not touch HEAD, current Index, or Stashes +# - COMPLETE: Handles modified, new (untracked), and deleted files +# - CLEAN: Respects .gitignore automatically +# +# WHAT WE CAN DETECT: +# | Scenario | Detected? | Explanation | +# |-----------------------|-----------|-------------------------------------------| +# | Modified files | ✅ Yes | Git detects content hash changed | +# | New untracked files | ✅ Yes | git add -A captures them in temp index | +# | Deleted files | ✅ Yes | Tree comparison shows them as missing | +# | Staged vs Unstaged | ✅ Yes | We look at disk state, ignore staging | +# | Ignored files (.gitignore) | ✅ Excluded | Correctly excluded - rules don't trigger | +# +# ============================================================================= set -e # Ensure .deepwork directory exists mkdir -p .deepwork -# Save the current HEAD commit ref for detecting committed changes -# This is used by get_changed_files_prompt() to detect files changed since prompt, -# even if those changes were committed during the agent response. +# 1. Create a temporary file to act as a separate git index +# This ensures we don't mess with the actual 'git add' staging area. +TEMP_INDEX=$(mktemp) + +# 2. Use this temp file instead of .git/index +export GIT_INDEX_FILE="$TEMP_INDEX" + +# 3. Add EVERYTHING to this temp index +# -A handles new files, deletions, and modifications +# It respects .gitignore automatically +git add -A 2>/dev/null || true + +# 4. Write this state to a "Tree Object" and get the hash +# This stores the snapshot in git's object database without creating a commit +TREE_HASH=$(git write-tree 2>/dev/null || echo "") + +# 5. Clean up the temporary index and restore environment +rm -f "$TEMP_INDEX" +unset GIT_INDEX_FILE # Restore normal Git behavior + +# 6. Save the tree hash for later comparison by rules_check +if [ -n "$TREE_HASH" ]; then + echo "$TREE_HASH" > .deepwork/.last_tree_hash +else + # Fallback: if git write-tree failed, clear the file + rm -f .deepwork/.last_tree_hash +fi + +# Also save the HEAD ref for backwards compatibility and as a fallback +# This is used for committed changes detection git rev-parse HEAD > .deepwork/.last_head_ref 2>/dev/null || echo "" > .deepwork/.last_head_ref -# Save ALL tracked files (not just changed files) -# This is critical for created: mode rules to distinguish between: -# - Newly created files (not in baseline) -> should trigger created: rules -# - Modified existing files (in baseline) -> should NOT trigger created: rules +# LEGACY: Keep .last_work_tree for backwards compatibility during transition +# This will be removed in a future version git ls-files > .deepwork/.last_work_tree 2>/dev/null || true - -# Also include untracked files that exist at prompt time -# These are files the user may have created before submitting the prompt git ls-files --others --exclude-standard >> .deepwork/.last_work_tree 2>/dev/null || true - -# Sort and deduplicate if [ -f .deepwork/.last_work_tree ]; then sort -u .deepwork/.last_work_tree -o .deepwork/.last_work_tree fi diff --git a/tests/unit/test_git_utils.py b/tests/unit/test_git_utils.py new file mode 100644 index 00000000..69ab4cf7 --- /dev/null +++ b/tests/unit/test_git_utils.py @@ -0,0 +1,809 @@ +""" +================================================================================ + REQUIREMENTS TESTS - DO NOT MODIFY +================================================================================ + +These tests verify CRITICAL REQUIREMENTS for the Git comparison utilities. +They ensure the git_utils module behaves correctly with respect to: + +1. INTERFACE: All comparators implement a common interface +2. FACTORY: get_comparator() returns the correct comparator type +3. CREATED FILES: CompareToPrompt.get_created_files() detects truly new files +4. CHANGED FILES: get_changed_files() captures all changes since baseline + +WARNING: These tests represent contractual requirements for the rules_check hook. +Modifying these tests may violate expected behavior and could cause rules to +not trigger correctly. If a test fails, fix the IMPLEMENTATION, not the test. + +Requirements tested: + - REQ-001: All comparators MUST implement GitComparator interface + - REQ-002: get_comparator() MUST return correct comparator for each mode + - REQ-003: CompareToPrompt.get_created_files() MUST correctly detect new files + (uses tree-based comparison when available, falls back to .last_work_tree) + - REQ-004: Created files are those NOT present in baseline + +================================================================================ +""" + +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +from deepwork.core.git_utils import ( + CompareToBase, + CompareToDefaultTip, + CompareToPrompt, + GitComparator, + _create_tree_from_working_dir, + _diff_trees, + _get_all_changes_vs_ref, + _parse_file_list, + get_comparator, + get_default_branch, +) + +# ============================================================================= +# REQ-001: All comparators MUST implement GitComparator interface +# ============================================================================= +# +# The git_utils module provides multiple comparator classes for different +# comparison modes (base, default_tip, prompt). All comparators MUST implement +# the same interface to allow rules_check.py to use them interchangeably. +# +# Required methods: +# - get_changed_files() -> list[str] +# - get_created_files() -> list[str] +# - get_baseline_ref() -> str +# +# DO NOT MODIFY THESE TESTS - They ensure interface compatibility. +# ============================================================================= + + +class TestGitComparatorInterface: + """ + REQUIREMENTS TEST: Verify all comparators implement GitComparator interface. + + ============================================================================ + WARNING: DO NOT MODIFY THESE TESTS + ============================================================================ + + These tests verify that all comparator classes implement the required + interface methods. Modifying these tests could result in rules not + being triggered correctly due to missing or incorrect method signatures. + """ + + def test_compare_to_base_implements_interface(self) -> None: + """ + REQ-001: CompareToBase MUST implement GitComparator interface. + + DO NOT MODIFY THIS TEST. + """ + comparator = CompareToBase() + assert isinstance(comparator, GitComparator) + assert hasattr(comparator, "get_changed_files") + assert hasattr(comparator, "get_created_files") + assert hasattr(comparator, "get_baseline_ref") + assert callable(comparator.get_changed_files) + assert callable(comparator.get_created_files) + assert callable(comparator.get_baseline_ref) + + def test_compare_to_default_tip_implements_interface(self) -> None: + """ + REQ-001: CompareToDefaultTip MUST implement GitComparator interface. + + DO NOT MODIFY THIS TEST. + """ + comparator = CompareToDefaultTip() + assert isinstance(comparator, GitComparator) + assert hasattr(comparator, "get_changed_files") + assert hasattr(comparator, "get_created_files") + assert hasattr(comparator, "get_baseline_ref") + assert callable(comparator.get_changed_files) + assert callable(comparator.get_created_files) + assert callable(comparator.get_baseline_ref) + + def test_compare_to_prompt_implements_interface(self) -> None: + """ + REQ-001: CompareToPrompt MUST implement GitComparator interface. + + DO NOT MODIFY THIS TEST. + """ + comparator = CompareToPrompt() + assert isinstance(comparator, GitComparator) + assert hasattr(comparator, "get_changed_files") + assert hasattr(comparator, "get_created_files") + assert hasattr(comparator, "get_baseline_ref") + assert callable(comparator.get_changed_files) + assert callable(comparator.get_created_files) + assert callable(comparator.get_baseline_ref) + + +# ============================================================================= +# REQ-002: get_comparator() MUST return correct comparator for each mode +# ============================================================================= +# +# The get_comparator() factory function is the primary entry point for +# rules_check.py. It MUST return the correct comparator type based on the +# mode parameter to ensure rules are checked against the correct baseline. +# +# DO NOT MODIFY THESE TESTS - They ensure correct factory behavior. +# ============================================================================= + + +class TestGetComparatorFactory: + """ + REQUIREMENTS TEST: Verify get_comparator() returns correct comparator types. + + ============================================================================ + WARNING: DO NOT MODIFY THESE TESTS + ============================================================================ + + These tests verify that the factory function returns the correct comparator + for each mode. Modifying these tests could result in rules being checked + against the wrong baseline. + """ + + def test_returns_compare_to_base_for_base_mode(self) -> None: + """ + REQ-002: get_comparator("base") MUST return CompareToBase. + + DO NOT MODIFY THIS TEST. + """ + comparator = get_comparator("base") + assert isinstance(comparator, CompareToBase) + + def test_returns_compare_to_default_tip_for_default_tip_mode(self) -> None: + """ + REQ-002: get_comparator("default_tip") MUST return CompareToDefaultTip. + + DO NOT MODIFY THIS TEST. + """ + comparator = get_comparator("default_tip") + assert isinstance(comparator, CompareToDefaultTip) + + def test_returns_compare_to_prompt_for_prompt_mode(self) -> None: + """ + REQ-002: get_comparator("prompt") MUST return CompareToPrompt. + + DO NOT MODIFY THIS TEST. + """ + comparator = get_comparator("prompt") + assert isinstance(comparator, CompareToPrompt) + + def test_defaults_to_compare_to_base_for_unknown_mode(self) -> None: + """ + REQ-002: get_comparator() MUST default to CompareToBase for unknown modes. + + DO NOT MODIFY THIS TEST. + """ + comparator = get_comparator("unknown_mode") + assert isinstance(comparator, CompareToBase) + + +# ============================================================================= +# REQ-003: CompareToPrompt.get_created_files() MUST correctly detect new files +# ============================================================================= +# +# CRITICAL REQUIREMENT: The get_created_files() method for CompareToPrompt +# MUST accurately detect files that were created AFTER the prompt was submitted. +# +# PRIMARY METHOD: Tree-based comparison using .last_tree_hash +# - Uses Git plumbing (git write-tree / git diff-tree) for accurate comparison +# - Captures the complete working directory state at prompt time +# - Handles all edge cases: modified, new, deleted, staged, unstaged +# +# FALLBACK METHOD: File-list comparison using .last_work_tree +# - Used when .last_tree_hash doesn't exist (backwards compatibility) +# - Contains list of files that existed at prompt time +# - MUST NOT use .last_head_ref (would miss uncommitted files) +# +# This is essential for: +# - Rules that trigger on newly created files only +# - Avoiding false positives for pre-existing uncommitted files +# +# DO NOT MODIFY THESE TESTS - They prevent critical bugs in file detection. +# ============================================================================= + + +class TestCreatedFilesDetection: + """ + REQUIREMENTS TEST: Verify created files detection uses correct comparison. + + ============================================================================ + WARNING: DO NOT MODIFY THESE TESTS + ============================================================================ + + These tests verify that get_created_files() correctly identifies files + created after the prompt was submitted. + + PRIMARY: Uses tree-based comparison (.last_tree_hash) when available + FALLBACK: Uses file-list comparison (.last_work_tree) for backwards compat + + The fallback tests below verify the file-based comparison works correctly + when no tree hash exists. This is critical for correctly identifying files + created during the current session without false positives for pre-existing + uncommitted files. + """ + + def test_get_created_files_uses_work_tree_not_head_ref(self, temp_dir: Path) -> None: + """ + REQ-003: get_created_files() MUST use .last_work_tree, NOT .last_head_ref. + + This test simulates a scenario where: + - .last_tree_hash does NOT exist (so fallback to file-based comparison) + - .last_head_ref exists (pointing to a git commit) + - .last_work_tree exists (with a list of files including uncommitted ones) + - An uncommitted file (existing_uncommitted.py) was present at prompt time + + The method MUST use .last_work_tree, so existing_uncommitted.py should + NOT be flagged as "created" (it was in the baseline). + + DO NOT MODIFY THIS TEST. + """ + ref_file = temp_dir / ".last_head_ref" + ref_file.write_text("abc123") + work_tree_file = temp_dir / ".last_work_tree" + work_tree_file.write_text("existing_uncommitted.py\n") + + with ( + patch("deepwork.core.git_utils._stage_all_changes"), + patch( + "deepwork.core.git_utils._get_all_changes_vs_ref", + return_value={"existing_uncommitted.py", "new_file.py"}, + ), + patch("deepwork.core.git_utils._get_untracked_files", return_value=set()), + # No tree hash - tests fallback to file-based comparison + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", temp_dir / "nonexistent"), + patch.object(CompareToPrompt, "BASELINE_REF_PATH", ref_file), + patch.object(CompareToPrompt, "BASELINE_WORK_TREE_PATH", work_tree_file), + ): + comparator = CompareToPrompt() + created = comparator.get_created_files() + + # CRITICAL: existing_uncommitted.py was in .last_work_tree baseline + # so it MUST NOT be flagged as created + assert "existing_uncommitted.py" not in created, ( + "CRITICAL BUG: File from .last_work_tree flagged as created! " + "get_created_files() must use .last_work_tree for comparison, " + "not .last_head_ref. Pre-existing uncommitted files should not " + "be flagged as 'created'." + ) + + # new_file.py was NOT in .last_work_tree, so it IS created + assert "new_file.py" in created + + +# ============================================================================= +# REQ-004: Created files are those NOT present in baseline +# ============================================================================= +# +# The definition of "created file" is: a file that exists now but was NOT +# present in the baseline at prompt time. This requirement ensures consistent +# behavior across different scenarios. +# +# DO NOT MODIFY THESE TESTS - They define the contract for created file logic. +# ============================================================================= + + +class TestCreatedFilesDefinition: + """ + REQUIREMENTS TEST: Verify correct definition of "created files". + + ============================================================================ + WARNING: DO NOT MODIFY THESE TESTS + ============================================================================ + + These tests verify the fundamental definition of what constitutes a + "created file" - a file that exists now but was NOT present at baseline. + """ + + def test_files_in_baseline_are_not_created(self, temp_dir: Path) -> None: + """ + REQ-004: Files present in baseline MUST NOT be flagged as created. + + DO NOT MODIFY THIS TEST. + """ + work_tree_file = temp_dir / ".last_work_tree" + work_tree_file.write_text("existing.py\n") + + with ( + patch("deepwork.core.git_utils._stage_all_changes"), + patch( + "deepwork.core.git_utils._get_all_changes_vs_ref", + return_value={"existing.py", "new.py"}, + ), + patch("deepwork.core.git_utils._get_untracked_files", return_value=set()), + # No tree hash - tests fallback to file-based comparison + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", temp_dir / "nonexistent"), + patch.object(CompareToPrompt, "BASELINE_REF_PATH", temp_dir / "nonexistent2"), + patch.object(CompareToPrompt, "BASELINE_WORK_TREE_PATH", work_tree_file), + ): + comparator = CompareToPrompt() + created = comparator.get_created_files() + + assert "existing.py" not in created, ( + "File in baseline was incorrectly flagged as created" + ) + assert "new.py" in created + + def test_all_current_files_created_when_no_baseline(self, temp_dir: Path) -> None: + """ + REQ-004: When no baseline exists, ALL current files are considered created. + + DO NOT MODIFY THIS TEST. + """ + with ( + patch("deepwork.core.git_utils._stage_all_changes"), + patch( + "deepwork.core.git_utils._get_all_changes_vs_ref", + return_value={"file1.py"}, + ), + patch( + "deepwork.core.git_utils._get_untracked_files", + return_value={"file2.py"}, + ), + # No tree hash or any baseline files + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", temp_dir / "nonexistent"), + patch.object(CompareToPrompt, "BASELINE_REF_PATH", temp_dir / "nonexistent2"), + patch.object( + CompareToPrompt, + "BASELINE_WORK_TREE_PATH", + temp_dir / "nonexistent3", + ), + ): + comparator = CompareToPrompt() + created = comparator.get_created_files() + + assert "file1.py" in created, "Staged file should be created when no baseline" + assert "file2.py" in created, "Untracked file should be created when no baseline" + + +# ============================================================================= +# IMPLEMENTATION TESTS +# ============================================================================= +# +# The tests below verify implementation details and helper functions. +# These may be modified if the implementation changes, as long as the +# REQUIREMENTS tests above continue to pass. +# +# ============================================================================= + + +class TestParseFileList: + """Tests for _parse_file_list helper function.""" + + def test_parses_newline_separated_files(self) -> None: + output = "file1.py\nfile2.py\nfile3.py" + result = _parse_file_list(output) + assert result == {"file1.py", "file2.py", "file3.py"} + + def test_returns_empty_set_for_empty_string(self) -> None: + assert _parse_file_list("") == set() + + def test_returns_empty_set_for_whitespace_only(self) -> None: + assert _parse_file_list(" \n\n ") == set() + + def test_filters_empty_lines(self) -> None: + output = "file1.py\n\nfile2.py\n" + result = _parse_file_list(output) + assert result == {"file1.py", "file2.py"} + + def test_strips_outer_whitespace_only(self) -> None: + """Document that _parse_file_list strips outer whitespace but not per-line. + + This is fine because git commands don't output whitespace-padded filenames. + """ + output = " file1.py \n file2.py " + result = _parse_file_list(output) + # After strip(): "file1.py \n file2.py" -> split -> ["file1.py ", " file2.py"] + assert result == {"file1.py ", " file2.py"} + + +class TestGetAllChangesVsRef: + """Tests for _get_all_changes_vs_ref helper function.""" + + def test_returns_files_from_git_diff(self) -> None: + with patch("deepwork.core.git_utils._run_git") as mock_run: + mock_run.return_value = MagicMock(stdout="file1.py\nfile2.py\n") + result = _get_all_changes_vs_ref("abc123") + assert result == {"file1.py", "file2.py"} + mock_run.assert_called_once_with("diff", "--name-only", "--cached", "abc123") + + def test_applies_diff_filter(self) -> None: + with patch("deepwork.core.git_utils._run_git") as mock_run: + mock_run.return_value = MagicMock(stdout="new_file.py\n") + result = _get_all_changes_vs_ref("abc123", diff_filter="A") + assert result == {"new_file.py"} + mock_run.assert_called_once_with( + "diff", "--name-only", "--diff-filter=A", "--cached", "abc123" + ) + + def test_returns_empty_set_for_no_changes(self) -> None: + with patch("deepwork.core.git_utils._run_git") as mock_run: + mock_run.return_value = MagicMock(stdout="") + result = _get_all_changes_vs_ref("abc123") + assert result == set() + + +class TestCreateTreeFromWorkingDir: + """Tests for _create_tree_from_working_dir helper function. + + This function creates a tree object from the current working directory + using a temporary index to avoid touching the actual staging area. + """ + + def test_returns_tree_hash_on_success(self) -> None: + with patch("deepwork.core.git_utils.subprocess.run") as mock_run: + # git add -A succeeds, git write-tree returns hash + mock_run.side_effect = [ + MagicMock(returncode=0), # git add -A + MagicMock(stdout="abc123def456\n", returncode=0), # git write-tree + ] + result = _create_tree_from_working_dir() + assert result == "abc123def456" + + def test_returns_none_on_write_tree_failure(self) -> None: + with patch("deepwork.core.git_utils.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(returncode=0), # git add -A + subprocess.CalledProcessError(1, "git write-tree"), + ] + result = _create_tree_from_working_dir() + assert result is None + + def test_uses_temporary_index_file(self) -> None: + import os + + original_env = os.environ.get("GIT_INDEX_FILE") + + with patch("deepwork.core.git_utils.subprocess.run") as mock_run: + captured_env = {} + + def capture_env(*args, **kwargs): + captured_env["GIT_INDEX_FILE"] = os.environ.get("GIT_INDEX_FILE") + if args[0][1] == "write-tree": + return MagicMock(stdout="abc123\n", returncode=0) + return MagicMock(returncode=0) + + mock_run.side_effect = capture_env + _create_tree_from_working_dir() + + # Should have used a temp index file during execution + assert captured_env.get("GIT_INDEX_FILE") is not None + assert captured_env["GIT_INDEX_FILE"] != original_env + + # Should restore original env after execution + assert os.environ.get("GIT_INDEX_FILE") == original_env + + +class TestDiffTrees: + """Tests for _diff_trees helper function. + + This function compares two tree objects using git diff-tree. + """ + + def test_returns_changed_files(self) -> None: + with patch("deepwork.core.git_utils._run_git") as mock_run: + mock_run.return_value = MagicMock(stdout="file1.py\nfile2.py\n") + result = _diff_trees("tree_a", "tree_b") + assert result == {"file1.py", "file2.py"} + mock_run.assert_called_once_with( + "diff-tree", "--name-only", "-r", "tree_a", "tree_b" + ) + + def test_applies_diff_filter(self) -> None: + with patch("deepwork.core.git_utils._run_git") as mock_run: + mock_run.return_value = MagicMock(stdout="new_file.py\n") + result = _diff_trees("tree_a", "tree_b", diff_filter="A") + assert result == {"new_file.py"} + mock_run.assert_called_once_with( + "diff-tree", "--name-only", "-r", "--diff-filter=A", "tree_a", "tree_b" + ) + + def test_returns_empty_set_for_identical_trees(self) -> None: + with patch("deepwork.core.git_utils._run_git") as mock_run: + mock_run.return_value = MagicMock(stdout="") + result = _diff_trees("same_tree", "same_tree") + assert result == set() + + +class TestGetDefaultBranch: + """Tests for get_default_branch function.""" + + def test_returns_main_when_origin_head_points_to_main(self) -> None: + with patch("deepwork.core.git_utils.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="refs/remotes/origin/main\n", returncode=0) + assert get_default_branch() == "main" + + def test_returns_master_when_origin_head_points_to_master(self) -> None: + with patch("deepwork.core.git_utils.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="refs/remotes/origin/master\n", returncode=0) + assert get_default_branch() == "master" + + def test_falls_back_to_checking_main_branch(self) -> None: + with patch("deepwork.core.git_utils.subprocess.run") as mock_run: + # First call fails (symbolic-ref), second succeeds (rev-parse for main) + mock_run.side_effect = [ + subprocess.CalledProcessError(1, "git"), + MagicMock(returncode=0), + ] + assert get_default_branch() == "main" + + def test_falls_back_to_master_if_main_not_found(self) -> None: + with patch("deepwork.core.git_utils.subprocess.run") as mock_run: + # First call fails, second fails (main), third succeeds (master) + mock_run.side_effect = [ + subprocess.CalledProcessError(1, "git"), + subprocess.CalledProcessError(1, "git"), + MagicMock(returncode=0), + ] + assert get_default_branch() == "master" + + def test_defaults_to_main_when_nothing_found(self) -> None: + with patch("deepwork.core.git_utils.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, "git") + assert get_default_branch() == "main" + + +class TestCompareToBaseImplementation: + """Implementation tests for CompareToBase comparator.""" + + def test_get_fallback_name_returns_base(self) -> None: + comparator = CompareToBase() + assert comparator._get_fallback_name() == "base" + + def test_get_baseline_ref_returns_fallback_when_ref_unavailable(self) -> None: + with patch.object(CompareToBase, "_get_ref", return_value=None): + comparator = CompareToBase() + assert comparator.get_baseline_ref() == "base" + + def test_get_baseline_ref_returns_commit_sha(self) -> None: + with patch.object(CompareToBase, "_get_ref", return_value="abc123def456"): + comparator = CompareToBase() + assert comparator.get_baseline_ref() == "abc123def456" + + def test_get_changed_files_returns_empty_when_no_ref(self) -> None: + with patch.object(CompareToBase, "_get_ref", return_value=None): + comparator = CompareToBase() + assert comparator.get_changed_files() == [] + + def test_get_created_files_returns_empty_when_no_ref(self) -> None: + with patch.object(CompareToBase, "_get_ref", return_value=None): + comparator = CompareToBase() + assert comparator.get_created_files() == [] + + +class TestCompareToDefaultTipImplementation: + """Implementation tests for CompareToDefaultTip comparator.""" + + def test_get_fallback_name_returns_default_tip(self) -> None: + comparator = CompareToDefaultTip() + assert comparator._get_fallback_name() == "default_tip" + + def test_get_baseline_ref_returns_fallback_when_ref_unavailable(self) -> None: + with patch.object(CompareToDefaultTip, "_get_ref", return_value=None): + comparator = CompareToDefaultTip() + assert comparator.get_baseline_ref() == "default_tip" + + +class TestCompareToPromptImplementation: + """Implementation tests for CompareToPrompt comparator.""" + + def test_get_baseline_ref_returns_prompt_when_no_baseline_file(self, temp_dir: Path) -> None: + with ( + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", temp_dir / "nonexistent"), + patch.object(CompareToPrompt, "BASELINE_WORK_TREE_PATH", temp_dir / "nonexistent2"), + ): + comparator = CompareToPrompt() + assert comparator.get_baseline_ref() == "prompt" + + def test_get_baseline_ref_returns_tree_hash_when_tree_exists(self, temp_dir: Path) -> None: + tree_file = temp_dir / ".last_tree_hash" + tree_file.write_text("abc123def456789012") + + with ( + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", tree_file), + patch.object(CompareToPrompt, "BASELINE_WORK_TREE_PATH", temp_dir / "nonexistent"), + ): + comparator = CompareToPrompt() + ref = comparator.get_baseline_ref() + # Should return short hash (first 12 chars) + assert ref == "abc123def456" + + def test_get_baseline_ref_falls_back_to_mtime_when_no_tree(self, temp_dir: Path) -> None: + baseline_file = temp_dir / ".last_work_tree" + baseline_file.write_text("file1.py\nfile2.py") + + with ( + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", temp_dir / "nonexistent"), + patch.object(CompareToPrompt, "BASELINE_WORK_TREE_PATH", baseline_file), + ): + comparator = CompareToPrompt() + ref = comparator.get_baseline_ref() + # Should be a numeric timestamp string (fallback) + assert ref.isdigit() + + # ------------------------------------------------------------------------- + # Tree-based comparison tests (primary path) + # ------------------------------------------------------------------------- + + def test_get_changed_files_uses_tree_comparison_when_available(self, temp_dir: Path) -> None: + """When tree hash exists, uses git diff-tree for comparison.""" + tree_file = temp_dir / ".last_tree_hash" + tree_file.write_text("baseline_tree_abc123") + + with ( + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", tree_file), + patch( + "deepwork.core.git_utils._create_tree_from_working_dir", + return_value="current_tree_def456", + ), + patch( + "deepwork.core.git_utils._diff_trees", + return_value={"changed.py", "new.py"}, + ) as mock_diff, + ): + comparator = CompareToPrompt() + changed = comparator.get_changed_files() + + # Should use tree-based comparison + mock_diff.assert_called_once_with("baseline_tree_abc123", "current_tree_def456") + assert changed == ["changed.py", "new.py"] + + def test_get_created_files_uses_tree_comparison_when_available(self, temp_dir: Path) -> None: + """When tree hash exists, uses git diff-tree with filter=A for created files.""" + tree_file = temp_dir / ".last_tree_hash" + tree_file.write_text("baseline_tree_abc123") + + with ( + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", tree_file), + patch( + "deepwork.core.git_utils._create_tree_from_working_dir", + return_value="current_tree_def456", + ), + patch( + "deepwork.core.git_utils._diff_trees", + return_value={"new.py"}, + ) as mock_diff, + ): + comparator = CompareToPrompt() + created = comparator.get_created_files() + + # Should use tree-based comparison with diff_filter=A + mock_diff.assert_called_once_with( + "baseline_tree_abc123", "current_tree_def456", diff_filter="A" + ) + assert created == ["new.py"] + + # ------------------------------------------------------------------------- + # Fallback behavior tests (when tree hash not available) + # ------------------------------------------------------------------------- + + def test_get_changed_files_falls_back_to_ref_when_no_tree(self, temp_dir: Path) -> None: + """When no tree hash, falls back to .last_head_ref comparison.""" + ref_file = temp_dir / ".last_head_ref" + ref_file.write_text("abc123") + + with ( + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", temp_dir / "nonexistent"), + patch.object(CompareToPrompt, "BASELINE_REF_PATH", ref_file), + patch("deepwork.core.git_utils._stage_all_changes"), + patch( + "deepwork.core.git_utils._get_all_changes_vs_ref", + return_value={"committed.py", "staged.py"}, + ), + ): + comparator = CompareToPrompt() + changed = comparator.get_changed_files() + assert "committed.py" in changed + assert "staged.py" in changed + + def test_get_changed_files_with_no_baseline_files(self, temp_dir: Path) -> None: + """When no baseline files exist, returns staged changes and untracked files.""" + with ( + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", temp_dir / "nonexistent"), + patch.object(CompareToPrompt, "BASELINE_REF_PATH", temp_dir / "nonexistent2"), + patch("deepwork.core.git_utils._stage_all_changes"), + patch( + "deepwork.core.git_utils._get_all_changes_vs_ref", + return_value={"staged.py"}, + ), + patch( + "deepwork.core.git_utils._get_untracked_files", + return_value={"untracked.py"}, + ), + ): + comparator = CompareToPrompt() + changed = comparator.get_changed_files() + assert "staged.py" in changed + assert "untracked.py" in changed + +class TestRefBasedComparatorIntegration: + """Integration tests for RefBasedComparator.""" + + def test_comparator_caches_ref(self) -> None: + """Test that ref is cached after first resolution.""" + with patch("deepwork.core.git_utils.get_default_branch", return_value="main"): + comparator = CompareToBase() + + with patch.object(comparator, "_get_ref", return_value="abc123") as mock_get_ref: + # Call multiple times + comparator._resolve_ref() + comparator._resolve_ref() + comparator._resolve_ref() + + # Should only call _get_ref once due to caching + assert mock_get_ref.call_count == 1 + + +class TestCompareToPromptIntegration: + """Integration tests for CompareToPrompt with file system.""" + + def test_full_workflow_with_tree_hash(self, temp_dir: Path) -> None: + """Test complete workflow with tree-based comparison (primary path).""" + deepwork_dir = temp_dir / ".deepwork" + deepwork_dir.mkdir() + + # Write tree hash file (primary method) + tree_file = deepwork_dir / ".last_tree_hash" + tree_file.write_text("baseline_tree_abc123") + + with ( + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", tree_file), + patch( + "deepwork.core.git_utils._create_tree_from_working_dir", + return_value="current_tree_def456", + ), + patch( + "deepwork.core.git_utils._diff_trees", + side_effect=lambda a, b, diff_filter=None: ( + {"new_file.py"} if diff_filter == "A" else {"new_file.py", "modified.py"} + ), + ), + ): + comparator = CompareToPrompt() + + changed = comparator.get_changed_files() + created = comparator.get_created_files() + + # new_file.py should be in both changed and created + assert "new_file.py" in changed + assert "modified.py" in changed + assert "new_file.py" in created + # modified.py was modified, not created + assert "modified.py" not in created + + def test_full_workflow_with_fallback_files(self, temp_dir: Path) -> None: + """Test complete workflow with fallback to file-based comparison.""" + deepwork_dir = temp_dir / ".deepwork" + deepwork_dir.mkdir() + + # Write legacy baseline files (no tree hash) + ref_file = deepwork_dir / ".last_head_ref" + ref_file.write_text("abc123") + + work_tree_file = deepwork_dir / ".last_work_tree" + work_tree_file.write_text("README.md\n") + + # Mock git operations to simulate a new file being created + with ( + # No tree hash - forces fallback + patch.object(CompareToPrompt, "BASELINE_TREE_PATH", temp_dir / "nonexistent"), + patch.object(CompareToPrompt, "BASELINE_REF_PATH", ref_file), + patch.object(CompareToPrompt, "BASELINE_WORK_TREE_PATH", work_tree_file), + patch("deepwork.core.git_utils._stage_all_changes"), + patch( + "deepwork.core.git_utils._get_all_changes_vs_ref", + return_value={"new_file.py"}, + ), + ): + comparator = CompareToPrompt() + + changed = comparator.get_changed_files() + created = comparator.get_created_files() + + # new_file.py should appear in both changed and created + assert "new_file.py" in changed + assert "new_file.py" in created + # README.md was in baseline, so not created + assert "README.md" not in created