Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a79ba183d | |||
| a0807c20a0 | |||
| 03a0b21f38 | |||
| 72c0409c79 | |||
| 3217719709 | |||
| e8c4740389 | |||
| 4a75ea9f99 | |||
| 19f1cdedbb | |||
| 732fc1cd3f | |||
| 4a6df82792 | |||
| 819f98d9fa | |||
| 28a620079f | |||
| fb3a3fbda7 | |||
| 76d1d3b032 | |||
| 3cb05781fa | |||
| d98ff7d19c | |||
| 635b53eeaf | |||
| 2e4b5ac659 | |||
| 385f044144 | |||
| 7b0f3a2c03 | |||
| 3a7c4ca7a9 | |||
| 4091d1d4b5 | |||
| f40f79a2db | |||
| 603b9a24bf | |||
| ecb6158024 | |||
| ae13ce14c2 | |||
| e3b219288e | |||
| 6204d5fc2b | |||
| f735f0b49b | |||
| a4870fa0c3 | |||
| f1b8cd3a7a | |||
| 4d4234378f | |||
| d1fbccde39 | |||
| ed93df698b | |||
| 8872d33e32 | |||
| 3b3ad75c1b | |||
| 8ece0009ee | |||
| 115576e85d | |||
| 3791b37bbd | |||
| 2823873566 | |||
| 4f1b7b2a95 | |||
| 5e78d748ee | |||
| aa5fc7f952 | |||
| 1d64615211 | |||
| cd89147003 | |||
| ded6aad4f7 | |||
| f149a7fbd7 | |||
| c2245b8122 | |||
| 950da45e4a | |||
| 25acf2826c | |||
| 5ac40f57c1 | |||
| 39aa088725 | |||
| 8de8039db2 | |||
| 68e782df1f | |||
| 6f751e5e74 | |||
| f4788e4af8 | |||
| 3f95765cf2 | |||
| 923880f5b2 | |||
| 390ba6a588 | |||
| aa7f56e5d0 | |||
| 5745cb149f |
@@ -7,6 +7,7 @@
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
nul
|
||||
|
||||
# ===========================
|
||||
# Security - Environment & Secrets
|
||||
|
||||
+31
-4
@@ -127,6 +127,13 @@ fi
|
||||
if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "Python changes detected, running backend checks..."
|
||||
|
||||
# Detect if we're in a worktree
|
||||
IS_WORKTREE=false
|
||||
if [ -f ".git" ]; then
|
||||
# .git is a file (not directory) in worktrees
|
||||
IS_WORKTREE=true
|
||||
fi
|
||||
|
||||
# Determine ruff command (venv or global)
|
||||
RUFF=""
|
||||
if [ -f "apps/backend/.venv/bin/ruff" ]; then
|
||||
@@ -158,7 +165,16 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "$STAGED_PY_FILES" | xargs git add
|
||||
fi
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
if [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: ruff not available in this worktree."
|
||||
echo " Python linting checks will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
|
||||
@@ -192,17 +208,28 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
elif [ -d "apps/backend/.venv" ]; then
|
||||
echo "Warning: venv exists but Python not found in it, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
elif [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: Python venv not available in this worktree."
|
||||
echo " Python tests will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
exit 77 # GNU convention for 'test skipped' (avoids pytest exit-code collision)
|
||||
else
|
||||
echo "Warning: No .venv found in apps/backend, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
fi
|
||||
)
|
||||
if [ $? -ne 0 ]; then
|
||||
PYTHON_EXIT=$?
|
||||
if [ $PYTHON_EXIT -eq 77 ]; then
|
||||
echo "Backend checks passed! (Python tests skipped — worktree)"
|
||||
elif [ $PYTHON_EXIT -ne 0 ]; then
|
||||
echo "Python tests failed. Please fix failing tests before committing."
|
||||
exit 1
|
||||
else
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+12
-20
@@ -97,9 +97,8 @@ repos:
|
||||
- id: ruff-format
|
||||
files: ^apps/backend/
|
||||
|
||||
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
|
||||
# Python tests (apps/backend/) - run full test suite from project root
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
@@ -108,31 +107,24 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# This prevents path resolution issues with ../../tests/ in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping pytest in worktree (path resolution would fail)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/backend
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTEST_CMD=".venv/bin/pytest"
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD=".venv/Scripts/pytest.exe"
|
||||
# Run pytest directly from project root
|
||||
if [ -f "apps/backend/.venv/bin/pytest" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/bin/pytest"
|
||||
elif [ -f "apps/backend/.venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/Scripts/pytest.exe"
|
||||
else
|
||||
PYTEST_CMD="python -m pytest"
|
||||
fi
|
||||
PYTHONPATH=. $PYTEST_CMD \
|
||||
../../tests/ \
|
||||
$PYTEST_CMD tests/ \
|
||||
-v \
|
||||
--tb=short \
|
||||
-x \
|
||||
-m "not slow and not integration" \
|
||||
--ignore=../../tests/test_graphiti.py \
|
||||
--ignore=../../tests/test_merge_file_tracker.py \
|
||||
--ignore=../../tests/test_service_orchestrator.py \
|
||||
--ignore=../../tests/test_worktree.py \
|
||||
--ignore=../../tests/test_workspace.py
|
||||
--ignore=tests/test_graphiti.py \
|
||||
--ignore=tests/test_merge_file_tracker.py \
|
||||
--ignore=tests/test_service_orchestrator.py \
|
||||
--ignore=tests/test_worktree.py \
|
||||
--ignore=tests/test_workspace.py
|
||||
language: system
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
+231
@@ -1,3 +1,234 @@
|
||||
## 2.7.6 - Stability & Feature Enhancements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Multi-profile account management** — Unified profile swapping with automatic token refresh and rate limit recovery for both OAuth and API-compatible providers
|
||||
|
||||
- **Enhanced terminal experience** — Customizable terminal fonts with OS-specific defaults, Claude Code CLI settings injection, and improved worktree integration
|
||||
|
||||
- **Advanced roadmap management** — Expand/collapse functionality for phase features and real-time sync with task lifecycle
|
||||
|
||||
- **Queue System v2** — Smart task prioritization with auto-promotion and intelligent rate limit recovery
|
||||
|
||||
- **GitHub integration enhancements** — AI-powered PR template generation, user-friendly API error handling, and improved review visibility
|
||||
|
||||
- **UI/UX improvements** — Spell check support for text inputs, collapsible sidebar toggle, task screenshot capture, expandable task descriptions, and bulk worktree operations
|
||||
|
||||
- **Evidence-based PR validation** — Advanced review system with trigger-driven exploration and enhanced recovery mechanisms
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- **Performance optimizations** — Async parallel worktree listing prevents UI freezes and improves responsiveness
|
||||
|
||||
- **Robustness enhancements** — Atomic file writes, better error detection in AI responses, and improved OOM/orphaned agent management for overnight builds
|
||||
|
||||
- **Terminal stability** — Fixed GPU context exhaustion from large pastes, SIGABRT crashes on macOS shutdown, and session restoration on app restart
|
||||
|
||||
- **Build & packaging** — XState bundling for packaged apps, aligned Linux package builds, and improved auto-updater for beta releases and DMG installations
|
||||
|
||||
- **Diagnostic improvements** — Sentry instrumentation for Python subprocesses and better error tracking across the system
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Terminal & PTY** — Fixed paste size limits, race conditions, rendering issues, text alignment, worktree crashes, and terminal content resizing on expansion
|
||||
|
||||
- **PR review system** — Resolved error visibility in bundled apps, improved structured output validation with three-tier recovery, preserved findings during crashes, and fixed UTC timestamp detection for comment tracking
|
||||
|
||||
- **Planning & task execution** — Fixed handling of empty/greenfield projects, atomic writes to prevent 0-byte file corruption, planning phase crashes, and implementation plan file watching
|
||||
|
||||
- **Authentication & profiles** — Resolved OAuth token revocation loops, API profile mode support without OAuth requirement, subscription type preservation during token refresh, and Linux credential file detection
|
||||
|
||||
- **Windows/cross-platform** — Complete System32 executable path fixes for where.exe and taskkill.exe, Windows credential normalization, and proper shell detection for Windows terminals
|
||||
|
||||
- **Agent management** — Fixed infinite retry loops for tool concurrency errors, auth error detection, and title generator production path resolution
|
||||
|
||||
- **UI/UX fixes** — Resolved Insights scroll-to-blank-space issues, infinite re-render loops in terminal font settings, kanban board scaling collisions, ideation stuck states, and panel constraint errors during terminal exit
|
||||
|
||||
- **Worktree & Git** — Improved branch pattern validation, removed auto-commit on deletion, support for detached HEAD state during PR creation, and better merge conflict resolution with progress tracking
|
||||
|
||||
- **Integrations** — Fixed Ollama infinite subprocess spawning, Graphiti import paths, OpenRouter API URL suffix, and GitLab authentication bugs
|
||||
|
||||
- **Settings & configuration** — Corrected .auto-claude path discovery timeout, z.AI China preset URL, log order sorting, and onboarding completion state persistence
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Added Awesome Claude Code badge to README
|
||||
|
||||
- Added instructions for resetting PR review state in CLAUDE.md
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix: handle unknown SDK message types (rate_limit_event) to prevent session crashes by @AndyMik90 in 4a75ea9f9
|
||||
- fix: PR review error visibility and gh CLI resolution in bundled apps by @AndyMik90 in 732fc1cd3
|
||||
- fix: handle empty/greenfield projects in spec creation (#1426) (#1841) by @Andy in 819f98d9f
|
||||
- fix: clear terminalEventSeen on task restart to prevent stuck-after-planning (#1828) (#1840) by @Andy in 28a620079
|
||||
- fix: watch worktree path for implementation_plan.json changes (#1805) (#1842) by @Andy in fb3a3fbda
|
||||
- fix: resolve Claude CLI not found on Windows - PATH, prompt size, cwd (#1661) (#1843) by @Andy in 76d1d3b03
|
||||
- fix: handle planning phase crash and resume recovery (#1562) (#1844) by @Andy in 3cb05781f
|
||||
- fix: show dismissed PR review findings in UI instead of silently dropping them (#1852) by @Andy in d98ff7d19
|
||||
- fix: preserve file/line info in PR review extraction recovery (#1857) by @Andy in 635b53eea
|
||||
- docs: add Awesome Claude Code badge to README (#1838) by @Andy in 2e4b5ac65
|
||||
- test: achieve 100% test coverage for backend CLI commands (#1772) by @StillKnotKnown in 385f04414
|
||||
- fix: cap terminal paste size to 1MB to prevent GPU context exhaustion by @AndyMik90 in 7b0f3a2c0
|
||||
- fix: prevent OOM, orphaned agents, and unbounded growth during overnight builds (#1813) by @Andy in 4091d1d4b
|
||||
- docs: add instructions for resetting PR review state in CLAUDE.md by @AndyMik90 in ecb615802
|
||||
- auto-claude: 217-investigate-symlink-issues-in-work-tree-creation-f (#1808) by @Andy in ae13ce14c
|
||||
- auto-claude: 218-enable-claude-code-features-in-worktree-terminals (#1809) by @Andy in e3b219288
|
||||
- auto-claude: 219-investigate-and-fix-authentication-subscription-sy (#1810) by @Andy in 6204d5fc2
|
||||
- feat(roadmap): add expand/collapse functionality for phase features (#1796) by @Burak in f735f0b49
|
||||
- auto-claude: 216-display-ongoing-pr-review-logs-in-progress (#1807) by @Andy in a4870fa0c
|
||||
- fix(pr-review): reduce structured output failures and preserve findings in recovery (#1806) by @Andy in f1b8cd3a7
|
||||
- fix(sentry): enable Sentry for Python subprocesses and add diagnostic instrumentation (#1804) by @Andy in 4d4234378
|
||||
- fix(pr-review): add three-tier recovery for structured output validation failure (#1797) by @Andy in d1fbccde3
|
||||
- test: improve backend agent test coverage to 94% (#1779) by @StillKnotKnown in ed93df698
|
||||
- fix(github): use UTC timestamps for reviewed_at to fix comment detection (#1795) by @Andy in 8872d33e3
|
||||
- feat: add user-friendly GitHub API error handling (#1790) by @StillKnotKnown in 8ece0009e
|
||||
- fix(roadmap): sync roadmap features with task lifecycle (#1791) by @Andy in 115576e85
|
||||
- fix(github): resolve PR review hanging in bundled app (#1793) by @Andy in 3791b37bb
|
||||
- feat(profiles): implement unified profile swapping across OAuth and API accounts (#1794) by @StillKnotKnown in 282387356
|
||||
- test: improve backend memory system test coverage to 100% (#1780) by @StillKnotKnown in 4f1b7b2a9
|
||||
- fix(ideation): guard against non-string properties in IdeaCard badges by @AndyMik90 in 5e78d748e
|
||||
- fix(updater): convert HTML release notes to markdown before rendering by @AndyMik90 in aa5fc7f95
|
||||
- fix(pr-review): simplify structured output schema to reduce validation failures (#1787) by @Andy in cd8914700
|
||||
- fix(qa): enforce visual verification for UI changes and inject startup commands (#1784) by @Andy in f149a7fbd
|
||||
- fix(plan-files): use atomic writes to prevent 0-byte corruption (#1785) by @Andy in c2245b812
|
||||
- fix(terminal): make worktree dropdown scrollable and show all items by @AndyMik90 in 950da45e4
|
||||
- auto-claude: subtask-1-1 - Add adaptive thinking badge to thinking level label (#1782) by @Andy in 25acf2826
|
||||
- auto-claude: subtask-1-1 - Add overflow-hidden and break-words to subtask cards by @AndyMik90 in 39aa08872
|
||||
- refactor(app-updater): disable automatic downloads and allow intentional downgrades by @AndyMik90 in 8de8039db
|
||||
- fix(auth): detect auth errors in AI response text and prevent retry loops (#1776) by @Andy in f4788e4af
|
||||
- test: achieve 100% coverage for backend core workspace module (#1774) by @StillKnotKnown in 3f95765cf
|
||||
- fix(title-generator): add production path resolution for backend source (#1778) by @Andy in 923880f5b
|
||||
- fix(fast-mode): use setting_sources instead of env var for CLI fast mode (#1771) by @Andy in 390ba6a58
|
||||
- fix(windows): complete System32 executable path fixes for where.exe and taskkill.exe (#1715) by @VDT-91 in aa7f56e5d
|
||||
- fix(worktree): remove auto-commit on deletion and add uncommitted changes warning by @AndyMik90 in cec8e65ee
|
||||
- Smart PR Status Polling System (#1766) by @Andy in 48d5f7a32
|
||||
- feat: simplify thinking system and remove opus-1m model variant (#1760) by @Andy in bb7e18937
|
||||
- auto-claude: 203-fix-pr-review-ui-update-issue (#1732) by @Andy in 7589f8e4f
|
||||
- auto-claude: subtask-2-1 - Create isAPIProfileAuthenticated() function to val (#1745) by @Andy in 57e38a692
|
||||
- auto-claude: 202-fix-kanban-board-scaling-collisions (#1731) by @Andy in d09ebb850
|
||||
- auto-claude: 204-fix-pr-review-ui-not-updating-without-manual-navig (#1734) by @Andy in 087091cef
|
||||
- auto-claude: 203-fix-ui-not-updating-during-pr-review-operations (#1733) by @Andy in f085c08bd
|
||||
- auto-claude: 205-fix-insights-chat-only-shows-last-task-suggestion- (#1735) by @Andy in f121f9cdd
|
||||
- auto-claude: 197-roadmap-generation-stuck-at-50-file-locking-race-c (#1746) by @Andy in f41f15e59
|
||||
- auto-claude: 193-fix-update-context7-mcp-tool-name-from-get-library (#1744) by @Andy in bdff9141a
|
||||
- auto-claude: 192-changelog-generation-multiple-critical-bugs-tasks- (#1725) by @Andy in 8c9a504df
|
||||
- auto-claude: 194-bug-rate-limit-during-task-execution-causes-subtas (#1726) by @Andy in 8a7443d24
|
||||
- auto-claude: 201-bug-pr-review-logs-and-analysis (#1730) by @Andy in e0d53adb4
|
||||
- auto-claude: 196-fix-worktrees-dialog-auto-close-race-condition-and (#1727) by @Andy in 323b0d3be
|
||||
- auto-claude: 199-bug-logs-disappear-after-restart (#1728) by @Andy in d639f6ef8
|
||||
- auto-claude: 198-critical-oauth-token-revocation-causes-infinite-40 (#1747) by @Andy in 4438c0b10
|
||||
- Fix Panel Constraints Error During Terminal Exit (#1757) by @Andy in 32bf353da
|
||||
- auto-claude: 190-bug-context-page-crash-multiple-root-causes-when-v (#1724) by @Andy in 2db36982f
|
||||
- feat: add search/filter to WorktreeSelector dropdown (#1754) by @Andy in 09f059ca3
|
||||
- fix(terminal): push worktree branch to remote with tracking on creation (#1753) by @Andy in b5de0d9ff
|
||||
- auto-claude: 189-subtask-execution-stuck-in-infinite-retry-loop-whe (#1723) by @Andy in 445da186c
|
||||
- auto-claude: 188-terminal-claude-sessions-require-manual-click-to-r (#1743) by @Andy in f8499e965
|
||||
- auto-claude: 200-bug-changelog-and-release-generation (#1729) by @Andy in 826583b82
|
||||
- fix(terminal): use each terminal's cwd for invoke Claude all button (#1756) by @Andy in ac4fe4f42
|
||||
- feat(terminal): read Claude Code CLI settings and inject env vars into PTY sessions (#1750) by @Andy in 152e54093
|
||||
- fix: correct .auto-claude path mismatch causing discovery phase timeout (#1748) by @VDT-91 in 2c2a8a754
|
||||
- fix: remove incorrect /v1 suffix from OpenRouter API URL (#1749) by @StillKnotKnown in 7e799ee57
|
||||
- fix: prevent terminal worktree crash with race condition fixes (#1586) (#1658) by @VDT-91 in 216b58bcf
|
||||
- fix: correct log order sorting and add configurable log order setting (#1720) by @Burak in 2e2b82365
|
||||
- fix(ollama): stop infinite subprocess spawning from useEffect re-render loop (#1716) by @Quentin Veys in acb131b72
|
||||
- fix(graphiti): migrate graphiti_memory imports to canonical paths (#1714) by @Quentin Veys in df528f065
|
||||
- fix: improve auto-updater for beta releases and DMG installs (#1681) by @Andy in ff91a1af0
|
||||
- feat: unified operation registry for intelligent auth/rate limit recovery (#1698) by @Andy in 6d0222fa9
|
||||
- fix: Prevent stale worktree data from overriding correct task status (#1710) by @Burak in fe08c644c
|
||||
- feat: add subscriptionType and rateLimitTier to ClaudeProfile (#1688) by @Andy in a5e3cc9a2
|
||||
- auto-claude: subtask-1-1 - Add useTaskStore import and update task state after successful PR creation (#1683) by @Andy in 4587162e4
|
||||
- auto-claude: 182-implement-pagination-and-filtering-for-github-pr-l (#1654) by @Andy in b4e6b2fe4
|
||||
- auto-claude: 181-add-expand-button-for-long-task-descriptions (#1653) by @Andy in d9cd300fe
|
||||
- fix(terminal): resolve text alignment issues on expand/minimize (#1650) by @VDT-91 in f5a7e26d9
|
||||
- fix(windows): use full path to where.exe for reliable executable lookup (#1659) by @VDT-91 in 5f63daa3c
|
||||
- fix: resolve ideation stuck at 3/6 types bug (#1660) by @VDT-91 in e6e8da17c
|
||||
- Clarify Local and Origin Branch Distinction (#1652) by @Andy in 9317148b6
|
||||
- auto-claude: 186-set-default-dark-mode-on-startup (#1656) by @Andy in 473020621
|
||||
- auto-claude: subtask-1-1 - Add min-h-0 to enable scrolling in Roadmap tabs (#1655) by @Andy in ae703be9f
|
||||
- fix: XState status lifecycle & cross-project contamination fixes (#1647) by @kaigler in 5293fb399
|
||||
- refactor(frontend): complete XState task state machine migration (#1338) (#1575) by @kaigler in e2f9abadb
|
||||
- Merge conflict resolution progress bar and log viewer (#1620) by @Andy in d16be3077
|
||||
- fix: align Linux package builds (AppImage/deb/Flatpak) with target-specific extraResources (#1623) by @StillKnotKnown in bad1a9b2c
|
||||
- Fix/gitlab bugs (#1519 and #1521) (#1544) by @bu5hm4nn in cd423c65c
|
||||
- feat(kanban): add bulk task delete and worktree cleanup improvements (#1588) by @kaigler in 02ed91c91
|
||||
- fix: add worktree isolation warning to prevent agent escape (#1528) by @kaigler in fe5cc582b
|
||||
- feat(ui): add spell check support for text inputs (#1304) by @kaigler in 8f02a5129
|
||||
- fix(windows): complete Windows credential fixes with path normalization (#1585) by @kaigler in 1e1997167
|
||||
- AI-Powered GitHub PR Template Generation (#1618) by @Andy in 900dd4360
|
||||
- Fix pty.node SIGABRT crash on macOS shutdown (#1619) by @Andy in f355e09d7
|
||||
- fix(merge): use git merge for diverged branches with progress tracking (#1605) by @Andy in bde2ca4b2
|
||||
- Surface Billing/Credit Exhaustion Errors to UI (Issue #1580) (#1617) by @Andy in 7bf12e856
|
||||
- auto-claude: subtask-1-1 - Change $teamId type from ID! to String! in the team query (#1627) by @Andy in 54d0cd2f4
|
||||
- fix(auth): support API profile mode without OAuth requirement (#1616) by @StillKnotKnown in f8cc63af4
|
||||
- fix: agent retry loop for tool concurrency errors (#1546) [v3] (#1606) by @Michael Ludlow in 0aea4fb5e
|
||||
- fix(queue): enforce max parallel tasks and auto-refresh UI (#1594) by @Andy in 4070a4c29
|
||||
- Persist Kanban column collapse state per project via main process (#1579) by @Andy in a1114664e
|
||||
- feat(pr-review): evidence-based validation and trigger-driven exploration (#1593) by @Andy in bfc232825
|
||||
- fix(ui): smart auto-scroll for Insights streaming responses (#1591) by @kaigler in eee97e7ea
|
||||
- fix(changelog): validate Claude CLI exists before generation (#1305) by @kaigler in c1f24c07f
|
||||
- auto-claude: subtask-1-1 - Add min-w-0 class to subtask title row flex container (#1578) by @Andy in 286591c02
|
||||
- auto-claude: subtask-1-1 - Remove Popover wrapper and related functionality from ClaudeCodeStatusBadge (#1566) by @Andy in 8d18cc81a
|
||||
- fix(claude-profile): preserve subscriptionType and rateLimitTier during token refresh (#1556) by @Andy in 52e426a48
|
||||
- auto-claude: subtask-1-1 - Update cancelReview callback to handle both success and failure cases (#1551) by @Andy in d8f00fe5a
|
||||
- fix(backend): prioritize git remote detection over env var for repo (#1555) by @Andy in 9b07ed464
|
||||
- fix(backend): handle detached HEAD state when pushing branch for PR creation (#1560) by @Andy in 2b72694d0
|
||||
- fix: add explicit UTF-8 encoding across all Electron main process I/O (#1554) by @Andy in 4243530e9
|
||||
- fix(backend): pass OAuth token to Python subprocess for authentication by @AndyMik90 in 6f1002dd7
|
||||
- perf(frontend): async parallel worktree listing to prevent UI freezes (#1553) by @Andy in 399a7e736
|
||||
- auto-claude: subtask-1-1 - Remove amber lock indicator line from kanban resize handle (#1557) by @Andy in 83a64b88e
|
||||
- fix(frontend): resolve TerminalFontSettings infinite re-render loop (#1536) by @StillKnotKnown in 1c6266025
|
||||
- fix(frontend): respect hasCompletedOnboarding from ~/.claude.json (#1537) by @StillKnotKnown in 1860c2c43
|
||||
- fix: prevent planner from generating invalid verification types (#1388) (#1529) by @kaigler in 94d941333
|
||||
- fix(frontend): resolve Insights scroll-to-blank-space issue on macOS (ACS-382) (#1535) by @StillKnotKnown in 496b2b96a
|
||||
- feat: add customizable terminal fonts with OS-specific defaults (#1412) by @StillKnotKnown in f289107b8
|
||||
- Add dev mode screenshot capture warning (#1516) by @Andy in 16eeb301a
|
||||
- fix: add worktree isolation warnings to prevent agent escape (ACS-394) (#1495) by @StillKnotKnown in 1e453653b
|
||||
- fix: resolve flaky subprocess-spawn test on Windows CI (ACS-392) (#1494) by @StillKnotKnown in f6b264d56
|
||||
- feat(task-logger): strip ANSI escape codes from logs and extend coverage (#1411) by @StillKnotKnown in 988ec0c25
|
||||
- fix(frontend): use spawn() instead of exec() for Windows terminal launching (#1498) by @StillKnotKnown in 26c9083d3
|
||||
- fix(api-profiles): correct z.AI China preset URL and rename provider presets (#1500) by @StillKnotKnown in 05cf0a516
|
||||
- fix: validate branch pattern before worktree cleanup to prevent deleting wrong branch (#1493) by @StillKnotKnown in 8576754a1
|
||||
- Real-Time Updates for Insights Chat (#1511) by @Andy in d940b6ade
|
||||
- Fix Terminal UI Rendering Issues (#1514) by @Andy in 8d8306b8e
|
||||
- Fix terminal content resizing on expansion (#1512) by @Andy in 9f6c0026b
|
||||
- Restore Terminal Session History on App Restart (#1515) by @Andy in 63e2847fc
|
||||
- Move Reference Images Above Task Title & Fix Image Display Issues (#1513) by @Andy in b269ac305
|
||||
- auto-claude: 143-fix-github-integration-ui-refresh-issues (#1467) by @Andy in aa2cb4fa6
|
||||
- feat: Multi-profile account swapping with token refresh and queue routing (#1496) by @Andy in 1e72c8d77
|
||||
- Simplified Testing Strategy for Regression Prevention (#1379) by @Andy in ae4e48e8b
|
||||
- auto-claude: 152-persist-tasks-during-roadmap-regeneration (#1463) by @Andy in 9bd3d7e3b
|
||||
- Debug Kanban Memory & Add Sentry Monitoring (#1380) by @Andy in bc5f550ee
|
||||
- auto-claude: 147-remove-outdated-compatibility-shims (#1465) by @Andy in 53111dbb9
|
||||
- auto-claude: 162-fix-worktree-error-on-repeated-task-starts (#1453) by @Andy in b955badf7
|
||||
- auto-claude: 155-fix-pr-list-diff-display-metrics (#1458) by @Andy in 31f116db5
|
||||
- auto-claude: 151-fix-pr-review-agent-token-refresh-on-account-swap (#1456) by @Andy in d081af042
|
||||
- auto-claude: 148-add-progress-persistence-and-status-indicators (#1464) by @Andy in 4937d5745
|
||||
- auto-claude: 154-fix-task-modal-conflict-check-status-refresh (#1462) by @Andy in 0299009df
|
||||
- auto-claude: 153-widen-kanban-columns-and-add-collapse-feature (#1457) by @Andy in d65973075
|
||||
- auto-claude: subtask-1-1 - Add filter after map operation to remove empty str (#1466) by @Andy in 783f0fe0e
|
||||
- fix: add formatReleaseNotes helper for markdown changelog rendering (#1468) by @Andy in 43a97e1b3
|
||||
- feat(sidebar): add collapsible sidebar toggle (#1501) by @Michael Ludlow in d17c17887
|
||||
- fix(auth): check .credentials.json for Linux profile authentication (#1492) by @StillKnotKnown in 8d2f66291
|
||||
- auto-claude: subtask-1-1 - Replace ReleaseNotesRenderer with ReactMarkdown (#1454) by @Andy in 1185a558c
|
||||
- auto-claude: 156-fix-electron-app-version-detection-bug (#1459) by @Andy in 9a3b48c25
|
||||
- auto-claude: subtask-1-1 - Add --no-track flag to git worktree add command (#1455) by @Andy in 0c2990815
|
||||
- auto-claude: subtask-1-1 - Change task.specId to taskId in 3 startSpecCreation calls (#1461) by @Andy in 91edc0e14
|
||||
- fix(onboarding): align MemoryStep layout with Settings MemoryBackendSection (#1445) by @Michael Ludlow in e9de26d59
|
||||
- auto-claude: subtask-1-1 - Add metadata?.requireReviewBeforeCoding check (#1460) by @Andy in 426d56571
|
||||
- fix: use API profile environment variables for task title generation (#1471) by @JoshuaRileyDev in c5a0f042d
|
||||
- fix(auth): Long-lived OAuth authentication with multi-profile usage display (#1443) by @Andy in 12e788417
|
||||
- feat: Add screenshot capture to task creation modal (#1429) by @JoshuaRileyDev in 1a2a1b1fc
|
||||
- fix: prevent queue settings modal from disappearing when tasks change (#1430) by @JoshuaRileyDev in 33acc1430
|
||||
- feat: Queue System v2 with Auto-Promotion and Smart Task Management (#1203) by @JoshuaRileyDev in 3b87e24d7
|
||||
- feat: Add API profile providers usage endpoints support (#1279) by @StillKnotKnown in cfe7dedd0
|
||||
|
||||
## Thanks to all contributors
|
||||
|
||||
@AndyMik90, @Andy, @Burak, @StillKnotKnown, @VDT-91, @kaigler, @Michael Ludlow, @JoshuaRileyDev, @Quentin Veys, @bu5hm4nn
|
||||
|
||||
## 2.7.5 - Security & Platform Improvements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -40,6 +40,29 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
|
||||
|
||||
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
|
||||
|
||||
**No console.log for debugging production issues** — `console.log` output is not visible in bundled/packaged versions of the Electron app. Use Sentry for error tracking and diagnostics in production. Reserve `console.log` for development only.
|
||||
|
||||
## Work Approach
|
||||
|
||||
**Investigate before speculating** — Always read the actual code before proposing root causes. Spawn agents to grep and read relevant source files before forming any hypothesis. Never guess at causes without evidence from the codebase.
|
||||
|
||||
**Spawn agents for complex tasks** — When tackling complex tasks, spawn sub-agents/agent teams immediately rather than trying to handle everything in a single context window. Never attempt to analyze large codebases or multiple features monolithically.
|
||||
|
||||
**Minimal fixes only** — Prefer the simplest approach (e.g., prompt-only changes, single guard clause) before suggesting multi-component solutions. If the user asks for X, implement X — don't bundle additional fixes they didn't request.
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
**Electron path resolution** — For bug fixes in the Electron app, always check path resolution differences between dev and production builds (`app.isPackaged`, `process.resourcesPath`). Paths that work in dev often break when Electron is bundled for production — verify both contexts.
|
||||
|
||||
### Resetting PR Review State
|
||||
|
||||
To fully clear all PR review data so reviews run fresh, delete/reset these three things in `.auto-claude/github/`:
|
||||
|
||||
1. `rm .auto-claude/github/pr/logs_*.json` — review log files
|
||||
2. `rm .auto-claude/github/pr/review_*.json` — review result files
|
||||
3. Reset `pr/index.json` to `{"reviews": [], "last_updated": null}`
|
||||
4. Reset `bot_detection_state.json` to `{"reviewed_commits": {}}` — this is the gatekeeper; without clearing it, the bot detector skips already-seen commits
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -98,30 +121,6 @@ cd apps/backend && uv venv && uv pip install -r requirements.txt
|
||||
cd apps/frontend && npm install
|
||||
```
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd apps/backend
|
||||
python spec_runner.py --interactive # Create spec interactively
|
||||
python spec_runner.py --task "description" # Create from task
|
||||
python run.py --spec 001 # Run autonomous build
|
||||
python run.py --spec 001 --qa # Run QA validation
|
||||
python run.py --spec 001 --merge # Merge completed build
|
||||
python run.py --list # List all specs
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd apps/frontend
|
||||
npm run dev # Dev mode (Electron + Vite HMR)
|
||||
npm run build # Production build
|
||||
npm run test # Vitest unit tests
|
||||
npm run test:watch # Vitest watch mode
|
||||
npm run lint # Biome check
|
||||
npm run lint:fix # Biome auto-fix
|
||||
npm run typecheck # TypeScript strict check
|
||||
npm run package # Package for distribution
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
| Stack | Command | Tool |
|
||||
@@ -145,30 +144,7 @@ See [RELEASE.md](RELEASE.md) for full release process.
|
||||
|
||||
Client: `apps/backend/core/client.py` — `create_client()` returns a configured `ClaudeSDKClient` with security hooks, tool permissions, and MCP server integration.
|
||||
|
||||
Model and thinking level are user-configurable (via the Electron UI settings or CLI override). Use `phase_config.py` helpers to resolve the correct values:
|
||||
|
||||
```python
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
|
||||
# Resolve model/thinking from user settings (Electron UI or CLI override)
|
||||
phase_model = get_phase_model(spec_dir, "coding", cli_model=None)
|
||||
phase_thinking = get_phase_thinking_budget(spec_dir, "coding", cli_thinking=None)
|
||||
|
||||
client = create_client(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=phase_model,
|
||||
agent_type="coder", # planner | coder | qa_reviewer | qa_fixer
|
||||
max_thinking_tokens=phase_thinking,
|
||||
)
|
||||
|
||||
# Run agent session (uses context manager + run_agent_session helper)
|
||||
async with client:
|
||||
status, response = await run_agent_session(client, prompt, spec_dir)
|
||||
```
|
||||
|
||||
Working examples: `agents/planner.py`, `agents/coder.py`, `qa/reviewer.py`, `qa/fixer.py`, `spec/`
|
||||
Model and thinking level are user-configurable (via the Electron UI settings or CLI override). Use `phase_config.py` helpers to resolve the correct values
|
||||
|
||||
### Agent Prompts (`apps/backend/prompts/`)
|
||||
|
||||
@@ -323,6 +299,8 @@ cd apps/backend && python run.py --spec 001
|
||||
# Desktop app
|
||||
npm start # Production build + run
|
||||
npm run dev # Development mode with HMR
|
||||
npm run dev:debug # Debug mode with verbose output
|
||||
npm run dev:mcp # Electron MCP server for AI debugging
|
||||
|
||||
# Project data: .auto-claude/specs/ (gitignored)
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
[](https://github.com/hesreallyhim/awesome-claude-code)
|
||||
|
||||
---
|
||||
|
||||
@@ -16,18 +17,18 @@
|
||||
### Stable Release
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.6-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-linux-x86_64.flatpak) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
### Beta Release
|
||||
@@ -35,18 +36,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.6)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.6-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.6-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.6-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.6-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.6-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.6-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.6/Auto-Claude-2.7.6-beta.6-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# Test Optimization Plan
|
||||
|
||||
## Problem Summary
|
||||
- 3 test directories timeout after 600 seconds
|
||||
- Main culprit: `test_spec_pipeline_orchestrator.py` - integration tests that run real code
|
||||
- Autouse fixture adding overhead to every test
|
||||
|
||||
## Optimization Strategies
|
||||
|
||||
### 1. Use Pytest Markers (Immediate Impact)
|
||||
|
||||
Already defined in `pytest.ini`:
|
||||
```ini
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||
integration: marks tests as integration tests
|
||||
```
|
||||
|
||||
**Action:** Mark slow integration tests:
|
||||
|
||||
```python
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.integration
|
||||
async def test_run_with_auto_approve(self, tmp_path):
|
||||
...
|
||||
```
|
||||
|
||||
**Run fast tests only:**
|
||||
```bash
|
||||
pytest -m "not slow" -v
|
||||
pytest -m "not integration" -v
|
||||
```
|
||||
|
||||
**Run all tests when needed:**
|
||||
```bash
|
||||
pytest -v # or explicitly: pytest -m "slow or integration" -v
|
||||
```
|
||||
|
||||
### 2. Fix Autouse Fixture Overhead
|
||||
|
||||
The `ensure_modules_not_mocked()` fixture runs before EVERY test.
|
||||
|
||||
**Action:** Remove autouse or make it conditional:
|
||||
|
||||
```python
|
||||
@pytest.fixture(autouse=True, scope="function")
|
||||
def ensure_modules_not_mocked():
|
||||
"""Only run for tests that need real claude_agent_sdk"""
|
||||
# Skip for tests that explicitly mock everything
|
||||
if os.environ.get("PYTEST_SKIP_MODULE_CLEANUP"):
|
||||
return
|
||||
# ... existing code ...
|
||||
```
|
||||
|
||||
### 3. Separate Unit and Integration Tests
|
||||
|
||||
**Directory structure:**
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Fast tests (seconds)
|
||||
│ ├── test_*.py
|
||||
├── integration/ # Slow tests (minutes)
|
||||
│ ├── test_*.py
|
||||
└── e2e/ # Very slow tests (10+ minutes)
|
||||
├── test_*.py
|
||||
```
|
||||
|
||||
**pytest.ini:**
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = tests/unit # Default to fast tests
|
||||
# Override with: pytest --testpaths=integration
|
||||
```
|
||||
|
||||
### 4. Optimize Async Tests
|
||||
|
||||
Async tests have overhead. Use sync tests where possible:
|
||||
|
||||
```python
|
||||
# SLOW (async overhead)
|
||||
@pytest.mark.asyncio
|
||||
async def test_something():
|
||||
result = await some_async_function()
|
||||
assert result
|
||||
|
||||
# FASTER (mock the async call)
|
||||
def test_something():
|
||||
with patch('module.some_async_function', return_value=expected):
|
||||
result = some_function_that_calls_async()
|
||||
assert result
|
||||
```
|
||||
|
||||
### 5. Reduce Test Data Creation
|
||||
|
||||
Many tests create large temporary directories. Use shared fixtures:
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="session")
|
||||
def shared_test_data():
|
||||
"""Create test data once per session, not per test"""
|
||||
data = create_expensive_test_data()
|
||||
yield data
|
||||
# cleanup runs at session end
|
||||
```
|
||||
|
||||
### 6. Disable Coverage by Default
|
||||
|
||||
Coverage collection adds overhead.
|
||||
|
||||
**pytest.ini:**
|
||||
```ini
|
||||
[pytest]
|
||||
addopts = -q --tb=short # No --cov
|
||||
# Enable coverage explicitly: pytest --cov=apps/backend
|
||||
```
|
||||
|
||||
### 7. Increase Timeout for Integration Tests
|
||||
|
||||
For tests that must be slow:
|
||||
|
||||
```python
|
||||
@pytest.mark.timeout(300) # 5 minutes for this specific test
|
||||
async def test_slow_integration():
|
||||
...
|
||||
```
|
||||
|
||||
### 8. Use Test Profiles
|
||||
|
||||
Create pytest configs for different scenarios:
|
||||
|
||||
**pytest.unit.ini:**
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -m "not slow and not integration" -q
|
||||
```
|
||||
|
||||
**pytest.all.ini:**
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -v
|
||||
timeout = 1200 # 20 minutes
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
pytest -c pytest.unit.ini # Fast PR checks
|
||||
pytest -c pytest.all.ini # Full test suite
|
||||
```
|
||||
|
||||
### 9. Mock Expensive Operations
|
||||
|
||||
Identify what's actually slow in the tests:
|
||||
|
||||
```python
|
||||
# SLOW - Real file I/O
|
||||
async def test_orchestrator():
|
||||
orchestrator = SpecOrchestrator(project_dir)
|
||||
await orchestrator.run() # Does real file operations
|
||||
|
||||
# FASTER - Mock the expensive parts
|
||||
async def test_orchestrator():
|
||||
with patch('spec.pipeline.orchestrator.discover_project'):
|
||||
with patch('spec.pipeline.orchestrator.write_files'):
|
||||
orchestrator = SpecOrchestrator(project_dir)
|
||||
await orchestrator.run() # Just tests the logic
|
||||
```
|
||||
|
||||
### 10. Run Tests in Parallel (Already Done)
|
||||
|
||||
Use the scripts we created:
|
||||
```bash
|
||||
./scripts/run-parallel-tests.sh # Directory-level
|
||||
./scripts/run-tests-per-file.sh # File-level
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **HIGH** - Mark slow/integration tests (5 minutes)
|
||||
- Add `@pytest.mark.slow` to `test_spec_pipeline_orchestrator.py`
|
||||
- Add `@pytest.mark.integration` to integration tests
|
||||
|
||||
2. **HIGH** - Update CI to run fast tests by default (10 minutes)
|
||||
- CI: `pytest -m "not slow"` for PR checks
|
||||
- CI: `pytest` (full) for main branch
|
||||
|
||||
3. **MEDIUM** - Fix autouse fixture (30 minutes)
|
||||
- Remove autouse or make it conditional
|
||||
|
||||
4. **MEDIUM** - Create test profiles (1 hour)
|
||||
- Separate unit/integration configs
|
||||
|
||||
5. **LOW** - Refactor slow tests (ongoing)
|
||||
- Better mocking
|
||||
- Shared fixtures
|
||||
|
||||
## Quick Win Script
|
||||
|
||||
Add to Makefile or scripts:
|
||||
|
||||
```bash
|
||||
# makefile or script
|
||||
test-fast:
|
||||
pytest -m "not slow" -v
|
||||
|
||||
test-all:
|
||||
pytest -v
|
||||
|
||||
test-integration:
|
||||
pytest -m "integration" -v
|
||||
```
|
||||
@@ -269,9 +269,9 @@ GRAPHITI_ENABLED=true
|
||||
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# OpenRouter LLM Model (default: anthropic/claude-3.5-sonnet)
|
||||
# Popular choices: anthropic/claude-3.5-sonnet, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# OpenRouter LLM Model (default: anthropic/claude-sonnet-4)
|
||||
# Popular choices: anthropic/claude-sonnet-4, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
|
||||
# OpenRouter Embedding Model (default: openai/text-embedding-3-small)
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
@@ -368,5 +368,5 @@ GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=openrouter
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
|
||||
@@ -62,5 +62,14 @@ Thumbs.db
|
||||
# Tests (development only)
|
||||
tests/
|
||||
|
||||
# Exception: Allow colocated tests within integrations/graphiti
|
||||
!integrations/graphiti/tests/
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
|
||||
# Auto Claude generated files
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
.security-key
|
||||
logs/security/
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6-beta.2"
|
||||
__version__ = "2.7.6"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from core.agent instead."""
|
||||
|
||||
from core.agent import * # noqa: F401, F403
|
||||
from core.agent import * # noqa: F403
|
||||
|
||||
@@ -12,24 +12,11 @@ This module provides:
|
||||
- Utility functions for git and plan management
|
||||
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
|
||||
Note: Module-level placeholders are defined to satisfy CodeQL static analysis.
|
||||
These trigger the actual import on first access through __getattr__.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
find_subtask_in_plan,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
# Explicit import required by CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from .utils import sync_spec_to_source
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
@@ -55,67 +42,55 @@ __all__ = [
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
]
|
||||
|
||||
# Module cache for lazy imports
|
||||
_module_cache = {}
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy imports to avoid circular dependencies."""
|
||||
if name in ("AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE"):
|
||||
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Lazy imports to avoid circular dependencies.
|
||||
return locals()[name]
|
||||
elif name == "run_autonomous_agent":
|
||||
from .coder import run_autonomous_agent
|
||||
|
||||
Python 3.7+ calls this for attributes that don't exist at module level.
|
||||
"""
|
||||
if name == "run_autonomous_agent":
|
||||
from . import coder # Ensure agents.coder is registered in sys.modules
|
||||
|
||||
return coder.run_autonomous_agent
|
||||
elif name == "debug_memory_system_status":
|
||||
from . import (
|
||||
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
|
||||
return run_autonomous_agent
|
||||
elif name in (
|
||||
"debug_memory_system_status",
|
||||
"get_graphiti_context",
|
||||
"save_session_memory",
|
||||
"save_session_to_graphiti",
|
||||
):
|
||||
from .memory_manager import (
|
||||
debug_memory_system_status,
|
||||
get_graphiti_context,
|
||||
save_session_memory,
|
||||
save_session_to_graphiti,
|
||||
)
|
||||
|
||||
return memory_manager.debug_memory_system_status
|
||||
elif name == "get_graphiti_context":
|
||||
from . import (
|
||||
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
|
||||
)
|
||||
|
||||
return memory_manager.get_graphiti_context
|
||||
elif name == "save_session_memory":
|
||||
from . import (
|
||||
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
|
||||
)
|
||||
|
||||
return memory_manager.save_session_memory
|
||||
elif name == "save_session_to_graphiti":
|
||||
from . import (
|
||||
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
|
||||
)
|
||||
|
||||
return memory_manager.save_session_to_graphiti
|
||||
return locals()[name]
|
||||
elif name == "run_followup_planner":
|
||||
from . import planner # Ensure agents.planner is registered in sys.modules
|
||||
from .planner import run_followup_planner
|
||||
|
||||
return planner.run_followup_planner
|
||||
elif name == "post_session_processing":
|
||||
from . import session # Ensure agents.session is registered in sys.modules
|
||||
return run_followup_planner
|
||||
elif name in ("post_session_processing", "run_agent_session"):
|
||||
from .session import post_session_processing, run_agent_session
|
||||
|
||||
return session.post_session_processing
|
||||
elif name == "run_agent_session":
|
||||
from . import session # Ensure agents.session is registered in sys.modules
|
||||
return locals()[name]
|
||||
elif name in (
|
||||
"find_phase_for_subtask",
|
||||
"find_subtask_in_plan",
|
||||
"get_commit_count",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
"sync_spec_to_source",
|
||||
):
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
find_subtask_in_plan,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
return session.run_agent_session
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module 'agents' has no attribute '{name}'")
|
||||
|
||||
|
||||
def __dir__():
|
||||
"""Return list of module attributes for autocomplete and dir()."""
|
||||
return __all__ + [
|
||||
"__all__",
|
||||
"__doc__",
|
||||
"__file__",
|
||||
"__getattr__",
|
||||
"__name__",
|
||||
"__package__",
|
||||
"__loader__",
|
||||
"__spec__",
|
||||
]
|
||||
|
||||
@@ -21,7 +21,12 @@ from linear_updater import (
|
||||
linear_task_started,
|
||||
linear_task_stuck,
|
||||
)
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_config import (
|
||||
get_fast_mode,
|
||||
get_phase_client_thinking_kwargs,
|
||||
get_phase_model,
|
||||
get_phase_model_betas,
|
||||
)
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from progress import (
|
||||
count_subtasks,
|
||||
@@ -579,9 +584,17 @@ async def run_autonomous_agent(
|
||||
# first_run means we're in planning phase, otherwise coding phase
|
||||
current_phase = "planning" if first_run else "coding"
|
||||
phase_model = get_phase_model(spec_dir, current_phase, model)
|
||||
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
|
||||
phase_betas = get_phase_model_betas(spec_dir, current_phase, model)
|
||||
thinking_kwargs = get_phase_client_thinking_kwargs(
|
||||
spec_dir, current_phase, phase_model
|
||||
)
|
||||
|
||||
# Generate appropriate prompt
|
||||
fast_mode = get_fast_mode(spec_dir)
|
||||
logger.info(
|
||||
f"[Coder] [Fast Mode] {'ENABLED' if fast_mode else 'disabled'} for phase={current_phase}"
|
||||
)
|
||||
|
||||
if first_run:
|
||||
# Create client for planning phase
|
||||
client = create_client(
|
||||
@@ -589,7 +602,9 @@ async def run_autonomous_agent(
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="planner",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
betas=phase_betas,
|
||||
fast_mode=fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
prompt = generate_planner_prompt(spec_dir, project_dir)
|
||||
if planning_retry_context:
|
||||
@@ -647,13 +662,15 @@ async def run_autonomous_agent(
|
||||
"Waiting for implementation plan to be ready...", "progress"
|
||||
)
|
||||
for retry_attempt in range(3):
|
||||
await asyncio.sleep((retry_attempt + 1) * 2) # 2s, 4s, 6s
|
||||
delay = (retry_attempt + 1) * 2 # 2s, 4s, 6s
|
||||
await asyncio.sleep(delay)
|
||||
next_subtask = get_next_subtask(spec_dir)
|
||||
if next_subtask:
|
||||
# Successfully found subtask after retry
|
||||
delay = (retry_attempt + 1) * 2
|
||||
# Update subtask_id and phase_name after successful retry
|
||||
subtask_id = next_subtask.get("id")
|
||||
phase_name = next_subtask.get("phase_name")
|
||||
print_status(
|
||||
f"Found subtask {next_subtask.get('id')} after {delay}s delay",
|
||||
f"Found subtask {subtask_id} after {delay}s delay",
|
||||
"success",
|
||||
)
|
||||
break
|
||||
@@ -725,7 +742,9 @@ async def run_autonomous_agent(
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="coder",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
betas=phase_betas,
|
||||
fast_mode=fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
# Get attempt count for recovery context
|
||||
@@ -843,6 +862,7 @@ async def run_autonomous_agent(
|
||||
linear_enabled=linear_is_enabled,
|
||||
status_manager=status_manager,
|
||||
source_spec_dir=source_spec_dir,
|
||||
error_info=error_info,
|
||||
)
|
||||
|
||||
# Check for stuck subtasks
|
||||
|
||||
@@ -247,7 +247,7 @@ async def get_graphiti_context(
|
||||
if memory is not None:
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
)
|
||||
@@ -417,9 +417,9 @@ async def save_session_memory(
|
||||
if memory is not None:
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
"Failed to close Graphiti memory connection", exc_info=e
|
||||
)
|
||||
else:
|
||||
if is_debug_enabled():
|
||||
|
||||
@@ -9,7 +9,12 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_config import (
|
||||
get_fast_mode,
|
||||
get_phase_client_thinking_kwargs,
|
||||
get_phase_model,
|
||||
get_phase_model_betas,
|
||||
)
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
@@ -94,12 +99,22 @@ async def run_followup_planner(
|
||||
# Create client with phase-specific model and thinking budget
|
||||
# Respects task_metadata.json configuration when no CLI override
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
|
||||
planning_betas = get_phase_model_betas(spec_dir, "planning", model)
|
||||
thinking_kwargs = get_phase_client_thinking_kwargs(
|
||||
spec_dir, "planning", planning_model
|
||||
)
|
||||
fast_mode = get_fast_mode(spec_dir)
|
||||
logger.info(
|
||||
f"[Planner] [Fast Mode] {'ENABLED' if fast_mode else 'disabled'} for follow-up planning"
|
||||
)
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
planning_model,
|
||||
max_thinking_tokens=planning_thinking_budget,
|
||||
agent_type="planner",
|
||||
betas=planning_betas,
|
||||
fast_mode=fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
# Generate follow-up planner prompt
|
||||
|
||||
+123
-106
@@ -7,10 +7,16 @@ memory updates, recovery tracking, and Linear integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from core.error_utils import (
|
||||
is_authentication_error,
|
||||
is_rate_limit_error,
|
||||
is_tool_concurrency_error,
|
||||
safe_receive_messages,
|
||||
)
|
||||
from core.file_utils import write_json_atomic
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from insight_extractor import extract_session_insights
|
||||
from linear_updater import (
|
||||
@@ -21,7 +27,7 @@ from progress import (
|
||||
count_subtasks_detailed,
|
||||
is_build_complete,
|
||||
)
|
||||
from recovery import RecoveryManager
|
||||
from recovery import RecoveryManager, check_and_recover, reset_subtask
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
@@ -48,113 +54,36 @@ from .utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_tool_concurrency_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a 400 tool concurrency error from Claude API.
|
||||
def _execute_recovery_action(
|
||||
recovery_action,
|
||||
recovery_manager: RecoveryManager,
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
) -> None:
|
||||
"""Execute a recovery action (rollback/retry/skip/escalate)."""
|
||||
if not recovery_action:
|
||||
return
|
||||
|
||||
Tool concurrency errors occur when too many tools are used simultaneously
|
||||
in a single API request, hitting Claude's concurrent tool use limit.
|
||||
print_status(f"Recovery action: {recovery_action.action}", "info")
|
||||
print_status(f"Reason: {recovery_action.reason}", "info")
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
if recovery_action.action == "rollback":
|
||||
print_status(f"Rolling back to {recovery_action.target[:8]}", "warning")
|
||||
if recovery_manager.rollback_to_commit(recovery_action.target):
|
||||
print_status("Rollback successful", "success")
|
||||
else:
|
||||
print_status("Rollback failed", "error")
|
||||
|
||||
Returns:
|
||||
True if this is a tool concurrency error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
# Check for 400 status AND tool concurrency keywords
|
||||
return "400" in error_str and (
|
||||
("tool" in error_str and "concurrency" in error_str)
|
||||
or "too many tools" in error_str
|
||||
or "concurrent tool" in error_str
|
||||
)
|
||||
elif recovery_action.action == "retry":
|
||||
print_status(f"Resetting subtask {subtask_id} for retry", "info")
|
||||
reset_subtask(spec_dir, project_dir, subtask_id)
|
||||
print_status("Subtask reset - will retry with different approach", "success")
|
||||
|
||||
|
||||
def is_rate_limit_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a rate limit error (429 or similar).
|
||||
|
||||
Rate limit errors occur when the API usage quota is exceeded,
|
||||
either for session limits or weekly limits.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a rate limit error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 429 with word boundaries to avoid false positives
|
||||
if re.search(r"\b429\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other rate limit indicators
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"limit reached",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"usage limit",
|
||||
"quota exceeded",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def is_authentication_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is an authentication error (401, token expired, etc.).
|
||||
|
||||
Authentication errors occur when OAuth tokens are invalid, expired,
|
||||
or have been revoked (e.g., after token refresh on another process).
|
||||
|
||||
Validation approach:
|
||||
- HTTP 401 status code is checked with word boundaries to minimize false positives
|
||||
- Additional string patterns are validated against lowercase error messages
|
||||
- Patterns are designed to match known Claude API and OAuth error formats
|
||||
|
||||
Known false positive risks:
|
||||
- Generic error messages containing "unauthorized" or "access denied" may match
|
||||
even if not related to authentication (e.g., file permission errors)
|
||||
- Error messages containing these keywords in user-provided content could match
|
||||
- Mitigation: HTTP 401 check provides strong signal; string patterns are secondary
|
||||
|
||||
Real-world validation:
|
||||
- Pattern matching has been tested against actual Claude API error responses
|
||||
- False positive rate is acceptable given the recovery mechanism (prompt user to re-auth)
|
||||
- If false positive occurs, user can simply resume without re-authenticating
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is an authentication error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 401 with word boundaries to avoid false positives
|
||||
if re.search(r"\b401\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other authentication indicators
|
||||
# NOTE: "authentication failed" and "authentication error" are more specific patterns
|
||||
# to reduce false positives from generic "authentication" mentions
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"authentication failed",
|
||||
"authentication error",
|
||||
"unauthorized",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"authentication_error",
|
||||
"invalid_token",
|
||||
"token_expired",
|
||||
"not authenticated",
|
||||
"http 401",
|
||||
]
|
||||
)
|
||||
elif recovery_action.action in ("skip", "escalate"):
|
||||
print_status(f"Marking subtask {subtask_id} as stuck", "warning")
|
||||
recovery_manager.mark_subtask_stuck(subtask_id, recovery_action.reason)
|
||||
print_status("Subtask marked for human intervention", "warning")
|
||||
|
||||
|
||||
async def post_session_processing(
|
||||
@@ -168,6 +97,7 @@ async def post_session_processing(
|
||||
linear_enabled: bool = False,
|
||||
status_manager: StatusManager | None = None,
|
||||
source_spec_dir: Path | None = None,
|
||||
error_info: dict | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Process session results and update memory automatically.
|
||||
@@ -185,6 +115,7 @@ async def post_session_processing(
|
||||
linear_enabled: Whether Linear integration is enabled
|
||||
status_manager: Optional status manager for ccstatusline
|
||||
source_spec_dir: Original spec directory (for syncing back from worktree)
|
||||
error_info: Error information from run_agent_session (for rate limit detection)
|
||||
|
||||
Returns:
|
||||
True if subtask was completed successfully
|
||||
@@ -316,6 +247,77 @@ async def post_session_processing(
|
||||
error="Subtask not marked as completed",
|
||||
)
|
||||
|
||||
# Check if this was a concurrency error - if so, reset subtask to pending for retry
|
||||
is_concurrency_error = (
|
||||
error_info and error_info.get("type") == "tool_concurrency"
|
||||
)
|
||||
|
||||
if is_concurrency_error:
|
||||
print_status(
|
||||
f"Rate limit detected - resetting subtask {subtask_id} to pending for retry",
|
||||
"info",
|
||||
)
|
||||
|
||||
# Use recovery system's reset_subtask for consistency
|
||||
reset_subtask(spec_dir, project_dir, subtask_id)
|
||||
|
||||
# Also reset in implementation plan
|
||||
plan = load_implementation_plan(spec_dir)
|
||||
if plan:
|
||||
# Find and reset the subtask
|
||||
subtask_found = False
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
# Reset subtask to pending state
|
||||
subtask["status"] = "pending"
|
||||
subtask["started_at"] = None
|
||||
subtask["completed_at"] = None
|
||||
subtask_found = True
|
||||
break
|
||||
if subtask_found:
|
||||
break
|
||||
|
||||
if subtask_found:
|
||||
# Save plan atomically to prevent corruption
|
||||
try:
|
||||
plan_path = spec_dir / "implementation_plan.json"
|
||||
write_json_atomic(plan_path, plan, indent=2)
|
||||
print_status(
|
||||
f"Subtask {subtask_id} reset to pending status", "success"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to save implementation plan after reset: {e}"
|
||||
)
|
||||
print_status("Failed to save plan after reset", "error")
|
||||
else:
|
||||
print_status(
|
||||
f"Warning: Could not find subtask {subtask_id} in plan",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
print_status(
|
||||
"Warning: Could not load implementation plan for reset", "warning"
|
||||
)
|
||||
else:
|
||||
# Non-rate-limit error - use automatic recovery flow
|
||||
error_message = (
|
||||
error_info.get("message", "Subtask not marked as completed")
|
||||
if error_info
|
||||
else "Subtask not marked as completed"
|
||||
)
|
||||
|
||||
recovery_action = check_and_recover(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
error=error_message,
|
||||
)
|
||||
_execute_recovery_action(
|
||||
recovery_action, recovery_manager, spec_dir, project_dir, subtask_id
|
||||
)
|
||||
|
||||
# Still record commit if one was made (partial progress)
|
||||
if commit_after and commit_after != commit_before:
|
||||
recovery_manager.record_good_commit(commit_after, subtask_id)
|
||||
@@ -379,6 +381,21 @@ async def post_session_processing(
|
||||
error=f"Subtask status is {subtask_status}",
|
||||
)
|
||||
|
||||
# Automatic recovery flow - determine and execute recovery action
|
||||
error_message = f"Subtask status is {subtask_status}"
|
||||
if error_info:
|
||||
error_message = error_info.get("message", error_message)
|
||||
|
||||
recovery_action = check_and_recover(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
error=error_message,
|
||||
)
|
||||
_execute_recovery_action(
|
||||
recovery_action, recovery_manager, spec_dir, project_dir, subtask_id
|
||||
)
|
||||
|
||||
# Record Linear session result (if enabled)
|
||||
if linear_enabled:
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
@@ -474,7 +491,7 @@ async def run_agent_session(
|
||||
# Collect response text and show tool use
|
||||
response_text = ""
|
||||
debug("session", "Starting to receive response stream...")
|
||||
async for msg in client.receive_response():
|
||||
async for msg in safe_receive_messages(client, caller="session"):
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
debug_detailed(
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verification script for agent module refactoring.
|
||||
|
||||
This script verifies that:
|
||||
1. All modules can be imported
|
||||
2. All public API functions are accessible
|
||||
3. Backwards compatibility is maintained
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def test_imports():
|
||||
"""Test that all modules can be imported."""
|
||||
print("Testing module imports...")
|
||||
|
||||
# Test base module
|
||||
from agents import base
|
||||
|
||||
assert hasattr(base, "AUTO_CONTINUE_DELAY_SECONDS")
|
||||
assert hasattr(base, "HUMAN_INTERVENTION_FILE")
|
||||
print(" ✓ agents.base")
|
||||
|
||||
# Test utils module
|
||||
from agents import utils
|
||||
|
||||
assert hasattr(utils, "get_latest_commit")
|
||||
assert hasattr(utils, "load_implementation_plan")
|
||||
print(" ✓ agents.utils")
|
||||
|
||||
# Test memory module
|
||||
from agents import memory
|
||||
|
||||
assert hasattr(memory, "save_session_memory")
|
||||
assert hasattr(memory, "get_graphiti_context")
|
||||
print(" ✓ agents.memory")
|
||||
|
||||
# Test session module
|
||||
from agents import session
|
||||
|
||||
assert hasattr(session, "run_agent_session")
|
||||
assert hasattr(session, "post_session_processing")
|
||||
print(" ✓ agents.session")
|
||||
|
||||
# Test planner module
|
||||
from agents import planner
|
||||
|
||||
assert hasattr(planner, "run_followup_planner")
|
||||
print(" ✓ agents.planner")
|
||||
|
||||
# Test coder module
|
||||
from agents import coder
|
||||
|
||||
assert hasattr(coder, "run_autonomous_agent")
|
||||
print(" ✓ agents.coder")
|
||||
|
||||
print("\n✓ All module imports successful!\n")
|
||||
|
||||
|
||||
def test_public_api():
|
||||
"""Test that the public API is accessible."""
|
||||
print("Testing public API...")
|
||||
|
||||
# Test main agent module exports
|
||||
import agents
|
||||
|
||||
required_functions = [
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
"save_session_memory",
|
||||
"get_graphiti_context",
|
||||
"run_agent_session",
|
||||
"post_session_processing",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
]
|
||||
|
||||
for func_name in required_functions:
|
||||
assert hasattr(agents, func_name), f"Missing function: {func_name}"
|
||||
print(f" ✓ agents.{func_name}")
|
||||
|
||||
print("\n✓ All public API functions accessible!\n")
|
||||
|
||||
|
||||
def test_backwards_compatibility():
|
||||
"""Test that the old agent.py facade maintains backwards compatibility."""
|
||||
print("Testing backwards compatibility...")
|
||||
|
||||
# Test that agent.py can be imported
|
||||
import agent
|
||||
|
||||
required_functions = [
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
"save_session_memory",
|
||||
"save_session_to_graphiti",
|
||||
"run_agent_session",
|
||||
"post_session_processing",
|
||||
]
|
||||
|
||||
for func_name in required_functions:
|
||||
assert hasattr(agent, func_name), (
|
||||
f"Missing function in agent module: {func_name}"
|
||||
)
|
||||
print(f" ✓ agent.{func_name}")
|
||||
|
||||
print("\n✓ Backwards compatibility maintained!\n")
|
||||
|
||||
|
||||
def test_module_structure():
|
||||
"""Test that the module structure is correct."""
|
||||
print("Testing module structure...")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
agents_dir = Path(__file__).parent
|
||||
|
||||
required_files = [
|
||||
"__init__.py",
|
||||
"base.py",
|
||||
"utils.py",
|
||||
"memory.py",
|
||||
"session.py",
|
||||
"planner.py",
|
||||
"coder.py",
|
||||
]
|
||||
|
||||
for filename in required_files:
|
||||
filepath = agents_dir / filename
|
||||
assert filepath.exists(), f"Missing file: {filename}"
|
||||
print(f" ✓ agents/{filename}")
|
||||
|
||||
print("\n✓ Module structure correct!\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
test_module_structure()
|
||||
test_imports()
|
||||
test_public_api()
|
||||
test_backwards_compatibility()
|
||||
|
||||
print("=" * 60)
|
||||
print("✓ ALL TESTS PASSED - Refactoring verified!")
|
||||
print("=" * 60)
|
||||
|
||||
except AssertionError as e:
|
||||
print(f"\n✗ TEST FAILED: {e}")
|
||||
sys.exit(1)
|
||||
except ImportError as e:
|
||||
print(f"\n✗ IMPORT ERROR: {e}")
|
||||
print("Note: Some imports may fail due to missing dependencies.")
|
||||
print("This is expected in test environments.")
|
||||
sys.exit(0) # Don't fail on import errors (expected in test env)
|
||||
@@ -292,6 +292,14 @@ AGENT_CONFIGS = {
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_extraction": {
|
||||
# Lightweight extraction call for recovering data when structured output fails
|
||||
# Pure structured output extraction, no tools needed
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_finding_validator": {
|
||||
# Standalone validator for re-checking findings against actual code
|
||||
# Called separately from orchestrator to validate findings with fresh context
|
||||
|
||||
@@ -79,7 +79,7 @@ async def _save_to_graphiti_async(
|
||||
# Always close the memory connection (swallow exceptions to avoid overriding)
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
)
|
||||
@@ -312,8 +312,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
for path, info in list(discoveries.items())[:20]: # Limit to 20
|
||||
desc = info.get("description", "No description")
|
||||
result_parts.append(f"- `{path}`: {desc}")
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: memory files are optional
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load gotchas
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
@@ -326,8 +326,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
result_parts.append(
|
||||
content[-1000:] if len(content) > 1000 else content
|
||||
)
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: memory files are optional
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load patterns
|
||||
patterns_file = memory_dir / "patterns.md"
|
||||
@@ -339,8 +339,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
result_parts.append(
|
||||
content[-1000:] if len(content) > 1000 else content
|
||||
)
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: memory files are optional
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not result_parts:
|
||||
return {
|
||||
|
||||
@@ -23,7 +23,8 @@ from .ci_discovery import CIDiscovery
|
||||
from .project_analyzer import ProjectAnalyzer
|
||||
from .risk_classifier import RiskClassifier
|
||||
from .security_scanner import SecurityScanner
|
||||
from .test_discovery import TestDiscovery
|
||||
|
||||
# TestDiscovery was removed - tests are now co-located in their respective modules
|
||||
|
||||
# insight_extractor is a module with functions, not a class, so don't import it here
|
||||
# Import it directly when needed: from analysis import insight_extractor
|
||||
@@ -37,5 +38,5 @@ __all__ = [
|
||||
"RiskClassifier",
|
||||
"SecurityScanner",
|
||||
"CIDiscovery",
|
||||
"TestDiscovery",
|
||||
# "TestDiscovery", # Removed - tests now co-located in their modules
|
||||
]
|
||||
|
||||
@@ -29,47 +29,9 @@ All actual implementation is in focused submodules for better maintainability.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
# Import from the new modular structure
|
||||
from .analyzers import (
|
||||
ProjectAnalyzer,
|
||||
|
||||
@@ -14,6 +14,7 @@ Main exports:
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .project_analyzer_module import ProjectAnalyzer
|
||||
from .service_analyzer import ServiceAnalyzer
|
||||
|
||||
@@ -235,10 +235,15 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
|
||||
# Scripts
|
||||
scripts = pkg.get("scripts", {})
|
||||
pkg_mgr = self.analysis.get("package_manager", "npm")
|
||||
if "dev" in scripts:
|
||||
self.analysis["dev_command"] = "npm run dev"
|
||||
self.analysis["dev_command"] = f"{pkg_mgr} run dev"
|
||||
elif "start" in scripts:
|
||||
self.analysis["dev_command"] = "npm run start"
|
||||
self.analysis["dev_command"] = f"{pkg_mgr} run start"
|
||||
|
||||
# Capture available scripts for downstream consumers (QA agents, init.sh)
|
||||
if scripts:
|
||||
self.analysis["scripts"] = dict(scripts)
|
||||
|
||||
def _detect_go_framework(self, content: str) -> None:
|
||||
"""Detect Go framework."""
|
||||
@@ -362,8 +367,9 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
dependencies = self._detect_spm_dependencies()
|
||||
if dependencies:
|
||||
self.analysis["spm_dependencies"] = dependencies
|
||||
except Exception: # Silently fail if Swift detection has issues
|
||||
pass # no-op
|
||||
except Exception:
|
||||
# Silently fail if Swift detection has issues
|
||||
pass
|
||||
|
||||
def _detect_spm_dependencies(self) -> list[str]:
|
||||
"""Detect Swift Package Manager dependencies."""
|
||||
|
||||
@@ -31,6 +31,7 @@ class ProjectAnalyzer:
|
||||
"""Run full project analysis."""
|
||||
self._detect_project_type()
|
||||
self._find_and_analyze_services()
|
||||
self._aggregate_dependency_locations()
|
||||
self._analyze_infrastructure()
|
||||
self._detect_conventions()
|
||||
self._map_dependencies()
|
||||
@@ -124,6 +125,63 @@ class ProjectAnalyzer:
|
||||
|
||||
self.index["services"] = services
|
||||
|
||||
def _aggregate_dependency_locations(self) -> None:
|
||||
"""Aggregate dependency location metadata from all services.
|
||||
|
||||
Collects dependency_locations from each service and stores them as
|
||||
paths relative to the project root (e.g., 'apps/backend/.venv'
|
||||
instead of just '.venv').
|
||||
"""
|
||||
aggregated: list[dict[str, Any]] = []
|
||||
|
||||
for service_name, service_info in self.index.get("services", {}).items():
|
||||
service_deps = service_info.get("dependency_locations", [])
|
||||
service_path = service_info.get("path", "")
|
||||
|
||||
# Compute service-relative prefix once per service
|
||||
service_rel: Path | None = None
|
||||
if service_path:
|
||||
try:
|
||||
service_rel = Path(service_path).relative_to(self.project_dir)
|
||||
except ValueError:
|
||||
# Service path is outside the project root — skip its deps
|
||||
# to avoid producing absolute paths that bypass containment
|
||||
continue
|
||||
|
||||
for dep in service_deps:
|
||||
dep_path = dep.get("path")
|
||||
if not dep_path:
|
||||
continue
|
||||
|
||||
# Build project-relative path from service path + dep path
|
||||
if service_rel is not None:
|
||||
project_relative = str(service_rel / dep_path)
|
||||
else:
|
||||
project_relative = dep_path
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"type": dep.get("type", "unknown"),
|
||||
"path": project_relative,
|
||||
"exists": dep.get("exists", False),
|
||||
"service": service_name,
|
||||
}
|
||||
if dep.get("requirements_file"):
|
||||
# Convert to project-relative path like we do for "path"
|
||||
if service_rel is not None:
|
||||
entry["requirements_file"] = str(
|
||||
service_rel / dep["requirements_file"]
|
||||
)
|
||||
else:
|
||||
entry["requirements_file"] = dep["requirements_file"]
|
||||
pkg_mgr = dep.get("package_manager") or service_info.get(
|
||||
"package_manager"
|
||||
)
|
||||
if pkg_mgr:
|
||||
entry["package_manager"] = pkg_mgr
|
||||
aggregated.append(entry)
|
||||
|
||||
self.index["dependency_locations"] = aggregated
|
||||
|
||||
def _analyze_infrastructure(self) -> None:
|
||||
"""Analyze infrastructure configuration."""
|
||||
infra = {}
|
||||
|
||||
@@ -40,6 +40,8 @@ class ServiceAnalyzer(BaseAnalyzer):
|
||||
self._find_key_directories()
|
||||
self._find_entry_points()
|
||||
self._detect_dependencies()
|
||||
self._detect_dependency_locations()
|
||||
self._detect_package_manager()
|
||||
self._detect_testing()
|
||||
self._find_dockerfile()
|
||||
|
||||
@@ -209,6 +211,121 @@ class ServiceAnalyzer(BaseAnalyzer):
|
||||
deps.append(match.group(1))
|
||||
self.analysis["dependencies"] = deps[:20]
|
||||
|
||||
def _detect_dependency_locations(self) -> None:
|
||||
"""Detect where dependencies live on disk for this service."""
|
||||
locations: list[dict[str, Any]] = []
|
||||
|
||||
# Node.js: node_modules (only if package.json exists)
|
||||
if self._exists("package.json"):
|
||||
node_modules = self.path / "node_modules"
|
||||
locations.append(
|
||||
{
|
||||
"type": "node_modules",
|
||||
"path": "node_modules",
|
||||
"exists": node_modules.exists() and node_modules.is_dir(),
|
||||
}
|
||||
)
|
||||
|
||||
# Python: .venv or venv
|
||||
for venv_dir in [".venv", "venv"]:
|
||||
venv_path = self.path / venv_dir
|
||||
if venv_path.exists() and venv_path.is_dir():
|
||||
entry: dict[str, Any] = {
|
||||
"type": "venv",
|
||||
"path": venv_dir,
|
||||
"exists": True,
|
||||
}
|
||||
# Find requirements file
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
entry["requirements_file"] = req_file
|
||||
break
|
||||
locations.append(entry)
|
||||
break
|
||||
else:
|
||||
# No venv found, still record requirements file if present
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
locations.append(
|
||||
{
|
||||
"type": "venv",
|
||||
"path": ".venv",
|
||||
"exists": False,
|
||||
"requirements_file": req_file,
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
# PHP: vendor
|
||||
vendor_path = self.path / "vendor"
|
||||
if vendor_path.exists() and vendor_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_php",
|
||||
"path": "vendor",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Rust: target
|
||||
target_path = self.path / "target"
|
||||
if target_path.exists() and target_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "cargo_target",
|
||||
"path": "target",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Ruby: vendor/bundle
|
||||
bundle_path = self.path / "vendor" / "bundle"
|
||||
if bundle_path.exists() and bundle_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_bundle",
|
||||
"path": "vendor/bundle",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.analysis["dependency_locations"] = locations
|
||||
|
||||
def _detect_package_manager(self) -> None:
|
||||
"""Detect the package manager used by this service."""
|
||||
# Node.js package managers
|
||||
if self._exists("package-lock.json"):
|
||||
self.analysis["package_manager"] = "npm"
|
||||
elif self._exists("yarn.lock"):
|
||||
self.analysis["package_manager"] = "yarn"
|
||||
elif self._exists("pnpm-lock.yaml"):
|
||||
self.analysis["package_manager"] = "pnpm"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
self.analysis["package_manager"] = "bun"
|
||||
# Python package managers
|
||||
elif self._exists("Pipfile"):
|
||||
self.analysis["package_manager"] = "pipenv"
|
||||
elif self._exists("pyproject.toml"):
|
||||
if self._exists("uv.lock"):
|
||||
self.analysis["package_manager"] = "uv"
|
||||
elif self._exists("poetry.lock"):
|
||||
self.analysis["package_manager"] = "poetry"
|
||||
else:
|
||||
self.analysis["package_manager"] = "pip"
|
||||
elif self._exists("requirements.txt"):
|
||||
self.analysis["package_manager"] = "pip"
|
||||
# Other
|
||||
elif self._exists("Cargo.toml"):
|
||||
self.analysis["package_manager"] = "cargo"
|
||||
elif self._exists("go.mod"):
|
||||
self.analysis["package_manager"] = "go_mod"
|
||||
elif self._exists("Gemfile"):
|
||||
self.analysis["package_manager"] = "gem"
|
||||
elif self._exists("composer.json"):
|
||||
self.analysis["package_manager"] = "composer"
|
||||
else:
|
||||
self.analysis["package_manager"] = None
|
||||
|
||||
def _detect_testing(self) -> None:
|
||||
"""Detect testing framework and configuration."""
|
||||
if self._exists("package.json"):
|
||||
|
||||
@@ -115,14 +115,6 @@ class CIDiscovery:
|
||||
CIConfig if CI found, None otherwise
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
|
||||
# Return None if project directory doesn't exist or is inaccessible
|
||||
try:
|
||||
if not project_dir.exists():
|
||||
return None
|
||||
except (OSError, PermissionError):
|
||||
return None
|
||||
|
||||
cache_key = str(project_dir.resolve())
|
||||
|
||||
if cache_key in self._cache:
|
||||
@@ -307,8 +299,8 @@ class CIDiscovery:
|
||||
if isinstance(variables, dict):
|
||||
result.environment_variables.extend(variables.keys())
|
||||
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: CI parsing is best-effort
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
@@ -365,8 +357,8 @@ class CIDiscovery:
|
||||
)
|
||||
)
|
||||
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: CI parsing is best-effort
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
@@ -385,11 +377,17 @@ class CIDiscovery:
|
||||
matches = sh_pattern.findall(content)
|
||||
|
||||
steps = []
|
||||
test_related = False
|
||||
|
||||
for cmd in matches:
|
||||
steps.append(cmd)
|
||||
self._extract_test_commands(cmd, result)
|
||||
|
||||
if any(
|
||||
kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"]
|
||||
):
|
||||
test_related = True
|
||||
|
||||
# Extract stage names
|
||||
stage_pattern = re.compile(r'stage\s*\([\'"]([^\'"]+)[\'"]\)')
|
||||
stages = stage_pattern.findall(content)
|
||||
@@ -404,8 +402,8 @@ class CIDiscovery:
|
||||
)
|
||||
)
|
||||
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: CI parsing is best-effort
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Check for Claude SDK availability
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeSDKClient # noqa: F401 (re-export)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_AVAILABLE = False
|
||||
ClaudeAgentOptions = None
|
||||
ClaudeSDKClient = None
|
||||
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
|
||||
@@ -31,11 +31,12 @@ from typing import Any
|
||||
|
||||
# Import the existing secrets scanner
|
||||
try:
|
||||
from security.scan_secrets import get_all_tracked_files, scan_files
|
||||
from security.scan_secrets import SecretMatch, get_all_tracked_files, scan_files
|
||||
|
||||
HAS_SECRETS_SCANNER = True
|
||||
except ImportError:
|
||||
HAS_SECRETS_SCANNER = False
|
||||
SecretMatch = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -107,20 +108,6 @@ class SecurityScanner:
|
||||
self._bandit_available: bool | None = None
|
||||
self._npm_available: bool | None = None
|
||||
|
||||
def _is_accessible_directory(self, path: Path) -> bool:
|
||||
"""Check if a directory is accessible for scanning."""
|
||||
try:
|
||||
return path.is_dir()
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
|
||||
def _safe_exists(self, path: Path) -> bool:
|
||||
"""Safely check if a path exists, handling permission errors."""
|
||||
try:
|
||||
return path.exists()
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
|
||||
def scan(
|
||||
self,
|
||||
project_dir: Path,
|
||||
@@ -242,8 +229,9 @@ class SecurityScanner:
|
||||
src_dirs = []
|
||||
for candidate in ["src", "app", project_dir.name, "."]:
|
||||
candidate_path = project_dir / candidate
|
||||
if self._safe_exists(candidate_path) and self._safe_exists(
|
||||
candidate_path / "__init__.py"
|
||||
if (
|
||||
candidate_path.exists()
|
||||
and (candidate_path / "__init__.py").exists()
|
||||
):
|
||||
src_dirs.append(str(candidate_path))
|
||||
|
||||
@@ -310,7 +298,7 @@ class SecurityScanner:
|
||||
) -> None:
|
||||
"""Run dependency vulnerability audits."""
|
||||
# npm audit for JavaScript projects
|
||||
if self._safe_exists(project_dir / "package.json"):
|
||||
if (project_dir / "package.json").exists():
|
||||
self._run_npm_audit(project_dir, result)
|
||||
|
||||
# pip-audit for Python projects (if available)
|
||||
@@ -401,15 +389,15 @@ class SecurityScanner:
|
||||
else None,
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError: # Invalid JSON; skip
|
||||
pass # no-op: invalid audit output, skip
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except FileNotFoundError:
|
||||
pass # pip-audit not available
|
||||
except subprocess.TimeoutExpired: # Non-critical error; continue
|
||||
pass # no-op: timeout is non-critical
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: pip-audit is optional
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_python_project(self, project_dir: Path) -> bool:
|
||||
"""Check if this is a Python project."""
|
||||
@@ -419,7 +407,7 @@ class SecurityScanner:
|
||||
project_dir / "setup.py",
|
||||
project_dir / "setup.cfg",
|
||||
]
|
||||
return any(self._safe_exists(p) for p in indicators)
|
||||
return any(p.exists() for p in indicators)
|
||||
|
||||
def _check_bandit_available(self) -> bool:
|
||||
"""Check if Bandit is available."""
|
||||
@@ -439,20 +427,8 @@ class SecurityScanner:
|
||||
"""Redact a secret for safe logging."""
|
||||
if len(text) <= 8:
|
||||
return "*" * len(text)
|
||||
# Show only first 4 and last 4 characters, redact the middle
|
||||
return text[:4] + "*" * (len(text) - 8) + text[-4:]
|
||||
|
||||
def _redact_log_message(self, message: str) -> str:
|
||||
"""Redact potentially sensitive information from log messages."""
|
||||
# Remove common secret patterns from log messages
|
||||
import re
|
||||
|
||||
# Redact things that look like API keys, tokens, passwords
|
||||
redacted = re.sub(r"([a-zA-Z0-9_-]{32,})", "[REDACTED]", message)
|
||||
# Redact things that look like base64
|
||||
redacted = re.sub(r"[A-Za-z0-9+/]{64,}={0,2}", "[REDACTED]", redacted)
|
||||
return redacted[:500] # Also limit length
|
||||
|
||||
def _save_results(self, spec_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Save scan results to spec directory."""
|
||||
spec_dir = Path(spec_dir)
|
||||
@@ -465,12 +441,7 @@ class SecurityScanner:
|
||||
json.dump(output_data, f, indent=2)
|
||||
|
||||
def to_dict(self, result: SecurityScanResult) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for JSON serialization.
|
||||
|
||||
Note: All secret values are redacted via _redact_secret() before being
|
||||
included in the result dict. Only first 4 and last 4 chars are shown.
|
||||
"""
|
||||
# lgtm[py/clear-text-logging-of-sensitive-data] - secrets are redacted
|
||||
"""Convert result to dictionary for JSON serialization."""
|
||||
return {
|
||||
"secrets": result.secrets,
|
||||
"vulnerabilities": [
|
||||
@@ -599,7 +570,6 @@ def main() -> None:
|
||||
)
|
||||
|
||||
if args.json:
|
||||
# lgtm[py/clear-text-logging-of-sensitive-data] - secrets are redacted
|
||||
print(json.dumps(scanner.to_dict(result), indent=2))
|
||||
else:
|
||||
print(f"Secrets Found: {len(result.secrets)}")
|
||||
@@ -610,12 +580,7 @@ def main() -> None:
|
||||
if result.secrets:
|
||||
print("\nSecrets Detected:")
|
||||
for secret in result.secrets:
|
||||
# Only log safe fields (pattern name, file path, line number)
|
||||
# The actual secret value is already redacted in matched_text
|
||||
pattern = secret.get("pattern", "unknown")
|
||||
file_path = secret.get("file", "unknown")
|
||||
line_num = secret.get("line", "?")
|
||||
print(f" - {pattern} in {file_path}:{line_num}")
|
||||
print(f" - {secret['pattern']} in {secret['file']}:{secret['line']}")
|
||||
|
||||
if result.vulnerabilities:
|
||||
print(f"\nVulnerabilities ({len(result.vulnerabilities)}):")
|
||||
|
||||
@@ -1,690 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Discovery Module
|
||||
=====================
|
||||
|
||||
Detects test frameworks, test commands, and test directories in a project.
|
||||
This module analyzes project configuration files to discover how tests
|
||||
should be run.
|
||||
|
||||
The test discovery results are used by:
|
||||
- QA Agent: To determine what test commands to run
|
||||
- Test Creator: To know what framework to use when creating tests
|
||||
- Planner: To include correct test commands in verification strategy
|
||||
|
||||
Usage:
|
||||
from test_discovery import TestDiscovery
|
||||
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
|
||||
print(f"Test frameworks: {result['frameworks']}")
|
||||
print(f"Test command: {result['test_command']}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestFramework:
|
||||
"""
|
||||
Represents a detected test framework.
|
||||
|
||||
Attributes:
|
||||
name: Name of the framework (e.g., "pytest", "jest", "vitest")
|
||||
type: Type of testing (unit, integration, e2e, all)
|
||||
command: Command to run tests
|
||||
config_file: Configuration file if found
|
||||
version: Version if detected
|
||||
coverage_command: Command for coverage if available
|
||||
"""
|
||||
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
|
||||
name: str
|
||||
type: str # unit, integration, e2e, all
|
||||
command: str
|
||||
config_file: str | None = None
|
||||
version: str | None = None
|
||||
coverage_command: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestDiscoveryResult:
|
||||
"""
|
||||
Result of test framework discovery.
|
||||
|
||||
Attributes:
|
||||
frameworks: List of detected test frameworks
|
||||
test_command: Primary test command to run
|
||||
test_directories: Discovered test directories
|
||||
package_manager: Detected package manager
|
||||
has_tests: Whether any test files were found
|
||||
coverage_command: Command for coverage if available
|
||||
"""
|
||||
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
|
||||
frameworks: list[TestFramework] = field(default_factory=list)
|
||||
test_command: str = ""
|
||||
test_directories: list[str] = field(default_factory=list)
|
||||
package_manager: str = ""
|
||||
has_tests: bool = False
|
||||
coverage_command: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FRAMEWORK DETECTORS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# Pattern-based framework detection
|
||||
FRAMEWORK_PATTERNS = {
|
||||
# JavaScript/TypeScript
|
||||
"jest": {
|
||||
"config_files": [
|
||||
"jest.config.js",
|
||||
"jest.config.ts",
|
||||
"jest.config.mjs",
|
||||
"jest.config.cjs",
|
||||
],
|
||||
"package_key": "jest",
|
||||
"type": "unit",
|
||||
"command": "npx jest",
|
||||
"coverage_command": "npx jest --coverage",
|
||||
},
|
||||
"vitest": {
|
||||
"config_files": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"],
|
||||
"package_key": "vitest",
|
||||
"type": "unit",
|
||||
"command": "npx vitest run",
|
||||
"coverage_command": "npx vitest run --coverage",
|
||||
},
|
||||
"mocha": {
|
||||
"config_files": [
|
||||
".mocharc.js",
|
||||
".mocharc.json",
|
||||
".mocharc.yaml",
|
||||
".mocharc.yml",
|
||||
],
|
||||
"package_key": "mocha",
|
||||
"type": "unit",
|
||||
"command": "npx mocha",
|
||||
"coverage_command": "npx nyc mocha",
|
||||
},
|
||||
"playwright": {
|
||||
"config_files": ["playwright.config.js", "playwright.config.ts"],
|
||||
"package_key": "@playwright/test",
|
||||
"type": "e2e",
|
||||
"command": "npx playwright test",
|
||||
"coverage_command": None,
|
||||
},
|
||||
"cypress": {
|
||||
"config_files": ["cypress.config.js", "cypress.config.ts", "cypress.json"],
|
||||
"package_key": "cypress",
|
||||
"type": "e2e",
|
||||
"command": "npx cypress run",
|
||||
"coverage_command": None,
|
||||
},
|
||||
# Python
|
||||
"pytest": {
|
||||
"config_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"],
|
||||
"pyproject_key": "pytest",
|
||||
"requirements_key": "pytest",
|
||||
"type": "all",
|
||||
"command": "pytest",
|
||||
"coverage_command": "pytest --cov",
|
||||
},
|
||||
"unittest": {
|
||||
"config_files": [],
|
||||
"type": "unit",
|
||||
"command": "python -m unittest discover",
|
||||
"coverage_command": "coverage run -m unittest discover",
|
||||
},
|
||||
# Rust
|
||||
"cargo_test": {
|
||||
"config_files": ["Cargo.toml"],
|
||||
"type": "all",
|
||||
"command": "cargo test",
|
||||
"coverage_command": "cargo tarpaulin",
|
||||
},
|
||||
# Go
|
||||
"go_test": {
|
||||
"config_files": ["go.mod"],
|
||||
"type": "all",
|
||||
"command": "go test ./...",
|
||||
"coverage_command": "go test -cover ./...",
|
||||
},
|
||||
# Ruby
|
||||
"rspec": {
|
||||
"config_files": [".rspec", "spec/spec_helper.rb"],
|
||||
"gemfile_key": "rspec",
|
||||
"type": "all",
|
||||
"command": "bundle exec rspec",
|
||||
"coverage_command": "bundle exec rspec --format documentation",
|
||||
},
|
||||
"minitest": {
|
||||
"config_files": [],
|
||||
"gemfile_key": "minitest",
|
||||
"type": "unit",
|
||||
"command": "bundle exec rake test",
|
||||
"coverage_command": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDiscovery:
|
||||
"""
|
||||
Discovers test frameworks and configurations in a project.
|
||||
|
||||
Analyzes:
|
||||
- Package files (package.json, pyproject.toml, Cargo.toml, etc.)
|
||||
- Configuration files (jest.config.js, pytest.ini, etc.)
|
||||
- Directory structure (tests/, spec/, __tests__/)
|
||||
"""
|
||||
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the test discovery."""
|
||||
self._cache: dict[str, TestDiscoveryResult] = {}
|
||||
|
||||
def discover(self, project_dir: Path) -> TestDiscoveryResult:
|
||||
"""
|
||||
Discover test frameworks and configuration in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
TestDiscoveryResult with detected frameworks and commands
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
cache_key = str(project_dir.resolve())
|
||||
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
result = TestDiscoveryResult()
|
||||
|
||||
# Detect package manager
|
||||
result.package_manager = self._detect_package_manager(project_dir)
|
||||
|
||||
# Discover frameworks based on project type
|
||||
if (project_dir / "package.json").exists():
|
||||
self._discover_js_frameworks(project_dir, result)
|
||||
|
||||
# Check for Python project indicators
|
||||
python_indicators = [
|
||||
project_dir / "pyproject.toml",
|
||||
project_dir / "requirements.txt",
|
||||
project_dir / "setup.py",
|
||||
project_dir / "pytest.ini",
|
||||
project_dir / "conftest.py",
|
||||
project_dir / "tests" / "conftest.py",
|
||||
]
|
||||
if any(p.exists() for p in python_indicators):
|
||||
self._discover_python_frameworks(project_dir, result)
|
||||
|
||||
if (project_dir / "Cargo.toml").exists():
|
||||
self._discover_rust_frameworks(project_dir, result)
|
||||
if (project_dir / "go.mod").exists():
|
||||
self._discover_go_frameworks(project_dir, result)
|
||||
if (project_dir / "Gemfile").exists():
|
||||
self._discover_ruby_frameworks(project_dir, result)
|
||||
|
||||
# Find test directories
|
||||
result.test_directories = self._find_test_directories(project_dir)
|
||||
|
||||
# Check if tests exist
|
||||
result.has_tests = self._has_test_files(project_dir, result.test_directories)
|
||||
|
||||
# Set primary test command
|
||||
if result.frameworks:
|
||||
result.test_command = result.frameworks[0].command
|
||||
|
||||
# Set coverage command from first framework that has one
|
||||
if not result.coverage_command:
|
||||
for framework in result.frameworks:
|
||||
if framework.coverage_command:
|
||||
result.coverage_command = framework.coverage_command
|
||||
break
|
||||
|
||||
self._cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def _detect_package_manager(self, project_dir: Path) -> str:
|
||||
"""Detect the package manager used by the project."""
|
||||
if (project_dir / "pnpm-lock.yaml").exists():
|
||||
return "pnpm"
|
||||
if (project_dir / "yarn.lock").exists():
|
||||
return "yarn"
|
||||
if (project_dir / "package-lock.json").exists():
|
||||
return "npm"
|
||||
if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
|
||||
return "bun"
|
||||
if (project_dir / "uv.lock").exists():
|
||||
return "uv"
|
||||
if (project_dir / "poetry.lock").exists():
|
||||
return "poetry"
|
||||
if (project_dir / "Pipfile.lock").exists():
|
||||
return "pipenv"
|
||||
if (project_dir / "Cargo.lock").exists():
|
||||
return "cargo"
|
||||
if (project_dir / "go.sum").exists():
|
||||
return "go"
|
||||
if (project_dir / "Gemfile.lock").exists():
|
||||
return "bundler"
|
||||
return ""
|
||||
|
||||
def _discover_js_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover JavaScript/TypeScript test frameworks."""
|
||||
package_json = project_dir / "package.json"
|
||||
if not package_json.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(package_json, encoding="utf-8") as f:
|
||||
pkg = json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
|
||||
deps = pkg.get("dependencies", {})
|
||||
dev_deps = pkg.get("devDependencies", {})
|
||||
all_deps = {**deps, **dev_deps}
|
||||
scripts = pkg.get("scripts", {})
|
||||
|
||||
# Check for test frameworks in dependencies
|
||||
for name, pattern in FRAMEWORK_PATTERNS.items():
|
||||
if "package_key" not in pattern:
|
||||
continue
|
||||
|
||||
if pattern["package_key"] in all_deps:
|
||||
# Check for config file
|
||||
config_file = None
|
||||
for cf in pattern.get("config_files", []):
|
||||
if (project_dir / cf).exists():
|
||||
config_file = cf
|
||||
break
|
||||
|
||||
# Get version
|
||||
version = all_deps.get(pattern["package_key"], "")
|
||||
if version.startswith("^") or version.startswith("~"):
|
||||
version = version[1:]
|
||||
|
||||
# Determine command - prefer npm scripts if available
|
||||
command = pattern["command"]
|
||||
if "test" in scripts and pattern["package_key"] in scripts.get(
|
||||
"test", ""
|
||||
):
|
||||
command = f"{result.package_manager or 'npm'} test"
|
||||
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name=name,
|
||||
type=pattern["type"],
|
||||
command=command,
|
||||
config_file=config_file,
|
||||
version=version,
|
||||
coverage_command=pattern.get("coverage_command"),
|
||||
)
|
||||
)
|
||||
|
||||
# Check npm scripts for test commands
|
||||
if not result.frameworks and "test" in scripts:
|
||||
test_script = scripts["test"]
|
||||
if (
|
||||
test_script
|
||||
and test_script != 'echo "Error: no test specified" && exit 1'
|
||||
):
|
||||
# Try to infer framework from script
|
||||
framework_name = "npm_test"
|
||||
framework_type = "unit"
|
||||
|
||||
if "jest" in test_script:
|
||||
framework_name = "jest"
|
||||
elif "vitest" in test_script:
|
||||
framework_name = "vitest"
|
||||
elif "mocha" in test_script:
|
||||
framework_name = "mocha"
|
||||
elif "playwright" in test_script:
|
||||
framework_name = "playwright"
|
||||
framework_type = "e2e"
|
||||
elif "cypress" in test_script:
|
||||
framework_name = "cypress"
|
||||
framework_type = "e2e"
|
||||
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name=framework_name,
|
||||
type=framework_type,
|
||||
command=f"{result.package_manager or 'npm'} test",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_python_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Python test frameworks."""
|
||||
# Check for pytest.ini first (explicit pytest config)
|
||||
if (project_dir / "pytest.ini").exists():
|
||||
if not any(f.name == "pytest" for f in result.frameworks):
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file="pytest.ini",
|
||||
)
|
||||
)
|
||||
|
||||
# Check pyproject.toml
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text(encoding="utf-8")
|
||||
|
||||
# Check for pytest
|
||||
if "pytest" in content:
|
||||
if not any(f.name == "pytest" for f in result.frameworks):
|
||||
config_file = (
|
||||
"pyproject.toml" if "[tool.pytest" in content else None
|
||||
)
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file=config_file,
|
||||
)
|
||||
)
|
||||
|
||||
# Check requirements.txt
|
||||
requirements = project_dir / "requirements.txt"
|
||||
if requirements.exists():
|
||||
content = requirements.read_text(encoding="utf-8").lower()
|
||||
if "pytest" in content and not any(
|
||||
f.name == "pytest" for f in result.frameworks
|
||||
):
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
# Check for conftest.py (pytest marker)
|
||||
conftest_root = project_dir / "conftest.py"
|
||||
conftest_tests = project_dir / "tests" / "conftest.py"
|
||||
if conftest_root.exists() or conftest_tests.exists():
|
||||
if not any(f.name == "pytest" for f in result.frameworks):
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file="conftest.py",
|
||||
)
|
||||
)
|
||||
|
||||
# Fall back to unittest if test files exist but no framework detected
|
||||
if not result.frameworks:
|
||||
test_dirs = self._find_test_directories(project_dir)
|
||||
if test_dirs:
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="unittest",
|
||||
type="unit",
|
||||
command="python -m unittest discover",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_rust_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Rust test frameworks."""
|
||||
cargo_toml = project_dir / "Cargo.toml"
|
||||
if cargo_toml.exists():
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="cargo_test",
|
||||
type="all",
|
||||
command="cargo test",
|
||||
config_file="Cargo.toml",
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_go_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Go test frameworks."""
|
||||
go_mod = project_dir / "go.mod"
|
||||
if go_mod.exists():
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="go_test",
|
||||
type="all",
|
||||
command="go test ./...",
|
||||
config_file="go.mod",
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_ruby_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Ruby test frameworks."""
|
||||
gemfile = project_dir / "Gemfile"
|
||||
if not gemfile.exists():
|
||||
return
|
||||
|
||||
content = gemfile.read_text(encoding="utf-8").lower()
|
||||
|
||||
if "rspec" in content or (project_dir / ".rspec").exists():
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="rspec",
|
||||
type="all",
|
||||
command="bundle exec rspec",
|
||||
config_file=".rspec" if (project_dir / ".rspec").exists() else None,
|
||||
)
|
||||
)
|
||||
elif "minitest" in content:
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="minitest",
|
||||
type="unit",
|
||||
command="bundle exec rake test",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
def _find_test_directories(self, project_dir: Path) -> list[str]:
|
||||
"""Find test directories in the project."""
|
||||
test_dir_patterns = [
|
||||
"tests",
|
||||
"test",
|
||||
"spec",
|
||||
"__tests__",
|
||||
"specs",
|
||||
"test_*",
|
||||
]
|
||||
|
||||
found_dirs = []
|
||||
for pattern in test_dir_patterns:
|
||||
if pattern.endswith("*"):
|
||||
# Glob pattern
|
||||
for d in project_dir.glob(pattern):
|
||||
if d.is_dir():
|
||||
found_dirs.append(str(d.relative_to(project_dir)))
|
||||
else:
|
||||
# Exact name
|
||||
test_dir = project_dir / pattern
|
||||
if test_dir.is_dir():
|
||||
found_dirs.append(pattern)
|
||||
|
||||
return found_dirs
|
||||
|
||||
def _has_test_files(self, project_dir: Path, test_directories: list[str]) -> bool:
|
||||
"""Check if any test files exist."""
|
||||
test_file_patterns = [
|
||||
"**/test_*.py",
|
||||
"**/*_test.py",
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/test_*.go",
|
||||
"**/*_test.go",
|
||||
"**/*_test.rs",
|
||||
"**/spec/**/*_spec.rb",
|
||||
]
|
||||
|
||||
# Check in test directories
|
||||
for test_dir in test_directories:
|
||||
test_path = project_dir / test_dir
|
||||
if test_path.exists():
|
||||
for pattern in test_file_patterns:
|
||||
if list(test_path.glob(pattern.replace("**/", ""))):
|
||||
return True
|
||||
|
||||
# Check project-wide
|
||||
for pattern in test_file_patterns:
|
||||
if list(project_dir.glob(pattern)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for JSON serialization."""
|
||||
return {
|
||||
"frameworks": [
|
||||
{
|
||||
"name": f.name,
|
||||
"type": f.type,
|
||||
"command": f.command,
|
||||
"config_file": f.config_file,
|
||||
"version": f.version,
|
||||
"coverage_command": f.coverage_command,
|
||||
}
|
||||
for f in result.frameworks
|
||||
],
|
||||
"test_command": result.test_command,
|
||||
"test_directories": result.test_directories,
|
||||
"package_manager": result.package_manager,
|
||||
"has_tests": result.has_tests,
|
||||
"coverage_command": result.coverage_command,
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the internal cache."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def discover_tests(project_dir: Path) -> TestDiscoveryResult:
|
||||
"""
|
||||
Convenience function to discover tests in a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
TestDiscoveryResult with detected frameworks
|
||||
"""
|
||||
discovery = TestDiscovery()
|
||||
return discovery.discover(project_dir)
|
||||
|
||||
|
||||
def get_test_command(project_dir: Path) -> str:
|
||||
"""
|
||||
Get the primary test command for a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
Test command string, or empty string if not found
|
||||
"""
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
return result.test_command
|
||||
|
||||
|
||||
def get_test_frameworks(project_dir: Path) -> list[str]:
|
||||
"""
|
||||
Get list of test framework names in a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
List of framework names
|
||||
"""
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
return [f.name for f in result.frameworks]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for testing."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Discover test frameworks")
|
||||
parser.add_argument("project_dir", type=Path, help="Path to project root")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(args.project_dir)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(discovery.to_dict(result), indent=2))
|
||||
else:
|
||||
print(f"Package Manager: {result.package_manager or 'unknown'}")
|
||||
print(f"Has Tests: {result.has_tests}")
|
||||
print(f"Test Command: {result.test_command or 'none'}")
|
||||
print(f"Test Directories: {', '.join(result.test_directories) or 'none'}")
|
||||
print(f"Coverage Command: {result.coverage_command or 'none'}")
|
||||
print(f"\nFrameworks ({len(result.frameworks)}):")
|
||||
for f in result.frameworks:
|
||||
print(f" - {f.name} ({f.type})")
|
||||
print(f" Command: {f.command}")
|
||||
if f.config_file:
|
||||
print(f" Config: {f.config_file}")
|
||||
if f.version:
|
||||
print(f" Version: {f.version}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,6 +10,7 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from qa.criteria import is_fixes_applied, is_qa_approved, is_qa_rejected
|
||||
from ui import highlight, print_status
|
||||
|
||||
|
||||
@@ -140,25 +141,33 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
spec_name = spec_dir.name
|
||||
req_file = spec_dir / "requirements.json"
|
||||
|
||||
# Get title from requirements file, default to spec name
|
||||
status = "unknown"
|
||||
title = spec_name
|
||||
|
||||
if req_file.exists():
|
||||
try:
|
||||
with open(req_file, encoding="utf-8") as f:
|
||||
req = json.load(f)
|
||||
title = req.get("task_description", spec_name)
|
||||
title = req.get("task_description", title)
|
||||
except json.JSONDecodeError:
|
||||
# Invalid JSON; use default title from directory name
|
||||
title = spec_name
|
||||
else:
|
||||
title = spec_name
|
||||
pass
|
||||
|
||||
# Determine status
|
||||
if (spec_dir / "spec.md").exists():
|
||||
status = "spec_created"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
status = "building"
|
||||
elif (spec_dir / "qa_report.md").exists():
|
||||
# Determine status (highest priority first)
|
||||
# Use authoritative QA status check, not just file existence
|
||||
if is_qa_approved(spec_dir):
|
||||
status = "qa_approved"
|
||||
elif is_qa_rejected(spec_dir):
|
||||
status = "qa_rejected"
|
||||
elif is_fixes_applied(spec_dir):
|
||||
status = "fixes_applied"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
# Check if there's a qa_report.md but no approval yet (QA in progress)
|
||||
if (spec_dir / "qa_report.md").exists():
|
||||
status = "qa_in_progress"
|
||||
else:
|
||||
status = "building"
|
||||
elif (spec_dir / "spec.md").exists():
|
||||
status = "spec_created"
|
||||
else:
|
||||
status = "pending_spec"
|
||||
|
||||
@@ -166,7 +175,10 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
"pending_spec": "⏳",
|
||||
"spec_created": "📋",
|
||||
"building": "⚙️",
|
||||
"qa_in_progress": "🔍",
|
||||
"qa_approved": "✅",
|
||||
"qa_rejected": "❌",
|
||||
"fixes_applied": "🔧",
|
||||
"unknown": "❓",
|
||||
}.get(status, "❓")
|
||||
|
||||
@@ -193,10 +205,10 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
print_status("No specs directory found", "info")
|
||||
return True
|
||||
|
||||
# Find completed specs
|
||||
# Find completed specs (only QA-approved, matching status display logic)
|
||||
completed = []
|
||||
for spec_dir in specs_dir.iterdir():
|
||||
if spec_dir.is_dir() and (spec_dir / "qa_report.md").exists():
|
||||
if spec_dir.is_dir() and is_qa_approved(spec_dir):
|
||||
completed.append(spec_dir.name)
|
||||
|
||||
if not completed:
|
||||
|
||||
@@ -179,10 +179,10 @@ def handle_build_command(
|
||||
continue_existing = check_existing_build(project_dir, spec_dir.name)
|
||||
if continue_existing:
|
||||
# Continue with existing worktree
|
||||
pass # no-op
|
||||
pass
|
||||
else:
|
||||
# User chose to start fresh or merged existing
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
# Choose workspace (skip for parallel mode - it always uses worktrees)
|
||||
working_dir = project_dir
|
||||
@@ -297,6 +297,7 @@ def handle_build_command(
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nQA validation paused.")
|
||||
print(f"Resume: python auto-claude/run.py --spec {spec_dir.name} --qa")
|
||||
qa_approved = False
|
||||
|
||||
# Post-build finalization (only for isolated sequential mode)
|
||||
# This happens AFTER QA validation so the worktree still exists
|
||||
@@ -468,8 +469,9 @@ def _handle_build_interrupt(
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_inactive()
|
||||
sys.exit(0)
|
||||
except EOFError: # stdin closed
|
||||
pass # no-op
|
||||
except EOFError:
|
||||
# stdin closed
|
||||
pass
|
||||
|
||||
# Resume instructions (shown when user provided instructions or chose file/type/paste)
|
||||
print()
|
||||
|
||||
@@ -12,48 +12,11 @@ Usage:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
from cli.utils import find_specs_dir
|
||||
|
||||
|
||||
|
||||
@@ -325,32 +325,42 @@ def _detect_parallel_task_conflicts(
|
||||
try:
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
debug_error,
|
||||
debug_section,
|
||||
debug_success,
|
||||
debug_verbose,
|
||||
is_debug_enabled,
|
||||
)
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
"""Fallback debug function when debug module is not available."""
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
"""Fallback debug_error function when debug module is not available."""
|
||||
pass # no-op
|
||||
|
||||
def debug_section(*args, **kwargs):
|
||||
"""Fallback debug_section function when debug module is not available."""
|
||||
pass # no-op
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
"""Fallback debug_success function when debug module is not available."""
|
||||
pass # no-op
|
||||
def debug_detailed(*args, **kwargs):
|
||||
"""Fallback debug_detailed function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_verbose(*args, **kwargs):
|
||||
"""Fallback debug_verbose function when debug module is not available."""
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
"""Fallback debug_success function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
"""Fallback debug_error function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_section(*args, **kwargs):
|
||||
"""Fallback debug_section function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def is_debug_enabled():
|
||||
"""Fallback is_debug_enabled function when debug module is not available."""
|
||||
return False
|
||||
|
||||
|
||||
MODULE = "cli.workspace_commands"
|
||||
@@ -807,7 +817,7 @@ def _check_git_merge_conflicts(
|
||||
import re
|
||||
|
||||
match = re.search(
|
||||
r"Merge conflict in\s+(.+?)\s*$",
|
||||
r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()",
|
||||
line,
|
||||
)
|
||||
if match:
|
||||
|
||||
@@ -5,10 +5,6 @@ Provides Claude API client utilities.
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Explicit import required for CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from core.client import create_client as _create_client_impl
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy import to avoid circular imports with auto_claude_tools."""
|
||||
@@ -19,10 +15,11 @@ def __getattr__(name):
|
||||
|
||||
def create_client(*args, **kwargs):
|
||||
"""Create a Claude client instance."""
|
||||
return _create_client_impl(*args, **kwargs)
|
||||
from core.client import create_client as _create_client
|
||||
|
||||
return _create_client(*args, **kwargs)
|
||||
|
||||
|
||||
# Export list must come after all exports are defined to satisfy CodeQL
|
||||
__all__ = [
|
||||
"create_client",
|
||||
]
|
||||
|
||||
@@ -13,7 +13,6 @@ Features:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -21,46 +20,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
# This is needed because this module may be imported in contexts that log
|
||||
# Unicode content, and the logging system uses stdout/stderr.
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -40,12 +40,9 @@ class ContextBuilder:
|
||||
try:
|
||||
with open(index_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (
|
||||
OSError,
|
||||
json.JSONDecodeError,
|
||||
UnicodeDecodeError,
|
||||
): # Corrupted or legacy-encoded file, regenerate
|
||||
pass # no-op
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Corrupted or legacy-encoded file, regenerate
|
||||
pass
|
||||
|
||||
# Try to create one
|
||||
from analyzer import analyze_project
|
||||
@@ -117,7 +114,7 @@ class ContextBuilder:
|
||||
try:
|
||||
# Run the async function in a new event loop if necessary
|
||||
try:
|
||||
_ = asyncio.get_running_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're already in an async context - this shouldn't happen in CLI
|
||||
# but handle it gracefully
|
||||
graph_hints = []
|
||||
|
||||
@@ -26,47 +26,9 @@ The context builder will:
|
||||
4. Output focused context for AI agents
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
from context import (
|
||||
ContextBuilder,
|
||||
FileMatch,
|
||||
|
||||
@@ -3,63 +3,40 @@ Core Framework Module
|
||||
=====================
|
||||
|
||||
Core components for the Auto Claude autonomous coding framework.
|
||||
|
||||
Note: We use lazy imports here because the full agent module has many dependencies
|
||||
that may not be needed for basic operations.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Module-level placeholders for CodeQL static analysis.
|
||||
# The actual exported names trigger __getattr__ for lazy loading.
|
||||
# Use list placeholder to satisfy CodeQL's "defined but not set to None" check.
|
||||
run_autonomous_agent: Any = []
|
||||
run_followup_planner: Any = []
|
||||
WorktreeManager: Any = []
|
||||
# Note: We use lazy imports here because the full agent module has many dependencies
|
||||
# that may not be needed for basic operations like workspace management.
|
||||
|
||||
__all__ = [
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
"WorkspaceManager",
|
||||
"WorktreeManager",
|
||||
"create_claude_client",
|
||||
"ClaudeClient",
|
||||
"ProgressTracker",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
def __getattr__(name):
|
||||
"""Lazy imports to avoid circular dependencies and heavy imports."""
|
||||
if name == "run_autonomous_agent":
|
||||
from .agent import run_autonomous_agent
|
||||
if name in ("run_autonomous_agent", "run_followup_planner"):
|
||||
from .agent import run_autonomous_agent, run_followup_planner
|
||||
|
||||
return run_autonomous_agent
|
||||
elif name == "run_followup_planner":
|
||||
from .agent import run_followup_planner
|
||||
return locals()[name]
|
||||
elif name == "WorkspaceManager":
|
||||
from .workspace import WorkspaceManager
|
||||
|
||||
return run_followup_planner
|
||||
return WorkspaceManager
|
||||
elif name == "WorktreeManager":
|
||||
from .worktree import WorktreeManager
|
||||
|
||||
return WorktreeManager
|
||||
elif name == "ProgressTracker":
|
||||
from .progress import ProgressTracker
|
||||
|
||||
return ProgressTracker
|
||||
elif name in ("create_claude_client", "ClaudeClient"):
|
||||
from . import client as _client
|
||||
|
||||
return getattr(_client, name)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def _do_lazy_import(name: str) -> Any:
|
||||
"""Perform the actual lazy import for a given name."""
|
||||
if name == "run_autonomous_agent":
|
||||
from .agent import run_autonomous_agent
|
||||
|
||||
return run_autonomous_agent
|
||||
if name == "run_followup_planner":
|
||||
from .agent import run_followup_planner
|
||||
|
||||
return run_followup_planner
|
||||
if name == "WorktreeManager":
|
||||
from .worktree import WorktreeManager
|
||||
|
||||
return WorktreeManager
|
||||
raise AssertionError(f"Unknown lazy import name: {name}")
|
||||
|
||||
+66
-15
@@ -15,6 +15,7 @@ import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.platform import (
|
||||
get_where_exe_path,
|
||||
is_linux,
|
||||
is_macos,
|
||||
is_windows,
|
||||
@@ -466,6 +467,7 @@ def _get_token_from_macos_keychain(config_dir: str | None = None) -> str | None:
|
||||
if not (token.startswith("sk-ant-oat01-") or token.startswith("enc:")):
|
||||
return None
|
||||
|
||||
logger.debug(f"Found token in keychain service '{service_name}'")
|
||||
return token
|
||||
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception):
|
||||
@@ -500,6 +502,7 @@ def _get_token_from_windows_credential_files(
|
||||
token.startswith("sk-ant-oat01-")
|
||||
or token.startswith("enc:")
|
||||
):
|
||||
logger.debug(f"Found token in {cred_path}")
|
||||
return token
|
||||
# If config_dir provided but no token found, don't fall back to default
|
||||
return None
|
||||
@@ -595,6 +598,9 @@ def _get_token_from_linux_secret_service(config_dir: str | None = None) -> str |
|
||||
if token and (
|
||||
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
|
||||
):
|
||||
logger.debug(
|
||||
f"Found token in secret service with label '{target_label}'"
|
||||
)
|
||||
return token
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
@@ -655,6 +661,7 @@ def _get_token_from_config_dir(config_dir: str) -> str | None:
|
||||
if token and (
|
||||
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
|
||||
):
|
||||
logger.debug(f"Found token in {cred_path}")
|
||||
return token
|
||||
except (json.JSONDecodeError, KeyError, Exception) as e:
|
||||
logger.debug(f"Failed to read {cred_path}: {e}")
|
||||
@@ -687,10 +694,25 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
_debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
|
||||
if _debug:
|
||||
# Log which auth env vars are set (presence only, never values)
|
||||
set_vars = [v for v in AUTH_TOKEN_ENV_VARS if os.environ.get(v)]
|
||||
logger.info(
|
||||
"[Auth] get_auth_token() called — config_dir param=%s, "
|
||||
"env vars present: %s, CLAUDE_CONFIG_DIR env=%s",
|
||||
repr(config_dir),
|
||||
set_vars or "(none)",
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
)
|
||||
|
||||
# First check environment variables (highest priority)
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info("[Auth] Token resolved from env var: %s", var)
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Check CLAUDE_CONFIG_DIR environment variable (profile's custom config directory)
|
||||
@@ -698,12 +720,13 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
|
||||
# Debug: Log which config_dir is being used for credential resolution
|
||||
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
if debug and effective_config_dir:
|
||||
if _debug and effective_config_dir:
|
||||
service_name = _get_keychain_service_name(effective_config_dir)
|
||||
logger.info(
|
||||
f"[Auth] Resolving credentials for profile config_dir: {effective_config_dir} "
|
||||
f"(Keychain service: {service_name})"
|
||||
"[Auth] Resolving credentials for profile config_dir: %s "
|
||||
"(Keychain service: %s)",
|
||||
effective_config_dir,
|
||||
service_name,
|
||||
)
|
||||
|
||||
# If a custom config directory is specified, read from there first
|
||||
@@ -711,24 +734,37 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
# Try reading from .credentials.json file in the config directory
|
||||
token = _get_token_from_config_dir(effective_config_dir)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] Token resolved from config dir file: %s",
|
||||
effective_config_dir,
|
||||
)
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Also try the system credential store with hash-based service name
|
||||
# This is needed because macOS stores credentials in Keychain, not files
|
||||
token = get_token_from_keychain(effective_config_dir)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info("[Auth] Token resolved from Keychain (profile-specific)")
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# If config_dir was explicitly provided, DON'T fall back to default keychain
|
||||
# - that would return the wrong profile's token
|
||||
logger.debug(
|
||||
f"No credentials found for config_dir '{effective_config_dir}' "
|
||||
"in file or keychain"
|
||||
"No credentials found for config_dir '%s' in file or keychain",
|
||||
effective_config_dir,
|
||||
)
|
||||
return None
|
||||
|
||||
# No config_dir specified - use default system credential store
|
||||
return _try_decrypt_token(get_token_from_keychain())
|
||||
keychain_token = get_token_from_keychain()
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] Token resolved from default Keychain: %s",
|
||||
"found" if keychain_token else "not found",
|
||||
)
|
||||
return _try_decrypt_token(keychain_token)
|
||||
|
||||
|
||||
def get_auth_token_source(config_dir: str | None = None) -> str | None:
|
||||
@@ -848,9 +884,9 @@ def _find_git_bash_path() -> str | None:
|
||||
|
||||
# Method 1: Use 'where' command to find git.exe
|
||||
try:
|
||||
# Use where.exe explicitly for reliability
|
||||
# Use full path to where.exe for reliability (works even when System32 isn't in PATH)
|
||||
result = subprocess.run(
|
||||
["where.exe", "git"],
|
||||
[get_where_exe_path(), "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
@@ -861,12 +897,9 @@ def _find_git_bash_path() -> str | None:
|
||||
git_paths = result.stdout.strip().splitlines()
|
||||
if git_paths:
|
||||
git_path = git_paths[0].strip()
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
FileNotFoundError,
|
||||
subprocess.SubprocessError,
|
||||
): # Intentionally suppress errors - best-effort detection with fallback to common paths
|
||||
pass # no-op
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
# Intentionally suppress errors - best-effort detection with fallback to common paths
|
||||
pass
|
||||
|
||||
# Method 2: Check common installation paths if 'where' didn't work
|
||||
if not git_path:
|
||||
@@ -966,8 +999,18 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
- API profile mode: requires ANTHROPIC_AUTH_TOKEN
|
||||
- OAuth mode: requires CLAUDE_CODE_OAUTH_TOKEN (from Keychain or env)
|
||||
"""
|
||||
_debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL", "").strip())
|
||||
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] configure_sdk_authentication() — mode=%s, config_dir=%s, "
|
||||
"CLAUDE_CONFIG_DIR env=%s",
|
||||
"api_profile" if api_profile_mode else "oauth",
|
||||
repr(config_dir),
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
)
|
||||
|
||||
if api_profile_mode:
|
||||
# API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present
|
||||
if not os.environ.get("ANTHROPIC_AUTH_TOKEN"):
|
||||
@@ -995,6 +1038,14 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
logger.info("Using OAuth authentication")
|
||||
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] SDK env check — CLAUDE_CONFIG_DIR=%s, "
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=%s",
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
"set" if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") else "unset",
|
||||
)
|
||||
|
||||
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
"""
|
||||
|
||||
+130
-16
@@ -21,6 +21,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.fast_mode import ensure_fast_mode_in_user_settings
|
||||
from core.platform import (
|
||||
is_windows,
|
||||
validate_cli_path,
|
||||
@@ -28,6 +29,89 @@ from core.platform import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# SDK Message Parser Patch
|
||||
# =============================================================================
|
||||
# The Claude Agent SDK's message_parser raises MessageParseError for unknown
|
||||
# message types (e.g., "rate_limit_event"). Since parse_message runs inside an
|
||||
# async generator, the exception kills the entire agent session stream.
|
||||
# Patch to log a warning and return a SystemMessage instead of crashing.
|
||||
# This is needed until the SDK natively handles all CLI message types.
|
||||
|
||||
|
||||
def _patch_sdk_message_parser() -> None:
|
||||
"""Patch the SDK's parse_message to handle unknown message types gracefully.
|
||||
|
||||
The Claude CLI may emit message types that the installed SDK version doesn't
|
||||
recognize (e.g., rate_limit_event, usage_event). Without this patch, any
|
||||
unrecognized type raises MessageParseError inside the SDK's async generator,
|
||||
which terminates the entire response stream and kills the agent session.
|
||||
|
||||
The patch converts unknown types into SystemMessage objects with a
|
||||
'unknown_<type>' subtype, which all message consumers silently skip.
|
||||
"""
|
||||
try:
|
||||
import claude_agent_sdk._internal.message_parser as _parser
|
||||
from claude_agent_sdk._errors import MessageParseError
|
||||
from claude_agent_sdk.types import SystemMessage
|
||||
|
||||
_original_parse = _parser.parse_message
|
||||
|
||||
def _patched_parse(data):
|
||||
try:
|
||||
return _original_parse(data)
|
||||
except MessageParseError as e:
|
||||
msg = str(e)
|
||||
if "Unknown message type" in msg:
|
||||
msg_type = (
|
||||
data.get("type", "unknown")
|
||||
if isinstance(data, dict)
|
||||
else "unknown"
|
||||
)
|
||||
# Rate limit events deserve a visible warning; others just debug-level
|
||||
if "rate_limit" in msg_type:
|
||||
retry_after = (
|
||||
data.get("retry_after")
|
||||
or data.get("data", {}).get("retry_after")
|
||||
if isinstance(data, dict)
|
||||
else None
|
||||
)
|
||||
retry_info = (
|
||||
f" (retry_after={retry_after}s)" if retry_after else ""
|
||||
)
|
||||
logger.warning(
|
||||
f"Rate limit event received from CLI{retry_info} — "
|
||||
f"the SDK will handle backoff automatically"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"SDK received unhandled message type '{msg_type}', skipping"
|
||||
)
|
||||
return SystemMessage(
|
||||
subtype=f"unknown_{msg_type}",
|
||||
data=data if isinstance(data, dict) else {},
|
||||
)
|
||||
raise
|
||||
|
||||
_parser.parse_message = _patched_parse
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to patch SDK message parser: {e}")
|
||||
|
||||
|
||||
_patch_sdk_message_parser()
|
||||
|
||||
# =============================================================================
|
||||
# Windows System Prompt Limits
|
||||
# =============================================================================
|
||||
# Windows CreateProcessW has a 32,768 character limit for the entire command line.
|
||||
# When CLAUDE.md is very large and passed as --system-prompt, the command can exceed
|
||||
# this limit, causing ERROR_FILE_NOT_FOUND. We cap CLAUDE.md content to stay safe.
|
||||
# 20,000 chars leaves ~12KB headroom for CLI overhead (model, tools, MCP config, etc.)
|
||||
WINDOWS_MAX_SYSTEM_PROMPT_CHARS = 20000
|
||||
WINDOWS_TRUNCATION_MESSAGE = (
|
||||
"\n\n[... CLAUDE.md truncated due to Windows command-line length limit ...]"
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Project Index Cache
|
||||
# =============================================================================
|
||||
@@ -483,9 +567,10 @@ def create_client(
|
||||
"medium", "high"). When set, injected as CLAUDE_CODE_EFFORT_LEVEL
|
||||
env var for the SDK subprocess. Only meaningful for models that
|
||||
support adaptive thinking (e.g., Opus 4.6).
|
||||
fast_mode: Enable Fast Mode for faster Opus 4.6 output. When True, injected
|
||||
as CLAUDE_CODE_FAST_MODE=true env var. Requires extra usage enabled
|
||||
on Claude subscription; falls back to standard speed automatically.
|
||||
fast_mode: Enable Fast Mode for faster Opus 4.6 output. When True, enables
|
||||
the "user" setting source so the CLI reads fastMode from
|
||||
~/.claude/settings.json. Requires extra usage enabled on Claude
|
||||
subscription; falls back to standard speed automatically.
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient
|
||||
@@ -510,16 +595,6 @@ def create_client(
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
if config_dir:
|
||||
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
|
||||
|
||||
# Get the config dir for profile-specific credential lookup
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
if config_dir:
|
||||
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
|
||||
|
||||
@@ -527,9 +602,19 @@ def create_client(
|
||||
if effort_level:
|
||||
sdk_env["CLAUDE_CODE_EFFORT_LEVEL"] = effort_level
|
||||
|
||||
# Inject fast mode for faster Opus 4.6 output
|
||||
# Fast mode requires the CLI to read "fastMode" from user settings.
|
||||
# The SDK default (setting_sources=None) passes --setting-sources "" which
|
||||
# blocks ALL filesystem settings. We must explicitly enable "user" source
|
||||
# so the CLI reads ~/.claude/settings.json where fastMode: true lives.
|
||||
# See: https://code.claude.com/docs/en/fast-mode
|
||||
if fast_mode:
|
||||
sdk_env["CLAUDE_CODE_FAST_MODE"] = "true"
|
||||
ensure_fast_mode_in_user_settings()
|
||||
logger.info("[Fast Mode] ACTIVE — will enable user setting source for fastMode")
|
||||
print(
|
||||
"[Fast Mode] ACTIVE — enabling user settings source for CLI to read fastMode"
|
||||
)
|
||||
else:
|
||||
logger.info("[Fast Mode] inactive — not requested for this client")
|
||||
|
||||
# Debug: Log git-bash path detection on Windows
|
||||
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
|
||||
@@ -819,8 +904,31 @@ def create_client(
|
||||
if should_use_claude_md():
|
||||
claude_md_content = load_claude_md(project_dir)
|
||||
if claude_md_content:
|
||||
# On Windows, the SDK passes system_prompt as a --system-prompt CLI argument.
|
||||
# Windows CreateProcessW has a 32,768 character limit for the entire command line.
|
||||
# When CLAUDE.md is very large, the command can exceed this limit, causing Windows
|
||||
# to return ERROR_FILE_NOT_FOUND which the SDK misreports as "Claude Code not found".
|
||||
# Cap CLAUDE.md content to keep total command line under the limit. (#1661)
|
||||
was_truncated = False
|
||||
if is_windows():
|
||||
max_claude_md_chars = (
|
||||
WINDOWS_MAX_SYSTEM_PROMPT_CHARS
|
||||
- len(base_prompt)
|
||||
- len(WINDOWS_TRUNCATION_MESSAGE)
|
||||
- len("\n\n# Project Instructions (from CLAUDE.md)\n\n")
|
||||
)
|
||||
if len(claude_md_content) > max_claude_md_chars > 0:
|
||||
claude_md_content = (
|
||||
claude_md_content[:max_claude_md_chars]
|
||||
+ WINDOWS_TRUNCATION_MESSAGE
|
||||
)
|
||||
print(
|
||||
" - CLAUDE.md: truncated (exceeded Windows command-line limit)"
|
||||
)
|
||||
was_truncated = True
|
||||
base_prompt = f"{base_prompt}\n\n# Project Instructions (from CLAUDE.md)\n\n{claude_md_content}"
|
||||
print(" - CLAUDE.md: included in system prompt")
|
||||
if not was_truncated:
|
||||
print(" - CLAUDE.md: included in system prompt")
|
||||
else:
|
||||
print(" - CLAUDE.md: not found in project root")
|
||||
else:
|
||||
@@ -851,6 +959,12 @@ def create_client(
|
||||
"enable_file_checkpointing": True,
|
||||
}
|
||||
|
||||
# Fast mode: enable user setting source so CLI reads fastMode from
|
||||
# ~/.claude/settings.json. Without this, the SDK's default --setting-sources ""
|
||||
# blocks all filesystem settings and the CLI never sees fastMode: true.
|
||||
if fast_mode:
|
||||
options_kwargs["setting_sources"] = ["user"]
|
||||
|
||||
# Optional: Allow CLI path override via environment variable
|
||||
# The SDK bundles its own CLI, but users can override if needed
|
||||
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
|
||||
|
||||
@@ -6,7 +6,17 @@ Common error detection and classification functions used across
|
||||
agent sessions, QA, and other modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from claude_agent_sdk.types import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_tool_concurrency_error(error: Exception) -> bool:
|
||||
@@ -114,5 +124,65 @@ def is_authentication_error(error: Exception) -> bool:
|
||||
"token_expired",
|
||||
"not authenticated",
|
||||
"http 401",
|
||||
"does not have access to claude",
|
||||
"please login again",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def safe_receive_messages(
|
||||
client,
|
||||
*,
|
||||
caller: str = "agent",
|
||||
) -> AsyncIterator[Message]:
|
||||
"""Iterate over SDK messages with resilience against unexpected errors.
|
||||
|
||||
The SDK's ``receive_response()`` async generator can terminate early if:
|
||||
1. An unhandled message type slips past the monkey-patch (e.g., SDK upgrade
|
||||
removes the patch surface).
|
||||
2. A transient parse error corrupts a single message in the stream.
|
||||
3. An unexpected ``StopAsyncIteration`` or runtime error occurs mid-stream.
|
||||
|
||||
This wrapper catches per-message errors, logs them, and continues yielding
|
||||
subsequent messages so the agent session can complete its work.
|
||||
|
||||
It also detects rate-limit events (surfaced as ``SystemMessage`` with
|
||||
subtype ``unknown_rate_limit_event``) and logs a user-visible warning.
|
||||
|
||||
Args:
|
||||
client: A ``ClaudeSDKClient`` instance (must be inside ``async with``).
|
||||
caller: Label for log messages (e.g., "session", "agent_runner").
|
||||
|
||||
Yields:
|
||||
Parsed ``Message`` objects from the SDK response stream.
|
||||
"""
|
||||
try:
|
||||
async for msg in client.receive_response():
|
||||
# Detect rate-limit events surfaced by the monkey-patch
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "SystemMessage":
|
||||
subtype = getattr(msg, "subtype", "")
|
||||
if subtype.startswith("unknown_"):
|
||||
original_type = subtype[len("unknown_") :]
|
||||
if "rate_limit" in original_type:
|
||||
data = getattr(msg, "data", {})
|
||||
retry_after = data.get("retry_after") or data.get(
|
||||
"data", {}
|
||||
).get("retry_after")
|
||||
retry_info = (
|
||||
f" (retry in {retry_after}s)" if retry_after else ""
|
||||
)
|
||||
logger.warning(f"[{caller}] Rate limit event{retry_info}")
|
||||
else:
|
||||
logger.debug(
|
||||
f"[{caller}] Skipping unknown SDK message type: {original_type}"
|
||||
)
|
||||
continue
|
||||
yield msg
|
||||
except GeneratorExit:
|
||||
return
|
||||
except Exception as e:
|
||||
# If the generator itself raises (e.g., transport error), log and stop
|
||||
# gracefully so callers can process whatever was collected so far.
|
||||
logger.error(f"[{caller}] SDK response stream terminated unexpectedly: {e}")
|
||||
return
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Fast Mode Settings Helper
|
||||
=========================
|
||||
|
||||
Manages the fastMode flag in ~/.claude/settings.json for temporary
|
||||
per-task fast mode overrides. Shared by both client.py and simple_client.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_fast_mode_atexit_registered = False
|
||||
|
||||
|
||||
def _write_fast_mode_setting(enabled: bool) -> None:
|
||||
"""Write fastMode value to ~/.claude/settings.json (atomic read-modify-write).
|
||||
|
||||
Uses write_json_atomic from core.file_utils to prevent corruption when
|
||||
multiple concurrent task processes modify the file simultaneously.
|
||||
"""
|
||||
settings_file = Path.home() / ".claude" / "settings.json"
|
||||
try:
|
||||
settings: dict = {}
|
||||
if settings_file.exists():
|
||||
settings = json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
|
||||
if settings.get("fastMode") != enabled:
|
||||
settings["fastMode"] = enabled
|
||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomic write using shared utility
|
||||
write_json_atomic(settings_file, settings)
|
||||
state = "true" if enabled else "false"
|
||||
logger.info(
|
||||
f"[Fast Mode] Wrote fastMode={state} to ~/.claude/settings.json"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Fast Mode] Could not update ~/.claude/settings.json: {e}")
|
||||
|
||||
|
||||
def _disable_fast_mode_on_exit() -> None:
|
||||
"""atexit handler: restore fastMode=false so interactive CLI sessions stay standard."""
|
||||
_write_fast_mode_setting(False)
|
||||
|
||||
|
||||
def ensure_fast_mode_in_user_settings() -> None:
|
||||
"""
|
||||
Enable fastMode in ~/.claude/settings.json and register cleanup.
|
||||
|
||||
The CLI reads fastMode from user settings (loaded via --setting-sources user).
|
||||
This function:
|
||||
1. Writes fastMode=true before spawning the CLI subprocess
|
||||
2. Registers an atexit handler to restore fastMode=false when the process exits
|
||||
|
||||
This ensures fast mode is a temporary override per task process, not a permanent
|
||||
setting change. The CLI subprocess reads settings at startup, so restoring false
|
||||
after exit doesn't affect running tasks — only prevents fast mode from leaking
|
||||
into subsequent interactive CLI sessions or non-fast-mode tasks.
|
||||
"""
|
||||
global _fast_mode_atexit_registered
|
||||
|
||||
_write_fast_mode_setting(True)
|
||||
|
||||
# Register cleanup once per process — idempotent on repeated calls
|
||||
if not _fast_mode_atexit_registered:
|
||||
import atexit
|
||||
|
||||
atexit.register(_disable_fast_mode_on_exit)
|
||||
_fast_mode_atexit_registered = True
|
||||
logger.info(
|
||||
"[Fast Mode] Registered atexit cleanup (will restore fastMode=false)"
|
||||
)
|
||||
@@ -10,6 +10,8 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from core.platform import get_where_exe_path
|
||||
|
||||
_cached_gh_path: str | None = None
|
||||
|
||||
|
||||
@@ -53,12 +55,11 @@ def _run_where_command() -> str | None:
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where gh",
|
||||
[get_where_exe_path(), "gh"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
shell=True, # Required: 'where' command must be executed through shell on Windows
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
@@ -68,11 +69,9 @@ def _run_where_command() -> str | None:
|
||||
and _verify_gh_executable(found_path)
|
||||
):
|
||||
return found_path
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
OSError,
|
||||
): # 'where' command failed or timed out - fall through to return None
|
||||
pass # no-op
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
# 'where' command failed or timed out - fall through to return None
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -135,7 +134,7 @@ def _find_gh_executable() -> str | None:
|
||||
if os.path.isfile(path) and _verify_gh_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
# 5. Try 'where' command with full path (works even when System32 isn't in PATH)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
@@ -15,12 +15,7 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = [
|
||||
"get_git_executable",
|
||||
"get_isolated_git_env",
|
||||
"run_git",
|
||||
"GIT_ENV_VARS_TO_CLEAR",
|
||||
]
|
||||
from core.platform import get_where_exe_path
|
||||
|
||||
# Git environment variables that can interfere with worktree operations
|
||||
# when set by pre-commit hooks or other git configurations.
|
||||
@@ -86,8 +81,9 @@ def get_git_executable() -> str:
|
||||
if _cached_git_path is not None:
|
||||
return _cached_git_path
|
||||
|
||||
_cached_git_path = _find_git_executable()
|
||||
return _cached_git_path
|
||||
git_path = _find_git_executable()
|
||||
_cached_git_path = git_path
|
||||
return git_path
|
||||
|
||||
|
||||
def _find_git_executable() -> str:
|
||||
@@ -130,14 +126,13 @@ def _find_git_executable() -> str:
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# 4. Try 'where' command with shell=True (more reliable on Windows)
|
||||
# 4. Try 'where' command with full path (works even when System32 isn't in PATH)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where git",
|
||||
[get_where_exe_path(), "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
|
||||
@@ -10,6 +10,8 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from core.platform import get_where_exe_path
|
||||
|
||||
_cached_glab_path: str | None = None
|
||||
|
||||
|
||||
@@ -53,12 +55,11 @@ def _run_where_command() -> str | None:
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where glab",
|
||||
[get_where_exe_path(), "glab"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
shell=True, # Required: 'where' command must be executed through shell on Windows
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
@@ -68,11 +69,9 @@ def _run_where_command() -> str | None:
|
||||
and _verify_glab_executable(found_path)
|
||||
):
|
||||
return found_path
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
OSError,
|
||||
): # 'where' command failed or timed out - fall through to return None
|
||||
pass # no-op
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
# 'where' command failed or timed out - fall through to return None
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -136,7 +135,7 @@ def _find_glab_executable() -> str | None:
|
||||
if os.path.isfile(path) and _verify_glab_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
# 5. Try 'where' command with full path (works even when System32 isn't in PATH)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
@@ -13,7 +13,6 @@ cases gracefully.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,9 +20,6 @@ logger = logging.getLogger(__name__)
|
||||
# Track if pipe is broken to avoid repeated failed writes
|
||||
_pipe_broken = False
|
||||
|
||||
# Check if running in tests to prevent closing stdout
|
||||
_IN_TESTS = os.environ.get("AUTO_CLAUDE_TESTS", "0") == "1"
|
||||
|
||||
|
||||
def safe_print(message: str, flush: bool = True) -> None:
|
||||
"""
|
||||
@@ -49,12 +45,10 @@ def safe_print(message: str, flush: bool = True) -> None:
|
||||
# Pipe closed by parent process - this is expected during shutdown
|
||||
_pipe_broken = True
|
||||
# Quietly close stdout to prevent further errors
|
||||
# Skip closing stdout during tests to avoid pytest capture issues
|
||||
if not _IN_TESTS:
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception: # stdout may already be closed
|
||||
pass # no-op
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Output pipe closed by parent process")
|
||||
except ValueError as e:
|
||||
# Handle writes to closed file (can happen after stdout.close())
|
||||
@@ -68,12 +62,10 @@ def safe_print(message: str, flush: bool = True) -> None:
|
||||
# Handle other pipe-related errors (EPIPE, etc.)
|
||||
if e.errno == 32: # EPIPE - Broken pipe
|
||||
_pipe_broken = True
|
||||
# Skip closing stdout during tests to avoid pytest capture issues
|
||||
if not _IN_TESTS:
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception: # stdout may already be closed
|
||||
pass # no-op
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Output pipe closed (EPIPE)")
|
||||
else:
|
||||
# Re-raise unexpected OS errors
|
||||
|
||||
@@ -430,6 +430,22 @@ def requires_shell(command: str) -> bool:
|
||||
return ext.lower() in {".cmd", ".bat", ".ps1"}
|
||||
|
||||
|
||||
def get_where_exe_path() -> str:
|
||||
"""Get full path to where.exe on Windows.
|
||||
|
||||
Using the full path ensures where.exe works even when System32 isn't in PATH,
|
||||
which can happen in restricted environments or when the app doesn't inherit
|
||||
the full system PATH.
|
||||
|
||||
Returns:
|
||||
Full path to where.exe (e.g., C:\\Windows\\System32\\where.exe)
|
||||
"""
|
||||
system_root = os.environ.get(
|
||||
"SystemRoot", os.environ.get("SYSTEMROOT", "C:\\Windows")
|
||||
)
|
||||
return os.path.join(system_root, "System32", "where.exe")
|
||||
|
||||
|
||||
def get_comspec_path() -> str:
|
||||
"""
|
||||
Get the path to cmd.exe on Windows.
|
||||
|
||||
@@ -9,8 +9,11 @@ Enhanced with colored output, icons, and better visual formatting.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from core.plan_normalization import normalize_subtask_aliases
|
||||
from ui import (
|
||||
Icons,
|
||||
@@ -230,8 +233,8 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
|
||||
f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}"
|
||||
)
|
||||
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass # Ignore corrupted/unreadable progress files
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.debug(f"Failed to load plan file for phase summary: {e}")
|
||||
else:
|
||||
print()
|
||||
print_status("No implementation subtasks yet - planner needs to run", "pending")
|
||||
@@ -404,6 +407,8 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
"""
|
||||
Find the next subtask to work on, respecting phase dependencies.
|
||||
|
||||
Skips subtasks that are marked as stuck in the recovery manager's attempt history.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing implementation_plan.json
|
||||
|
||||
@@ -415,6 +420,23 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
if not plan_file.exists():
|
||||
return None
|
||||
|
||||
# Load stuck subtasks from recovery manager's attempt history
|
||||
stuck_subtask_ids = set()
|
||||
attempt_history_file = spec_dir / "memory" / "attempt_history.json"
|
||||
if attempt_history_file.exists():
|
||||
try:
|
||||
with open(attempt_history_file, encoding="utf-8") as f:
|
||||
attempt_history = json.load(f)
|
||||
# Collect IDs of subtasks marked as stuck
|
||||
stuck_subtask_ids = {
|
||||
entry["subtask_id"]
|
||||
for entry in attempt_history.get("stuck_subtasks", [])
|
||||
if "subtask_id" in entry
|
||||
}
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
# If we can't read the file, continue without stuck checking
|
||||
pass
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
@@ -455,9 +477,15 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
if not deps_satisfied:
|
||||
continue
|
||||
|
||||
# Find first pending subtask in this phase
|
||||
# Find first pending subtask in this phase (skip stuck subtasks)
|
||||
for subtask in phase.get("subtasks", phase.get("chunks", [])):
|
||||
status = subtask.get("status", "pending")
|
||||
subtask_id = subtask.get("id")
|
||||
|
||||
# Skip stuck subtasks
|
||||
if subtask_id in stuck_subtask_ids:
|
||||
continue
|
||||
|
||||
if status in {"pending", "not_started", "not started"}:
|
||||
subtask_out, _changed = normalize_subtask_aliases(subtask)
|
||||
subtask_out["status"] = "pending"
|
||||
|
||||
@@ -186,14 +186,12 @@ def _before_send(event: dict, hint: dict) -> dict | None:
|
||||
|
||||
def init_sentry(
|
||||
component: str = "backend",
|
||||
force_enable: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Initialize Sentry for the Python backend.
|
||||
|
||||
Args:
|
||||
component: Component name for tagging (e.g., "backend", "github-runner")
|
||||
force_enable: Force enable even without packaged app detection
|
||||
|
||||
Returns:
|
||||
True if Sentry was initialized, False otherwise
|
||||
@@ -212,20 +210,11 @@ def init_sentry(
|
||||
logger.debug("[Sentry] No SENTRY_DSN configured - error reporting disabled")
|
||||
return False
|
||||
|
||||
# Check if we should enable Sentry
|
||||
# Enable if:
|
||||
# - Running from packaged app (detected by __compiled__ or frozen)
|
||||
# - SENTRY_DEV=true is set
|
||||
# - force_enable is True
|
||||
# DSN is present (checked above), so Sentry should be enabled.
|
||||
# The Electron main process only passes SENTRY_DSN to subprocesses in
|
||||
# production builds, so its presence is sufficient to gate activation.
|
||||
# In dev, set SENTRY_DSN in your environment to opt-in.
|
||||
is_packaged = getattr(sys, "frozen", False) or hasattr(sys, "__compiled__")
|
||||
sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes")
|
||||
should_enable = is_packaged or sentry_dev or force_enable
|
||||
|
||||
if not should_enable:
|
||||
logger.debug(
|
||||
"[Sentry] Development mode - error reporting disabled (set SENTRY_DEV=true to enable)"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
@@ -248,8 +237,8 @@ def init_sentry(
|
||||
parsed = float(env_rate)
|
||||
if 0 <= parsed <= 1:
|
||||
traces_sample_rate = parsed
|
||||
except Exception: # Sentry not configured or unavailable
|
||||
pass # no-op
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Configure logging integration to capture errors and warnings
|
||||
logging_integration = LoggingIntegration(
|
||||
|
||||
@@ -31,6 +31,7 @@ from core.auth import (
|
||||
configure_sdk_authentication,
|
||||
get_sdk_env_vars,
|
||||
)
|
||||
from core.fast_mode import ensure_fast_mode_in_user_settings
|
||||
from core.platform import validate_cli_path
|
||||
from phase_config import get_thinking_budget
|
||||
|
||||
@@ -71,8 +72,8 @@ def create_simple_client(
|
||||
betas: Optional list of SDK beta header strings (e.g., ["context-1m-2025-08-07"])
|
||||
effort_level: Optional effort level for adaptive thinking models (e.g., "low",
|
||||
"medium", "high"). Injected as CLAUDE_CODE_EFFORT_LEVEL env var.
|
||||
fast_mode: Enable Fast Mode for faster Opus 4.6 output. Injected as
|
||||
CLAUDE_CODE_FAST_MODE=true env var.
|
||||
fast_mode: Enable Fast Mode for faster Opus 4.6 output. Enables the "user"
|
||||
setting source so the CLI reads fastMode from ~/.claude/settings.json.
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient for single-turn operations
|
||||
@@ -90,6 +91,17 @@ def create_simple_client(
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
# Inject effort level for adaptive thinking models (e.g., Opus 4.6)
|
||||
if effort_level:
|
||||
sdk_env["CLAUDE_CODE_EFFORT_LEVEL"] = effort_level
|
||||
|
||||
# Fast mode: the CLI reads "fastMode" from user settings (~/.claude/settings.json).
|
||||
# By default the SDK passes --setting-sources "" which blocks all filesystem settings.
|
||||
# We enable "user" source so the CLI can read fastMode from user settings.
|
||||
if fast_mode:
|
||||
ensure_fast_mode_in_user_settings()
|
||||
logger.info("[Fast Mode] ACTIVE — will enable user setting source for fastMode")
|
||||
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
@@ -112,6 +124,11 @@ def create_simple_client(
|
||||
"env": sdk_env,
|
||||
}
|
||||
|
||||
# Fast mode: enable user setting source so CLI reads fastMode from
|
||||
# ~/.claude/settings.json. Without this, --setting-sources "" blocks it.
|
||||
if fast_mode:
|
||||
options_kwargs["setting_sources"] = ["user"]
|
||||
|
||||
# Only add max_thinking_tokens if not None (Haiku doesn't support extended thinking)
|
||||
if max_thinking_tokens is not None:
|
||||
options_kwargs["max_thinking_tokens"] = max_thinking_tokens
|
||||
|
||||
@@ -44,23 +44,30 @@ try:
|
||||
debug_success,
|
||||
debug_verbose,
|
||||
debug_warning,
|
||||
is_debug_enabled,
|
||||
)
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_detailed(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def debug_verbose(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_warning(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def is_debug_enabled():
|
||||
return False
|
||||
|
||||
|
||||
# Import merge system
|
||||
@@ -978,6 +985,10 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
debug_warning(MODULE, "Could not find merge base")
|
||||
return result
|
||||
|
||||
_merge_base = (
|
||||
merge_base_result.stdout.strip()
|
||||
) # Reserved for future conflict detection
|
||||
|
||||
# Get commit hashes
|
||||
main_commit_result = run_git(
|
||||
["rev-parse", result["base_branch"]],
|
||||
|
||||
@@ -17,7 +17,6 @@ Public API exported from sub-modules.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import merge functions from workspace.py (which coexists with this package)
|
||||
@@ -28,10 +27,17 @@ _workspace_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_workspace_module)
|
||||
merge_existing_build = _workspace_module.merge_existing_build
|
||||
_run_parallel_merges = _workspace_module._run_parallel_merges
|
||||
_resolve_git_conflicts_with_ai = _workspace_module._resolve_git_conflicts_with_ai
|
||||
AI_MERGE_SYSTEM_PROMPT = _workspace_module.AI_MERGE_SYSTEM_PROMPT
|
||||
_build_merge_prompt = _workspace_module._build_merge_prompt
|
||||
_check_git_conflicts = _workspace_module._check_git_conflicts
|
||||
_rebase_spec_branch = _workspace_module._rebase_spec_branch
|
||||
_create_merge_progress_callback = _workspace_module._create_merge_progress_callback
|
||||
_infer_language_from_path = _workspace_module._infer_language_from_path
|
||||
_strip_code_fences = _workspace_module._strip_code_fences
|
||||
_try_simple_3way_merge = _workspace_module._try_simple_3way_merge
|
||||
_attempt_ai_merge = _workspace_module._attempt_ai_merge
|
||||
_merge_file_with_ai_async = _workspace_module._merge_file_with_ai_async
|
||||
|
||||
# Models and Enums
|
||||
# Display Functions
|
||||
@@ -74,7 +80,9 @@ from .git_utils import (
|
||||
# Export private names for backward compatibility
|
||||
_is_process_running,
|
||||
_validate_merged_syntax,
|
||||
apply_path_mapping,
|
||||
create_conflict_file_with_git,
|
||||
detect_file_renames,
|
||||
get_binary_file_content_from_ref,
|
||||
get_changed_files_from_branch,
|
||||
get_current_branch,
|
||||
@@ -91,6 +99,8 @@ from .models import (
|
||||
MergeLockError,
|
||||
ParallelMergeResult,
|
||||
ParallelMergeTask,
|
||||
SpecNumberLock,
|
||||
SpecNumberLockError,
|
||||
WorkspaceChoice,
|
||||
WorkspaceMode,
|
||||
)
|
||||
@@ -110,11 +120,9 @@ from .setup import (
|
||||
__all__ = [
|
||||
# Merge Operations (from workspace.py)
|
||||
"merge_existing_build",
|
||||
"_run_parallel_merges", # Private but used internally
|
||||
"AI_MERGE_SYSTEM_PROMPT", # System prompt for AI merge (ACS-194)
|
||||
"_build_merge_prompt", # Internal prompt builder (ACS-194)
|
||||
"_check_git_conflicts", # Internal git conflict detection (ACS-224)
|
||||
"_rebase_spec_branch", # Internal rebase function (ACS-224)
|
||||
# Note: Private functions (_run_parallel_merges, _resolve_git_conflicts_with_ai, etc.)
|
||||
# are kept as module-level assignments for internal use but not exported in __all__
|
||||
# to maintain the underscore convention for private/internal APIs
|
||||
# Models
|
||||
"WorkspaceMode",
|
||||
"WorkspaceChoice",
|
||||
@@ -122,6 +130,8 @@ __all__ = [
|
||||
"ParallelMergeResult",
|
||||
"MergeLock",
|
||||
"MergeLockError",
|
||||
"SpecNumberLock",
|
||||
"SpecNumberLockError",
|
||||
# Git Utils
|
||||
"has_uncommitted_changes",
|
||||
"get_current_branch",
|
||||
@@ -131,8 +141,11 @@ __all__ = [
|
||||
"get_changed_files_from_branch",
|
||||
"is_process_running",
|
||||
"is_binary_file",
|
||||
"is_lock_file",
|
||||
"validate_merged_syntax",
|
||||
"create_conflict_file_with_git",
|
||||
"detect_file_renames", # File rename detection
|
||||
"apply_path_mapping", # Path mapping for renamed files
|
||||
# Setup
|
||||
"choose_workspace",
|
||||
"copy_spec_to_worktree",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Dependency Strategy Mapping
|
||||
============================
|
||||
|
||||
Maps dependency types to sharing strategies for worktree creation.
|
||||
|
||||
Each dependency ecosystem has different constraints:
|
||||
|
||||
- **node_modules**: Safe to symlink. Node's resolution algorithm follows symlinks
|
||||
correctly, and the directory is self-contained.
|
||||
|
||||
- **venv / .venv**: Must be recreated. Python's ``pyvenv.cfg`` discovery walks the
|
||||
real directory hierarchy without resolving symlinks (CPython bug #106045), so a
|
||||
symlinked venv resolves paths relative to the *target*, not the worktree.
|
||||
|
||||
- **vendor (PHP)**: Safe to symlink. Composer's autoloader uses ``__DIR__``-relative
|
||||
paths that resolve correctly through symlinks.
|
||||
|
||||
- **cargo target / go modules**: Skip entirely. Rust's ``target/`` dir contains
|
||||
per-machine build artifacts that must be rebuilt. Go uses a global module cache
|
||||
(``$GOPATH/pkg/mod``), so there is nothing in-tree to share.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import DependencyShareConfig, DependencyStrategy
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default strategy map
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps dependency type identifiers to the strategy that should be used when
|
||||
# sharing that dependency across worktrees. Data-driven — add new entries
|
||||
# here rather than writing if/else branches.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_STRATEGY_MAP: dict[str, DependencyStrategy] = {
|
||||
# JavaScript / Node.js — symlink is safe and fast
|
||||
"node_modules": DependencyStrategy.SYMLINK,
|
||||
# Python — venvs MUST be recreated (pyvenv.cfg symlink bug)
|
||||
"venv": DependencyStrategy.RECREATE,
|
||||
".venv": DependencyStrategy.RECREATE,
|
||||
# PHP — Composer vendor dir is safe to symlink
|
||||
"vendor_php": DependencyStrategy.SYMLINK,
|
||||
# Ruby — Bundler vendor/bundle is safe to symlink
|
||||
"vendor_bundle": DependencyStrategy.SYMLINK,
|
||||
# Rust — build output dir, skip (rebuilt per-worktree)
|
||||
"cargo_target": DependencyStrategy.SKIP,
|
||||
# Go — global module cache, nothing in-tree to share
|
||||
"go_modules": DependencyStrategy.SKIP,
|
||||
}
|
||||
|
||||
|
||||
def get_dependency_configs(
|
||||
project_index: dict | None,
|
||||
project_dir: Path | None = None,
|
||||
) -> list[DependencyShareConfig]:
|
||||
"""Derive dependency share configs from a project index.
|
||||
|
||||
If *project_index* is ``None`` or lacks ``dependency_locations``,
|
||||
falls back to a hardcoded node_modules config for backward compatibility
|
||||
with existing worktree setups.
|
||||
|
||||
Args:
|
||||
project_index: Parsed ``project_index.json`` dict, or ``None``.
|
||||
project_dir: Project root directory for resolved-path containment
|
||||
checks (defense-in-depth). Should always be provided when
|
||||
*project_index* is not ``None`` — omitting it disables the
|
||||
resolved-path security check.
|
||||
|
||||
Returns:
|
||||
List of :class:`DependencyShareConfig` objects — one per discovered
|
||||
dependency location.
|
||||
"""
|
||||
|
||||
configs: list[DependencyShareConfig] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
if project_index is not None:
|
||||
if project_dir is None:
|
||||
logger.warning(
|
||||
"get_dependency_configs called with project_index but no "
|
||||
"project_dir — resolved-path containment check is disabled"
|
||||
)
|
||||
|
||||
# Use the aggregated top-level dependency_locations which already
|
||||
# contain project-relative paths (e.g. "apps/backend/.venv" instead
|
||||
# of just ".venv"). This avoids a monorepo path resolution bug
|
||||
# where service-relative paths were incorrectly treated as project-
|
||||
# relative.
|
||||
dep_locations = project_index.get("dependency_locations") or []
|
||||
for dep in dep_locations:
|
||||
if not isinstance(dep, dict):
|
||||
continue
|
||||
|
||||
dep_type = dep.get("type", "")
|
||||
rel_path = dep.get("path", "")
|
||||
|
||||
if not dep_type or not rel_path:
|
||||
continue
|
||||
|
||||
# Path containment: reject absolute paths and traversals.
|
||||
# Check both POSIX and Windows path styles for cross-platform safety.
|
||||
p = PurePosixPath(rel_path)
|
||||
if p.is_absolute() or PureWindowsPath(rel_path).is_absolute():
|
||||
continue
|
||||
if ".." in p.parts or ".." in PureWindowsPath(rel_path).parts:
|
||||
continue
|
||||
|
||||
# Defense-in-depth: verify the resolved path stays within project_dir
|
||||
if project_dir is not None:
|
||||
resolved = (project_dir / rel_path).resolve()
|
||||
if not str(resolved).startswith(str(project_dir.resolve()) + os.sep):
|
||||
continue
|
||||
|
||||
# Deduplicate by relative path
|
||||
if rel_path in seen:
|
||||
continue
|
||||
seen.add(rel_path)
|
||||
|
||||
strategy = DEFAULT_STRATEGY_MAP.get(dep_type, DependencyStrategy.SKIP)
|
||||
|
||||
# Validate requirements_file path containment too
|
||||
req_file = dep.get("requirements_file")
|
||||
if req_file:
|
||||
rp = PurePosixPath(req_file)
|
||||
if (
|
||||
rp.is_absolute()
|
||||
or PureWindowsPath(req_file).is_absolute()
|
||||
or ".." in rp.parts
|
||||
or ".." in PureWindowsPath(req_file).parts
|
||||
):
|
||||
req_file = None
|
||||
|
||||
# Defense-in-depth: resolved-path containment (matches rel_path check)
|
||||
if req_file and project_dir is not None:
|
||||
resolved_req = (project_dir / req_file).resolve()
|
||||
if not str(resolved_req).startswith(
|
||||
str(project_dir.resolve()) + os.sep
|
||||
):
|
||||
req_file = None
|
||||
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type=dep_type,
|
||||
strategy=strategy,
|
||||
source_rel_path=rel_path,
|
||||
requirements_file=req_file,
|
||||
package_manager=dep.get("package_manager"),
|
||||
)
|
||||
)
|
||||
|
||||
# Fallback: if no configs were discovered, default to node_modules-only
|
||||
# so existing worktree behaviour is preserved.
|
||||
if not configs:
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type="node_modules",
|
||||
strategy=DependencyStrategy.SYMLINK,
|
||||
source_rel_path="node_modules",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type="node_modules",
|
||||
strategy=DependencyStrategy.SYMLINK,
|
||||
source_rel_path="apps/frontend/node_modules",
|
||||
)
|
||||
)
|
||||
|
||||
return configs
|
||||
@@ -15,19 +15,11 @@ from ui import (
|
||||
)
|
||||
from worktree import WorktreeManager
|
||||
|
||||
__all__ = [
|
||||
"print_merge_success",
|
||||
"print_conflict_info",
|
||||
"_print_merge_success",
|
||||
"_print_conflict_info",
|
||||
"show_build_summary",
|
||||
"show_changed_files",
|
||||
]
|
||||
|
||||
|
||||
def show_build_summary(manager: WorktreeManager, spec_name: str) -> None:
|
||||
"""Show a summary of what was built."""
|
||||
summary = manager.get_change_summary(spec_name)
|
||||
files = manager.get_changed_files(spec_name)
|
||||
|
||||
total = summary["new_files"] + summary["modified_files"] + summary["deleted_files"]
|
||||
|
||||
@@ -233,5 +225,5 @@ def print_conflict_info(result: dict) -> None:
|
||||
|
||||
|
||||
# Export private names for backward compatibility
|
||||
_print_merge_success = print_merge_success # noqa: F811 (intentional re-export)
|
||||
_print_conflict_info = print_conflict_info # noqa: F811 (intentional re-export)
|
||||
_print_merge_success = print_merge_success
|
||||
_print_conflict_info = print_conflict_info
|
||||
|
||||
@@ -88,6 +88,7 @@ def finalize_workspace(
|
||||
|
||||
# Get the worktree path for test instructions
|
||||
worktree_info = manager.get_worktree_info(spec_name)
|
||||
staging_path = worktree_info.path if worktree_info else None
|
||||
|
||||
# Enhanced menu for post-build options
|
||||
options = [
|
||||
|
||||
@@ -503,7 +503,7 @@ def validate_merged_syntax(
|
||||
return True, "" # Timeout = assume ok
|
||||
except FileNotFoundError:
|
||||
return True, "" # No esbuild = skip validation
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
return True, "" # Other errors = skip validation
|
||||
|
||||
# Python validation
|
||||
@@ -589,7 +589,7 @@ def create_conflict_file_with_git(
|
||||
Path(wt_path).unlink(missing_ok=True)
|
||||
Path(base_path).unlink(missing_ok=True)
|
||||
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
return None, False
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class ParallelMergeResult:
|
||||
class MergeLockError(Exception):
|
||||
"""Raised when a merge lock cannot be acquired."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
class MergeLock:
|
||||
@@ -88,14 +88,12 @@ class MergeLock:
|
||||
fd = os.open(
|
||||
str(self.lock_file),
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
||||
0o600,
|
||||
0o644,
|
||||
)
|
||||
# Ensure permissions are correct (override umask)
|
||||
os.fchmod(fd, 0o600)
|
||||
# Write PID directly to the file descriptor
|
||||
os.write(fd, str(os.getpid()).encode("utf-8"))
|
||||
os.close(fd)
|
||||
|
||||
# Write our PID to the lock file
|
||||
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
self.acquired = True
|
||||
return self
|
||||
|
||||
@@ -142,7 +140,7 @@ class MergeLock:
|
||||
class SpecNumberLockError(Exception):
|
||||
"""Raised when a spec number lock cannot be acquired."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
class SpecNumberLock:
|
||||
@@ -180,14 +178,12 @@ class SpecNumberLock:
|
||||
fd = os.open(
|
||||
str(self.lock_file),
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
||||
0o600,
|
||||
0o644,
|
||||
)
|
||||
# Ensure permissions are correct (override umask)
|
||||
os.fchmod(fd, 0o600)
|
||||
# Write PID directly to the file descriptor
|
||||
os.write(fd, str(os.getpid()).encode("utf-8"))
|
||||
os.close(fd)
|
||||
|
||||
# Write our PID to the lock file
|
||||
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
self.acquired = True
|
||||
return self
|
||||
|
||||
@@ -273,7 +269,35 @@ class SpecNumberLock:
|
||||
try:
|
||||
num = int(folder.name[:3])
|
||||
max_num = max(max_num, num)
|
||||
except ValueError: # Folder name doesn't start with a number; skip it
|
||||
pass # no-op: skip invalid folder names
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
|
||||
|
||||
class DependencyStrategy(Enum):
|
||||
"""Strategy for sharing dependency directories across worktrees.
|
||||
|
||||
SYMLINK is fast but unsafe for certain ecosystems. Notably, Python venv
|
||||
breaks when symlinked because CPython's pyvenv.cfg discovery walks the
|
||||
real directory hierarchy without resolving symlinks first
|
||||
(CPython bug #106045). This means a symlinked venv resolves its home
|
||||
path relative to the symlink target's parent, not the worktree, causing
|
||||
import failures and broken interpreters.
|
||||
"""
|
||||
|
||||
SYMLINK = "symlink" # Create a symlink to the source (fast, works for node_modules)
|
||||
RECREATE = "recreate" # Re-run the package manager to create a fresh copy
|
||||
COPY = "copy" # Deep-copy the directory (slow but always correct)
|
||||
SKIP = "skip" # Do nothing; let the agent handle it
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyShareConfig:
|
||||
"""Configuration for how a specific dependency type should be shared."""
|
||||
|
||||
dep_type: str # e.g. "node_modules", "venv", ".venv"
|
||||
strategy: DependencyStrategy
|
||||
source_rel_path: str # Relative path from project root, e.g. "node_modules"
|
||||
requirements_file: str | None = None # e.g. "requirements.txt", "pyproject.toml"
|
||||
package_manager: str | None = None # e.g. "npm", "uv", "pip"
|
||||
|
||||
@@ -14,6 +14,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
from core.platform import is_windows
|
||||
from merge import FileTimelineTracker
|
||||
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
|
||||
from ui import (
|
||||
@@ -28,18 +29,9 @@ from ui import (
|
||||
)
|
||||
from worktree import WorktreeManager
|
||||
|
||||
from .dependency_strategy import get_dependency_configs
|
||||
from .git_utils import has_uncommitted_changes
|
||||
from .models import WorkspaceMode
|
||||
|
||||
__all__ = [
|
||||
"setup_workspace",
|
||||
"copy_spec_to_worktree",
|
||||
"choose_workspace",
|
||||
"ensure_timeline_hook_installed",
|
||||
"initialize_timeline_tracking",
|
||||
"_ensure_timeline_hook_installed",
|
||||
"_initialize_timeline_tracking",
|
||||
]
|
||||
from .models import DependencyShareConfig, DependencyStrategy, WorkspaceMode
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
@@ -47,10 +39,10 @@ try:
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_warning(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
# Track if we've already tried to install the git hook this session
|
||||
@@ -199,11 +191,37 @@ def symlink_node_modules_to_worktree(
|
||||
"""
|
||||
Symlink node_modules directories from project root to worktree.
|
||||
|
||||
This ensures the worktree has access to dependencies for TypeScript checks
|
||||
and other tooling without requiring a separate npm install.
|
||||
.. deprecated::
|
||||
Use :func:`setup_worktree_dependencies` instead, which handles all
|
||||
dependency types (node_modules, venvs, vendor dirs, etc.) via
|
||||
strategy-based dispatch.
|
||||
|
||||
Works with npm workspace hoisting where dependencies are hoisted to root
|
||||
and workspace-specific dependencies remain in nested node_modules.
|
||||
This is a thin backward-compatibility wrapper that delegates to
|
||||
``setup_worktree_dependencies()`` with no project index (fallback mode).
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
List of symlinked paths (relative to worktree)
|
||||
"""
|
||||
results = setup_worktree_dependencies(
|
||||
project_dir, worktree_path, project_index=None
|
||||
)
|
||||
# Flatten all processed paths for backward-compatible return value
|
||||
return [path for paths in results.values() for path in paths]
|
||||
|
||||
|
||||
def symlink_claude_config_to_worktree(
|
||||
project_dir: Path, worktree_path: Path
|
||||
) -> list[str]:
|
||||
"""
|
||||
Symlink .claude/ directory from project root to worktree.
|
||||
|
||||
This ensures the worktree has access to Claude Code configuration
|
||||
(settings, CLAUDE.md, MCP servers, etc.) so that terminals opened
|
||||
in the worktree behave identically to the project root.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
@@ -214,81 +232,52 @@ def symlink_node_modules_to_worktree(
|
||||
"""
|
||||
symlinked = []
|
||||
|
||||
# Node modules locations to symlink for TypeScript and tooling support.
|
||||
# These are the standard locations for this monorepo structure.
|
||||
#
|
||||
# Design rationale:
|
||||
# - Hardcoded paths are intentional for simplicity and reliability
|
||||
# - Dynamic discovery (reading workspaces from package.json) would add complexity
|
||||
# and potential failure points without significant benefit
|
||||
# - This monorepo uses npm workspaces with hoisting, so dependencies are primarily
|
||||
# in root node_modules with workspace-specific deps in apps/frontend/node_modules
|
||||
#
|
||||
# To add new workspace locations:
|
||||
# 1. Add (source_rel, target_rel) tuple below
|
||||
# 2. Update the parallel TypeScript implementation in
|
||||
# apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts
|
||||
# 3. Update the pre-commit hook check in .husky/pre-commit if needed
|
||||
node_modules_locations = [
|
||||
("node_modules", "node_modules"),
|
||||
("apps/frontend/node_modules", "apps/frontend/node_modules"),
|
||||
]
|
||||
source_path = project_dir / ".claude"
|
||||
target_path = worktree_path / ".claude"
|
||||
|
||||
for source_rel, target_rel in node_modules_locations:
|
||||
source_path = project_dir / source_rel
|
||||
target_path = worktree_path / target_rel
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, "Skipping .claude/ - source does not exist")
|
||||
return symlinked
|
||||
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping {source_rel} - source does not exist")
|
||||
continue
|
||||
# Skip if target already exists
|
||||
if target_path.exists():
|
||||
debug(MODULE, "Skipping .claude/ - target already exists")
|
||||
return symlinked
|
||||
|
||||
# Skip if target already exists (don't overwrite existing node_modules)
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping {target_rel} - target already exists")
|
||||
continue
|
||||
# Also skip if target is a symlink (even if broken)
|
||||
if target_path.is_symlink():
|
||||
debug(MODULE, "Skipping .claude/ - symlink already exists (possibly broken)")
|
||||
return symlinked
|
||||
|
||||
# Also skip if target is a symlink (even if broken - exists() returns False for broken symlinks)
|
||||
if target_path.is_symlink():
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping {target_rel} - symlink already exists (possibly broken)",
|
||||
)
|
||||
continue
|
||||
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
# Junctions require absolute paths
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(target_rel)
|
||||
debug(MODULE, f"Symlinked {target_rel} -> {source_path}")
|
||||
except OSError as e:
|
||||
# Symlink/junction creation can fail on some systems (e.g., FAT32 filesystem)
|
||||
# Log warning but don't fail - worktree is still usable, just without
|
||||
# TypeScript checking
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {target_rel}: {e}. TypeScript checks may fail.",
|
||||
)
|
||||
# Warn user - pre-commit hooks may fail without dependencies
|
||||
print_status(
|
||||
f"Warning: Could not link {target_rel} - TypeScript checks may fail",
|
||||
"warning",
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(".claude")
|
||||
debug(MODULE, f"Symlinked .claude/ -> {source_path}")
|
||||
except OSError as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink .claude/: {e}. Claude Code features may not work in worktree terminals.",
|
||||
)
|
||||
print_status(
|
||||
"Warning: Could not link .claude/ - Claude Code features may not work in terminals",
|
||||
"warning",
|
||||
)
|
||||
|
||||
return symlinked
|
||||
|
||||
@@ -384,13 +373,33 @@ def setup_workspace(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Symlink node_modules to worktree for TypeScript and tooling support
|
||||
# This allows pre-commit hooks to run typecheck without npm install in worktree
|
||||
symlinked_modules = symlink_node_modules_to_worktree(
|
||||
# Set up dependencies in worktree using strategy-based dispatch
|
||||
# Load project index if available for ecosystem-aware dependency handling
|
||||
project_index = None
|
||||
project_index_path = project_dir / ".auto-claude" / "project_index.json"
|
||||
if project_index_path.is_file():
|
||||
try:
|
||||
with open(project_index_path, encoding="utf-8") as f:
|
||||
project_index = json.load(f)
|
||||
debug(MODULE, "Loaded project_index.json for dependency setup")
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
debug_warning(MODULE, f"Could not load project_index.json: {e}")
|
||||
|
||||
dep_results = setup_worktree_dependencies(
|
||||
project_dir, worktree_info.path, project_index=project_index
|
||||
)
|
||||
for strategy_name, paths in dep_results.items():
|
||||
if paths:
|
||||
print_status(
|
||||
f"Dependencies ({strategy_name}): {', '.join(paths)}", "success"
|
||||
)
|
||||
|
||||
# Symlink .claude/ config to worktree for Claude Code features (settings, commands, etc.)
|
||||
symlinked_claude = symlink_claude_config_to_worktree(
|
||||
project_dir, worktree_info.path
|
||||
)
|
||||
if symlinked_modules:
|
||||
print_status(f"Dependencies linked: {', '.join(symlinked_modules)}", "success")
|
||||
if symlinked_claude:
|
||||
print_status(f"Claude config linked: {', '.join(symlinked_claude)}", "success")
|
||||
|
||||
# Copy security configuration files if they exist
|
||||
# Note: Unlike env files, security files always overwrite to ensure
|
||||
@@ -584,6 +593,299 @@ def initialize_timeline_tracking(
|
||||
print(muted(f" Note: Timeline tracking could not be initialized: {e}"))
|
||||
|
||||
|
||||
def setup_worktree_dependencies(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
project_index: dict | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
Set up dependencies in a worktree using strategy-based dispatch.
|
||||
|
||||
Reads dependency configs from the project index and applies the correct
|
||||
strategy for each: symlink, recreate, copy, or skip.
|
||||
|
||||
All operations are non-blocking — failures produce warnings but do not
|
||||
prevent worktree creation.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
project_index: Parsed project_index.json dict, or None
|
||||
|
||||
Returns:
|
||||
Dict mapping strategy names to lists of paths that were processed.
|
||||
"""
|
||||
configs = get_dependency_configs(project_index, project_dir=project_dir)
|
||||
results: dict[str, list[str]] = {}
|
||||
|
||||
for config in configs:
|
||||
strategy_name = config.strategy.value
|
||||
if strategy_name not in results:
|
||||
results[strategy_name] = []
|
||||
|
||||
try:
|
||||
performed = True
|
||||
if config.strategy == DependencyStrategy.SYMLINK:
|
||||
performed = _apply_symlink_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.RECREATE:
|
||||
performed = _apply_recreate_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.COPY:
|
||||
performed = _apply_copy_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.SKIP:
|
||||
_apply_skip_strategy(config)
|
||||
# Don't record skipped entries — only report actual work
|
||||
continue
|
||||
if performed:
|
||||
results[strategy_name].append(config.source_rel_path)
|
||||
except Exception as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Failed to apply {strategy_name} strategy for "
|
||||
f"{config.source_rel_path}: {e}",
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _apply_symlink_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Create a symlink (or Windows junction) from worktree to project source.
|
||||
|
||||
Returns True if a symlink was created, False if skipped.
|
||||
"""
|
||||
source_path = project_dir / config.source_rel_path
|
||||
target_path = worktree_path / config.source_rel_path
|
||||
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping symlink {config.source_rel_path} - source missing")
|
||||
return False
|
||||
|
||||
if target_path.exists() or target_path.is_symlink():
|
||||
debug(MODULE, f"Skipping symlink {config.source_rel_path} - target exists")
|
||||
return False
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if is_windows():
|
||||
# Windows: use directory junctions (no admin rights required).
|
||||
# os.symlink creates a directory symlink that needs admin/DevMode,
|
||||
# so we use mklink /J which creates a junction without privileges.
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# macOS/Linux: relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
debug(MODULE, f"Symlinked {config.source_rel_path} -> {source_path}")
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Symlink creation timed out for {config.source_rel_path}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Symlink creation timed out for {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
return False
|
||||
except OSError as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {config.source_rel_path}: {e}",
|
||||
)
|
||||
print_status(f"Warning: Could not link {config.source_rel_path}", "warning")
|
||||
return False
|
||||
|
||||
|
||||
def _apply_recreate_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Create a fresh virtual environment in the worktree and install deps.
|
||||
|
||||
Returns True if the venv was successfully created, False if skipped or failed.
|
||||
"""
|
||||
venv_path = worktree_path / config.source_rel_path
|
||||
|
||||
if venv_path.exists():
|
||||
debug(MODULE, f"Skipping recreate {config.source_rel_path} - already exists")
|
||||
return False
|
||||
|
||||
# Detect Python executable from the source venv or fall back to sys.executable
|
||||
source_venv = project_dir / config.source_rel_path
|
||||
python_exec = sys.executable
|
||||
|
||||
if source_venv.exists():
|
||||
# Try to use the same Python version as the source venv
|
||||
for candidate in ("bin/python", "Scripts/python.exe"):
|
||||
candidate_path = source_venv / candidate
|
||||
if candidate_path.exists():
|
||||
python_exec = str(candidate_path.resolve())
|
||||
break
|
||||
|
||||
# Create the venv
|
||||
try:
|
||||
debug(MODULE, f"Creating venv at {venv_path}")
|
||||
result = subprocess.run(
|
||||
[python_exec, "-m", "venv", str(venv_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
debug_warning(MODULE, f"venv creation failed: {result.stderr}")
|
||||
print_status(
|
||||
f"Warning: Could not create venv at {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up partial venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(MODULE, f"venv creation timed out for {config.source_rel_path}")
|
||||
print_status(
|
||||
f"Warning: venv creation timed out for {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up partial venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
|
||||
# Install from requirements file if specified
|
||||
req_file = config.requirements_file
|
||||
if req_file:
|
||||
req_path = project_dir / req_file
|
||||
if req_path.is_file():
|
||||
# Determine pip executable inside the new venv
|
||||
if is_windows():
|
||||
pip_exec = str(venv_path / "Scripts" / "pip.exe")
|
||||
else:
|
||||
pip_exec = str(venv_path / "bin" / "pip")
|
||||
|
||||
# Build install command based on file type
|
||||
req_basename = Path(req_file).name
|
||||
if req_basename == "pyproject.toml":
|
||||
# pyproject.toml: snapshot-install from the worktree copy.
|
||||
# Non-editable so the venv doesn't symlink back to the source.
|
||||
worktree_req = worktree_path / req_file
|
||||
install_dir = str(
|
||||
worktree_req.parent if worktree_req.is_file() else req_path.parent
|
||||
)
|
||||
install_cmd = [pip_exec, "install", install_dir]
|
||||
elif req_basename == "Pipfile":
|
||||
# Pipfile: not directly installable via pip, skip
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping Pipfile-based install for {req_file} "
|
||||
"(use pipenv in the worktree)",
|
||||
)
|
||||
install_cmd = None
|
||||
else:
|
||||
# requirements.txt or similar: pip install -r
|
||||
install_cmd = [pip_exec, "install", "-r", str(req_path)]
|
||||
|
||||
if install_cmd:
|
||||
try:
|
||||
debug(MODULE, f"Installing deps from {req_file}")
|
||||
pip_result = subprocess.run(
|
||||
install_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if pip_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"pip install failed (exit {pip_result.returncode}): "
|
||||
f"{pip_result.stderr}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Dependency install failed for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"pip install timed out for {req_file}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Dependency install timed out for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except OSError as e:
|
||||
debug_warning(MODULE, f"pip install failed: {e}")
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
|
||||
debug(MODULE, f"Recreated venv at {config.source_rel_path}")
|
||||
return True
|
||||
|
||||
|
||||
def _apply_copy_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Deep-copy a dependency directory from project to worktree.
|
||||
|
||||
Returns True if the copy was performed, False if skipped.
|
||||
"""
|
||||
source_path = project_dir / config.source_rel_path
|
||||
target_path = worktree_path / config.source_rel_path
|
||||
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping copy {config.source_rel_path} - source missing")
|
||||
return False
|
||||
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping copy {config.source_rel_path} - target exists")
|
||||
return False
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if source_path.is_file():
|
||||
shutil.copy2(source_path, target_path)
|
||||
else:
|
||||
shutil.copytree(source_path, target_path)
|
||||
debug(MODULE, f"Copied {config.source_rel_path} to worktree")
|
||||
return True
|
||||
except (OSError, shutil.Error) as e:
|
||||
debug_warning(MODULE, f"Could not copy {config.source_rel_path}: {e}")
|
||||
print_status(f"Warning: Could not copy {config.source_rel_path}", "warning")
|
||||
return False
|
||||
|
||||
|
||||
def _apply_skip_strategy(config: DependencyShareConfig) -> None:
|
||||
"""Skip — nothing to do for this dependency type."""
|
||||
debug(
|
||||
MODULE, f"Skipping {config.dep_type} ({config.source_rel_path}) - skip strategy"
|
||||
)
|
||||
|
||||
|
||||
# Export private functions for backward compatibility
|
||||
_ensure_timeline_hook_installed = ensure_timeline_hook_installed # noqa: F811 (intentional re-export)
|
||||
_initialize_timeline_tracking = initialize_timeline_tracking # noqa: F811 (intentional re-export)
|
||||
_ensure_timeline_hook_installed = ensure_timeline_hook_installed
|
||||
_initialize_timeline_tracking = initialize_timeline_tracking
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pytest Configuration and Shared Fixtures for Workspace Tests
|
||||
==============================================================
|
||||
|
||||
Provides test fixtures for the workspace module tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# =============================================================================
|
||||
# MODULE MOCK CLEANUP - Prevents test isolation issues
|
||||
# =============================================================================
|
||||
|
||||
# List of modules that might be mocked by test files
|
||||
_POTENTIALLY_MOCKED_MODULES = [
|
||||
"claude_code_sdk",
|
||||
"claude_code_sdk.types",
|
||||
"claude_agent_sdk",
|
||||
"claude_agent_sdk.types",
|
||||
]
|
||||
|
||||
# Store original module references at import time (BEFORE pre-mocking)
|
||||
_original_module_state = {}
|
||||
for _name in _POTENTIALLY_MOCKED_MODULES:
|
||||
if _name in sys.modules:
|
||||
_original_module_state[_name] = sys.modules[_name]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRE-MOCK EXTERNAL SDK MODULES - Must happen BEFORE adding auto-claude to path
|
||||
# =============================================================================
|
||||
# These SDK modules may not be installed, so we mock them before any imports
|
||||
# that might trigger loading code that depends on them.
|
||||
|
||||
|
||||
def _create_sdk_mock():
|
||||
"""Create a comprehensive mock for SDK modules."""
|
||||
mock = MagicMock()
|
||||
mock.ClaudeAgentOptions = MagicMock
|
||||
mock.ClaudeSDKClient = MagicMock
|
||||
mock.HookMatcher = MagicMock
|
||||
return mock
|
||||
|
||||
|
||||
# Pre-mock claude_agent_sdk if not installed
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = _create_sdk_mock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
# Pre-mock claude_code_sdk if not installed
|
||||
if "claude_code_sdk" not in sys.modules:
|
||||
sys.modules["claude_code_sdk"] = _create_sdk_mock()
|
||||
sys.modules["claude_code_sdk.types"] = MagicMock()
|
||||
|
||||
# Add backend directory to path for imports
|
||||
# When co-located at workspace/tests/, go up to backend directory
|
||||
# workspace/tests -> workspace -> core -> backend (4 levels up)
|
||||
_backend = Path(__file__).resolve().parent.parent.parent.parent
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Add repo root to sys.path for test_fixtures import fallback
|
||||
_repo_root = _backend.parent.parent
|
||||
sys.path.insert(0, str(_repo_root))
|
||||
|
||||
|
||||
def _cleanup_mocked_modules():
|
||||
"""Remove any MagicMock modules from sys.modules."""
|
||||
for name in _POTENTIALLY_MOCKED_MODULES:
|
||||
if name in sys.modules:
|
||||
module = sys.modules[name]
|
||||
if isinstance(module, MagicMock):
|
||||
if name in _original_module_state:
|
||||
sys.modules[name] = _original_module_state[name]
|
||||
else:
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def pytest_sessionstart(session):
|
||||
"""Clean up any mocked modules before the test session starts."""
|
||||
_cleanup_mocked_modules()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DIRECTORY FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir() -> Generator[Path, None, None]:
|
||||
"""Create a temporary directory that's cleaned up after the test."""
|
||||
temp_path = Path(tempfile.mkdtemp())
|
||||
yield temp_path
|
||||
shutil.rmtree(temp_path, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_git_repo(temp_dir: Path) -> Generator[Path, None, None]:
|
||||
"""Create a temporary git repository with initial commit.
|
||||
|
||||
IMPORTANT: This fixture properly isolates git operations by clearing
|
||||
git environment variables that may be set by pre-commit hooks. Without
|
||||
this isolation, git operations could affect the parent repository when
|
||||
tests run inside a git worktree (e.g., during pre-commit validation).
|
||||
|
||||
See: https://git-scm.com/docs/git#_environment_variables
|
||||
"""
|
||||
# Save original environment values to restore later
|
||||
orig_env = {}
|
||||
|
||||
# These git env vars may be set by pre-commit hooks and MUST be cleared
|
||||
# to avoid git operations affecting the parent repository instead of
|
||||
# our isolated test repo. This is critical when running inside worktrees.
|
||||
git_vars_to_clear = [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
]
|
||||
|
||||
# Clear interfering git environment variables
|
||||
for key in git_vars_to_clear:
|
||||
orig_env[key] = os.environ.get(key)
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
# Set GIT_CEILING_DIRECTORIES to prevent git from discovering parent .git
|
||||
# directories. This is critical for test isolation when running inside
|
||||
# another git repo (like during pre-commit hooks in worktrees).
|
||||
orig_env["GIT_CEILING_DIRECTORIES"] = os.environ.get("GIT_CEILING_DIRECTORIES")
|
||||
os.environ["GIT_CEILING_DIRECTORIES"] = str(temp_dir.parent)
|
||||
|
||||
try:
|
||||
# Initialize git repo
|
||||
subprocess.run(["git", "init"], cwd=temp_dir, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=temp_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"],
|
||||
cwd=temp_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Create initial commit
|
||||
test_file = temp_dir / "README.md"
|
||||
test_file.write_text("# Test Project\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Initial commit"], cwd=temp_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Ensure branch is named 'main' (some git configs default to 'master')
|
||||
subprocess.run(
|
||||
["git", "branch", "-M", "main"], cwd=temp_dir, capture_output=True
|
||||
)
|
||||
|
||||
yield temp_dir
|
||||
finally:
|
||||
# Restore original environment variables
|
||||
for key, value in orig_env.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir(temp_dir: Path) -> Path:
|
||||
"""Create a spec directory inside temp_dir."""
|
||||
spec_path = temp_dir / "spec"
|
||||
spec_path.mkdir(parents=True)
|
||||
return spec_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir: Path) -> Path:
|
||||
"""Create a project directory inside temp_dir."""
|
||||
project_path = temp_dir / "project"
|
||||
project_path.mkdir(parents=True)
|
||||
return project_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_commit(temp_git_repo: Path):
|
||||
"""Fixture to make commits in the test git repo.
|
||||
|
||||
Usage:
|
||||
def test_something(make_commit):
|
||||
make_commit("message", files={"file.txt": "content"})
|
||||
"""
|
||||
|
||||
def _make_commit(message: str, files: dict[str, str] | None = None):
|
||||
"""Create a commit with the given message and files.
|
||||
|
||||
Args:
|
||||
message: Commit message
|
||||
files: Optional dict of {filepath: content} to create before committing
|
||||
"""
|
||||
if files:
|
||||
for file_path, content in files.items():
|
||||
full_path = temp_git_repo / file_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content, encoding="utf-8")
|
||||
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", message],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
return _make_commit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage_files(temp_git_repo: Path):
|
||||
"""Fixture to stage files in the test git repo.
|
||||
|
||||
Usage:
|
||||
def test_something(stage_files):
|
||||
stage_files({"file.txt": "content"})
|
||||
"""
|
||||
|
||||
def _stage_files(files: dict[str, str]):
|
||||
"""Stage files for commit.
|
||||
|
||||
Args:
|
||||
files: Dict of {filepath: content} to create and stage
|
||||
"""
|
||||
for file_path, content in files.items():
|
||||
full_path = temp_git_repo / file_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content, encoding="utf-8")
|
||||
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
|
||||
return _stage_files
|
||||
@@ -0,0 +1,10 @@
|
||||
[pytest]
|
||||
# Pytest configuration for workspace module tests
|
||||
|
||||
# Async test mode
|
||||
asyncio_mode = auto
|
||||
|
||||
# Register custom markers
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||
integration: marks tests as integration tests (deselect with '-m "not integration"')
|
||||
@@ -0,0 +1,856 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Display Functions
|
||||
======================================
|
||||
|
||||
Tests the display.py module functionality including:
|
||||
- Build summary display
|
||||
- Changed files display
|
||||
- Merge success printing
|
||||
- Conflict info display
|
||||
- Environment file operations
|
||||
- Node modules symlink operations
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
|
||||
class TestShowBuildSummary:
|
||||
"""Tests for show_build_summary display function."""
|
||||
|
||||
def test_show_build_summary_no_changes(self, capsys):
|
||||
"""show_build_summary prints info message when no changes."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 0,
|
||||
"modified_files": 0,
|
||||
"deleted_files": 0,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = []
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No changes were made" in captured.out
|
||||
|
||||
def test_show_build_summary_with_new_files(self, capsys):
|
||||
"""show_build_summary displays new files count correctly."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 3,
|
||||
"modified_files": 0,
|
||||
"deleted_files": 0,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [
|
||||
("A", "file1.py"),
|
||||
("A", "file2.py"),
|
||||
("A", "file3.py"),
|
||||
]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "What was built" in captured.out
|
||||
assert "+ 3 new files" in captured.out
|
||||
|
||||
def test_show_build_summary_singular_new_file(self, capsys):
|
||||
"""show_build_summary uses singular form for one new file."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 1,
|
||||
"modified_files": 0,
|
||||
"deleted_files": 0,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [("A", "file1.py")]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ 1 new file" in captured.out
|
||||
assert "files" not in captured.out.split("new file")[1].split("\n")[0]
|
||||
|
||||
def test_show_build_summary_with_modified_files(self, capsys):
|
||||
"""show_build_summary displays modified files count correctly."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 0,
|
||||
"modified_files": 2,
|
||||
"deleted_files": 0,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [
|
||||
("M", "file1.py"),
|
||||
("M", "file2.py"),
|
||||
]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "~ 2 modified files" in captured.out
|
||||
|
||||
def test_show_build_summary_with_deleted_files(self, capsys):
|
||||
"""show_build_summary displays deleted files count correctly."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 0,
|
||||
"modified_files": 0,
|
||||
"deleted_files": 1,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [("D", "old.py")]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "- 1 deleted file" in captured.out
|
||||
|
||||
def test_show_build_summary_mixed_changes(self, capsys):
|
||||
"""show_build_summary displays all change types together."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 2,
|
||||
"modified_files": 3,
|
||||
"deleted_files": 1,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [
|
||||
("A", "new1.py"),
|
||||
("A", "new2.py"),
|
||||
("M", "mod1.py"),
|
||||
("M", "mod2.py"),
|
||||
("M", "mod3.py"),
|
||||
("D", "old.py"),
|
||||
]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ 2 new files" in captured.out
|
||||
assert "~ 3 modified files" in captured.out
|
||||
assert "- 1 deleted file" in captured.out
|
||||
|
||||
|
||||
class TestShowChangedFiles:
|
||||
"""Tests for show_changed_files display function."""
|
||||
|
||||
def test_show_changed_files_empty_list(self, capsys):
|
||||
"""show_changed_files prints info message when no files changed."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = []
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No changes" in captured.out
|
||||
|
||||
def test_show_changed_files_with_added_file(self, capsys):
|
||||
"""show_changed_files displays added file with + prefix."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [("A", "new_file.py")]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Changed files" in captured.out
|
||||
assert "+ new_file.py" in captured.out
|
||||
|
||||
def test_show_changed_files_with_modified_file(self, capsys):
|
||||
"""show_changed_files displays modified file with ~ prefix."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [("M", "changed.py")]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "~ changed.py" in captured.out
|
||||
|
||||
def test_show_changed_files_with_deleted_file(self, capsys):
|
||||
"""show_changed_files displays deleted file with - prefix."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [("D", "removed.py")]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "- removed.py" in captured.out
|
||||
|
||||
def test_show_changed_files_with_unknown_status(self, capsys):
|
||||
"""show_changed_files displays unknown status code without decoration."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [("R", "renamed.py")]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "R renamed.py" in captured.out
|
||||
|
||||
def test_show_changed_files_multiple_files(self, capsys):
|
||||
"""show_changed_files displays all changed files."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [
|
||||
("A", "new.py"),
|
||||
("M", "modified.py"),
|
||||
("D", "deleted.py"),
|
||||
("R", "renamed.py"),
|
||||
]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ new.py" in captured.out
|
||||
assert "~ modified.py" in captured.out
|
||||
assert "- deleted.py" in captured.out
|
||||
assert "R renamed.py" in captured.out
|
||||
|
||||
|
||||
class TestPrintMergeSuccess:
|
||||
"""Tests for print_merge_success display function."""
|
||||
|
||||
def test_print_merge_success_no_commit_basic(self, capsys):
|
||||
"""print_merge_success with no_commit=True shows basic message."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
print_merge_success(no_commit=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "working directory" in captured.out
|
||||
assert "Review the changes" in captured.out
|
||||
assert "commit when ready" in captured.out
|
||||
|
||||
def test_print_merge_success_no_commit_with_lock_files(self, capsys):
|
||||
"""print_merge_success with lock_files_excluded shows lock file note."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {"lock_files_excluded": 2}
|
||||
print_merge_success(no_commit=True, stats=stats)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "Lock files kept from main" in captured.out
|
||||
assert "npm install" in captured.out
|
||||
|
||||
def test_print_merge_success_no_commit_with_keep_worktree(self, capsys):
|
||||
"""print_merge_success with keep_worktree shows discard command."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
print_merge_success(no_commit=True, spec_name="spec-001", keep_worktree=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "Worktree kept for testing" in captured.out
|
||||
assert "python auto-claude/run.py --spec spec-001 --discard" in captured.out
|
||||
|
||||
def test_print_merge_success_no_commit_full_scenario(self, capsys):
|
||||
"""print_merge_success with all optional parameters."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {"lock_files_excluded": 1}
|
||||
print_merge_success(
|
||||
no_commit=True,
|
||||
stats=stats,
|
||||
spec_name="test-spec",
|
||||
keep_worktree=True,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "Lock files kept from main" in captured.out
|
||||
assert "Worktree kept for testing" in captured.out
|
||||
assert "--spec test-spec --discard" in captured.out
|
||||
|
||||
def test_print_merge_success_with_commit_basic(self, capsys):
|
||||
"""print_merge_success with no_commit=False shows commit message."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
print_merge_success(no_commit=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "separate workspace has been cleaned up" in captured.out
|
||||
|
||||
def test_print_merge_success_with_commit_and_stats(self, capsys):
|
||||
"""print_merge_success with stats shows file counts."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {
|
||||
"files_added": 5,
|
||||
"files_modified": 3,
|
||||
"files_deleted": 1,
|
||||
}
|
||||
print_merge_success(no_commit=False, stats=stats)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "What changed" in captured.out
|
||||
assert "+ 5 files added" in captured.out
|
||||
assert "~ 3 files modified" in captured.out
|
||||
assert "- 1 file deleted" in captured.out
|
||||
|
||||
def test_print_merge_success_singular_file_counts(self, capsys):
|
||||
"""print_merge_success uses singular form for single file counts."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {
|
||||
"files_added": 1,
|
||||
"files_modified": 1,
|
||||
"files_deleted": 1,
|
||||
}
|
||||
print_merge_success(no_commit=False, stats=stats)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ 1 file added" in captured.out
|
||||
assert "~ 1 file modified" in captured.out
|
||||
assert "- 1 file deleted" in captured.out
|
||||
|
||||
def test_print_merge_success_with_keep_worktree(self, capsys):
|
||||
"""print_merge_success with keep_worktree shows discard command."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
print_merge_success(no_commit=False, keep_worktree=True, spec_name="my-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "Worktree kept for testing" in captured.out
|
||||
assert "--spec my-spec --discard" in captured.out
|
||||
assert "separate workspace has been cleaned up" not in captured.out
|
||||
|
||||
def test_print_merge_success_zero_file_counts_not_shown(self, capsys):
|
||||
"""print_merge_success doesn't show file types with zero count."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {
|
||||
"files_added": 2,
|
||||
"files_modified": 0,
|
||||
"files_deleted": 0,
|
||||
}
|
||||
print_merge_success(no_commit=False, stats=stats)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ 2 files added" in captured.out
|
||||
assert "files modified" not in captured.out
|
||||
assert "files deleted" not in captured.out
|
||||
|
||||
|
||||
class TestPrintConflictInfoExtended:
|
||||
"""Extended tests for print_conflict_info display function."""
|
||||
|
||||
def test_print_conflict_info_empty_conflicts(self, capsys):
|
||||
"""print_conflict_info returns early with empty conflicts list."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": []}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
def test_print_conflict_info_no_conflicts_key(self, capsys):
|
||||
"""print_conflict_info returns early when conflicts key missing."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
def test_print_conflict_info_critical_severity(self, capsys):
|
||||
"""print_conflict_info shows critical severity icon."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{
|
||||
"file": "critical.py",
|
||||
"reason": "Breaking change",
|
||||
"severity": "critical",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "critical.py" in captured.out
|
||||
assert "⛔" in captured.out
|
||||
assert "Breaking change" in captured.out
|
||||
|
||||
def test_print_conflict_info_high_severity(self, capsys):
|
||||
"""print_conflict_info shows high severity icon."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "high.py", "reason": "Major conflict", "severity": "high"}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "high.py" in captured.out
|
||||
assert "🔴" in captured.out
|
||||
assert "Major conflict" in captured.out
|
||||
|
||||
def test_print_conflict_info_medium_severity(self, capsys):
|
||||
"""print_conflict_info shows medium severity icon."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "medium.py", "reason": "Minor conflict", "severity": "medium"}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "medium.py" in captured.out
|
||||
assert "🟡" in captured.out
|
||||
assert "Minor conflict" in captured.out
|
||||
|
||||
def test_print_conflict_info_low_severity_no_icon(self, capsys):
|
||||
"""print_conflict_info shows no icon for low severity."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "low.py", "reason": "Trivial issue", "severity": "low"}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "low.py" in captured.out
|
||||
assert "Trivial issue" in captured.out
|
||||
assert "⛔" not in captured.out
|
||||
assert "🔴" not in captured.out
|
||||
assert "🟡" not in captured.out
|
||||
|
||||
def test_print_conflict_info_unknown_severity(self, capsys):
|
||||
"""print_conflict_info handles unknown severity gracefully."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "unknown.py", "reason": "Unknown", "severity": "unknown"}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "unknown.py" in captured.out
|
||||
assert "Unknown" in captured.out
|
||||
|
||||
def test_print_conflict_info_missing_file_key(self, capsys):
|
||||
"""print_conflict_info handles missing file key."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": [{"reason": "No file specified", "severity": "high"}]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "unknown" in captured.out
|
||||
assert "No file specified" in captured.out
|
||||
|
||||
def test_print_conflict_info_missing_reason_key(self, capsys):
|
||||
"""print_conflict_info handles missing reason key."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": [{"file": "noreason.py", "severity": "medium"}]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "noreason.py" in captured.out
|
||||
|
||||
def test_print_conflict_info_dict_no_reason(self, capsys):
|
||||
"""print_conflict_info with dict missing reason."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": [{"file": "test.py", "severity": "high"}]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "test.py" in captured.out
|
||||
assert "🔴" in captured.out
|
||||
|
||||
def test_print_conflict_info_multiple_conflicts(self, capsys):
|
||||
"""print_conflict_info handles multiple conflicts."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "critical.py", "reason": "Critical", "severity": "critical"},
|
||||
{"file": "high.py", "reason": "High", "severity": "high"},
|
||||
{"file": "medium.py", "reason": "Medium", "severity": "medium"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "3 file" in captured.out
|
||||
assert "⛔" in captured.out
|
||||
assert "🔴" in captured.out
|
||||
assert "🟡" in captured.out
|
||||
|
||||
def test_print_conflict_info_shows_marker_conflict_message(self, capsys):
|
||||
"""print_conflict_info shows marker conflict message for string conflicts."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": ["conflict.py"]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "conflict markers" in captured.out
|
||||
# Check that the conflict markers are mentioned in the message
|
||||
|
||||
def test_print_conflict_info_shows_ai_conflict_message(self, capsys):
|
||||
"""print_conflict_info shows AI conflict message for dict conflicts."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{
|
||||
"file": "ai-conflict.py",
|
||||
"reason": "AI merge failed",
|
||||
"severity": "high",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "could not be auto-merged" in captured.out
|
||||
|
||||
def test_print_conflict_info_shows_both_messages_mixed(self, capsys):
|
||||
"""print_conflict_info shows both messages for mixed conflicts."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
"marker.py",
|
||||
{"file": "ai.py", "reason": "AI failed", "severity": "high"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "conflict markers" in captured.out
|
||||
assert "could not be auto-merged" in captured.out
|
||||
|
||||
def test_print_conflict_info_shows_git_commands(self, capsys):
|
||||
"""print_conflict_info shows git add and commit commands."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": ["file1.py", "file2.py"]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "git add" in captured.out
|
||||
assert "git commit" in captured.out
|
||||
|
||||
def test_print_conflict_info_quotes_special_paths(self, capsys):
|
||||
"""print_conflict_info properly quotes file paths with special characters."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": ["file with spaces.py", "file'with'quotes.py"]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# shlex.quote should quote paths with spaces
|
||||
assert "git add" in captured.out
|
||||
assert "file with spaces.py" in captured.out
|
||||
|
||||
def test_print_conflict_info_deduplicates_files(self, capsys):
|
||||
"""print_conflict_info deduplicates file paths in git command."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
"file1.py",
|
||||
{"file": "file1.py", "reason": "Also here", "severity": "medium"},
|
||||
"file2.py",
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Count occurrences of file1.py
|
||||
count = captured.out.count("file1.py")
|
||||
assert count == 3 # Display shows it twice (string + dict), once in git add
|
||||
|
||||
def test_print_conflict_info_preserves_order(self, capsys):
|
||||
"""print_conflict_info preserves file order while deduplicating."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
"first.py",
|
||||
{"file": "second.py", "severity": "high"},
|
||||
"first.py", # Duplicate
|
||||
{"file": "third.py", "severity": "medium"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# First occurrence should be preserved
|
||||
lines = captured.out.split("\n")
|
||||
first_idx = None
|
||||
second_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if "first.py" in line:
|
||||
if first_idx is None:
|
||||
first_idx = i
|
||||
if "second.py" in line:
|
||||
if second_idx is None:
|
||||
second_idx = i
|
||||
assert first_idx is not None
|
||||
assert second_idx is not None
|
||||
|
||||
|
||||
class TestCopyEnvFilesToWorktree:
|
||||
"""Tests for copy_env_files_to_worktree function."""
|
||||
|
||||
def test_copies_all_env_files(self, temp_git_repo: Path):
|
||||
"""Copies all .env files when they exist in project dir."""
|
||||
from core.workspace.setup import copy_env_files_to_worktree
|
||||
|
||||
# Create .env files in project
|
||||
(temp_git_repo / ".env").write_text("FOO=bar", encoding="utf-8")
|
||||
(temp_git_repo / ".env.local").write_text("LOCAL=1", encoding="utf-8")
|
||||
(temp_git_repo / ".env.development").write_text("DEV=1", encoding="utf-8")
|
||||
|
||||
# Create worktree directory
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Copy env files
|
||||
copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
# Check all files were copied
|
||||
assert ".env" in copied
|
||||
assert ".env.local" in copied
|
||||
assert ".env.development" in copied
|
||||
assert len(copied) == 3
|
||||
|
||||
# Verify files exist in worktree
|
||||
assert (worktree_path / ".env").exists()
|
||||
assert (worktree_path / ".env.local").exists()
|
||||
assert (worktree_path / ".env.development").exists()
|
||||
|
||||
def test_skips_nonexistent_env_files(self, temp_git_repo: Path):
|
||||
"""Only copies env files that exist."""
|
||||
from core.workspace.setup import copy_env_files_to_worktree
|
||||
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
assert len(copied) == 0
|
||||
|
||||
def test_does_not_overwrite_existing_env_files(self, temp_git_repo: Path):
|
||||
"""Does not overwrite .env files that already exist in worktree."""
|
||||
from core.workspace.setup import copy_env_files_to_worktree
|
||||
|
||||
# Create .env in project
|
||||
(temp_git_repo / ".env").write_text("PROJECT=1", encoding="utf-8")
|
||||
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Create existing .env in worktree with different content
|
||||
(worktree_path / ".env").write_text("WORKTREE=1", encoding="utf-8")
|
||||
|
||||
copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
# .env should not be in copied list since it already existed
|
||||
assert ".env" not in copied
|
||||
|
||||
# Worktree .env should keep its original content
|
||||
assert (worktree_path / ".env").read_text(encoding="utf-8") == "WORKTREE=1"
|
||||
|
||||
|
||||
class TestSymlinkNodeModulesToWorktree:
|
||||
"""Tests for symlink_node_modules_to_worktree function."""
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="Unix-specific test")
|
||||
def test_symlinks_node_modules_on_unix(self, temp_git_repo: Path):
|
||||
"""Creates relative symlinks on Unix systems."""
|
||||
from core.workspace.setup import symlink_node_modules_to_worktree
|
||||
|
||||
# Create node_modules in project
|
||||
node_modules = temp_git_repo / "node_modules"
|
||||
node_modules.mkdir()
|
||||
(node_modules / "test.txt").write_text("test", encoding="utf-8")
|
||||
|
||||
# Create apps/frontend/node_modules
|
||||
frontend_node_modules = temp_git_repo / "apps" / "frontend" / "node_modules"
|
||||
frontend_node_modules.mkdir(parents=True)
|
||||
(frontend_node_modules / "test2.txt").write_text("test2", encoding="utf-8")
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
(worktree_path / "apps" / "frontend").mkdir(parents=True)
|
||||
|
||||
# Create symlinks
|
||||
symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
assert len(symlinked) == 2
|
||||
assert "node_modules" in symlinked
|
||||
assert "apps/frontend/node_modules" in symlinked
|
||||
|
||||
# Verify symlinks exist and point to correct location
|
||||
assert (worktree_path / "node_modules").is_symlink()
|
||||
assert (worktree_path / "apps" / "frontend" / "node_modules").is_symlink()
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
|
||||
def test_creates_junctions_on_windows(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Creates junctions on Windows systems."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.workspace.setup import symlink_node_modules_to_worktree
|
||||
|
||||
# Create node_modules in project
|
||||
node_modules = temp_git_repo / "node_modules"
|
||||
node_modules.mkdir()
|
||||
(node_modules / "test.txt").write_text("test", encoding="utf-8")
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock subprocess.run to simulate mklink /J success
|
||||
def mock_subprocess_run(cmd, capture_output=False, text=False):
|
||||
result = type("obj", (object,), {"returncode": 0, "stderr": ""})()
|
||||
return result
|
||||
|
||||
with patch("subprocess.run", side_effect=mock_subprocess_run):
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr("sys.platform", "win32")
|
||||
symlinked = symlink_node_modules_to_worktree(
|
||||
temp_git_repo, worktree_path
|
||||
)
|
||||
|
||||
assert "node_modules" in symlinked
|
||||
|
||||
def test_skips_nonexistent_node_modules(self, temp_git_repo: Path):
|
||||
"""Skips node_modules that don't exist in project."""
|
||||
from core.workspace.setup import symlink_node_modules_to_worktree
|
||||
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
assert len(symlinked) == 0
|
||||
|
||||
def test_skips_existing_symlinks(self, temp_git_repo: Path):
|
||||
"""Does not recreate symlinks that already exist."""
|
||||
from core.workspace.setup import symlink_node_modules_to_worktree
|
||||
|
||||
# Create node_modules in project
|
||||
node_modules = temp_git_repo / "node_modules"
|
||||
node_modules.mkdir()
|
||||
(node_modules / "test.txt").write_text("test", encoding="utf-8")
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Create existing symlink
|
||||
if sys.platform != "win32":
|
||||
os.symlink(temp_git_repo / "node_modules", worktree_path / "node_modules")
|
||||
|
||||
symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
# Should skip existing symlink
|
||||
assert "node_modules" not in symlinked
|
||||
@@ -0,0 +1,805 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Selection and Management
|
||||
=============================================
|
||||
|
||||
Tests the workspace.py module functionality including:
|
||||
- Workspace mode selection (isolated vs direct)
|
||||
- Uncommitted changes detection
|
||||
- Workspace setup
|
||||
- Build finalization workflows
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add parent directory to path so we can import the workspace module
|
||||
# When co-located at workspace/tests/, we need to add backend to path
|
||||
# workspace/tests -> workspace -> core -> backend (4 levels up)
|
||||
_backend = Path(__file__).resolve().parent.parent.parent.parent
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
from core.workspace import (
|
||||
WorkspaceChoice,
|
||||
WorkspaceMode,
|
||||
get_current_branch,
|
||||
get_existing_build_worktree,
|
||||
has_uncommitted_changes,
|
||||
setup_workspace,
|
||||
)
|
||||
from worktree import WorktreeError, WorktreeManager
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR finalization.py
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFinalizeWorkspace:
|
||||
"""Tests for finalize_workspace function."""
|
||||
|
||||
def test_direct_mode_returns_merge(self, temp_git_repo: Path, monkeypatch, capsys):
|
||||
"""Direct mode returns MERGE choice and shows completion message."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
# Mock the UI functions
|
||||
def mock_box(content, width=60, style="heavy"):
|
||||
return content
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.box", mock_box)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
manager=None,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.MERGE
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "BUILD COMPLETE" in captured.out
|
||||
assert "directly to your project" in captured.out
|
||||
|
||||
def test_auto_continue_mode_returns_later(self, temp_git_repo: Path):
|
||||
"""Auto-continue mode returns LATER choice."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree info
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=True,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.LATER
|
||||
|
||||
def test_isolated_mode_shows_menu(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Isolated mode shows menu with test/review/merge/later options."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "test"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "test"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.TEST
|
||||
|
||||
|
||||
class TestHandleWorkspaceChoice:
|
||||
"""Tests for handle_workspace_choice function."""
|
||||
|
||||
def test_choice_test_shows_instructions(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""TEST choice shows testing instructions."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "TEST YOUR FEATURE" in captured.out
|
||||
assert str(worktree_path) in captured.out
|
||||
|
||||
def test_choice_merge_calls_merge_worktree(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""MERGE choice calls manager.merge_worktree."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree and commit something
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
(worktree_path / "test.py").write_text("test", encoding="utf-8")
|
||||
|
||||
# Initialize git in worktree and commit
|
||||
subprocess.run(["git", "init"], cwd=worktree_path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=worktree_path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Test"], cwd=worktree_path, capture_output=True
|
||||
)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.MERGE, temp_git_repo, spec_name, manager
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Adding changes" in captured.out
|
||||
|
||||
def test_choice_review_shows_changed_files(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""REVIEW choice shows changed files."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock show_changed_files
|
||||
mock_shown = []
|
||||
|
||||
def mock_show_changed_files(manager, spec_name):
|
||||
mock_shown.append(spec_name)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.workspace.finalization.show_changed_files", mock_show_changed_files
|
||||
)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.REVIEW, temp_git_repo, spec_name, manager
|
||||
)
|
||||
|
||||
assert len(mock_shown) == 1
|
||||
assert mock_shown[0] == spec_name
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "To see full details" in captured.out
|
||||
|
||||
def test_choice_later_shows_deferred_message(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""LATER choice shows deferral message."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No problem!" in captured.out
|
||||
assert "saved" in captured.out
|
||||
|
||||
|
||||
class TestReviewExistingBuild:
|
||||
"""Tests for review_existing_build function."""
|
||||
|
||||
def test_no_existing_build_shows_warning(self, temp_git_repo: Path, capsys):
|
||||
"""Shows warning when no existing build found."""
|
||||
from core.workspace.finalization import review_existing_build
|
||||
|
||||
result = review_existing_build(temp_git_repo, "nonexistent-spec")
|
||||
|
||||
assert result is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No existing build found" in captured.out
|
||||
|
||||
def test_shows_build_contents(self, temp_git_repo: Path, capsys):
|
||||
"""Shows build summary and changed files when build exists."""
|
||||
from core.workspace.finalization import review_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
result = review_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "BUILD CONTENTS" in captured.out
|
||||
|
||||
|
||||
class TestDiscardExistingBuild:
|
||||
"""Tests for discard_existing_build function."""
|
||||
|
||||
def test_no_existing_build_returns_false(self, temp_git_repo: Path, capsys):
|
||||
"""Returns False when no existing build found."""
|
||||
from core.workspace.finalization import discard_existing_build
|
||||
|
||||
result = discard_existing_build(temp_git_repo, "nonexistent-spec")
|
||||
|
||||
assert result is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No existing build found" in captured.out
|
||||
|
||||
def test_confirmation_deletes_build(self, temp_git_repo: Path, monkeypatch, capsys):
|
||||
"""Deletes build when user types 'delete' to confirm."""
|
||||
from core.workspace.finalization import discard_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock input to return "delete"
|
||||
monkeypatch.setattr("builtins.input", lambda: "delete")
|
||||
|
||||
result = discard_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is True
|
||||
captured = capsys.readouterr()
|
||||
assert "Build deleted" in captured.out
|
||||
|
||||
def test_cancelled_confirmation_returns_false(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Returns False when user doesn't confirm."""
|
||||
from core.workspace.finalization import discard_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock input to return "no"
|
||||
monkeypatch.setattr("builtins.input", lambda: "no")
|
||||
|
||||
result = discard_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is False
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out
|
||||
|
||||
|
||||
class TestCheckExistingBuild:
|
||||
"""Tests for check_existing_build function."""
|
||||
|
||||
def test_no_existing_build_returns_false(self, temp_git_repo: Path):
|
||||
"""Returns False when no existing build."""
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
result = check_existing_build(temp_git_repo, "nonexistent-spec")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_shows_menu_for_existing_build(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Shows menu when existing build found."""
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "continue"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "continue"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = check_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_review_choice_reviews_and_continues(
|
||||
self, temp_git_repo: Path, monkeypatch
|
||||
):
|
||||
"""Review choice reviews build then continues."""
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
review_called = []
|
||||
|
||||
def mock_review(project_dir, spec_name):
|
||||
review_called.append(spec_name)
|
||||
return True
|
||||
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "review"
|
||||
|
||||
def mock_input(prompt):
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.workspace.finalization.review_existing_build", mock_review
|
||||
)
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
|
||||
result = check_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is True
|
||||
assert spec_name in review_called
|
||||
|
||||
|
||||
class TestListAllWorktrees:
|
||||
"""Tests for list_all_worktrees function."""
|
||||
|
||||
def test_returns_empty_list_when_no_worktrees(self, temp_git_repo: Path):
|
||||
"""Returns empty list when no worktrees exist."""
|
||||
from core.workspace.finalization import list_all_worktrees
|
||||
|
||||
result = list_all_worktrees(temp_git_repo)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_lists_existing_worktrees(self, temp_git_repo: Path):
|
||||
"""Returns list of existing worktrees."""
|
||||
from core.workspace.finalization import list_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
(worktrees_dir / "spec-001").mkdir()
|
||||
(worktrees_dir / "spec-002").mkdir()
|
||||
|
||||
result = list_all_worktrees(temp_git_repo)
|
||||
|
||||
assert len(result) == 2
|
||||
spec_names = {wt.spec_name for wt in result}
|
||||
assert "spec-001" in spec_names
|
||||
assert "spec-002" in spec_names
|
||||
|
||||
|
||||
class TestCleanupAllWorktrees:
|
||||
"""Tests for cleanup_all_worktrees function."""
|
||||
|
||||
def test_no_worktrees_returns_false(self, temp_git_repo: Path, capsys):
|
||||
"""Returns False when no worktrees found."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=False)
|
||||
|
||||
assert result is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No worktrees found" in captured.out
|
||||
|
||||
def test_cleanup_without_confirmation(self, temp_git_repo: Path):
|
||||
"""Cleans up worktrees when confirm=False."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
spec1_path = worktrees_dir / "spec-001"
|
||||
spec1_path.mkdir()
|
||||
spec2_path = worktrees_dir / "spec-002"
|
||||
spec2_path.mkdir()
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=False)
|
||||
|
||||
assert result is True
|
||||
assert not spec1_path.exists()
|
||||
assert not spec2_path.exists()
|
||||
|
||||
def test_cleanup_with_confirmation_yes(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Cleans up worktrees when user confirms with 'yes'."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
spec1_path = worktrees_dir / "spec-001"
|
||||
spec1_path.mkdir()
|
||||
|
||||
# Mock input to return "yes"
|
||||
monkeypatch.setattr("builtins.input", lambda: "yes")
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=True)
|
||||
|
||||
assert result is True
|
||||
assert not spec1_path.exists()
|
||||
|
||||
def test_cleanup_with_confirmation_no(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Cancels cleanup when user doesn't confirm."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
spec1_path = worktrees_dir / "spec-001"
|
||||
spec1_path.mkdir()
|
||||
|
||||
# Mock input to return "no"
|
||||
monkeypatch.setattr("builtins.input", lambda: "no")
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=True)
|
||||
|
||||
assert result is False
|
||||
assert spec1_path.exists() # Should still exist
|
||||
|
||||
def test_cleanup_with_confirmation_keyboard_interrupt(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Cancels cleanup when user presses Ctrl+C (KeyboardInterrupt)."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
spec1_path = worktrees_dir / "spec-001"
|
||||
spec1_path.mkdir()
|
||||
|
||||
# Mock input to raise KeyboardInterrupt
|
||||
def mock_input(prompt=""):
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=True)
|
||||
|
||||
assert result is False
|
||||
assert spec1_path.exists() # Should still exist
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out
|
||||
|
||||
|
||||
class TestFinalizeWorkspaceBranchCoverage:
|
||||
"""Additional tests for finalize_workspace to cover missing branches."""
|
||||
|
||||
def test_isolated_mode_merge_choice(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Isolated mode returns MERGE when user selects merge."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "merge"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "merge"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.MERGE
|
||||
|
||||
def test_isolated_mode_review_choice(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Isolated mode returns REVIEW when user selects review."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "review"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "review"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.REVIEW
|
||||
|
||||
def test_isolated_mode_later_choice(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Isolated mode returns LATER when user selects later."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "later"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "later"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.LATER
|
||||
|
||||
|
||||
class TestHandleWorkspaceChoiceBranchCoverage:
|
||||
"""Additional tests for handle_workspace_choice to cover missing branches."""
|
||||
|
||||
def test_choice_test_without_staging_path(self, temp_git_repo: Path, capsys):
|
||||
"""TEST choice shows fallback instructions when staging_path is None."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree directory (but not through manager, so no staging_path)
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "TEST YOUR FEATURE" in captured.out
|
||||
# Should show the fallback path
|
||||
assert (
|
||||
str(worktree_path) in captured.out
|
||||
or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out
|
||||
)
|
||||
|
||||
def test_choice_merge_success(self, temp_git_repo: Path, capsys):
|
||||
"""MERGE choice shows success message when merge succeeds."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
from worktree import WorktreeManager
|
||||
|
||||
# Setup a proper isolated workspace with git worktree
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Make changes and commit
|
||||
(working_dir / "test.py").write_text("test content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Add test"], cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.MERGE, temp_git_repo, "test-spec", manager
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Your feature has been added" in captured.out
|
||||
|
||||
def test_choice_later_without_staging_path(self, temp_git_repo: Path, capsys):
|
||||
"""LATER choice shows fallback path when staging_path is None."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree directory (but not through manager, so no staging_path)
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No problem!" in captured.out
|
||||
# Should show the fallback path
|
||||
assert (
|
||||
str(worktree_path) in captured.out
|
||||
or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out
|
||||
)
|
||||
|
||||
|
||||
class TestDiscardExistingBuildBranchCoverage:
|
||||
"""Additional tests for discard_existing_build to cover missing branches."""
|
||||
|
||||
def test_keyboard_interrupt_cancels_discard(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""KeyboardInterrupt during confirmation returns False."""
|
||||
from core.workspace.finalization import discard_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock input to raise KeyboardInterrupt
|
||||
def mock_input(prompt=""):
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
|
||||
result = discard_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out
|
||||
|
||||
|
||||
class TestCheckExistingBuildBranchCoverage:
|
||||
"""Additional tests for check_existing_build to cover missing branches."""
|
||||
|
||||
def test_none_choice_exits(self, temp_git_repo: Path, monkeypatch):
|
||||
"""None choice (quit) calls sys.exit(0)."""
|
||||
import sys
|
||||
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return None (quit)
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
# Should raise SystemExit
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
check_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
def test_merge_choice_merges_and_returns_false(
|
||||
self, temp_git_repo: Path, monkeypatch
|
||||
):
|
||||
"""Merge choice calls merge_existing_build and returns False."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
merge_called = []
|
||||
|
||||
def mock_merge_existing_build(project_dir, spec_name):
|
||||
merge_called.append(spec_name)
|
||||
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "merge"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
# Mock the workspace module import
|
||||
import workspace as ws
|
||||
|
||||
original_merge = getattr(ws, "merge_existing_build", None)
|
||||
ws.merge_existing_build = mock_merge_existing_build
|
||||
|
||||
try:
|
||||
result = check_existing_build(temp_git_repo, spec_name)
|
||||
assert result is False
|
||||
assert spec_name in merge_called
|
||||
finally:
|
||||
if original_merge:
|
||||
ws.merge_existing_build = original_merge
|
||||
|
||||
def test_fresh_choice_discards_and_returns_false(
|
||||
self, temp_git_repo: Path, monkeypatch
|
||||
):
|
||||
"""Fresh choice discards build and returns False (start fresh)."""
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "fresh"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
# Mock input to return "delete" for confirmation
|
||||
monkeypatch.setattr("builtins.input", lambda: "delete")
|
||||
|
||||
result = check_existing_build(temp_git_repo, spec_name)
|
||||
assert result is False, "Fresh choice should return False"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,638 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Models
|
||||
==========================
|
||||
|
||||
Tests the workspace.py module models including:
|
||||
- WorkspaceMode enum
|
||||
- WorkspaceChoice enum
|
||||
- ParallelMergeTask
|
||||
- ParallelMergeResult
|
||||
- MergeLock and MergeLockError
|
||||
- SpecNumberLock and SpecNumberLockError
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add parent directory to path so we can import the workspace module
|
||||
# When co-located at workspace/tests/, we need to add backend to path
|
||||
# workspace/tests -> workspace -> core -> backend (4 levels up)
|
||||
_backend = Path(__file__).resolve().parent.parent.parent.parent
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
from core.workspace.models import (
|
||||
MergeLock,
|
||||
MergeLockError,
|
||||
ParallelMergeResult,
|
||||
ParallelMergeTask,
|
||||
SpecNumberLock,
|
||||
SpecNumberLockError,
|
||||
)
|
||||
from worktree import WorktreeError, WorktreeManager
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
|
||||
class TestWorkspaceMode:
|
||||
"""Tests for WorkspaceMode enum."""
|
||||
|
||||
def test_isolated_mode(self):
|
||||
"""ISOLATED mode value is correct."""
|
||||
from core.workspace.models import WorkspaceMode
|
||||
|
||||
assert WorkspaceMode.ISOLATED.value == "isolated"
|
||||
|
||||
def test_direct_mode(self):
|
||||
"""DIRECT mode value is correct."""
|
||||
from core.workspace.models import WorkspaceMode
|
||||
|
||||
assert WorkspaceMode.DIRECT.value == "direct"
|
||||
|
||||
|
||||
class TestWorkspaceChoice:
|
||||
"""Tests for WorkspaceChoice enum."""
|
||||
|
||||
def test_merge_choice(self):
|
||||
"""MERGE choice value is correct."""
|
||||
from core.workspace.models import WorkspaceChoice
|
||||
|
||||
assert WorkspaceChoice.MERGE.value == "merge"
|
||||
|
||||
def test_review_choice(self):
|
||||
"""REVIEW choice value is correct."""
|
||||
from core.workspace.models import WorkspaceChoice
|
||||
|
||||
assert WorkspaceChoice.REVIEW.value == "review"
|
||||
|
||||
def test_test_choice(self):
|
||||
"""TEST choice value is correct."""
|
||||
from core.workspace.models import WorkspaceChoice
|
||||
|
||||
assert WorkspaceChoice.TEST.value == "test"
|
||||
|
||||
def test_later_choice(self):
|
||||
"""LATER choice value is correct."""
|
||||
from core.workspace.models import WorkspaceChoice
|
||||
|
||||
assert WorkspaceChoice.LATER.value == "later"
|
||||
|
||||
|
||||
class TestParallelMergeTask:
|
||||
"""Tests for ParallelMergeTask dataclass."""
|
||||
|
||||
def test_create_merge_task(self):
|
||||
"""ParallelMergeTask can be instantiated with all fields."""
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/example.py",
|
||||
main_content="main content",
|
||||
worktree_content="worktree content",
|
||||
base_content="base content",
|
||||
spec_name="test-spec",
|
||||
project_dir=Path("/project"),
|
||||
)
|
||||
|
||||
assert task.file_path == "src/example.py"
|
||||
assert task.main_content == "main content"
|
||||
assert task.worktree_content == "worktree content"
|
||||
assert task.base_content == "base content"
|
||||
assert task.spec_name == "test-spec"
|
||||
assert task.project_dir == Path("/project")
|
||||
|
||||
def test_merge_task_with_none_base(self):
|
||||
"""ParallelMergeTask can have None for base_content."""
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/example.py",
|
||||
main_content="main content",
|
||||
worktree_content="worktree content",
|
||||
base_content=None,
|
||||
spec_name="test-spec",
|
||||
project_dir=Path("/project"),
|
||||
)
|
||||
|
||||
assert task.base_content is None
|
||||
|
||||
def test_merge_task_field_assignment(self):
|
||||
"""ParallelMergeTask fields can be reassigned."""
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/example.py",
|
||||
main_content="main",
|
||||
worktree_content="worktree",
|
||||
base_content=None,
|
||||
spec_name="spec-1",
|
||||
project_dir=Path("/project"),
|
||||
)
|
||||
|
||||
task.file_path = "src/updated.py"
|
||||
task.main_content = "updated main"
|
||||
task.worktree_content = "updated worktree"
|
||||
task.base_content = "updated base"
|
||||
task.spec_name = "spec-2"
|
||||
task.project_dir = Path("/updated")
|
||||
|
||||
assert task.file_path == "src/updated.py"
|
||||
assert task.main_content == "updated main"
|
||||
assert task.worktree_content == "updated worktree"
|
||||
assert task.base_content == "updated base"
|
||||
assert task.spec_name == "spec-2"
|
||||
assert task.project_dir == Path("/updated")
|
||||
|
||||
|
||||
class TestParallelMergeResult:
|
||||
"""Tests for ParallelMergeResult dataclass."""
|
||||
|
||||
def test_create_successful_result(self):
|
||||
"""ParallelMergeResult can represent a successful merge."""
|
||||
result = ParallelMergeResult(
|
||||
file_path="src/example.py",
|
||||
merged_content="merged content",
|
||||
success=True,
|
||||
error=None,
|
||||
was_auto_merged=True,
|
||||
)
|
||||
|
||||
assert result.file_path == "src/example.py"
|
||||
assert result.merged_content == "merged content"
|
||||
assert result.success is True
|
||||
assert result.error is None
|
||||
assert result.was_auto_merged is True
|
||||
|
||||
def test_create_failed_result(self):
|
||||
"""ParallelMergeResult can represent a failed merge."""
|
||||
result = ParallelMergeResult(
|
||||
file_path="src/example.py",
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error="Merge conflict occurred",
|
||||
was_auto_merged=False,
|
||||
)
|
||||
|
||||
assert result.file_path == "src/example.py"
|
||||
assert result.merged_content is None
|
||||
assert result.success is False
|
||||
assert result.error == "Merge conflict occurred"
|
||||
assert result.was_auto_merged is False
|
||||
|
||||
def test_result_default_values(self):
|
||||
"""ParallelMergeResult has correct default values."""
|
||||
result = ParallelMergeResult(
|
||||
file_path="src/example.py",
|
||||
merged_content="content",
|
||||
success=True,
|
||||
)
|
||||
|
||||
assert result.error is None
|
||||
assert result.was_auto_merged is False
|
||||
|
||||
def test_result_field_assignment(self):
|
||||
"""ParallelMergeResult fields can be reassigned."""
|
||||
result = ParallelMergeResult(
|
||||
file_path="src/example.py",
|
||||
merged_content="merged",
|
||||
success=True,
|
||||
error=None,
|
||||
was_auto_merged=False,
|
||||
)
|
||||
|
||||
result.file_path = "src/updated.py"
|
||||
result.merged_content = "updated merged"
|
||||
result.success = False
|
||||
result.error = "New error"
|
||||
result.was_auto_merged = True
|
||||
|
||||
assert result.file_path == "src/updated.py"
|
||||
assert result.merged_content == "updated merged"
|
||||
assert result.success is False
|
||||
assert result.error == "New error"
|
||||
assert result.was_auto_merged is True
|
||||
|
||||
|
||||
class TestMergeLockError:
|
||||
"""Tests for MergeLockError exception."""
|
||||
|
||||
def test_merge_lock_error_creation(self):
|
||||
"""MergeLockError can be instantiated with a message."""
|
||||
error = MergeLockError("Could not acquire lock")
|
||||
assert str(error) == "Could not acquire lock"
|
||||
|
||||
def test_merge_lock_error_is_exception(self):
|
||||
"""MergeLockError is an Exception subclass."""
|
||||
error = MergeLockError("test")
|
||||
assert isinstance(error, Exception)
|
||||
assert isinstance(error, MergeLockError)
|
||||
|
||||
def test_raise_merge_lock_error(self):
|
||||
"""MergeLockError can be raised and caught."""
|
||||
with pytest.raises(MergeLockError) as exc_info:
|
||||
raise MergeLockError("Lock timeout")
|
||||
assert str(exc_info.value) == "Lock timeout"
|
||||
|
||||
|
||||
class TestMergeLock:
|
||||
"""Tests for MergeLock context manager."""
|
||||
|
||||
def test_merge_lock_initialization(self, temp_git_repo: Path):
|
||||
"""MergeLock initializes with correct paths."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
assert lock.project_dir == temp_git_repo
|
||||
assert lock.spec_name == "test-spec"
|
||||
assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks"
|
||||
assert lock.lock_file == lock.lock_dir / "merge-test-spec.lock"
|
||||
assert lock.acquired is False
|
||||
|
||||
def test_merge_lock_acquire_and_release(self, temp_git_repo: Path):
|
||||
"""MergeLock can be acquired and released."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock:
|
||||
assert lock.acquired is True
|
||||
assert lock.lock_file.exists()
|
||||
|
||||
# After context, lock should be released
|
||||
assert lock.lock_file.exists() is False
|
||||
|
||||
def test_merge_lock_creates_lock_dir(self, temp_git_repo: Path):
|
||||
"""MergeLock creates lock directory if it doesn't exist."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
# Remove lock dir if it exists
|
||||
if lock.lock_dir.exists():
|
||||
lock.lock_dir.rmdir()
|
||||
|
||||
with lock:
|
||||
assert lock.lock_dir.exists()
|
||||
|
||||
def test_merge_lock_writes_pid(self, temp_git_repo: Path):
|
||||
"""MergeLock writes current PID to lock file."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock:
|
||||
pid_content = lock.lock_file.read_text(encoding="utf-8").strip()
|
||||
assert pid_content == str(os.getpid())
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_merge_lock_timeout_on_contention(self, temp_git_repo: Path):
|
||||
"""MergeLock raises MergeLockError when lock is held by another process."""
|
||||
lock1 = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
# Acquire first lock
|
||||
lock1.__enter__()
|
||||
|
||||
try:
|
||||
# Create a second lock for the same spec
|
||||
lock2 = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
# This should timeout because lock1 holds the lock
|
||||
with pytest.raises(MergeLockError) as exc_info:
|
||||
lock2.__enter__()
|
||||
|
||||
assert "Could not acquire merge lock" in str(exc_info.value)
|
||||
assert "test-spec" in str(exc_info.value)
|
||||
assert "after 30s" in str(exc_info.value)
|
||||
finally:
|
||||
lock1.__exit__(None, None, None)
|
||||
|
||||
def test_merge_lock_removes_stale_lock(self, temp_git_repo: Path):
|
||||
"""MergeLock removes stale lock from dead process."""
|
||||
lock1 = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock1:
|
||||
# Write a fake PID that doesn't exist
|
||||
fake_pid = 999999
|
||||
lock1.lock_file.write_text(str(fake_pid), encoding="utf-8")
|
||||
|
||||
# Create a new lock - it should remove the stale lock
|
||||
lock2 = MergeLock(temp_git_repo, "test-spec")
|
||||
with lock2:
|
||||
assert lock2.acquired is True
|
||||
|
||||
def test_merge_lock_handles_invalid_pid(self, temp_git_repo: Path):
|
||||
"""MergeLock handles invalid PID in lock file."""
|
||||
lock1 = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock1:
|
||||
# Write invalid content to lock file
|
||||
lock1.lock_file.write_text("invalid-pid", encoding="utf-8")
|
||||
|
||||
# Create a new lock - it should remove the invalid lock
|
||||
lock2 = MergeLock(temp_git_repo, "test-spec")
|
||||
with lock2:
|
||||
assert lock2.acquired is True
|
||||
|
||||
def test_merge_lock_cleanup_on_exception(self, temp_git_repo: Path):
|
||||
"""MergeLock releases lock even if exception occurs in context."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
try:
|
||||
with lock:
|
||||
assert lock.acquired is True
|
||||
raise ValueError("Test exception")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Lock should be released despite exception
|
||||
assert lock.lock_file.exists() is False
|
||||
|
||||
def test_merge_lock_idempotent_release(self, temp_git_repo: Path):
|
||||
"""MergeLock __exit__ can be called multiple times safely."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock:
|
||||
pass
|
||||
|
||||
# Call __exit__ again - should not raise
|
||||
lock.__exit__(None, None, None)
|
||||
lock.__exit__(None, None, None)
|
||||
|
||||
def test_merge_lock_different_specs_dont_conflict(self, temp_git_repo: Path):
|
||||
"""MergeLock for different specs can be held simultaneously."""
|
||||
lock1 = MergeLock(temp_git_repo, "spec-1")
|
||||
lock2 = MergeLock(temp_git_repo, "spec-2")
|
||||
|
||||
with lock1:
|
||||
with lock2:
|
||||
assert lock1.acquired is True
|
||||
assert lock2.acquired is True
|
||||
assert lock1.lock_file != lock2.lock_file
|
||||
|
||||
|
||||
class TestSpecNumberLockError:
|
||||
"""Tests for SpecNumberLockError exception."""
|
||||
|
||||
def test_spec_number_lock_error_creation(self):
|
||||
"""SpecNumberLockError can be instantiated with a message."""
|
||||
error = SpecNumberLockError("Could not acquire spec numbering lock")
|
||||
assert str(error) == "Could not acquire spec numbering lock"
|
||||
|
||||
def test_spec_number_lock_error_is_exception(self):
|
||||
"""SpecNumberLockError is an Exception subclass."""
|
||||
error = SpecNumberLockError("test")
|
||||
assert isinstance(error, Exception)
|
||||
assert isinstance(error, SpecNumberLockError)
|
||||
|
||||
def test_raise_spec_number_lock_error(self):
|
||||
"""SpecNumberLockError can be raised and caught."""
|
||||
with pytest.raises(SpecNumberLockError) as exc_info:
|
||||
raise SpecNumberLockError("Lock timeout")
|
||||
assert str(exc_info.value) == "Lock timeout"
|
||||
|
||||
|
||||
class TestSpecNumberLock:
|
||||
"""Tests for SpecNumberLock context manager."""
|
||||
|
||||
def test_spec_number_lock_initialization(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock initializes with correct paths."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
assert lock.project_dir == temp_git_repo
|
||||
assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks"
|
||||
assert lock.lock_file == lock.lock_dir / "spec-numbering.lock"
|
||||
assert lock.acquired is False
|
||||
assert lock._global_max is None
|
||||
|
||||
def test_spec_number_lock_acquire_and_release(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock can be acquired and released."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
assert lock.acquired is True
|
||||
assert lock.lock_file.exists()
|
||||
|
||||
# After context, lock should be released
|
||||
assert lock.lock_file.exists() is False
|
||||
|
||||
def test_spec_number_lock_creates_lock_dir(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock creates lock directory if it doesn't exist."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
# Remove lock dir if it exists
|
||||
if lock.lock_dir.exists():
|
||||
lock.lock_dir.rmdir()
|
||||
|
||||
with lock:
|
||||
assert lock.lock_dir.exists()
|
||||
|
||||
def test_spec_number_lock_writes_pid(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock writes current PID to lock file."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
pid_content = lock.lock_file.read_text(encoding="utf-8").strip()
|
||||
assert pid_content == str(os.getpid())
|
||||
|
||||
def test_get_next_spec_number_no_existing_specs(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number returns 1 when no specs exist."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
next_num = lock.get_next_spec_number()
|
||||
assert next_num == 1
|
||||
|
||||
def test_get_next_spec_number_with_existing_specs(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number returns max existing spec number + 1."""
|
||||
# Create spec directories
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "001-first").mkdir()
|
||||
(specs_dir / "003-third").mkdir()
|
||||
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
next_num = lock.get_next_spec_number()
|
||||
assert next_num == 4
|
||||
|
||||
def test_get_next_spec_number_caches_result(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number caches the global max."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "005-test").mkdir()
|
||||
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
next_num1 = lock.get_next_spec_number()
|
||||
next_num2 = lock.get_next_spec_number()
|
||||
|
||||
# Should return the same value (cached)
|
||||
assert next_num1 == next_num2 == 6
|
||||
assert lock._global_max == 5
|
||||
|
||||
def test_get_next_spec_number_requires_lock(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number raises SpecNumberLockError if lock not acquired."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with pytest.raises(SpecNumberLockError) as exc_info:
|
||||
lock.get_next_spec_number()
|
||||
|
||||
assert "Lock must be acquired" in str(exc_info.value)
|
||||
|
||||
def test_get_next_spec_number_scans_worktrees(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number scans all worktree spec directories."""
|
||||
# Create main project specs
|
||||
main_specs = temp_git_repo / ".auto-claude" / "specs"
|
||||
main_specs.mkdir(parents=True)
|
||||
(main_specs / "002-main").mkdir()
|
||||
|
||||
# Create worktree with specs
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_spec_dir = worktrees_dir / "test-worktree" / ".auto-claude" / "specs"
|
||||
worktree_spec_dir.mkdir(parents=True)
|
||||
(worktree_spec_dir / "005-worktree").mkdir()
|
||||
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
next_num = lock.get_next_spec_number()
|
||||
# Should find max of 2 and 5, return 6
|
||||
assert next_num == 6
|
||||
|
||||
def test_scan_specs_dir_nonexistent(self, temp_git_repo: Path):
|
||||
"""_scan_specs_dir returns 0 for nonexistent directory."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
# Use a path inside temp_dir that doesn't exist
|
||||
nonexistent = temp_git_repo / "this_does_not_exist_specs"
|
||||
result = lock._scan_specs_dir(nonexistent)
|
||||
assert result == 0
|
||||
|
||||
def test_scan_specs_dir_ignores_invalid_names(self, temp_git_repo: Path):
|
||||
"""_scan_specs_dir ignores directories with invalid spec names."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "001-valid").mkdir()
|
||||
(specs_dir / "invalid-name").mkdir()
|
||||
(specs_dir / "abc").mkdir()
|
||||
(specs_dir / "100-valid").mkdir()
|
||||
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
result = lock._scan_specs_dir(specs_dir)
|
||||
# Should only count 001 and 100
|
||||
assert result == 100
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_spec_number_lock_timeout_on_contention(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock raises SpecNumberLockError when lock is held."""
|
||||
lock1 = SpecNumberLock(temp_git_repo)
|
||||
|
||||
# Acquire first lock
|
||||
lock1.__enter__()
|
||||
|
||||
try:
|
||||
# Create a second lock
|
||||
lock2 = SpecNumberLock(temp_git_repo)
|
||||
|
||||
# This should timeout because lock1 holds the lock
|
||||
with pytest.raises(SpecNumberLockError) as exc_info:
|
||||
lock2.__enter__()
|
||||
|
||||
assert "Could not acquire spec numbering lock" in str(exc_info.value)
|
||||
assert "after 30s" in str(exc_info.value)
|
||||
finally:
|
||||
lock1.__exit__(None, None, None)
|
||||
|
||||
def test_spec_number_lock_removes_stale_lock(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock removes stale lock from dead process."""
|
||||
lock1 = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock1:
|
||||
# Write a fake PID that doesn't exist
|
||||
fake_pid = 999999
|
||||
lock1.lock_file.write_text(str(fake_pid), encoding="utf-8")
|
||||
|
||||
# Create a new lock - it should remove the stale lock
|
||||
lock2 = SpecNumberLock(temp_git_repo)
|
||||
with lock2:
|
||||
assert lock2.acquired is True
|
||||
|
||||
def test_spec_number_lock_handles_invalid_pid(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock handles invalid PID in lock file."""
|
||||
lock1 = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock1:
|
||||
# Write invalid content to lock file
|
||||
lock1.lock_file.write_text("invalid-pid", encoding="utf-8")
|
||||
|
||||
# Create a new lock - it should remove the invalid lock
|
||||
lock2 = SpecNumberLock(temp_git_repo)
|
||||
with lock2:
|
||||
assert lock2.acquired is True
|
||||
|
||||
def test_spec_number_lock_cleanup_on_exception(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock releases lock even if exception occurs in context."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
try:
|
||||
with lock:
|
||||
assert lock.acquired is True
|
||||
raise ValueError("Test exception")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Lock should be released despite exception
|
||||
assert lock.lock_file.exists() is False
|
||||
|
||||
def test_spec_number_lock_idempotent_release(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock __exit__ can be called multiple times safely."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
pass
|
||||
|
||||
# Call __exit__ again - should not raise
|
||||
lock.__exit__(None, None, None)
|
||||
lock.__exit__(None, None, None)
|
||||
|
||||
def test_spec_number_lock_returns_self(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock __enter__ returns self."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock as entered_lock:
|
||||
assert entered_lock is lock
|
||||
|
||||
def test_merge_success_returns_true(self, temp_git_repo: Path):
|
||||
"""Successful merge returns True (ACS-163 verification)."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
# Create a worktree with non-conflicting changes
|
||||
worker_info = manager.create_worktree("worker-spec")
|
||||
(worker_info.path / "worker-file.txt").write_text(
|
||||
"worker content", encoding="utf-8"
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Worker commit"],
|
||||
cwd=worker_info.path,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Merge should succeed
|
||||
result = manager.merge_worktree("worker-spec", delete_after=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the file was merged into base branch
|
||||
subprocess.run(
|
||||
["git", "checkout", manager.base_branch],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
assert (temp_git_repo / "worker-file.txt").exists(), (
|
||||
"Merged file should exist in base branch"
|
||||
)
|
||||
merged_content = (temp_git_repo / "worker-file.txt").read_text(encoding="utf-8")
|
||||
assert merged_content == "worker content", (
|
||||
"Merged file should have worktree content"
|
||||
)
|
||||
@@ -1,542 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Selection and Management
|
||||
=============================================
|
||||
Tests for Workspace Rebase Operations
|
||||
======================================
|
||||
|
||||
Tests the workspace.py module functionality including:
|
||||
- Workspace mode selection (isolated vs direct)
|
||||
- Uncommitted changes detection
|
||||
- Workspace setup
|
||||
- Build finalization workflows
|
||||
Tests the rebase functionality including:
|
||||
- Rebase detection (_check_git_conflicts)
|
||||
- Spec branch rebase operations
|
||||
- Rebase integration tests
|
||||
- Rebase error handling
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from workspace import (
|
||||
WorkspaceChoice,
|
||||
WorkspaceMode,
|
||||
get_current_branch,
|
||||
get_existing_build_worktree,
|
||||
has_uncommitted_changes,
|
||||
setup_workspace,
|
||||
)
|
||||
from worktree import WorktreeManager
|
||||
|
||||
# Mark all tests in this file as slow (heavy git operations)
|
||||
pytestmark = pytest.mark.slow
|
||||
from worktree import WorktreeError, WorktreeManager
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
|
||||
class TestWorkspaceMode:
|
||||
"""Tests for WorkspaceMode enum."""
|
||||
|
||||
def test_isolated_mode(self):
|
||||
"""ISOLATED mode value is correct."""
|
||||
assert WorkspaceMode.ISOLATED.value == "isolated"
|
||||
|
||||
def test_direct_mode(self):
|
||||
"""DIRECT mode value is correct."""
|
||||
assert WorkspaceMode.DIRECT.value == "direct"
|
||||
|
||||
|
||||
class TestWorkspaceChoice:
|
||||
"""Tests for WorkspaceChoice enum."""
|
||||
|
||||
def test_merge_choice(self):
|
||||
"""MERGE choice value is correct."""
|
||||
assert WorkspaceChoice.MERGE.value == "merge"
|
||||
|
||||
def test_review_choice(self):
|
||||
"""REVIEW choice value is correct."""
|
||||
assert WorkspaceChoice.REVIEW.value == "review"
|
||||
|
||||
def test_test_choice(self):
|
||||
"""TEST choice value is correct."""
|
||||
assert WorkspaceChoice.TEST.value == "test"
|
||||
|
||||
def test_later_choice(self):
|
||||
"""LATER choice value is correct."""
|
||||
assert WorkspaceChoice.LATER.value == "later"
|
||||
|
||||
|
||||
class TestHasUncommittedChanges:
|
||||
"""Tests for uncommitted changes detection."""
|
||||
|
||||
def test_clean_repo_no_changes(self, temp_git_repo: Path):
|
||||
"""Clean repo returns False."""
|
||||
result = has_uncommitted_changes(temp_git_repo)
|
||||
assert result is False
|
||||
|
||||
def test_untracked_file_has_changes(self, temp_git_repo: Path):
|
||||
"""Untracked file counts as changes."""
|
||||
(temp_git_repo / "new_file.txt").write_text("content")
|
||||
|
||||
result = has_uncommitted_changes(temp_git_repo)
|
||||
assert result is True
|
||||
|
||||
def test_modified_file_has_changes(self, temp_git_repo: Path):
|
||||
"""Modified tracked file counts as changes."""
|
||||
(temp_git_repo / "README.md").write_text("modified content")
|
||||
|
||||
result = has_uncommitted_changes(temp_git_repo)
|
||||
assert result is True
|
||||
|
||||
def test_staged_file_has_changes(self, temp_git_repo: Path):
|
||||
"""Staged file counts as changes."""
|
||||
(temp_git_repo / "README.md").write_text("modified")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=temp_git_repo, capture_output=True)
|
||||
|
||||
result = has_uncommitted_changes(temp_git_repo)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestGetCurrentBranch:
|
||||
"""Tests for current branch detection."""
|
||||
|
||||
def test_gets_main_branch(self, temp_git_repo: Path):
|
||||
"""Gets the main/master branch."""
|
||||
branch = get_current_branch(temp_git_repo)
|
||||
|
||||
# Could be main or master depending on git config
|
||||
assert branch in ["main", "master"]
|
||||
|
||||
def test_gets_feature_branch(self, temp_git_repo: Path):
|
||||
"""Gets feature branch name."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "feature/test-branch"],
|
||||
cwd=temp_git_repo, capture_output=True
|
||||
)
|
||||
|
||||
branch = get_current_branch(temp_git_repo)
|
||||
assert branch == "feature/test-branch"
|
||||
|
||||
|
||||
class TestGetExistingBuildWorktree:
|
||||
"""Tests for existing build worktree detection."""
|
||||
|
||||
def test_no_existing_worktree(self, temp_git_repo: Path):
|
||||
"""Returns None when no worktree exists."""
|
||||
result = get_existing_build_worktree(temp_git_repo, "test-spec")
|
||||
assert result is None
|
||||
|
||||
def test_existing_worktree(self, temp_git_repo: Path):
|
||||
"""Returns path when worktree exists."""
|
||||
# Create the worktree directory structure (per-spec architecture)
|
||||
worktree_path = temp_git_repo / ".worktrees" / TEST_SPEC_NAME
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
result = get_existing_build_worktree(temp_git_repo, TEST_SPEC_NAME)
|
||||
assert result == worktree_path
|
||||
|
||||
|
||||
class TestSetupWorkspace:
|
||||
"""Tests for workspace setup."""
|
||||
|
||||
def test_setup_direct_mode(self, temp_git_repo: Path):
|
||||
"""Direct mode returns project dir and no manager."""
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.DIRECT,
|
||||
)
|
||||
|
||||
assert working_dir == temp_git_repo
|
||||
assert manager is None
|
||||
|
||||
def test_setup_isolated_mode(self, temp_git_repo: Path):
|
||||
"""Isolated mode creates worktree and returns manager."""
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
TEST_SPEC_NAME,
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
assert working_dir != temp_git_repo
|
||||
assert manager is not None
|
||||
assert working_dir.exists()
|
||||
# Per-spec architecture: worktree is named after the spec
|
||||
assert working_dir.name == TEST_SPEC_NAME
|
||||
|
||||
def test_setup_isolated_creates_worktrees_dir(self, temp_git_repo: Path):
|
||||
"""Isolated mode creates worktrees directory."""
|
||||
setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
assert (temp_git_repo / ".auto-claude" / "worktrees" / "tasks").exists()
|
||||
|
||||
|
||||
class TestWorkspaceUtilities:
|
||||
"""Tests for workspace utility functions."""
|
||||
|
||||
def test_per_spec_worktree_naming(self, temp_git_repo: Path):
|
||||
"""Per-spec architecture uses spec name for worktree directory."""
|
||||
spec_name = "my-spec-001"
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Worktree should be named after the spec
|
||||
assert working_dir.name == spec_name
|
||||
# New path: .auto-claude/worktrees/tasks/{spec_name}
|
||||
assert working_dir.parent.name == "tasks"
|
||||
|
||||
|
||||
class TestWorkspaceIntegration:
|
||||
"""Integration tests for workspace management."""
|
||||
|
||||
def test_isolated_workflow(self, temp_git_repo: Path):
|
||||
"""Full isolated workflow: setup -> work -> finalize."""
|
||||
# Setup isolated workspace
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Make changes in workspace
|
||||
(working_dir / "feature.py").write_text("# New feature\n")
|
||||
|
||||
# Verify changes are in workspace
|
||||
assert (working_dir / "feature.py").exists()
|
||||
|
||||
# Verify changes are NOT in main project
|
||||
assert not (temp_git_repo / "feature.py").exists()
|
||||
|
||||
def test_direct_workflow(self, temp_git_repo: Path):
|
||||
"""Full direct workflow: setup -> work."""
|
||||
# Setup direct workspace
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.DIRECT,
|
||||
)
|
||||
|
||||
# Working dir is the project dir
|
||||
assert working_dir == temp_git_repo
|
||||
|
||||
# Make changes directly
|
||||
(working_dir / "feature.py").write_text("# New feature\n")
|
||||
|
||||
# Changes are in main project
|
||||
assert (temp_git_repo / "feature.py").exists()
|
||||
|
||||
def test_isolated_merge(self, temp_git_repo: Path):
|
||||
"""Can merge isolated workspace back to main."""
|
||||
# Setup
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Make changes and commit using git directly
|
||||
(working_dir / "feature.py").write_text("# New feature\n")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Add feature"],
|
||||
cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Merge back using merge_worktree
|
||||
result = manager.merge_worktree("test-spec", delete_after=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Check changes are in main
|
||||
subprocess.run(
|
||||
["git", "checkout", manager.base_branch],
|
||||
cwd=temp_git_repo, capture_output=True
|
||||
)
|
||||
assert (temp_git_repo / "feature.py").exists()
|
||||
|
||||
|
||||
class TestWorkspaceCleanup:
|
||||
"""Tests for workspace cleanup."""
|
||||
|
||||
def test_cleanup_after_merge(self, temp_git_repo: Path):
|
||||
"""Workspace is cleaned up after merge with delete_after=True."""
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Commit changes using git directly
|
||||
(working_dir / "test.py").write_text("test")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Test"],
|
||||
cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Merge with cleanup
|
||||
manager.merge_worktree("test-spec", delete_after=True)
|
||||
|
||||
# Workspace should be removed
|
||||
assert not working_dir.exists()
|
||||
|
||||
def test_workspace_preserved_after_merge_no_delete(self, temp_git_repo: Path):
|
||||
"""Workspace preserved after merge with delete_after=False."""
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Commit changes using git directly
|
||||
(working_dir / "test.py").write_text("test")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Test"],
|
||||
cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Merge without cleanup
|
||||
manager.merge_worktree("test-spec", delete_after=False)
|
||||
|
||||
# Workspace should still exist
|
||||
assert working_dir.exists()
|
||||
|
||||
|
||||
class TestWorkspaceReuse:
|
||||
"""Tests for reusing existing workspaces."""
|
||||
|
||||
def test_reuse_existing_workspace(self, temp_git_repo: Path):
|
||||
"""Can reuse existing workspace on second setup."""
|
||||
# First setup
|
||||
working_dir1, manager1, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Add a marker file
|
||||
(working_dir1 / "marker.txt").write_text("marker")
|
||||
|
||||
# Second setup (should reuse)
|
||||
working_dir2, manager2, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Should be the same directory
|
||||
assert working_dir1 == working_dir2
|
||||
|
||||
# Marker should still exist
|
||||
assert (working_dir2 / "marker.txt").exists()
|
||||
|
||||
|
||||
class TestWorkspaceErrors:
|
||||
"""Tests for workspace error handling."""
|
||||
|
||||
def test_setup_non_git_directory(self, temp_dir: Path):
|
||||
"""Handles non-git directories gracefully."""
|
||||
with pytest.raises(Exception):
|
||||
# This should fail because temp_dir is not a git repo
|
||||
setup_workspace(
|
||||
temp_dir,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
|
||||
class TestPerSpecWorktreeName:
|
||||
"""Tests for per-spec worktree naming (new architecture)."""
|
||||
|
||||
def test_worktree_named_after_spec(self, temp_git_repo: Path):
|
||||
"""Worktree is named after the spec."""
|
||||
spec_name = "spec-1"
|
||||
working_dir, _, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Per-spec architecture: worktree directory matches spec name
|
||||
assert working_dir.name == spec_name
|
||||
|
||||
def test_different_specs_get_different_worktrees(self, temp_git_repo: Path):
|
||||
"""Different specs create separate worktrees."""
|
||||
working_dir1, _, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"spec-1",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
working_dir2, _, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"spec-2",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Each spec has its own worktree
|
||||
assert working_dir1.name == "spec-1"
|
||||
assert working_dir2.name == "spec-2"
|
||||
assert working_dir1 != working_dir2
|
||||
|
||||
def test_worktree_path_in_worktrees_dir(self, temp_git_repo: Path):
|
||||
"""Worktree is created in worktrees directory."""
|
||||
working_dir, _, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# New path: .auto-claude/worktrees/tasks/{spec_name}
|
||||
assert "worktrees" in str(working_dir)
|
||||
assert working_dir.parent.name == "tasks"
|
||||
|
||||
|
||||
class TestConflictInfoDisplay:
|
||||
"""Tests for conflict info display function (ACS-179)."""
|
||||
|
||||
def test_print_conflict_info_with_string_list(self, capsys):
|
||||
"""print_conflict_info handles string list of file paths (ACS-179)."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": ["file1.txt", "file2.py", "file3.js"]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "3 file" in captured.out
|
||||
assert "file1.txt" in captured.out
|
||||
assert "file2.py" in captured.out
|
||||
assert "file3.js" in captured.out
|
||||
assert "git add" in captured.out
|
||||
|
||||
def test_print_conflict_info_with_dict_list(self, capsys):
|
||||
"""print_conflict_info handles dict list with file/reason/severity (ACS-179)."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "file1.txt", "reason": "Syntax error", "severity": "high"},
|
||||
{"file": "file2.py", "reason": "Merge conflict", "severity": "medium"},
|
||||
{"file": "file3.js", "reason": "Unknown error", "severity": "low"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "3 file" in captured.out
|
||||
assert "file1.txt" in captured.out
|
||||
assert "file2.py" in captured.out
|
||||
assert "file3.js" in captured.out
|
||||
assert "Syntax error" in captured.out
|
||||
assert "Merge conflict" in captured.out
|
||||
# Verify severity emoji indicators
|
||||
assert "🔴" in captured.out # High severity
|
||||
assert "🟡" in captured.out # Medium severity
|
||||
|
||||
def test_print_conflict_info_mixed_formats(self, capsys):
|
||||
"""print_conflict_info handles mixed string and dict conflicts (ACS-179)."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
"simple-file.txt",
|
||||
{"file": "complex-file.py", "reason": "AI merge failed", "severity": "high"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "2 file" in captured.out
|
||||
assert "simple-file.txt" in captured.out
|
||||
assert "complex-file.py" in captured.out
|
||||
assert "AI merge failed" in captured.out
|
||||
|
||||
|
||||
class TestMergeErrorHandling:
|
||||
"""Tests for merge error handling (ACS-163)."""
|
||||
|
||||
def test_merge_failure_returns_false_immediately(self, temp_git_repo: Path):
|
||||
"""Failed merge returns False without falling through (ACS-163)."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
# Create a worktree with changes
|
||||
worker_info = manager.create_worktree("worker-spec")
|
||||
(worker_info.path / "worker-file.txt").write_text("worker content")
|
||||
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Worker commit"],
|
||||
cwd=worker_info.path, capture_output=True
|
||||
)
|
||||
|
||||
# Create a conflicting change on main
|
||||
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
|
||||
(temp_git_repo / "worker-file.txt").write_text("main content")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main commit"],
|
||||
cwd=temp_git_repo, capture_output=True
|
||||
)
|
||||
|
||||
# Merge should fail (conflict) and return False
|
||||
# This tests the fix for ACS-163 where failed merge would fall through
|
||||
result = manager.merge_worktree("worker-spec", delete_after=False)
|
||||
|
||||
# Should return False on merge conflict
|
||||
assert result is False
|
||||
|
||||
# Verify side effects: base branch content is unchanged
|
||||
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
|
||||
base_content = (temp_git_repo / "worker-file.txt").read_text()
|
||||
assert base_content == "main content", "Base branch should be unchanged after failed merge"
|
||||
|
||||
# Verify worktree still exists (delete_after=False)
|
||||
assert worker_info.path.exists(), "Worktree should still exist after failed merge"
|
||||
|
||||
# Verify worktree content is unchanged
|
||||
worktree_content = (worker_info.path / "worker-file.txt").read_text()
|
||||
assert worktree_content == "worker content", "Worktree content should be unchanged"
|
||||
|
||||
def test_merge_success_returns_true(self, temp_git_repo: Path):
|
||||
"""Successful merge returns True (ACS-163 verification)."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
# Create a worktree with non-conflicting changes
|
||||
worker_info = manager.create_worktree("worker-spec")
|
||||
(worker_info.path / "worker-file.txt").write_text("worker content")
|
||||
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Worker commit"],
|
||||
cwd=worker_info.path, capture_output=True
|
||||
)
|
||||
|
||||
# Merge should succeed
|
||||
result = manager.merge_worktree("worker-spec", delete_after=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the file was merged into base branch
|
||||
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
|
||||
assert (temp_git_repo / "worker-file.txt").exists(), "Merged file should exist in base branch"
|
||||
merged_content = (temp_git_repo / "worker-file.txt").read_text()
|
||||
assert merged_content == "worker content", "Merged file should have worktree content"
|
||||
|
||||
|
||||
class TestRebaseDetection:
|
||||
"""Tests for automatic rebase detection (ACS-224)."""
|
||||
|
||||
def test_check_git_conflicts_detects_branch_behind(self, temp_git_repo: Path):
|
||||
"""_check_git_conflicts detects when spec branch is behind base branch (ACS-224)."""
|
||||
from core.workspace import _check_git_conflicts
|
||||
@@ -550,7 +39,7 @@ class TestRebaseDetection:
|
||||
)
|
||||
|
||||
# Add a commit to spec branch
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -564,7 +53,7 @@ class TestRebaseDetection:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "main-file.txt").write_text("main content")
|
||||
(temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main commit after spec"],
|
||||
@@ -577,7 +66,9 @@ class TestRebaseDetection:
|
||||
|
||||
assert result is not None
|
||||
assert result.get("needs_rebase") is True, "Should detect branch is behind"
|
||||
assert result.get("commits_behind") == 1, "Should count commits behind correctly"
|
||||
assert result.get("commits_behind") == 1, (
|
||||
"Should count commits behind correctly"
|
||||
)
|
||||
assert result.get("spec_branch") == spec_branch
|
||||
|
||||
def test_check_git_conflicts_no_commits_behind(self, temp_git_repo: Path):
|
||||
@@ -591,7 +82,7 @@ class TestRebaseDetection:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -627,7 +118,7 @@ class TestRebaseDetection:
|
||||
)
|
||||
|
||||
# Add a commit to spec branch
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -642,7 +133,9 @@ class TestRebaseDetection:
|
||||
capture_output=True,
|
||||
)
|
||||
for i in range(3):
|
||||
(temp_git_repo / f"main-file-{i}.txt").write_text(f"main content {i}")
|
||||
(temp_git_repo / f"main-file-{i}.txt").write_text(
|
||||
f"main content {i}", encoding="utf-8"
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"Main commit {i}"],
|
||||
@@ -674,7 +167,7 @@ class TestRebaseSpecBranch:
|
||||
)
|
||||
|
||||
# Add a commit to spec branch
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -688,7 +181,7 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "main-file.txt").write_text("main content")
|
||||
(temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main commit"],
|
||||
@@ -702,8 +195,6 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).stdout.strip()
|
||||
|
||||
# Rebase the spec branch
|
||||
@@ -717,8 +208,6 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).stdout.strip()
|
||||
|
||||
# Commits should be different (rebase changed the commit hash)
|
||||
@@ -730,12 +219,12 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).stdout
|
||||
assert "Main commit" in log, "Spec branch should have main commit after rebase"
|
||||
|
||||
def test_rebase_spec_branch_with_conflicts_aborts_cleanly(self, temp_git_repo: Path):
|
||||
def test_rebase_spec_branch_with_conflicts_aborts_cleanly(
|
||||
self, temp_git_repo: Path
|
||||
):
|
||||
"""_rebase_spec_branch handles conflicts by aborting and returning False (ACS-224)."""
|
||||
from core.workspace import _rebase_spec_branch
|
||||
|
||||
@@ -748,7 +237,7 @@ class TestRebaseSpecBranch:
|
||||
)
|
||||
|
||||
# Create a file that will conflict
|
||||
(temp_git_repo / "conflict.txt").write_text("spec version")
|
||||
(temp_git_repo / "conflict.txt").write_text("spec version", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec conflict"],
|
||||
@@ -762,7 +251,7 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "conflict.txt").write_text("main version")
|
||||
(temp_git_repo / "conflict.txt").write_text("main version", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main conflict"],
|
||||
@@ -803,8 +292,6 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
assert current_branch.stdout.strip() == "main", "Should still be on main branch"
|
||||
|
||||
@@ -820,8 +307,6 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
assert status_result.stdout.strip() == "", "Git status should be clean"
|
||||
|
||||
@@ -836,7 +321,7 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -855,7 +340,9 @@ class TestRebaseSpecBranch:
|
||||
# (branch already up-to-date is a success condition)
|
||||
result = _rebase_spec_branch(temp_git_repo, "test-spec", "main")
|
||||
|
||||
assert result is True, "Rebase should return True when branch is already up-to-date"
|
||||
assert result is True, (
|
||||
"Rebase should return True when branch is already up-to-date"
|
||||
)
|
||||
|
||||
|
||||
class TestRebaseIntegration:
|
||||
@@ -872,7 +359,9 @@ class TestRebaseIntegration:
|
||||
worker_info = manager.create_worktree("test-spec")
|
||||
|
||||
# Add a file in spec worktree and commit
|
||||
(worker_info.path / "spec-file.txt").write_text("spec content")
|
||||
(worker_info.path / "spec-file.txt").write_text(
|
||||
"spec content", encoding="utf-8"
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -887,7 +376,7 @@ class TestRebaseIntegration:
|
||||
capture_output=True,
|
||||
)
|
||||
for i in range(2):
|
||||
(temp_git_repo / f"main-{i}.txt").write_text(f"main {i}")
|
||||
(temp_git_repo / f"main-{i}.txt").write_text(f"main {i}", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"Main {i}"],
|
||||
@@ -919,7 +408,7 @@ class TestRebaseIntegration:
|
||||
)
|
||||
|
||||
# Add a commit to spec
|
||||
(temp_git_repo / "spec.txt").write_text("spec")
|
||||
(temp_git_repo / "spec.txt").write_text("spec", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec"],
|
||||
@@ -933,7 +422,7 @@ class TestRebaseIntegration:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "main.txt").write_text("main")
|
||||
(temp_git_repo / "main.txt").write_text("main", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main"],
|
||||
@@ -979,7 +468,7 @@ class TestRebaseErrorHandling:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -993,8 +482,6 @@ class TestRebaseErrorHandling:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
commit_hash = commit_result.stdout.strip()
|
||||
subprocess.run(
|
||||
@@ -1022,7 +509,6 @@ class TestRebaseErrorHandling:
|
||||
|
||||
def test_check_git_conflicts_handles_corrupted_repo(self, temp_git_repo: Path):
|
||||
"""_check_git_conflicts handles corrupted repo metadata gracefully (ACS-224)."""
|
||||
import shutil
|
||||
|
||||
from core.workspace import _check_git_conflicts
|
||||
|
||||
@@ -1033,7 +519,7 @@ class TestRebaseErrorHandling:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Setup Operations
|
||||
=====================================
|
||||
|
||||
Tests the setup functionality including:
|
||||
- Spec copy to workspace operations
|
||||
- Timeline hook installation
|
||||
- Timeline tracking initialization
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
|
||||
class TestCopySpecToWorktree:
|
||||
"""Tests for copy_spec_to_worktree function."""
|
||||
|
||||
def test_copies_spec_files_to_worktree(self, temp_git_repo: Path):
|
||||
"""Copies spec directory to worktree .auto-claude/specs/ location."""
|
||||
from core.workspace.setup import copy_spec_to_worktree
|
||||
|
||||
# Create source spec directory
|
||||
source_spec = temp_git_repo / "specs" / "test-spec"
|
||||
source_spec.mkdir(parents=True)
|
||||
(source_spec / "spec.md").write_text("# Test Spec", encoding="utf-8")
|
||||
(source_spec / "requirements.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Copy spec
|
||||
result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
|
||||
|
||||
# Verify path is correct
|
||||
expected = worktree_path / ".auto-claude" / "specs" / "test-spec"
|
||||
assert result == expected
|
||||
|
||||
# Verify files were copied
|
||||
assert (expected / "spec.md").exists()
|
||||
assert (expected / "requirements.json").exists()
|
||||
assert (expected / "spec.md").read_text(encoding="utf-8") == "# Test Spec"
|
||||
|
||||
def test_overwrites_existing_spec_in_worktree(self, temp_git_repo: Path):
|
||||
"""Overwrites spec files if they already exist in worktree."""
|
||||
from core.workspace.setup import copy_spec_to_worktree
|
||||
|
||||
# Create source spec
|
||||
source_spec = temp_git_repo / "specs" / "test-spec"
|
||||
source_spec.mkdir(parents=True)
|
||||
(source_spec / "spec.md").write_text("# New Spec", encoding="utf-8")
|
||||
|
||||
# Create worktree with existing spec
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
existing_spec = worktree_path / ".auto-claude" / "specs" / "test-spec"
|
||||
existing_spec.mkdir(parents=True)
|
||||
(existing_spec / "spec.md").write_text("# Old Spec", encoding="utf-8")
|
||||
|
||||
# Copy spec
|
||||
result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
|
||||
|
||||
# Verify new content was copied
|
||||
assert (result / "spec.md").read_text(encoding="utf-8") == "# New Spec"
|
||||
|
||||
def test_creates_parent_directories(self, temp_git_repo: Path):
|
||||
"""Creates .auto-claude/specs directory if it doesn't exist."""
|
||||
from core.workspace.setup import copy_spec_to_worktree
|
||||
|
||||
source_spec = temp_git_repo / "specs" / "test-spec"
|
||||
source_spec.mkdir(parents=True)
|
||||
(source_spec / "spec.md").write_text("# Test", encoding="utf-8")
|
||||
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
|
||||
|
||||
# Parent directories should be created
|
||||
assert result.exists()
|
||||
assert (result.parent).exists()
|
||||
|
||||
|
||||
class TestEnsureTimelineHookInstalled:
|
||||
"""Tests for ensure_timeline_hook_installed function."""
|
||||
|
||||
def test_skips_if_not_git_repo(self, temp_dir: Path):
|
||||
"""Skips hook installation if directory is not a git repo."""
|
||||
from core.workspace.setup import ensure_timeline_hook_installed
|
||||
|
||||
# Should not raise exception
|
||||
ensure_timeline_hook_installed(temp_dir)
|
||||
|
||||
def test_skips_if_hook_already_installed(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Skips if FileTimelineTracker hook is already installed."""
|
||||
from core.workspace.setup import ensure_timeline_hook_installed
|
||||
|
||||
# Create hooks directory
|
||||
hooks_dir = temp_git_repo / ".git" / "hooks"
|
||||
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create hook with FileTimelineTracker marker
|
||||
hook_file = hooks_dir / "post-commit"
|
||||
hook_file.write_text(
|
||||
"#!/bin/sh\n# FileTimelineTracker hook\necho 'tracked'", encoding="utf-8"
|
||||
)
|
||||
|
||||
# Mock install_hook to track if it was called
|
||||
install_called = []
|
||||
|
||||
def mock_install_hook(project_dir):
|
||||
install_called.append(True)
|
||||
|
||||
monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook)
|
||||
|
||||
ensure_timeline_hook_installed(temp_git_repo)
|
||||
|
||||
# install_hook should not be called
|
||||
assert len(install_called) == 0
|
||||
|
||||
def test_installs_hook_if_missing(self, temp_git_repo: Path):
|
||||
"""Installs hook if it doesn't exist."""
|
||||
from core.workspace.setup import ensure_timeline_hook_installed
|
||||
|
||||
# Create hooks directory but no hook file
|
||||
hooks_dir = temp_git_repo / ".git" / "hooks"
|
||||
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# This test verifies the function runs without error
|
||||
# The actual install_hook call is hard to mock because it's imported locally
|
||||
# In production, the real install_hook would be called
|
||||
ensure_timeline_hook_installed(temp_git_repo)
|
||||
|
||||
# Verify hooks directory exists (function ran)
|
||||
assert hooks_dir.exists()
|
||||
|
||||
|
||||
class TestInitializeTimelineTracking:
|
||||
"""Tests for initialize_timeline_tracking function."""
|
||||
|
||||
def test_with_implementation_plan(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Initializes tracking with files from implementation plan."""
|
||||
from core.workspace.setup import initialize_timeline_tracking
|
||||
|
||||
# Create source spec with implementation plan
|
||||
spec_name = "test-spec"
|
||||
source_spec = temp_git_repo / ".auto-claude" / "specs" / spec_name
|
||||
source_spec.mkdir(parents=True)
|
||||
|
||||
plan = {
|
||||
"title": "Test Feature",
|
||||
"description": "Test description",
|
||||
"phases": [
|
||||
{
|
||||
"subtasks": [
|
||||
{"files": ["app/main.py", "app/utils.py"]},
|
||||
{"files": ["tests/test_main.py"]},
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
(source_spec / "implementation_plan.json").write_text(
|
||||
json.dumps(plan), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock FileTimelineTracker
|
||||
mock_tracker_calls = []
|
||||
|
||||
class MockTracker:
|
||||
def __init__(self, project_dir):
|
||||
pass
|
||||
|
||||
def on_task_start(
|
||||
self,
|
||||
task_id,
|
||||
files_to_modify,
|
||||
branch_point_commit,
|
||||
task_intent,
|
||||
task_title,
|
||||
):
|
||||
mock_tracker_calls.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"files": files_to_modify,
|
||||
"branch": branch_point_commit,
|
||||
"intent": task_intent,
|
||||
"title": task_title,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker)
|
||||
|
||||
initialize_timeline_tracking(
|
||||
temp_git_repo, spec_name, worktree_path, source_spec
|
||||
)
|
||||
|
||||
# Verify tracker was called with correct parameters
|
||||
assert len(mock_tracker_calls) == 1
|
||||
call = mock_tracker_calls[0]
|
||||
assert call["task_id"] == spec_name
|
||||
assert set(call["files"]) == {
|
||||
"app/main.py",
|
||||
"app/utils.py",
|
||||
"tests/test_main.py",
|
||||
}
|
||||
assert call["title"] == "Test Feature"
|
||||
assert call["intent"] == "Test description"
|
||||
|
||||
def test_without_implementation_plan(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Initializes tracking retroactively from worktree if no plan."""
|
||||
from core.workspace.setup import initialize_timeline_tracking
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock FileTimelineTracker
|
||||
mock_calls = []
|
||||
|
||||
class MockTracker:
|
||||
def __init__(self, project_dir):
|
||||
pass
|
||||
|
||||
def initialize_from_worktree(
|
||||
self, task_id, worktree_path, task_intent, task_title
|
||||
):
|
||||
mock_calls.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"worktree": worktree_path,
|
||||
"intent": task_intent,
|
||||
"title": task_title,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker)
|
||||
|
||||
initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None)
|
||||
|
||||
# Should use retroactive initialization
|
||||
assert len(mock_calls) == 1
|
||||
assert mock_calls[0]["task_id"] == spec_name
|
||||
|
||||
def test_handles_exception_gracefully(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Logs warning but doesn't raise exception on error."""
|
||||
from core.workspace.setup import initialize_timeline_tracking
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock FileTimelineTracker to raise exception
|
||||
class FailingTracker:
|
||||
def __init__(self, project_dir):
|
||||
raise Exception("Tracker init failed")
|
||||
|
||||
monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", FailingTracker)
|
||||
|
||||
# Should not raise
|
||||
initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None)
|
||||
|
||||
# Should print warning
|
||||
captured = capsys.readouterr()
|
||||
assert "Timeline tracking" in captured.out or "Note:" in captured.out
|
||||
File diff suppressed because it is too large
Load Diff
@@ -153,7 +153,7 @@ class PushAndCreatePRResult(TypedDict, total=False):
|
||||
class WorktreeError(Exception):
|
||||
"""Error during worktree operations."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -429,8 +429,9 @@ class WorktreeManager:
|
||||
if current_path.exists() and resolved_path.exists():
|
||||
if os.path.samefile(resolved_path, current_path):
|
||||
return line[len("branch refs/heads/") :]
|
||||
except OSError: # Files may not exist or be inaccessible; fall through to case comparison
|
||||
pass # no-op: fall through to case comparison
|
||||
except OSError:
|
||||
# File system comparison errors are handled by fallback below
|
||||
pass
|
||||
# Fallback to normalized case comparison
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
str(current_path)
|
||||
@@ -509,8 +510,9 @@ class WorktreeManager:
|
||||
if resolved_path.exists() and registered_path.exists():
|
||||
if os.path.samefile(resolved_path, registered_path):
|
||||
return True
|
||||
except OSError: # Files may not exist or be inaccessible; fall through to case comparison
|
||||
pass # no-op: fall through to case comparison
|
||||
except OSError:
|
||||
# File system errors handled by fallback comparison below
|
||||
pass
|
||||
# Fallback to normalized case comparison for non-existent paths
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
str(registered_path)
|
||||
@@ -583,11 +585,9 @@ class WorktreeManager:
|
||||
stats["days_since_last_commit"] = (
|
||||
datetime.now() - last_commit_date
|
||||
).days
|
||||
except (
|
||||
ValueError,
|
||||
TypeError,
|
||||
): # If parsing fails, silently continue without date info
|
||||
pass # no-op
|
||||
except (ValueError, TypeError) as e:
|
||||
# If parsing fails, silently continue without date info
|
||||
pass
|
||||
|
||||
# Diff stats
|
||||
result = self._run_git(
|
||||
@@ -1211,6 +1211,9 @@ class WorktreeManager:
|
||||
)
|
||||
|
||||
target = target_branch or self.base_branch
|
||||
# Strip remote prefix (e.g., "origin/feat/x" → "feat/x") since gh expects branch names only
|
||||
if target.startswith("origin/"):
|
||||
target = target[len("origin/") :]
|
||||
pr_title = title or f"auto-claude: {spec_name}"
|
||||
|
||||
# Try AI-powered PR body from project's PR template, fall back to spec summary
|
||||
@@ -1381,6 +1384,9 @@ class WorktreeManager:
|
||||
)
|
||||
|
||||
target = target_branch or self.base_branch
|
||||
# Strip remote prefix (e.g., "origin/feat/x" → "feat/x") since glab expects branch names only
|
||||
if target.startswith("origin/"):
|
||||
target = target[len("origin/") :]
|
||||
mr_title = title or f"auto-claude: {spec_name}"
|
||||
|
||||
# Get MR body from spec.md if available
|
||||
@@ -1796,8 +1802,9 @@ class WorktreeManager:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
return data.get("web_url")
|
||||
except json.JSONDecodeError: # If JSON parsing fails, return None
|
||||
pass # no-op
|
||||
except json.JSONDecodeError:
|
||||
# If JSON parsing fails, return None
|
||||
pass
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
FileNotFoundError,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from spec.critique instead."""
|
||||
|
||||
from spec.critique import * # noqa: F401, F403
|
||||
from spec.critique import * # noqa: F403
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from integrations.graphiti.config instead."""
|
||||
|
||||
from integrations.graphiti.config import * # noqa: F401, F403
|
||||
from integrations.graphiti.config import * # noqa: F403
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from integrations.graphiti.providers_pkg instead."""
|
||||
|
||||
from integrations.graphiti.providers_pkg import * # noqa: F401, F403
|
||||
from integrations.graphiti.providers_pkg import * # noqa: F403
|
||||
|
||||
@@ -53,22 +53,15 @@ class ProjectAnalyzer:
|
||||
try:
|
||||
with open(project_index_path, encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
# Extract tech stack from services (only if index is a dict)
|
||||
if isinstance(index, dict):
|
||||
for service_name, service_info in index.get(
|
||||
"services", {}
|
||||
).items():
|
||||
if service_info.get("language"):
|
||||
context["tech_stack"].append(service_info["language"])
|
||||
if service_info.get("framework"):
|
||||
context["tech_stack"].append(service_info["framework"])
|
||||
context["tech_stack"] = list(set(context["tech_stack"]))
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
# Extract tech stack from services
|
||||
for service_name, service_info in index.get("services", {}).items():
|
||||
if service_info.get("language"):
|
||||
context["tech_stack"].append(service_info["language"])
|
||||
if service_info.get("framework"):
|
||||
context["tech_stack"].append(service_info["framework"])
|
||||
context["tech_stack"] = list(set(context["tech_stack"]))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Get roadmap context if enabled
|
||||
if self.include_roadmap:
|
||||
@@ -85,8 +78,8 @@ class ProjectAnalyzer:
|
||||
# Get target audience
|
||||
audience = roadmap.get("target_audience", {})
|
||||
context["target_audience"] = audience.get("primary")
|
||||
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Also check discovery for audience
|
||||
discovery_path = (
|
||||
@@ -104,8 +97,8 @@ class ProjectAnalyzer:
|
||||
context["existing_features"] = current_state.get(
|
||||
"existing_features", []
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Get kanban context if enabled
|
||||
if self.include_kanban:
|
||||
|
||||
@@ -45,8 +45,8 @@ class IdeationFormatter:
|
||||
print_status(
|
||||
f"Preserving {len(existing_ideas)} existing ideas", "info"
|
||||
)
|
||||
except json.JSONDecodeError: # Invalid JSON; skip
|
||||
pass # no-op: treat as empty file
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Collect new ideas from the enabled types
|
||||
new_ideas = []
|
||||
@@ -61,8 +61,8 @@ class IdeationFormatter:
|
||||
ideas = data.get(ideation_type, [])
|
||||
new_ideas.extend(ideas)
|
||||
output_files.append(str(type_file))
|
||||
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# In append mode, filter out ideas from types we're regenerating
|
||||
# (to avoid duplicates) and keep ideas from other types
|
||||
@@ -141,6 +141,6 @@ class IdeationFormatter:
|
||||
try:
|
||||
with open(context_file, encoding="utf-8") as f:
|
||||
context_data = json.load(f)
|
||||
except json.JSONDecodeError: # Invalid JSON; skip
|
||||
pass # no-op: treat as empty file
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return context_data
|
||||
|
||||
@@ -100,12 +100,19 @@ class IdeationGenerator:
|
||||
# Create client with thinking budget
|
||||
# Use agent_type="ideation" to avoid loading unnecessary MCP servers
|
||||
# which can cause 60-second timeout delays
|
||||
resolved_model = resolve_model_id(self.model)
|
||||
betas = get_model_betas(self.model)
|
||||
thinking_kwargs = get_thinking_kwargs_for_model(
|
||||
resolved_model, self.thinking_level
|
||||
)
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
resolved_model,
|
||||
agent_type="ideation",
|
||||
betas=betas,
|
||||
fast_mode=self.fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -195,12 +202,19 @@ Write the fixed JSON to the file now.
|
||||
"""
|
||||
|
||||
# Use agent_type="ideation" for recovery agent as well
|
||||
resolved_model = resolve_model_id(self.model)
|
||||
betas = get_model_betas(self.model)
|
||||
thinking_kwargs = get_thinking_kwargs_for_model(
|
||||
resolved_model, self.thinking_level
|
||||
)
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
resolved_model,
|
||||
agent_type="ideation",
|
||||
betas=betas,
|
||||
fast_mode=self.fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -302,7 +302,7 @@ Generate up to {self.max_ideas_per_type} {self.generator.get_type_label(ideation
|
||||
Avoid duplicating features that are already planned (see ideation_context.json).
|
||||
Output your ideas to {output_file.name}.
|
||||
"""
|
||||
await self.generator.run_agent(
|
||||
success, output = await self.generator.run_agent(
|
||||
prompt_file,
|
||||
additional_context=context,
|
||||
)
|
||||
|
||||
@@ -207,7 +207,7 @@ class ImplementationPlan:
|
||||
# (spec is complete, waiting for user to approve before coding starts)
|
||||
if self.status == "human_review" and self.planStatus == "review":
|
||||
# Keep the plan approval status - don't reset to backlog
|
||||
pass # no-op
|
||||
pass
|
||||
else:
|
||||
self.status = "backlog"
|
||||
self.planStatus = "pending"
|
||||
|
||||
@@ -3,14 +3,9 @@ Integrations Module
|
||||
===================
|
||||
|
||||
External service integrations for Auto Claude.
|
||||
|
||||
Submodules:
|
||||
- linear: Linear issue tracking integration
|
||||
- graphiti: Graphiti knowledge graph integration
|
||||
"""
|
||||
|
||||
# Submodules are accessible via standard Python import mechanism:
|
||||
# from integrations.linear import ...
|
||||
# from integrations.graphiti import ...
|
||||
|
||||
__all__ = []
|
||||
__all__ = [
|
||||
"linear",
|
||||
"graphiti",
|
||||
]
|
||||
|
||||
@@ -3,23 +3,12 @@ Graphiti Integration
|
||||
====================
|
||||
|
||||
Integration with Graphiti knowledge graph for semantic memory.
|
||||
|
||||
Module-level placeholders (with _ prefix) are defined for CodeQL static
|
||||
analysis. The actual exported names (without _ prefix) trigger __getattr__
|
||||
for lazy loading.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Config imports don't require graphiti package
|
||||
from .config import GraphitiConfig, validate_graphiti_config
|
||||
|
||||
# Module-level placeholders for CodeQL static analysis.
|
||||
# Use list placeholder to satisfy CodeQL's "defined but not set to None" check.
|
||||
GraphitiMemory: Any = []
|
||||
create_llm_client: Any = []
|
||||
create_embedder: Any = []
|
||||
|
||||
# Lazy imports for components that require graphiti package
|
||||
__all__ = [
|
||||
"GraphitiConfig",
|
||||
"validate_graphiti_config",
|
||||
@@ -29,34 +18,18 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
def __getattr__(name):
|
||||
"""Lazy import to avoid requiring graphiti package for config-only imports."""
|
||||
private_map = {
|
||||
"GraphitiMemory": "_GraphitiMemory",
|
||||
"create_llm_client": "_create_llm_client",
|
||||
"create_embedder": "_create_embedder",
|
||||
}
|
||||
|
||||
if name in private_map:
|
||||
private_name = private_map[name]
|
||||
globals()[private_name] = _do_lazy_import(name)
|
||||
return globals()[private_name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def _do_lazy_import(name: str) -> Any:
|
||||
"""Perform the actual lazy import for a given name."""
|
||||
if name == "GraphitiMemory":
|
||||
from .memory import GraphitiMemory
|
||||
|
||||
return GraphitiMemory
|
||||
if name == "create_llm_client":
|
||||
elif name == "create_llm_client":
|
||||
from .providers import create_llm_client
|
||||
|
||||
return create_llm_client
|
||||
if name == "create_embedder":
|
||||
elif name == "create_embedder":
|
||||
from .providers import create_embedder
|
||||
|
||||
return create_embedder
|
||||
raise AssertionError(f"Unknown lazy import name: {name}")
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -210,7 +210,7 @@ class GraphitiConfig:
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api"
|
||||
)
|
||||
openrouter_llm_model = os.environ.get(
|
||||
"OPENROUTER_LLM_MODEL", "anthropic/claude-3.5-sonnet"
|
||||
"OPENROUTER_LLM_MODEL", "anthropic/claude-sonnet-4"
|
||||
)
|
||||
openrouter_embedding_model = os.environ.get(
|
||||
"OPENROUTER_EMBEDDING_MODEL", "openai/text-embedding-3-small"
|
||||
@@ -624,7 +624,10 @@ def get_graphiti_status() -> dict:
|
||||
|
||||
# CRITICAL FIX: Actually verify packages are importable before reporting available
|
||||
# Don't just check config.is_valid() - actually try to import the module
|
||||
if not config.is_valid():
|
||||
# Note: This branch is currently unreachable because is_valid() returns True
|
||||
# whenever enabled is True. Kept for defensive purposes in case is_valid()
|
||||
# logic changes in the future.
|
||||
if not config.is_valid(): # pragma: no cover
|
||||
status["reason"] = errors[0] if errors else "Configuration invalid"
|
||||
return status
|
||||
|
||||
@@ -635,7 +638,7 @@ def get_graphiti_status() -> dict:
|
||||
from graphiti_core.driver.falkordb_driver import FalkorDriver # noqa: F401
|
||||
|
||||
# If we got here, packages are importable
|
||||
status["available"] = True
|
||||
status["available"] = True # pragma: no cover
|
||||
except ImportError as e:
|
||||
status["available"] = False
|
||||
status["reason"] = f"Graphiti packages not installed: {e}"
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Fixtures for graphiti integration tests."""
|
||||
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _check_graphiti_available() -> bool:
|
||||
"""Check if graphiti dependencies are available."""
|
||||
return find_spec("kuzu") is not None or find_spec("real_ladybug") is not None
|
||||
|
||||
|
||||
# Skip all graphiti integration tests if dependencies are not available
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _check_graphiti_available(),
|
||||
reason="graphiti dependencies (kuzu or real_ladybug) not available",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path: Path) -> str:
|
||||
"""Provide a temporary database path for testing.
|
||||
|
||||
Args:
|
||||
tmp_path: pytest's built-in temporary directory fixture
|
||||
|
||||
Returns:
|
||||
str: Path to temporary database directory
|
||||
"""
|
||||
db_dir = tmp_path / "graphiti_db"
|
||||
db_dir.mkdir(exist_ok=True)
|
||||
return str(db_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database() -> str:
|
||||
"""Provide a test database name.
|
||||
|
||||
Returns:
|
||||
str: Test database name
|
||||
"""
|
||||
return "test_db"
|
||||
@@ -22,7 +22,7 @@ see graphiti/graphiti.py.
|
||||
from pathlib import Path
|
||||
|
||||
# Import config utilities
|
||||
from integrations.graphiti.config import (
|
||||
from graphiti_config import (
|
||||
GraphitiConfig,
|
||||
is_graphiti_enabled,
|
||||
)
|
||||
@@ -72,6 +72,8 @@ async def test_graphiti_connection() -> tuple[bool, str]:
|
||||
"""
|
||||
Test if LadybugDB is available and Graphiti can connect.
|
||||
|
||||
Uses the embedded LadybugDB via the patched KuzuDriver (no remote connection).
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
@@ -87,43 +89,48 @@ async def test_graphiti_connection() -> tuple[bool, str]:
|
||||
|
||||
try:
|
||||
from graphiti_core import Graphiti
|
||||
from graphiti_core.driver.falkordb_driver import FalkorDriver
|
||||
from graphiti_providers import ProviderError, create_embedder, create_llm_client
|
||||
|
||||
# Import the patched driver creator (handles LadybugDB monkeypatch internally)
|
||||
from integrations.graphiti.queries_pkg.client import _apply_ladybug_monkeypatch
|
||||
from integrations.graphiti.queries_pkg.kuzu_driver_patched import (
|
||||
create_patched_kuzu_driver,
|
||||
)
|
||||
|
||||
# Create providers
|
||||
try:
|
||||
llm_client = create_llm_client(config)
|
||||
embedder = create_embedder(config)
|
||||
llm_client = create_llm_client(config) # pragma: no cover
|
||||
embedder = create_embedder(config) # pragma: no cover
|
||||
except ProviderError as e:
|
||||
return False, f"Provider error: {e}"
|
||||
|
||||
# Try to connect
|
||||
driver = FalkorDriver(
|
||||
host=config.falkordb_host,
|
||||
port=config.falkordb_port,
|
||||
password=config.falkordb_password or None,
|
||||
database=config.database,
|
||||
)
|
||||
# Apply LadybugDB monkeypatch for embedded database
|
||||
if not _apply_ladybug_monkeypatch(): # pragma: no cover
|
||||
return False, "LadybugDB not installed (requires Python 3.12+)"
|
||||
|
||||
graphiti = Graphiti(
|
||||
# Create embedded database driver
|
||||
db_path = config.get_db_path()
|
||||
driver = create_patched_kuzu_driver(db=str(db_path)) # pragma: no cover
|
||||
|
||||
graphiti = Graphiti( # pragma: no cover
|
||||
graph_driver=driver,
|
||||
llm_client=llm_client,
|
||||
embedder=embedder,
|
||||
)
|
||||
|
||||
# Try a simple operation
|
||||
await graphiti.build_indices_and_constraints()
|
||||
await graphiti.close()
|
||||
await graphiti.build_indices_and_constraints() # pragma: no cover
|
||||
await graphiti.close() # pragma: no cover
|
||||
|
||||
return True, (
|
||||
f"Connected to LadybugDB at {config.falkordb_host}:{config.falkordb_port} "
|
||||
return True, ( # pragma: no cover
|
||||
f"Connected to LadybugDB at {db_path} "
|
||||
f"(providers: {config.get_provider_summary()})"
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
return False, f"Graphiti packages not installed: {e}"
|
||||
|
||||
except Exception as e:
|
||||
except Exception as e: # pragma: no cover
|
||||
return False, f"Connection failed: {e}"
|
||||
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ Exception classes for provider-related errors.
|
||||
class ProviderError(Exception):
|
||||
"""Raised when a provider cannot be initialized."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
class ProviderNotInstalled(ProviderError):
|
||||
"""Raised when required packages for a provider are not installed."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
@@ -35,7 +35,7 @@ def create_openrouter_llm_client(config: "GraphitiConfig") -> Any:
|
||||
>>> from auto_claude.integrations.graphiti.config import GraphitiConfig
|
||||
>>> config = GraphitiConfig(
|
||||
... openrouter_api_key="sk-or-...",
|
||||
... openrouter_llm_model="anthropic/claude-3.5-sonnet"
|
||||
... openrouter_llm_model="anthropic/claude-sonnet-4"
|
||||
... )
|
||||
>>> client = create_openrouter_llm_client(config)
|
||||
"""
|
||||
|
||||
@@ -80,7 +80,7 @@ async def test_llm_connection(config: "GraphitiConfig") -> tuple[bool, str]:
|
||||
from .factory import create_llm_client
|
||||
|
||||
try:
|
||||
_llm_client = create_llm_client(config)
|
||||
llm_client = create_llm_client(config)
|
||||
# Most clients don't have a ping method, so just verify creation succeeded
|
||||
return (
|
||||
True,
|
||||
@@ -112,7 +112,7 @@ async def test_embedder_connection(config: "GraphitiConfig") -> tuple[bool, str]
|
||||
return False, msg
|
||||
|
||||
try:
|
||||
_embedder = create_embedder(config)
|
||||
embedder = create_embedder(config)
|
||||
return (
|
||||
True,
|
||||
f"Embedder created successfully for provider: {config.embedder_provider}",
|
||||
@@ -139,11 +139,6 @@ async def test_ollama_connection(
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Normalize URL first (used in both paths)
|
||||
url = base_url.rstrip("/")
|
||||
if url.endswith("/v1"):
|
||||
url = url[:-3]
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
@@ -152,12 +147,16 @@ async def test_ollama_connection(
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
# Normalize URL (remove /v1 suffix if present)
|
||||
url = base_url.rstrip("/")
|
||||
if url.endswith("/v1"):
|
||||
url = url[:-3]
|
||||
|
||||
req = urllib.request.Request(f"{url}/api/tags", method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
status = response.status
|
||||
if status == 200:
|
||||
if response.status == 200:
|
||||
return True, f"Ollama is running at {url}"
|
||||
return False, f"Ollama returned status {status}"
|
||||
return False, f"Ollama returned status {response.status}"
|
||||
except urllib.error.URLError as e:
|
||||
return False, f"Cannot connect to Ollama at {url}: {e.reason}"
|
||||
except Exception as e:
|
||||
@@ -165,20 +164,21 @@ async def test_ollama_connection(
|
||||
|
||||
# Use aiohttp if available
|
||||
try:
|
||||
# Normalize URL
|
||||
url = base_url.rstrip("/")
|
||||
if url.endswith("/v1"):
|
||||
url = url[:-3]
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.get(
|
||||
f"{url}/api/tags", timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as response:
|
||||
status = response.status
|
||||
if status == 200:
|
||||
return True, f"Ollama is running at {url}"
|
||||
return False, f"Ollama returned status {status}"
|
||||
except asyncio.TimeoutError:
|
||||
return False, f"Ollama connection timed out at {url}"
|
||||
except Exception:
|
||||
# Catch aiohttp.ClientError and any other exceptions from session.get
|
||||
return False, f"Cannot connect to Ollama at {url}"
|
||||
except Exception:
|
||||
# Catch any exceptions from ClientSession creation
|
||||
return False, f"Cannot connect to Ollama at {url}"
|
||||
async with session.get(
|
||||
f"{url}/api/tags", timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return True, f"Ollama is running at {url}"
|
||||
return False, f"Ollama returned status {response.status}"
|
||||
except asyncio.TimeoutError:
|
||||
return False, f"Ollama connection timed out at {url}"
|
||||
except aiohttp.ClientError as e:
|
||||
return False, f"Cannot connect to Ollama at {url}: {e}"
|
||||
except Exception as e:
|
||||
return False, f"Ollama connection error: {e}"
|
||||
|
||||
@@ -25,14 +25,6 @@ from .schema import (
|
||||
GroupIdMode,
|
||||
)
|
||||
|
||||
# Import kuzu_driver_patched for test mocking support
|
||||
# This module is used internally but needs to be accessible for patching
|
||||
try:
|
||||
from . import kuzu_driver_patched # noqa: F401
|
||||
except ImportError:
|
||||
# kuzu_driver_patched may not be available if real_ladybug is not installed
|
||||
kuzu_driver_patched = None # type: ignore
|
||||
|
||||
# Re-export for convenience
|
||||
__all__ = [
|
||||
"GraphitiMemory",
|
||||
|
||||
@@ -5,7 +5,9 @@ Handles database connection, initialization, and lifecycle management.
|
||||
Uses LadybugDB as the embedded graph database (no Docker required, Python 3.12+).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -14,6 +16,27 @@ from graphiti_config import GraphitiConfig, GraphitiState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Retry configuration for LadybugDB lock contention
|
||||
MAX_LOCK_RETRIES = 5
|
||||
INITIAL_BACKOFF_SECONDS = 0.5
|
||||
MAX_BACKOFF_SECONDS = 8.0
|
||||
JITTER_PERCENT = 0.2
|
||||
|
||||
|
||||
def _is_lock_error(error: Exception) -> bool:
|
||||
"""Check if an error indicates database lock contention."""
|
||||
error_msg = str(error).lower()
|
||||
return "could not set lock" in error_msg or (
|
||||
"lock" in error_msg and ("file" in error_msg or "database" in error_msg)
|
||||
)
|
||||
|
||||
|
||||
def _backoff_with_jitter(attempt: int) -> float:
|
||||
"""Calculate exponential backoff with jitter for retry delays."""
|
||||
backoff = min(INITIAL_BACKOFF_SECONDS * (2**attempt), MAX_BACKOFF_SECONDS)
|
||||
jitter = backoff * JITTER_PERCENT * (2 * random.random() - 1)
|
||||
return max(0.01, backoff + jitter)
|
||||
|
||||
|
||||
def _apply_ladybug_monkeypatch() -> bool:
|
||||
"""
|
||||
@@ -196,32 +219,36 @@ class GraphitiClient:
|
||||
)
|
||||
|
||||
db_path = self.config.get_db_path()
|
||||
try:
|
||||
self._driver = create_patched_kuzu_driver(db=str(db_path))
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(
|
||||
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
|
||||
)
|
||||
capture_exception(
|
||||
e,
|
||||
error_type=type(e).__name__,
|
||||
db_path=str(db_path),
|
||||
llm_provider=self.config.llm_provider,
|
||||
embedder_provider=self.config.embedder_provider,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Unexpected error initializing LadybugDB driver at {db_path}: {e}"
|
||||
)
|
||||
capture_exception(
|
||||
e,
|
||||
error_type=type(e).__name__,
|
||||
db_path=str(db_path),
|
||||
llm_provider=self.config.llm_provider,
|
||||
embedder_provider=self.config.embedder_provider,
|
||||
)
|
||||
return False
|
||||
|
||||
# Retry with exponential backoff for lock contention
|
||||
for attempt in range(MAX_LOCK_RETRIES + 1):
|
||||
try:
|
||||
self._driver = create_patched_kuzu_driver(db=str(db_path))
|
||||
if attempt > 0:
|
||||
logger.info(
|
||||
f"LadybugDB lock acquired after {attempt} retries"
|
||||
)
|
||||
break # Success
|
||||
except Exception as e:
|
||||
if _is_lock_error(e) and attempt < MAX_LOCK_RETRIES:
|
||||
wait_time = _backoff_with_jitter(attempt)
|
||||
logger.debug(
|
||||
f"LadybugDB lock contention (attempt {attempt + 1}/{MAX_LOCK_RETRIES}), retrying in {wait_time:.2f}s"
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
logger.warning(
|
||||
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
|
||||
)
|
||||
capture_exception(
|
||||
e,
|
||||
error_type=type(e).__name__,
|
||||
db_path=str(db_path),
|
||||
llm_provider=self.config.llm_provider,
|
||||
embedder_provider=self.config.embedder_provider,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(f"Initialized LadybugDB driver (patched) at: {db_path}")
|
||||
except ImportError as e:
|
||||
logger.warning(f"KuzuDriver not available: {e}")
|
||||
|
||||
@@ -362,7 +362,7 @@ class GraphitiMemory:
|
||||
|
||||
if result and self.state:
|
||||
# Episode count updated in queries module
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
@@ -15,7 +15,10 @@ from typing import Any
|
||||
# Import kuzu (might be real_ladybug via monkeypatch)
|
||||
try:
|
||||
import kuzu
|
||||
except ImportError:
|
||||
except ImportError: # pragma: no cover
|
||||
# Fallback to real_ladybug if kuzu is not available.
|
||||
# This import-time fallback is hard to test in normal unit tests
|
||||
# since the module is imported once before tests can mock anything.
|
||||
import real_ladybug as kuzu # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -117,8 +120,9 @@ def create_patched_kuzu_driver(db: str = ":memory:", max_concurrent_queries: int
|
||||
logger.debug(
|
||||
f"Dropped existing FTS index: {index_name}"
|
||||
)
|
||||
except Exception: # Index might not exist, that's fine
|
||||
pass # no-op
|
||||
except Exception:
|
||||
# Index might not exist, that's fine
|
||||
pass
|
||||
|
||||
# Create the FTS index
|
||||
conn.execute(query)
|
||||
@@ -172,7 +176,4 @@ def create_patched_kuzu_driver(db: str = ":memory:", max_concurrent_queries: int
|
||||
# Run the parent schema setup (creates tables)
|
||||
super().setup_schema()
|
||||
|
||||
# Export the class at module level for tests to import
|
||||
globals()["PatchedKuzuDriver"] = PatchedKuzuDriver
|
||||
|
||||
return PatchedKuzuDriver(db=db, max_concurrent_queries=max_concurrent_queries)
|
||||
|
||||
@@ -67,7 +67,8 @@ class GraphitiSearch:
|
||||
Args:
|
||||
query: Search query
|
||||
num_results: Maximum number of results to return
|
||||
include_project_context: If True and in PROJECT mode, search project-wide
|
||||
include_project_context: If True and in SPEC mode, also search project-wide
|
||||
min_score: Minimum relevance score threshold (0.0 to 1.0)
|
||||
|
||||
Returns:
|
||||
List of relevant context items with content, score, and type
|
||||
@@ -101,10 +102,14 @@ class GraphitiSearch:
|
||||
or str(result)
|
||||
)
|
||||
|
||||
# Normalize score to float, treating None as 0.0
|
||||
raw_score = getattr(result, "score", None)
|
||||
score = raw_score if raw_score is not None else 0.0
|
||||
|
||||
context_items.append(
|
||||
{
|
||||
"content": content,
|
||||
"score": getattr(result, "score", 0.0),
|
||||
"score": score,
|
||||
"type": getattr(result, "type", "unknown"),
|
||||
}
|
||||
)
|
||||
@@ -112,7 +117,9 @@ class GraphitiSearch:
|
||||
# Filter by minimum score if specified
|
||||
if min_score > 0:
|
||||
context_items = [
|
||||
item for item in context_items if item.get("score", 0) >= min_score
|
||||
item
|
||||
for item in context_items
|
||||
if (item.get("score", 0.0)) >= min_score
|
||||
]
|
||||
|
||||
logger.info(
|
||||
@@ -225,12 +232,14 @@ class GraphitiSearch:
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if data.get("type") == EPISODE_TYPE_TASK_OUTCOME:
|
||||
raw_score = getattr(result, "score", None)
|
||||
score = raw_score if raw_score is not None else 0.0
|
||||
outcomes.append(
|
||||
{
|
||||
"task_id": data.get("task_id"),
|
||||
"success": data.get("success"),
|
||||
"outcome": data.get("outcome"),
|
||||
"score": getattr(result, "score", 0.0),
|
||||
"score": score,
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
@@ -284,7 +293,8 @@ class GraphitiSearch:
|
||||
content = getattr(result, "content", None) or getattr(
|
||||
result, "fact", None
|
||||
)
|
||||
score = getattr(result, "score", 0.0)
|
||||
raw_score = getattr(result, "score", None)
|
||||
score = raw_score if raw_score is not None else 0.0
|
||||
|
||||
if score < min_score:
|
||||
continue
|
||||
@@ -320,7 +330,8 @@ class GraphitiSearch:
|
||||
content = getattr(result, "content", None) or getattr(
|
||||
result, "fact", None
|
||||
)
|
||||
score = getattr(result, "score", 0.0)
|
||||
raw_score = getattr(result, "score", None)
|
||||
score = raw_score if raw_score is not None else 0.0
|
||||
|
||||
if score < min_score:
|
||||
continue
|
||||
|
||||
+35
-78
@@ -22,35 +22,29 @@ Usage:
|
||||
|
||||
# Run the test:
|
||||
cd auto-claude
|
||||
python integrations/graphiti/test_graphiti_memory.py
|
||||
python integrations/graphiti/run_graphiti_memory_test.py
|
||||
|
||||
# Or run specific tests:
|
||||
python integrations/graphiti/test_graphiti_memory.py --test connection
|
||||
python integrations/graphiti/test_graphiti_memory.py --test save
|
||||
python integrations/graphiti/test_graphiti_memory.py --test search
|
||||
python integrations/graphiti/test_graphiti_memory.py --test ollama
|
||||
python integrations/graphiti/run_graphiti_memory_test.py --test connection
|
||||
python integrations/graphiti/run_graphiti_memory_test.py --test save
|
||||
python integrations/graphiti/run_graphiti_memory_test.py --test search
|
||||
python integrations/graphiti/run_graphiti_memory_test.py --test ollama
|
||||
"""
|
||||
|
||||
# Exclude this file from pytest collection - this is a standalone test script
|
||||
__test__ = False
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add auto-claude to path
|
||||
auto_claude_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(auto_claude_dir))
|
||||
|
||||
# Load .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = auto_claude_dir / ".env"
|
||||
env_file = Path(__file__).parent.parent.parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
print(f"Loaded .env from {env_file}")
|
||||
@@ -65,8 +59,8 @@ def apply_ladybug_monkeypatch():
|
||||
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
return True
|
||||
except Exception: # Test cleanup, ignore errors
|
||||
pass # no-op
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try native kuzu as fallback
|
||||
try:
|
||||
@@ -122,15 +116,8 @@ async def test_ladybugdb_connection(db_path: str, database: str) -> bool:
|
||||
|
||||
# Test basic query
|
||||
result = conn.execute("RETURN 1 + 1 as test")
|
||||
query_result = result.get_as_df()
|
||||
|
||||
# Handle both list and DataFrame return types from get_as_df()
|
||||
if isinstance(query_result, list):
|
||||
# real-ladybug returns a list of dicts
|
||||
test_value = query_result[0].get("test") if query_result else None
|
||||
else:
|
||||
# pandas DataFrame
|
||||
test_value = query_result["test"].iloc[0] if len(query_result) > 0 else None
|
||||
df = result.get_as_df()
|
||||
test_value = df["test"].iloc[0] if len(df) > 0 else None
|
||||
|
||||
if test_value == 2:
|
||||
print_result("Connection", "SUCCESS - Database responds correctly", True)
|
||||
@@ -180,7 +167,9 @@ async def test_save_episode(db_path: str, database: str) -> tuple[str, str]:
|
||||
"embedder": config.embedder_provider,
|
||||
}
|
||||
|
||||
episode_name = f"test_episode_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
episode_name = (
|
||||
f"test_episode_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
|
||||
)
|
||||
group_id = "ladybug_test_group"
|
||||
|
||||
print(f" Episode name: {episode_name}")
|
||||
@@ -196,7 +185,7 @@ async def test_save_episode(db_path: str, database: str) -> tuple[str, str]:
|
||||
name=episode_name,
|
||||
episode_body=json.dumps(test_data),
|
||||
source=EpisodeType.text,
|
||||
source_description="Test episode from test_graphiti_memory.py",
|
||||
source_description="Test episode from run_graphiti_memory_test.py",
|
||||
reference_time=datetime.now(timezone.utc),
|
||||
group_id=group_id,
|
||||
)
|
||||
@@ -251,29 +240,15 @@ async def test_keyword_search(db_path: str, database: str) -> bool:
|
||||
|
||||
try:
|
||||
result = conn.execute(query)
|
||||
query_result = result.get_as_df()
|
||||
df = result.get_as_df()
|
||||
|
||||
# Handle both list and DataFrame return types from get_as_df()
|
||||
if isinstance(query_result, list):
|
||||
# real-ladybug returns a list of dicts
|
||||
print(f" Found {len(query_result)} results:")
|
||||
for row in query_result:
|
||||
name = row.get("name", "unknown")[:50]
|
||||
content = str(row.get("content", ""))[:60]
|
||||
print(f" - {name}: {content}...")
|
||||
print_result(
|
||||
"Keyword Search", f"Found {len(query_result)} results", True
|
||||
)
|
||||
else:
|
||||
# pandas DataFrame
|
||||
print(f" Found {len(query_result)} results:")
|
||||
for _, row in query_result.iterrows():
|
||||
name = row.get("name", "unknown")[:50]
|
||||
content = str(row.get("content", ""))[:60]
|
||||
print(f" - {name}: {content}...")
|
||||
print_result(
|
||||
"Keyword Search", f"Found {len(query_result)} results", True
|
||||
)
|
||||
print(f" Found {len(df)} results:")
|
||||
for _, row in df.iterrows():
|
||||
name = row.get("name", "unknown")[:50]
|
||||
content = str(row.get("content", ""))[:60]
|
||||
print(f" - {name}: {content}...")
|
||||
|
||||
print_result("Keyword Search", f"Found {len(df)} results", True)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -456,11 +431,8 @@ async def test_graphiti_memory_class(db_path: str, database: str) -> bool:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
|
||||
# Create temporary directories for testing
|
||||
test_spec_dir = Path("/tmp/graphiti_test_spec")
|
||||
test_spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
test_project_dir = Path("/tmp/graphiti_test_project")
|
||||
test_project_dir.mkdir(parents=True, exist_ok=True)
|
||||
test_spec_dir = Path(tempfile.mkdtemp(prefix="graphiti_test_spec_"))
|
||||
test_project_dir = Path(tempfile.mkdtemp(prefix="graphiti_test_project_"))
|
||||
|
||||
print(f" Spec dir: {test_spec_dir}")
|
||||
print(f" Project dir: {test_project_dir}")
|
||||
@@ -587,15 +559,8 @@ async def test_database_contents(db_path: str, database: str) -> bool:
|
||||
for table in tables_to_check:
|
||||
try:
|
||||
result = conn.execute(f"MATCH (n:{table}) RETURN count(n) as count")
|
||||
query_result = result.get_as_df()
|
||||
|
||||
# Handle both list and DataFrame return types from get_as_df()
|
||||
if isinstance(query_result, list):
|
||||
count = query_result[0].get("count", 0) if query_result else 0
|
||||
else:
|
||||
count = (
|
||||
query_result["count"].iloc[0] if len(query_result) > 0 else 0
|
||||
)
|
||||
df = result.get_as_df()
|
||||
count = df["count"].iloc[0] if len(df) > 0 else 0
|
||||
print(f" {table}: {count} nodes")
|
||||
except Exception as e:
|
||||
if "not exist" in str(e).lower() or "cannot" in str(e).lower():
|
||||
@@ -613,21 +578,13 @@ async def test_database_contents(db_path: str, database: str) -> bool:
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 5
|
||||
""")
|
||||
query_result = result.get_as_df()
|
||||
df = result.get_as_df()
|
||||
|
||||
# Handle both list and DataFrame return types from get_as_df()
|
||||
if isinstance(query_result, list):
|
||||
if len(query_result) == 0:
|
||||
print(" (none)")
|
||||
else:
|
||||
for row in query_result:
|
||||
print(f" - {row.get('name', 'unknown')}")
|
||||
if len(df) == 0:
|
||||
print(" (none)")
|
||||
else:
|
||||
if len(query_result) == 0:
|
||||
print(" (none)")
|
||||
else:
|
||||
for _, row in query_result.iterrows():
|
||||
print(f" - {row.get('name', 'unknown')}")
|
||||
for _, row in df.iterrows():
|
||||
print(f" - {row.get('name', 'unknown')}")
|
||||
except Exception as e:
|
||||
if "Episodic" in str(e):
|
||||
print(" (table not created yet)")
|
||||
@@ -743,14 +700,14 @@ async def main():
|
||||
print()
|
||||
print(" Quick commands:")
|
||||
print(" # Run all tests:")
|
||||
print(" python integrations/graphiti/test_graphiti_memory.py")
|
||||
print(" python integrations/graphiti/run_graphiti_memory_test.py")
|
||||
print()
|
||||
print(" # Test just Ollama embeddings:")
|
||||
print(" python integrations/graphiti/test_graphiti_memory.py --test ollama")
|
||||
print(" python integrations/graphiti/run_graphiti_memory_test.py --test ollama")
|
||||
print()
|
||||
print(" # Test with production database:")
|
||||
print(
|
||||
" python integrations/graphiti/test_graphiti_memory.py --database auto_claude_memory"
|
||||
" python integrations/graphiti/run_graphiti_memory_test.py --database auto_claude_memory"
|
||||
)
|
||||
print()
|
||||
|
||||
+15
-18
@@ -36,18 +36,15 @@ NOTE: graphiti-core internally uses an OpenAI reranker for search ranking.
|
||||
|
||||
Usage:
|
||||
cd apps/backend
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py
|
||||
python integrations/graphiti/run_ollama_embedding_test.py
|
||||
|
||||
# Run specific tests:
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test create
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test retrieve
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle
|
||||
python integrations/graphiti/run_ollama_embedding_test.py --test embeddings
|
||||
python integrations/graphiti/run_ollama_embedding_test.py --test create
|
||||
python integrations/graphiti/run_ollama_embedding_test.py --test retrieve
|
||||
python integrations/graphiti/run_ollama_embedding_test.py --test full-cycle
|
||||
"""
|
||||
|
||||
# Exclude this file from pytest collection - this is a standalone test script
|
||||
__test__ = False
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
@@ -57,15 +54,15 @@ import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add auto-claude to path
|
||||
auto_claude_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(auto_claude_dir))
|
||||
# Add backend to path
|
||||
backend_dir = Path(__file__).parent.parent.parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
# Load .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = auto_claude_dir / ".env"
|
||||
env_file = backend_dir / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
print(f"Loaded .env from {env_file}")
|
||||
@@ -108,8 +105,8 @@ def apply_ladybug_monkeypatch():
|
||||
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
return True
|
||||
except Exception: # Test cleanup, ignore errors
|
||||
pass # no-op
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try native kuzu as fallback
|
||||
try:
|
||||
@@ -846,18 +843,18 @@ async def main():
|
||||
print()
|
||||
print(" Commands:")
|
||||
print(" # Run all tests:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py")
|
||||
print(" python integrations/graphiti/run_ollama_embedding_test.py")
|
||||
print()
|
||||
print(" # Run specific test:")
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings"
|
||||
" python integrations/graphiti/run_ollama_embedding_test.py --test embeddings"
|
||||
)
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle"
|
||||
" python integrations/graphiti/run_ollama_embedding_test.py --test full-cycle"
|
||||
)
|
||||
print()
|
||||
print(" # Keep database for inspection:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db")
|
||||
print(" python integrations/graphiti/run_ollama_embedding_test.py --keep-db")
|
||||
print()
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick test to demonstrate provider-specific database naming.
|
||||
|
||||
Shows how Auto Claude automatically generates provider-specific database names
|
||||
to prevent embedding dimension mismatches.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add auto-claude to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from integrations.graphiti.config import GraphitiConfig
|
||||
|
||||
|
||||
def test_provider_naming():
|
||||
"""Demonstrate provider-specific database naming."""
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" PROVIDER-SPECIFIC DATABASE NAMING")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
providers = [
|
||||
("openai", None, None),
|
||||
("ollama", "embeddinggemma", 768),
|
||||
("ollama", "qwen3-embedding:0.6b", 1024),
|
||||
("voyage", None, None),
|
||||
("google", None, None),
|
||||
]
|
||||
|
||||
for provider, model, dim in providers:
|
||||
# Create config
|
||||
config = GraphitiConfig.from_env()
|
||||
config.embedder_provider = provider
|
||||
|
||||
if provider == "ollama" and model:
|
||||
config.ollama_embedding_model = model
|
||||
if dim:
|
||||
config.ollama_embedding_dim = dim
|
||||
|
||||
# Get naming info
|
||||
dimension = config.get_embedding_dimension()
|
||||
signature = config.get_provider_signature()
|
||||
db_name = config.get_provider_specific_database_name("auto_claude_memory")
|
||||
|
||||
print(f"Provider: {provider}")
|
||||
if model:
|
||||
print(f" Model: {model}")
|
||||
print(f" Embedding Dimension: {dimension}")
|
||||
print(f" Provider Signature: {signature}")
|
||||
print(f" Database Name: {db_name}")
|
||||
print(f" Full Path: ~/.auto-claude/memories/{db_name}/")
|
||||
print()
|
||||
|
||||
print("=" * 70)
|
||||
print("\nKey Benefits:")
|
||||
print(" ✅ No dimension mismatch errors")
|
||||
print(" ✅ Each provider uses its own database")
|
||||
print(" ✅ Can switch providers without conflicts")
|
||||
print(" ✅ Migration utility available for data transfer")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_provider_naming()
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for Graphiti memory integration."""
|
||||
@@ -0,0 +1,610 @@
|
||||
"""
|
||||
Pytest configuration and fixtures for graphiti integration tests.
|
||||
|
||||
This module provides shared fixtures for testing the memory system integration,
|
||||
including mocks for external dependencies, test configurations, and client fixtures.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the backend directory to sys.path to allow imports
|
||||
backend_dir = Path(__file__).parent.parent.parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""
|
||||
Exclude validator functions from test collection.
|
||||
|
||||
The validators.py module contains functions named test_llm_connection and
|
||||
test_embedder_connection which are not pytest tests but validator functions.
|
||||
"""
|
||||
# Filter out items that are from validators.py and are not in test classes
|
||||
filtered_items = []
|
||||
for item in items:
|
||||
# Get the full path of the test
|
||||
item_path = str(item.fspath) if hasattr(item, "fspath") else str(item.path)
|
||||
|
||||
# Skip the standalone test_llm_connection and test_embedder_connection
|
||||
# functions from validators.py (they're not pytest tests)
|
||||
if item.name in [
|
||||
"test_llm_connection",
|
||||
"test_embedder_connection",
|
||||
"test_ollama_connection",
|
||||
]:
|
||||
# Check if it's from validators.py
|
||||
if "validators.py" in item_path or "test_providers.py" in item_path:
|
||||
# Only skip if it's a standalone function (not in a TestClass)
|
||||
if not item.parent.name.startswith("Test"):
|
||||
continue
|
||||
|
||||
filtered_items.append(item)
|
||||
|
||||
items[:] = filtered_items
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# External Dependency Mocks
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graphiti_core():
|
||||
"""Mock graphiti_core.Graphiti and related classes.
|
||||
|
||||
Patches the graphiti_core library to prevent actual graph database connections
|
||||
during tests.
|
||||
|
||||
Yields:
|
||||
tuple: (mock_graphiti_class, mock_graphiti_instance)
|
||||
"""
|
||||
with patch(
|
||||
"integrations.graphiti.queries_pkg.graphiti.graphiti_core.Graphiti"
|
||||
) as mock_graphiti:
|
||||
# Configure the mock to return a mock instance
|
||||
mock_instance = MagicMock()
|
||||
mock_graphiti.return_value = mock_instance
|
||||
|
||||
# Mock common methods that might be called
|
||||
mock_instance.add_edges = AsyncMock()
|
||||
mock_instance.add_nodes = AsyncMock()
|
||||
mock_instance.search = AsyncMock(return_value=[])
|
||||
mock_instance.delete_graph = AsyncMock()
|
||||
mock_instance.close = AsyncMock()
|
||||
|
||||
yield mock_graphiti, mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_falkor_driver():
|
||||
"""Mock graphiti_core.driver.falkordb_driver.FalkorDriver.
|
||||
|
||||
Prevents actual FalkorDB connections during tests.
|
||||
|
||||
Yields:
|
||||
tuple: (mock_driver_class, mock_driver_instance)
|
||||
"""
|
||||
with patch(
|
||||
"integrations.graphiti.queries_pkg.graphiti.graphiti_core.driver.falkordb_driver.FalkorDriver"
|
||||
) as mock_driver:
|
||||
mock_instance = MagicMock()
|
||||
mock_driver.return_value = mock_instance
|
||||
|
||||
# Mock driver methods
|
||||
mock_instance.close = MagicMock()
|
||||
mock_instance.execute_query = MagicMock(return_value=[])
|
||||
|
||||
yield mock_driver, mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graphiti_providers():
|
||||
"""Mock graphiti_providers module.
|
||||
|
||||
Patches the graphiti_providers module to prevent actual LLM/embedder calls.
|
||||
|
||||
Yields:
|
||||
tuple: (mock_get_client, mock_client_instance)
|
||||
"""
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.providers.get_client"
|
||||
) as mock_get_client:
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
yield mock_get_client, mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ladybug_db():
|
||||
"""Mock real_ladybug and kuzu database connections.
|
||||
|
||||
Prevents actual database connections during tests.
|
||||
|
||||
Yields:
|
||||
dict: Dictionary with 'ladybug' and 'kuzu' keys, each containing
|
||||
(mock_class, mock_instance) tuples.
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"integrations.graphiti.queries_pkg.client.real_ladybug.Ladybug"
|
||||
) as mock_ladybug,
|
||||
patch("integrations.graphiti.queries_pkg.client.kuzu.Connection") as mock_kuzu,
|
||||
):
|
||||
# Mock Ladybug instance
|
||||
ladybug_instance = MagicMock()
|
||||
mock_ladybug.return_value = ladybug_instance
|
||||
ladybug_instance.close = MagicMock()
|
||||
|
||||
# Mock Kuzu connection
|
||||
kuzu_instance = MagicMock()
|
||||
mock_kuzu.return_value = kuzu_instance
|
||||
kuzu_instance.close = MagicMock()
|
||||
|
||||
yield {
|
||||
"ladybug": (mock_ladybug, ladybug_instance),
|
||||
"kuzu": (mock_kuzu, kuzu_instance),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Return a GraphitiConfig with test values.
|
||||
|
||||
Provides a test configuration that doesn't require real environment variables
|
||||
or database connections.
|
||||
|
||||
Returns:
|
||||
GraphitiConfig: Configuration with test values.
|
||||
"""
|
||||
from integrations.graphiti.config import GraphitiConfig
|
||||
|
||||
config = GraphitiConfig(
|
||||
enabled=True,
|
||||
database="test_dataset",
|
||||
db_path="/tmp/test_graphiti.db",
|
||||
llm_provider="openai",
|
||||
openai_model="gpt-5-mini",
|
||||
embedder_provider="openai",
|
||||
openai_embedding_model="text-embedding-3-small",
|
||||
openai_api_key="sk-test-key-for-testing",
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_vars(tmp_path):
|
||||
"""Set test environment variables for Graphiti configuration.
|
||||
|
||||
Sets up a clean environment with test values for all Graphiti-related
|
||||
environment variables.
|
||||
|
||||
Yields:
|
||||
dict: Dictionary of environment variables that were set.
|
||||
"""
|
||||
test_db_path = str(tmp_path / "test_graphiti.db")
|
||||
|
||||
env_vars = {
|
||||
"GRAPHITI_ENABLED": "true",
|
||||
"GRAPHITI_LLM_PROVIDER": "openai",
|
||||
"GRAPHITI_EMBEDDER_PROVIDER": "openai",
|
||||
"GRAPHITI_DATABASE": "test_dataset",
|
||||
"GRAPHITI_DB_PATH": test_db_path,
|
||||
"OPENAI_MODEL": "gpt-5-mini",
|
||||
"OPENAI_EMBEDDING_MODEL": "text-embedding-3-small",
|
||||
"OPENAI_API_KEY": "sk-test-key-for-testing",
|
||||
}
|
||||
|
||||
# Save original values
|
||||
original = {k: os.environ.get(k) for k in env_vars}
|
||||
|
||||
# Set test values
|
||||
for key, value in env_vars.items():
|
||||
os.environ[key] = value
|
||||
|
||||
yield env_vars
|
||||
|
||||
# Restore original values
|
||||
for key, original_value in original.items():
|
||||
if original_value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = original_value
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Client Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graphiti_client():
|
||||
"""Mock GraphitiClient with all necessary methods.
|
||||
|
||||
Provides a mock client that simulates the behavior of the GraphitiClient
|
||||
without requiring actual graph database connections.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked GraphitiClient with typical methods mocked.
|
||||
"""
|
||||
client = Mock()
|
||||
client.graphiti = Mock()
|
||||
|
||||
# Core client methods
|
||||
client.is_initialized = Mock(return_value=True)
|
||||
client.initialize = AsyncMock()
|
||||
client.get_session_id = Mock(return_value="test_session")
|
||||
client.get_user_id = Mock(return_value="test_user")
|
||||
client.get_project_id = Mock(return_value="test_project")
|
||||
|
||||
# Memory operations (async)
|
||||
client.add_episode = AsyncMock(return_value="episode_id_123")
|
||||
client.add_episodic_memories = AsyncMock(return_value=["mem_id_1", "mem_id_2"])
|
||||
client.add_abstract_memories = AsyncMock(return_value=["abstract_id_1"])
|
||||
client.search = AsyncMock(return_value=[])
|
||||
client.delete_graph = AsyncMock()
|
||||
|
||||
# Graphiti instance methods
|
||||
client.graphiti.search = AsyncMock(return_value=[])
|
||||
|
||||
# Configuration
|
||||
client.get_config = Mock(
|
||||
return_value=Mock(
|
||||
enabled=True, database="test_dataset", db_path="/tmp/test_graphiti.db"
|
||||
)
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graphiti_instance():
|
||||
"""Mock the Graphiti instance from graphiti_core.
|
||||
|
||||
Provides a mock of the actual Graphiti core instance with all methods
|
||||
that might be called during operations.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked Graphiti instance with typical methods mocked.
|
||||
"""
|
||||
instance = MagicMock()
|
||||
|
||||
# Search methods (async)
|
||||
instance.search = AsyncMock(return_value=[])
|
||||
instance.search_by_abstract = AsyncMock(return_value=[])
|
||||
instance.search_by_vector = AsyncMock(return_value=[])
|
||||
|
||||
# Add methods (async)
|
||||
instance.add_episode = AsyncMock(return_value="episode_id")
|
||||
instance.add_edges = AsyncMock()
|
||||
instance.add_nodes = AsyncMock()
|
||||
|
||||
# Graph management
|
||||
instance.delete_graph = AsyncMock()
|
||||
instance.close = AsyncMock()
|
||||
instance.get_graph_summary = Mock(return_value={"nodes": 0, "edges": 0})
|
||||
|
||||
# Configuration
|
||||
instance.database = "test_dataset"
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Directory Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_spec_dir(tmp_path):
|
||||
"""Create a temporary directory for spec testing.
|
||||
|
||||
Provides a temporary directory with spec-like structure for testing
|
||||
spec-related functionality.
|
||||
|
||||
Args:
|
||||
tmp_path: pytest's built-in tmp_path fixture.
|
||||
|
||||
Returns:
|
||||
Path: Path to the temporary spec directory.
|
||||
"""
|
||||
spec_dir = tmp_path / "spec_001_test"
|
||||
spec_dir.mkdir()
|
||||
|
||||
# Create common spec subdirectories
|
||||
(spec_dir / ".auto-claude").mkdir()
|
||||
(spec_dir / "context").mkdir()
|
||||
|
||||
return spec_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_project_dir(tmp_path):
|
||||
"""Create a temporary directory for project testing.
|
||||
|
||||
Provides a temporary directory with project-like structure for testing
|
||||
project-related functionality.
|
||||
|
||||
Args:
|
||||
tmp_path: pytest's built-in tmp_path fixture.
|
||||
|
||||
Returns:
|
||||
Path: Path to the temporary project directory.
|
||||
"""
|
||||
project_dir = tmp_path / "test_project"
|
||||
project_dir.mkdir()
|
||||
|
||||
# Create common project subdirectories
|
||||
(project_dir / "src").mkdir()
|
||||
(project_dir / "tests").mkdir()
|
||||
(project_dir / ".auto-claude").mkdir()
|
||||
|
||||
return project_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path):
|
||||
"""Create a temporary path for test database.
|
||||
|
||||
Provides a temporary file path that can be used for database testing
|
||||
without affecting real databases.
|
||||
|
||||
Args:
|
||||
tmp_path: pytest's built-in tmp_path fixture.
|
||||
|
||||
Returns:
|
||||
str: Path to temporary database file.
|
||||
"""
|
||||
db_path = str(tmp_path / "test_graphiti.db")
|
||||
return db_path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm_client():
|
||||
"""Mocked LLM client for testing.
|
||||
|
||||
Provides a mock client that simulates LLM responses without making
|
||||
actual API calls.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked LLM client.
|
||||
"""
|
||||
client = Mock()
|
||||
|
||||
# Message methods
|
||||
client.messages = Mock()
|
||||
mock_response = Mock()
|
||||
mock_response.id = "msg_test_123"
|
||||
mock_response.content = []
|
||||
mock_response.model = "claude-3-5-sonnet-20241022"
|
||||
mock_response.role = "assistant"
|
||||
client.messages.create = Mock(return_value=mock_response)
|
||||
|
||||
# Streaming support
|
||||
client.messages.stream = Mock(return_value=iter([]))
|
||||
|
||||
# Token counting
|
||||
client.count_tokens = Mock(return_value=100)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedder():
|
||||
"""Mocked embedder with get_embedding() method.
|
||||
|
||||
Provides a mock embedder that returns fake embeddings without making
|
||||
actual API calls. Uses deterministic values for reproducibility.
|
||||
|
||||
Returns:
|
||||
tuple: (mock_embedder, test_embedding_list)
|
||||
"""
|
||||
embedder = Mock()
|
||||
|
||||
# Return a deterministic embedding vector (1536 dimensions is common for OpenAI)
|
||||
# Using 0.1 for all values makes tests reproducible
|
||||
test_embedding = [0.1] * 1536
|
||||
|
||||
embedder.get_embedding = Mock(return_value=test_embedding)
|
||||
embedder.get_embeddings = Mock(return_value=[test_embedding])
|
||||
|
||||
return embedder, test_embedding
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# State Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
"""GraphitiState with test values.
|
||||
|
||||
Provides a mock state object with typical values for testing state-related
|
||||
functionality.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked GraphitiState with test values.
|
||||
"""
|
||||
from integrations.graphiti.config import GraphitiState
|
||||
|
||||
state = GraphitiState(
|
||||
initialized=True,
|
||||
database="test_dataset",
|
||||
indices_built=True,
|
||||
llm_provider="openai",
|
||||
embedder_provider="openai",
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_empty_state():
|
||||
"""Empty GraphitiState.
|
||||
|
||||
Provides a mock state object with default/uninitialized values for testing
|
||||
initialization logic.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked GraphitiState with empty/default values.
|
||||
"""
|
||||
from integrations.graphiti.config import GraphitiState
|
||||
|
||||
state = GraphitiState()
|
||||
|
||||
return state
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Data Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_episode_data():
|
||||
"""Sample episode data for testing.
|
||||
|
||||
Provides realistic episode data structure for testing memory operations.
|
||||
|
||||
Returns:
|
||||
dict: Sample episode data.
|
||||
"""
|
||||
return {
|
||||
"episode_id": "episode_123",
|
||||
"content": "Test episode content about a feature implementation",
|
||||
"metadata": {
|
||||
"task_id": "task_001",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"type": "implementation",
|
||||
},
|
||||
"session_id": "test_session",
|
||||
"user_id": "test_user",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_memory_nodes():
|
||||
"""Sample memory nodes for testing.
|
||||
|
||||
Provides realistic node data for testing graph operations.
|
||||
|
||||
Returns:
|
||||
list: List of sample memory node dictionaries.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"uuid": "node_1",
|
||||
"name": "Feature Implementation",
|
||||
"label": "CONCEPT",
|
||||
"summary": "Implementation of new feature",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"uuid": "node_2",
|
||||
"name": "Bug Fix",
|
||||
"label": "CONCEPT",
|
||||
"summary": "Fixed critical bug",
|
||||
"created_at": "2024-01-02T00:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_search_results():
|
||||
"""Sample search results for testing.
|
||||
|
||||
Provides realistic search result data for testing search operations.
|
||||
|
||||
Returns:
|
||||
list: List of sample search result dictionaries.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"uuid": "result_1",
|
||||
"name": "Search Result 1",
|
||||
"summary": "First search result",
|
||||
"score": 0.95,
|
||||
},
|
||||
{
|
||||
"uuid": "result_2",
|
||||
"name": "Search Result 2",
|
||||
"summary": "Second search result",
|
||||
"score": 0.87,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env():
|
||||
"""Fixture to ensure clean environment for each test.
|
||||
|
||||
Removes all Graphiti-related environment variables before the test
|
||||
and restores them afterward.
|
||||
|
||||
Yields:
|
||||
dict: Dictionary of original environment values.
|
||||
"""
|
||||
# Store original env vars
|
||||
env_keys = [
|
||||
"GRAPHITI_ENABLED",
|
||||
"GRAPHITI_LLM_PROVIDER",
|
||||
"GRAPHITI_EMBEDDER_PROVIDER",
|
||||
"GRAPHITI_DATABASE",
|
||||
"GRAPHITI_DB_PATH",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_MODEL",
|
||||
"OPENAI_EMBEDDING_MODEL",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GRAPHITI_ANTHROPIC_MODEL",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"AZURE_OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_LLM_DEPLOYMENT",
|
||||
"AZURE_OPENAI_EMBEDDING_DEPLOYMENT",
|
||||
"VOYAGE_API_KEY",
|
||||
"VOYAGE_EMBEDDING_MODEL",
|
||||
"GOOGLE_API_KEY",
|
||||
"GOOGLE_LLM_MODEL",
|
||||
"GOOGLE_EMBEDDING_MODEL",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENROUTER_BASE_URL",
|
||||
"OPENROUTER_LLM_MODEL",
|
||||
"OPENROUTER_EMBEDDING_MODEL",
|
||||
"OLLAMA_BASE_URL",
|
||||
"OLLAMA_LLM_MODEL",
|
||||
"OLLAMA_EMBEDDING_MODEL",
|
||||
"OLLAMA_EMBEDDING_DIM",
|
||||
]
|
||||
|
||||
original = {}
|
||||
for key in env_keys:
|
||||
original[key] = os.environ.get(key)
|
||||
if key in os.environ:
|
||||
os.environ.pop(key)
|
||||
|
||||
yield original
|
||||
|
||||
# Restore original values
|
||||
for key, value in original.items():
|
||||
if value is not None:
|
||||
os.environ[key] = value
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Tests for integrations.graphiti.providers_pkg.cross_encoder module.
|
||||
|
||||
Tests cover:
|
||||
1. create_cross_encoder():
|
||||
- Returns None for non-Ollama providers
|
||||
- Returns None when llm_client is None
|
||||
- Returns None on ImportError (graphiti_core not available)
|
||||
- Returns None on Exception during creation
|
||||
- Creates correct base_url for Ollama
|
||||
- Creates LLMConfig with correct parameters
|
||||
"""
|
||||
|
||||
import builtins
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# =============================================================================
|
||||
# Test Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.llm_provider = "ollama"
|
||||
config.ollama_base_url = "http://localhost:11434"
|
||||
config.ollama_llm_model = "llama3.2"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm_client():
|
||||
"""Mock LLM client."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graphiti_core_mocks():
|
||||
"""Mock graphiti_core modules and capture LLMConfig calls."""
|
||||
captured_config = {}
|
||||
|
||||
def capture_llm_config(**kwargs):
|
||||
captured_config.update(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.cross_encoder": MagicMock(),
|
||||
"graphiti_core.cross_encoder.openai_reranker_client": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.cross_encoder.openai_reranker_client import (
|
||||
OpenAIRerankerClient,
|
||||
)
|
||||
from graphiti_core.llm_client.config import LLMConfig
|
||||
|
||||
LLMConfig.side_effect = capture_llm_config
|
||||
OpenAIRerankerClient.return_value = MagicMock()
|
||||
|
||||
yield captured_config
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test create_cross_encoder()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateCrossEncoder:
|
||||
"""Tests for create_cross_encoder() function."""
|
||||
|
||||
def test_returns_none_for_non_ollama_provider(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None for non-Ollama providers."""
|
||||
mock_config.llm_provider = "openai"
|
||||
|
||||
import integrations.graphiti.providers_pkg.cross_encoder as ce_module
|
||||
|
||||
# The function returns None for non-ollama providers
|
||||
result = ce_module.create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_anthropic_provider(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None for Anthropic provider."""
|
||||
mock_config.llm_provider = "anthropic"
|
||||
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
result = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_google_provider(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None for Google provider."""
|
||||
mock_config.llm_provider = "google"
|
||||
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
result = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_llm_client_is_none(self, mock_config):
|
||||
"""Test create_cross_encoder returns None when llm_client is None."""
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
result = create_cross_encoder(mock_config, llm_client=None)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_base_url_without_v1_gets_suffix_added(
|
||||
self, mock_config, mock_llm_client, graphiti_core_mocks
|
||||
):
|
||||
"""Test that base_url without /v1 gets /v1 suffix added."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434"
|
||||
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
_ = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
# Verify base_url was captured and has /v1 suffix added
|
||||
assert "base_url" in graphiti_core_mocks
|
||||
assert graphiti_core_mocks["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
def test_base_url_with_v1_is_preserved(
|
||||
self, mock_config, mock_llm_client, graphiti_core_mocks
|
||||
):
|
||||
"""Test that base_url with /v1 suffix is preserved."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434/v1"
|
||||
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
_ = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
# Verify base_url was preserved with /v1 suffix
|
||||
assert "base_url" in graphiti_core_mocks
|
||||
assert graphiti_core_mocks["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
def test_import_error_returns_none(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None when graphiti_core modules not available."""
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "graphiti_core.cross_encoder.openai_reranker_client":
|
||||
raise ImportError("graphiti_core not installed")
|
||||
if name == "graphiti_core.llm_client.config":
|
||||
raise ImportError("graphiti_core not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_exception_during_creation_returns_none(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None on exception during creation."""
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
# Mock the graphiti_core modules but make LLMConfig raise an exception
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.cross_encoder": MagicMock(),
|
||||
"graphiti_core.cross_encoder.openai_reranker_client": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.llm_client.config import LLMConfig
|
||||
|
||||
# Make LLMConfig raise an exception
|
||||
LLMConfig.side_effect = Exception("Config creation failed")
|
||||
|
||||
result = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test module exports
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestModuleExports:
|
||||
"""Tests for cross_encoder module exports."""
|
||||
|
||||
def test_create_cross_encoder_is_exported(self):
|
||||
"""Test that create_cross_encoder is exported from module."""
|
||||
from integrations.graphiti.providers_pkg import cross_encoder
|
||||
|
||||
assert hasattr(cross_encoder, "create_cross_encoder")
|
||||
assert callable(cross_encoder.create_cross_encoder)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Tests for integrations.graphiti.__init__ module.
|
||||
|
||||
Tests cover:
|
||||
- __getattr__ lazy import functionality
|
||||
- Direct imports (GraphitiConfig, validate_graphiti_config)
|
||||
- Invalid attribute access raises AttributeError
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestInitModuleDirectImports:
|
||||
"""Test direct imports that don't require lazy loading."""
|
||||
|
||||
def test_import_graphiti_config_directly(self):
|
||||
"""Test GraphitiConfig can be imported directly."""
|
||||
from integrations.graphiti import GraphitiConfig
|
||||
|
||||
assert GraphitiConfig is not None
|
||||
|
||||
def test_import_validate_graphiti_config_directly(self):
|
||||
"""Test validate_graphiti_config can be imported directly."""
|
||||
from integrations.graphiti import validate_graphiti_config
|
||||
|
||||
assert validate_graphiti_config is not None
|
||||
|
||||
def test___all___exports(self):
|
||||
"""Test __all__ contains expected exports."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
expected_all = [
|
||||
"GraphitiConfig",
|
||||
"validate_graphiti_config",
|
||||
"GraphitiMemory",
|
||||
"create_llm_client",
|
||||
"create_embedder",
|
||||
]
|
||||
assert graphiti_module.__all__ == expected_all
|
||||
|
||||
|
||||
class TestInitModuleLazyImports:
|
||||
"""Test __getattr__ lazy import functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory_module(self):
|
||||
"""Mock the memory module."""
|
||||
memory_mock = MagicMock()
|
||||
memory_mock.GraphitiMemory = MagicMock
|
||||
return memory_mock
|
||||
|
||||
@pytest.fixture
|
||||
def mock_providers_module(self):
|
||||
"""Mock the providers module."""
|
||||
providers_mock = MagicMock()
|
||||
providers_mock.create_llm_client = MagicMock(return_value=AsyncMock())
|
||||
providers_mock.create_embedder = MagicMock(return_value=AsyncMock())
|
||||
return providers_mock
|
||||
|
||||
def test_getattr_graphiti_memory_lazy_import(self, mock_memory_module):
|
||||
"""Test accessing GraphitiMemory triggers lazy import."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.memory": mock_memory_module,
|
||||
},
|
||||
):
|
||||
# Access the attribute via __getattr__
|
||||
result = graphiti_module.__getattr__("GraphitiMemory")
|
||||
|
||||
assert result == mock_memory_module.GraphitiMemory
|
||||
|
||||
def test_getattr_create_llm_client_lazy_import(self, mock_providers_module):
|
||||
"""Test accessing create_llm_client triggers lazy import."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.providers": mock_providers_module,
|
||||
},
|
||||
):
|
||||
result = graphiti_module.__getattr__("create_llm_client")
|
||||
|
||||
assert result == mock_providers_module.create_llm_client
|
||||
|
||||
def test_getattr_create_embedder_lazy_import(self, mock_providers_module):
|
||||
"""Test accessing create_embedder triggers lazy import."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.providers": mock_providers_module,
|
||||
},
|
||||
):
|
||||
result = graphiti_module.__getattr__("create_embedder")
|
||||
|
||||
assert result == mock_providers_module.create_embedder
|
||||
|
||||
def test_getattr_invalid_attribute_raises_attribute_error(self):
|
||||
"""Test accessing invalid attribute raises AttributeError."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with pytest.raises(AttributeError) as exc_info:
|
||||
graphiti_module.__getattr__("NonExistentAttribute")
|
||||
|
||||
assert "has no attribute" in str(exc_info.value)
|
||||
assert "NonExistentAttribute" in str(exc_info.value)
|
||||
|
||||
def test_getattr_empty_string_attribute(self):
|
||||
"""Test accessing empty string attribute raises AttributeError."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
graphiti_module.__getattr__("")
|
||||
|
||||
def test_getattr_case_sensitive(self):
|
||||
"""Test that __getattr__ is case-sensitive."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
# lowercase should fail
|
||||
with pytest.raises(AttributeError):
|
||||
graphiti_module.__getattr__("graphitimemory")
|
||||
|
||||
# mixed case should fail
|
||||
with pytest.raises(AttributeError):
|
||||
graphiti_module.__getattr__("Graphiti_Memory")
|
||||
|
||||
|
||||
class TestInitModuleAccessPatterns:
|
||||
"""Test various access patterns for the init module."""
|
||||
|
||||
def test_hasattr_on_graphiti_memory(self):
|
||||
"""Test hasattr works correctly with lazy imports."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
# Mock the import
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.memory": MagicMock(GraphitiMemory=MagicMock),
|
||||
},
|
||||
):
|
||||
# hasattr should call __getattr__ and not raise
|
||||
result = hasattr(graphiti_module, "GraphitiMemory")
|
||||
assert result is True
|
||||
|
||||
def test_hasattr_on_invalid_attribute(self):
|
||||
"""Test hasattr returns False for invalid attributes."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
result = hasattr(graphiti_module, "InvalidAttribute")
|
||||
assert result is False
|
||||
|
||||
def test_getattr_on_existing_direct_import(self):
|
||||
"""Test __getattr__ is not called for direct imports."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
# GraphitiConfig is imported directly, so __getattr__ shouldn't be called
|
||||
# This tests that the normal import mechanism works
|
||||
assert hasattr(graphiti_module, "GraphitiConfig")
|
||||
|
||||
def test_module_docstring(self):
|
||||
"""Test the module has a docstring."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
assert graphiti_module.__doc__ is not None
|
||||
assert "Graphiti" in graphiti_module.__doc__
|
||||
|
||||
|
||||
class TestInitModuleIntegration:
|
||||
"""Integration tests for the init module."""
|
||||
|
||||
def test_import_star(self):
|
||||
"""Test 'from integrations.graphiti import *' includes direct imports."""
|
||||
# Create a new namespace for the import
|
||||
namespace = {}
|
||||
exec("from integrations.graphiti import *", namespace)
|
||||
|
||||
# Direct imports should be available
|
||||
assert "GraphitiConfig" in namespace
|
||||
assert "validate_graphiti_config" in namespace
|
||||
|
||||
def test_reimport_does_not_fail(self):
|
||||
"""Test that re-importing the module doesn't cause issues."""
|
||||
import importlib
|
||||
|
||||
import integrations.graphiti
|
||||
|
||||
# Reload the module
|
||||
importlib.reload(integrations.graphiti)
|
||||
|
||||
# Should still work
|
||||
assert hasattr(integrations.graphiti, "GraphitiConfig")
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_concurrent_attribute_access(self):
|
||||
"""Test that concurrent attribute access doesn't cause issues."""
|
||||
import concurrent.futures
|
||||
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
# Mock the imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.memory": MagicMock(GraphitiMemory=MagicMock),
|
||||
"integrations.graphiti.providers": MagicMock(
|
||||
create_llm_client=MagicMock(return_value=AsyncMock()),
|
||||
create_embedder=MagicMock(return_value=AsyncMock()),
|
||||
),
|
||||
},
|
||||
):
|
||||
|
||||
def access_attribute(attr_name):
|
||||
try:
|
||||
return getattr(graphiti_module, attr_name)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
# Access multiple attributes concurrently
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = [
|
||||
executor.submit(access_attribute, "GraphitiMemory"),
|
||||
executor.submit(access_attribute, "create_llm_client"),
|
||||
executor.submit(access_attribute, "create_embedder"),
|
||||
]
|
||||
results = [f.result() for f in concurrent.futures.as_completed(futures)]
|
||||
|
||||
# All should succeed
|
||||
assert len(results) == 3
|
||||
assert all(r is not None for r in results)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user