diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index a610bcfa7..8f01b8539 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -81,7 +81,11 @@ jobs: - name: Install deps (Ubuntu) if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + run: sudo apt-get update && sudo apt-get install -y subversion zlib1g-dev + + - name: Install deps (macOS) + if: startsWith(matrix.os, 'macos') + run: brew install subversion - name: Test run: scripts/test.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} @@ -95,7 +99,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install deps (Ubuntu) - run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + run: sudo apt-get update && sudo apt-get install -y clang subversion zlib1g-dev - name: ThreadSanitizer tests run: make -f Makefile.cbm test-tsan CC=clang CXX=clang++ @@ -121,6 +125,7 @@ jobs: mingw-w64-clang-${{ matrix.pkg }}-zlib make git + subversion - name: Test shell: msys2 {0} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f025808df..41e0c0af5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ src/ pass_*.c Individual pipeline passes (definitions, calls, usages, etc.) httplink.c HTTP route extraction (Go/Express/Laravel/Ktor/Python) discover/ File discovery with gitignore support - watcher/ Git-based background auto-sync + watcher/ Git/SVN working-copy background auto-sync cli/ CLI subcommands (install, update, uninstall, config) ui/ Graph visualization HTTP server (first-party httpd) internal/cbm/ Tree-sitter AST extraction (64 languages, vendored C grammars) diff --git a/Makefile.cbm b/Makefile.cbm index b78018c11..4d4fe0653 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -65,7 +65,8 @@ CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 SANITIZE = -fsanitize=address,undefined -fno-omit-frame-pointer EDITOR_TEST_DEFINES = -DCBM_JSON_LIKE_ENABLE_TEST_API=1 \ -DCBM_TOML_EDIT_ENABLE_TEST_API=1 -DCBM_YAML_ENABLE_TEST_API=1 \ - -DCBM_TEXT_EDIT_ENABLE_TEST_API=1 -DCBM_CLI_ENABLE_TEST_API=1 + -DCBM_TEXT_EDIT_ENABLE_TEST_API=1 -DCBM_CLI_ENABLE_TEST_API=1 \ + -DCBM_SVN_STATE_ENABLE_TEST_API=1 CFLAGS_TEST = $(CFLAGS_COMMON) $(EDITOR_TEST_DEFINES) -g -O1 $(SANITIZE) CXXFLAGS_TEST = $(CXXFLAGS_COMMON) -g -O1 $(SANITIZE) @@ -236,7 +237,7 @@ UNIXCODER_BLOB_SRC = vendored/nomic/code_vectors_blob.S TRACES_SRCS = src/traces/traces.c # Watcher module (new) -WATCHER_SRCS = src/watcher/watcher.c +WATCHER_SRCS = src/watcher/watcher.c src/watcher/svn_state.c # Git context module (new) GIT_SRCS = src/git/git_context.c @@ -359,7 +360,7 @@ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c -TEST_WATCHER_SRCS = tests/test_watcher.c +TEST_WATCHER_SRCS = tests/test_watcher.c tests/test_svn_state.c TEST_LZ4_SRCS = tests/test_lz4.c TEST_ZSTD_SRCS = tests/test_zstd.c diff --git a/README.md b/README.md index bb4919ed5..dcc000f80 100644 --- a/README.md +++ b/README.md @@ -127,10 +127,12 @@ Enable automatic indexing on MCP session start: codebase-memory-mcp config set auto_index true ``` -When enabled, new projects are indexed automatically on first connection. Previously-indexed projects are registered with the background watcher for ongoing git-based change detection. Configurable file limit: `config set auto_index_limit 50000`. +When enabled, new projects are indexed automatically on first connection. Previously-indexed projects are registered with the background watcher for ongoing Git or SVN working-copy change detection. Configurable file limit: `config set auto_index_limit 50000`. Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher — useful when working across many projects and you want each session contained to explicit indexing. +SVN auto-watch requires a trusted system `svn` executable available on `PATH`; project-local clients are not used. Observation runs locally against the working copy and does not query SVN history or add an SVN-specific interval. Git and SVN projects share the watcher's existing adaptive polling cadence and the same indexing pipeline: ordinary changes take the incremental route, while the pipeline may perform a full rebuild when its existing safety threshold requires one. If SVN observation is unavailable or temporarily fails, the current graph remains available and the watcher retries on a later poll. + ### Keeping Up to Date ```bash @@ -191,7 +193,7 @@ Removes owned agent config entries, skills, hooks, instructions, and the install ### Distribution & operation - **Single static binary, zero infrastructure**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/` -- **Auto-sync**: Background watcher detects file changes and re-indexes automatically +- **Auto-sync**: Background watcher detects Git and SVN working-copy changes and re-indexes automatically - **Route nodes**: REST endpoints are first-class graph entities - **CLI mode**: `codebase-memory-mcp cli search_graph '{"project": "my-project", "name_pattern": ".*Handler.*"}'` - **Available on**: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, `go install` @@ -598,7 +600,7 @@ See [docs/cbmignore.md](docs/cbmignore.md) for the full `.cbmignore` how-to: syn codebase-memory-mcp config list # show all settings codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index -codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true) +codebase-memory-mcp config set auto_watch false # don't register background Git/SVN watcher (default: true) codebase-memory-mcp config reset auto_index # reset to default ``` @@ -712,7 +714,7 @@ src/ pipeline/ Multi-pass indexing (structure → definitions → calls → HTTP links → config → tests) cypher/ Cypher query lexer, parser, planner, executor discover/ File discovery (.gitignore, .cbmignore, symlink handling) - watcher/ Background auto-sync (git polling, adaptive intervals) + watcher/ Background auto-sync (Git/SVN polling, adaptive intervals) traces/ Runtime trace ingestion ui/ Embedded HTTP server + 3D graph visualization foundation/ Platform abstractions (threads, filesystem, logging, memory) diff --git a/docs/plans/2026-07-16-001-feat-svn-auto-watch-graph-sync-plan.md b/docs/plans/2026-07-16-001-feat-svn-auto-watch-graph-sync-plan.md new file mode 100644 index 000000000..976e21e12 --- /dev/null +++ b/docs/plans/2026-07-16-001-feat-svn-auto-watch-graph-sync-plan.md @@ -0,0 +1,384 @@ +--- +title: SVN Auto-Watch Graph Sync - Plan +type: feat +date: 2026-07-16 +topic: svn-auto-watch-graph-sync +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +deepened: 2026-07-16 +--- + +# SVN Auto-Watch Graph Sync - Plan + +## Goal Capsule + +- **Objective:** Keep an indexed graph synchronized with an SVN working copy without requiring a manual full regeneration after local edits, `svn update`, or `svn commit`. +- **Product authority:** This Product Contract defines SVN watcher behavior. Existing discovery rules and pipeline routing remain authoritative for file eligibility and incremental-versus-full indexing decisions. +- **Execution profile:** Deep cross-platform code change, implemented test-first in dependency order U1 through U4. +- **Stop conditions:** Stop if SVN polling cannot preserve the acknowledged-versus-pending baseline contract, if Git behavior regresses, or if upstream maintainers reject the design in the required tracking issue. +- **Tail ownership:** LFG owns implementation, review fixes, DCO-compliant English commits, the fork feature branch, the upstream PR, and CI stabilization. The fork's `main` synchronizes only after upstream merge. +- **Open blocker:** The upstream project requires a tracking issue and maintainer feedback before feature implementation begins. No product-scope question remains open. +- **Tracking issue:** `DeusData/codebase-memory-mcp#1113` requests the required design feedback before implementation. + +--- + +## Product Contract + +### Summary + +Extend background auto-watch to pure SVN working copies. Any distinct change to indexable working-copy files triggers the existing graph update pipeline on the next eligible watcher poll so the graph follows the current on-disk development state. + +### Problem Frame + +SVN users currently receive no background change detection because the watcher only recognizes Git repositories. After every SVN update or commit, they must request a full graph regeneration to restore synchronization, even when only a small set of files changed. + +### Actors + +- A1. **SVN developer:** Edits, adds, removes, renames, resolves conflicts in, commits, or updates a local SVN working copy. +- A2. **Background watcher and indexing pipeline:** Detects a new working-copy state, schedules reindexing, and updates the graph through the existing routing rules. + +### Key Decisions + +- **The on-disk working copy is authoritative** (session-settled: user-directed — chosen over operation-boundary-only or tracked-file-only synchronization: the graph must follow local development state before and after SVN operations). This includes versioned and unversioned source files, deletions, renames, and conflicted contents that remain eligible under existing discovery rules. +- **Scope is limited to auto-watch graph synchronization** (session-settled: user-directed — chosen over broader SVN feature parity: automatic graph freshness solves the observed manual-regeneration cost without adding unrelated history or analysis work). +- **Existing timing and pipeline routing remain authoritative** (session-settled: user-directed — chosen over a fixed five-second target or forced incremental routing: SVN should inherit the watcher's adaptive poll timing and the pipeline's safety fallback to full indexing). +- **SVN-aware polling is the change-detection mechanism** (session-settled: user-directed — chosen over filesystem snapshots or dual-signal detection: it extends the current VCS-aware watcher with the smallest ongoing maintenance surface). +- **The system SVN CLI is a runtime prerequisite on all supported platforms** (session-settled: user-directed — chosen over a bundled client or CLI-free implementation: this matches the current external VCS command pattern and preserves cross-platform scope). +- **Git takes precedence in mixed roots** (session-settled: user-directed — chosen over SVN precedence or simultaneous dual watching: one watcher strategy per project avoids duplicate reindex events). +- **Contribution work uses a fork feature branch** (session-settled: user-directed — chosen over pushing unreviewed feature commits to the fork's `main`: the fork main branch should synchronize with upstream only after the upstream PR merges). + +### Requirements + +**Registration and state detection** + +- R1. With `auto_watch` enabled, an indexed pure SVN working copy with a callable system `svn` command must register for background change detection. +- R2. A project root containing both `.git` and `.svn` must retain the existing Git watcher strategy and must not run a second SVN strategy. +- R3. SVN state detection must maintain a working-copy baseline that represents repository revision state and local working-copy changes without repeatedly triggering on an unchanged state. +- R4. The SVN state must distinguish distinct changes involving versioned files, unversioned source files, deletions, renames, and conflict states. + +**Graph synchronization** + +- R5. A distinct SVN working-copy state must trigger the existing indexing pipeline on the next eligible adaptive watcher poll. +- R6. After a successful update, the graph must reflect the current set and contents of on-disk files allowed by existing discovery rules. +- R7. Changes confined to `.svn` metadata or other non-indexable files must not add those files to the graph. +- R8. The existing pipeline must retain authority to choose incremental indexing for ordinary changes and full indexing when its current safety threshold requires it. +- R9. Local edits and the file effects of `svn update` or `svn commit` must require no manual graph regeneration. + +**Reliability and compatibility** + +- R10. A busy, skipped, or failed indexing callback must leave the detected SVN state pending so a later watcher poll retries it. +- R11. A missing or failing `svn` command must produce a diagnosable log, preserve retryable state, and leave Git-watched projects operational. +- R12. SVN auto-watch must behave consistently on macOS, Linux, and Windows when a compatible system `svn` command is available. +- R13. Common, mixed-revision, and sparse SVN working copies must be covered by compatibility tests for state detection. +- R14. Disabling `auto_watch` must prevent SVN watcher registration through the same existing gate used for Git projects. +- R15. The first release must not add an SVN-specific timing setting, VCS selector, status interface, bundled client, or new indexing route. + +### Key Flow + +- F1. SVN working-copy synchronization + - **Trigger:** A registered SVN working copy reaches its next eligible adaptive watcher poll. + - **Actors:** A1, A2. + - **Steps:** Detect the applicable VCS strategy, compare the current SVN state with the last successful baseline, invoke the existing pipeline for a distinct change, and commit the new baseline only after successful indexing. + - **Outcome:** The graph follows the current indexable working-copy files; failed or busy updates remain pending for retry. + - **Covered by:** R1-R14. + +```mermaid +flowchart TB + A[Eligible watcher poll] --> B{Git metadata present?} + B -->|yes| C[Keep existing Git strategy] + B -->|no| D{SVN working copy and CLI available?} + D -->|no| E[Log or skip without affecting Git] + D -->|yes| F[Read current SVN state] + F --> G{Distinct from successful baseline?} + G -->|no| H[Wait for next eligible poll] + G -->|yes| I[Run existing indexing pipeline] + I --> J{Indexing succeeded?} + J -->|yes| K[Commit SVN baseline] + J -->|no| L[Keep change pending for retry] +``` + +### Acceptance Examples + +- AE1. **Covers R4-R6, R9.** Given a clean SVN working copy, when a developer modifies a tracked source file or creates an unversioned source file, then one later eligible poll updates the graph without a manual full regeneration. +- AE2. **Covers R4-R7.** Given an indexed SVN working copy, when a source file is deleted, renamed, or contains conflict markers, then the next successful graph update reflects the current on-disk state while `.svn` metadata remains excluded. +- AE3. **Covers R3-R6, R9.** Given a clean working copy, when `svn update` changes, adds, or removes source files, then the changed SVN state triggers the existing pipeline even though the resulting working copy is clean. +- AE4. **Covers R5, R8-R9.** Given a developer whose local edits were already synchronized, when `svn commit` changes SVN state without further source content changes, then the watcher may route through a pipeline no-op and the graph remains correct without manual regeneration. +- AE5. **Covers R8.** Given an SVN update large enough to cross the existing pipeline safety threshold, when synchronization runs, then the pipeline may select full indexing without introducing an SVN-specific threshold. +- AE6. **Covers R10-R11.** Given a detected SVN change, when the pipeline is busy or `svn` temporarily fails, then the baseline is not committed and a later poll retries after logging the failure. +- AE7. **Covers R2, R12-R14.** Given a supported platform, when a root contains both Git and SVN metadata or `auto_watch` is disabled, then existing Git precedence and registration controls remain unchanged. + +### Success Criteria + +- SVN users no longer need to request full graph regeneration after ordinary working-copy edits, updates, or commits. +- A stable SVN state triggers no repeated indexing, while each distinct state is eligible for one successful synchronization. +- Git watcher regression coverage remains green and new SVN watcher behavior is covered across supported operating-system command conventions. +- User-facing documentation no longer describes auto-watch as Git-only and states the system `svn` prerequisite and failure behavior. + +### Scope Boundaries + +**In scope** + +- Pure SVN working-copy recognition, baselining, polling, retry behavior, and graph-update triggering. +- Existing `auto_watch`, discovery, pipeline routing, and logging conventions. +- Cross-platform behavior using the system SVN CLI. + +**Out of scope** + +- SVN history, author, revision metadata in the graph, impact analysis, or SVN support in other Git-coupled MCP capabilities. +- A generic filesystem watcher, filesystem snapshot polling, or simultaneous Git and SVN event merging. +- New VCS configuration, watcher status surfaces, fixed synchronization timing, bundled SVN binaries, or changed discovery eligibility. + +### Dependencies and Assumptions + +- A compatible `svn` executable is installed and available to the server process. +- Existing discovery and pipeline contracts remain unchanged; this feature supplies an additional watcher trigger, not a new indexing algorithm. +- Implementation begins only after an upstream tracking issue receives maintainer feedback, as required by `CONTRIBUTING.md`. +- Every commit carries a matching DCO `Signed-off-by` trailer, and commit messages plus PR title and body are written in English. +- The upstream PR is opened from a fork feature branch. After upstream merge, the fork's `main` is synchronized to `upstream/main`. + +### Sources and Research + +- `src/watcher/watcher.c` and `src/watcher/watcher.h`: current Git-only strategy, adaptive polling, baselines, and retry contract. +- `src/main.c`: watcher callback integration with the existing indexing pipeline. +- `src/pipeline/pipeline.c` and `src/pipeline/pipeline_incremental.c`: incremental routing and full-index safety fallback. +- `src/mcp/mcp.c` and `README.md`: `auto_watch` registration behavior and current product wording. +- `src/discover/discover.c`: VCS metadata exclusions. +- `tests/test_watcher.c` and `tests/test_mcp.c`: watcher state, retry, registration, and regression patterns. +- `CONTRIBUTING.md`, `DCO`, and `.github/pull_request_template.md`: issue-first contribution policy, PR scope, testing, and sign-off requirements. + +--- + +## Planning Contract + +**Product Contract preservation:** Product Contract unchanged. + +### Key Technical Decisions + +- KTD1. **Use one positively identified VCS strategy per watch registration** (session-settled: user-directed — chosen over SVN precedence or simultaneous dual watching: Git-first classification prevents duplicate graph updates). A successful Git or SVN classification remains fixed until unwatch/rewatch or restart, while uncertain SVN detection stays unclassified and retryable; only a definitive non-working-copy result settles to `none`. Root Git metadata is authoritative for precedence: a failed Git probe cannot fall through to SVN. Without Git metadata, root `.svn` metadata identifies an SVN candidate whose CLI/XML failure remains retryable; a root with neither metadata type settles to `none` without invoking SVN. +- KTD2. **Represent SVN state with recursive semantic and content signatures** (session-settled: user-directed — chosen over filesystem snapshots or dual-signal watching: SVN-aware polling is the smallest extension of the proven VCS watcher). A local-only, machine-readable verbose status supplies per-entry revisions and states; targeted content-sensitive fingerprints distinguish repeated edits whose SVN state label remains unchanged. A schema-scoped incremental parser accepts only the required Subversion status structure, predefined/numeric entities, and bounded tokens; it rejects DTD/external-entity constructs and incomplete structure while safely ignoring unknown schema extensions. The parser streams entries in `O(N + K + B)` work, where `N` is status entries, `K` is unique candidate files, and `B` is bytes fingerprinted under the existing per-file indexing limit. +- KTD3. **Advance only the pre-callback observation after confirmed successful indexing.** Keep acknowledged and pending SVN signatures separate, stage the current observation before the callback, and commit that exact observation only when the pipeline return code or supervised MCP response explicitly represents success. A non-null error envelope remains a callback failure, and a later edit made during indexing remains detectable on the next poll. +- KTD4. **Reuse adaptive polling and the existing pipeline callback** (session-settled: user-directed — chosen over a fixed five-second target or forced incremental routing: the current watcher and pipeline already own timing and safety fallback). SVN adds no routing threshold, indexing mode, or graph mutation path. +- KTD5. **Use the system SVN CLI without a new library dependency** (session-settled: user-directed — chosen over a bundled client or CLI-free implementation: the existing watcher already uses a validated cross-platform VCS command path). Resolve and pin an absolute executable once from trusted `PATH` entries that exclude the current/watched root, reject an executable beneath the project, then reuse the stream-oriented `cbm_popen` boundary as a scoped compromise. Reject truncated command construction, validate complete XML and process success, and treat overflow or parse failure as retryable uncertainty. Localized stderr is diagnostic only. +- KTD6. **Keep materialized SVN externals inside the disk-authoritative state without blocking the primary working copy.** Follow one local status traversal, namespace external entries by root-relative path, and fingerprint only status-selected external candidates. Primary and external observation completeness are tracked separately: a broken external retains its last acknowledged contribution and retries later while a complete primary observation may still synchronize. Every nested `.svn` administration directory remains excluded. +- KTD7. **Coordinate graph replacement with the existing MCP store cache on its owning thread.** The watcher feeds the same pipeline and project database used by current MCP queries, but a full rebuild may replace the database beneath a cached connection. After confirmed indexing success, the watcher thread publishes only a thread-safe stale-store notification; the single MCP event-loop thread consumes it before its next store resolution, closes the affected cached handle, and reopens the replacement. The background watcher never closes a store used by an in-flight query, and failed indexing publishes no invalidation. +- KTD8. **Ship from a fork feature branch** (session-settled: user-directed — chosen over pushing unreviewed work to the fork's `main`: the fork main branch remains a clean upstream synchronization point). +- KTD9. **Keep graph eligibility owned by discovery while allowing conservative watch triggers.** The SVN leaf may use stable public skip/language predicates and the existing per-file indexing byte limit when fingerprinting status-selected paths. It must not duplicate private ignore logic or run whole-root discovery on every poll; a bounded extra pipeline no-op is preferable to a second eligibility implementation, while the pipeline remains the sole authority over graph contents. + +### High-Level Technical Design + +The watcher extends its current Git-specific state into a strategy-owned baseline while retaining the existing scheduler, root lifecycle, callback, and pipeline boundaries. + +```mermaid +flowchart TB + A[Watch registration] --> B{Initial strategy probe} + B -->|Git metadata; probe succeeds| C[Git strategy] + B -->|Git metadata; probe uncertain| O[Probe pending] + B -->|No Git; SVN metadata and probe succeeds| D[SVN strategy] + B -->|SVN metadata; probe uncertain| O + B -->|No Git or SVN metadata| E[No VCS strategy] + O --> B + C --> F[Existing Git state check] + D --> G[Local verbose XML status] + G --> H[Semantic revision and status signature] + G --> I[Targeted file fingerprints] + H --> J[Combined SVN observation] + I --> J + F --> K{Distinct from acknowledged baseline?} + J --> K + K -->|no| L[Wait for next adaptive poll] + K -->|yes| M[Existing index callback] + M --> N[Existing incremental or full pipeline route] +``` + +The baseline is an acknowledgement state machine, not a last-observed cache. + +```mermaid +stateDiagram-v2 + [*] --> Unclassified + Unclassified --> BaselineReady: strategy probe succeeds + Unclassified --> ProbePending: SVN detection uncertain + Unclassified --> BaselineReady: definitive non-VCS result + ProbePending --> BaselineReady: later probe succeeds + ProbePending --> BaselineReady: later definitive non-VCS result + BaselineReady --> BaselineReady: unchanged observation + BaselineReady --> IndexPending: distinct observation staged + IndexPending --> BaselineReady: callback succeeds and staged observation is acknowledged + IndexPending --> RetryPending: callback busy or fails + RetryPending --> IndexPending: next poll recomputes latest observation +``` + +### Assumptions and Implementation Constraints + +- SVN polling uses a recursive verbose XML status with ignored items visible and no remote out-of-date lookup, so normal polls remain local and non-interactive. +- The SVN observation module uses a schema-scoped incremental parser, streams decoded semantic fields, rejects DTD/external-entity constructs and incomplete output, and bounds individual tokens/paths. Order-independent fixed-size entry digests avoid retaining or sorting the full XML document, and localized stderr never controls state. +- Every XML-derived path must be relative, normalized, and contained beneath the watched root. Fingerprinting and descendant walks skip symlinks, junctions/reparse points, non-regular files, `.svn`, and paths that are absolute, drive-qualified, parent-traversing, or outside the canonical root. +- Status-driven content fingerprints cover modified, added, replaced, conflicted, obstructed, unversioned, and ignored candidates that pass stable public discovery predicates and the existing per-file indexing byte limit. Topmost unversioned or ignored directories are coalesced before a bounded descendant walk so each candidate is visited once; private ignore logic is not duplicated and pipeline discovery remains the sole authority over what enters the graph. +- A dirty working copy present at registration follows existing Git semantics: the initial poll establishes revision state but leaves the nonzero dirty signature unacknowledged, causing one synchronization on the next eligible poll. +- Probe failure and index failure are distinct. Probe failure never invokes indexing or overwrites an acknowledged baseline; callback failure stages no acknowledgement. +- SVN scheduling starts the next adaptive interval after probe/callback completion, preventing a slow recursive poll from becoming immediately due again; the interval formula remains unchanged. +- `auto_watch=false` remains a registration-time gate, and VCS metadata changes do not hot-switch an existing watch entry. +- SVN 1.14.x is the primary compatibility reference. CI-installed client versions may vary only where the required XML/status options and semantics remain compatible. +- No applicable institutional learning documents exist under `docs/solutions/`; current source, tests, and official Subversion documentation are authoritative. + +### System-Wide Impact + +- **Watcher state:** `src/watcher/watcher.c` gains metadata-first strategy selection and SVN acknowledgement fields while a private `src/watcher/svn_state.c` leaf owns trusted CLI resolution and XML observation construction. Scheduling, missing-root pruning, deferred free, and callback concurrency stay in the watcher. +- **Pipeline and graph:** No pipeline implementation changes are planned. The existing callback and stored file hashes remain the sole graph synchronization route. +- **MCP context parity:** A successful SVN-triggered update must become visible through existing graph query tools in the same running server session, including after a full route replaces the database file. +- **Performance:** Recursive verbose status and targeted candidate hashing must remain one status process, one streaming entry pass, one visit per unique candidate, and no read beyond the existing per-file indexing limit. Characterization reports total bytes hashed as well as entries and candidates. Adaptive polling and completion-relative scheduling remain the load controls; no aggregate byte cap or wall-clock SLA is added because either would make distinct eligible changes unobservable. +- **Security and portability:** Machine-readable output avoids localization drift. Existing shell-argument validation, Windows command isolation, XML entity decoding, separators, spaces, Unicode, and peg-revision-sensitive paths require explicit coverage. +- **CI and contributor workflow:** Native SVN fixtures require `svn` and `svnadmin` on Unix and MSYS2 test jobs. The feature remains one issue and one focused PR. + +### Risks and Mitigations + +| Risk | Mitigation | +|---|---| +| A root-only revision or `svnversion` aggregate misses interior mixed-revision changes | Hash per-entry working revisions from recursive verbose status. | +| XML state remains unchanged across repeated edits or Windows mtime resolution collides | Content-hash status-selected files and eligible descendants of coalesced unversioned directories; stat metadata may only be a fast-path input. | +| Malformed, truncated, locked, or unavailable CLI output is mistaken for a clean tree | Require successful process completion plus a complete XML document and preserve the acknowledged baseline on any uncertainty. | +| A concurrent edit lands while indexing | Acknowledge the staged pre-callback observation, never a post-index reread. | +| Externals or large working copies make polling expensive | Keep status local-only, retain adaptive intervals, avoid whole-tree fingerprints except status-selected unversioned/ignored directories, cap each file at the existing indexing read limit, and characterize entries plus bytes hashed. | +| Windows paths or XML escaping destabilize signatures | Decode XML entities, normalize separators for internal lookup, and test spaces, Unicode, `@`, leading dashes, CRLF, and long paths. | +| A decoded status path or unversioned link escapes the project | Reject unsafe path forms, require canonical root containment, and never follow symlinks, junctions/reparse points, or non-regular files. | +| A repository-local executable shadows the system SVN client | Resolve from trusted non-project `PATH` entries, pin the canonical absolute executable, and test hostile current-directory shadowing. | +| A full pipeline route replaces the database under a cached MCP store | Publish a post-success stale notification and let the MCP event-loop thread close/reopen before its next query; verify overlapping queries and replacement on every CI platform. | +| A supervised worker returns an MCP error envelope | Parse the response outcome; report callback failure, retain the pending observation, and skip store invalidation. | +| A recursive SVN command hangs indefinitely at the existing stream boundary | Prevent overlapping polls and schedule from completion; true process timeout/supervision remains a documented residual risk outside this focused PR. | +| New code changes existing Git behavior | Keep strategy-specific probes behind Git-first classification and run the full existing watcher suite unchanged. | + +### Alternatives Considered + +- **Root revision or `svnversion` only:** Rejected because commits and updates can change interior node revisions without changing a useful root or aggregate signal. +- **Hash raw XML output:** Rejected because formatting and path representation are not the semantic contract, and raw status still cannot detect repeated content edits. +- **Generic filesystem snapshot watcher:** Rejected by the settled scope because it scans beyond SVN state and introduces a broader non-VCS feature. +- **SVN plus filesystem dual watcher:** Rejected by the settled scope because duplicate event ownership and baseline reconciliation exceed the functional core. +- **New cross-platform subprocess capture layer:** Deferred because the established watcher command path already provides PATH lookup, streamed output, and Windows isolation; expanding the process API would broaden the PR without changing product behavior or solving the entire timeout contract. +- **Ignore externals:** Rejected because existing discovery may index their on-disk source, so ignoring their changes would violate the disk-authoritative graph requirement. + +### Sources and Research + +- `src/watcher/watcher.c`, `src/watcher/watcher.h`, and `tests/test_watcher.c`: acknowledged-versus-pending signatures, dirty-at-baseline behavior, adaptive scheduling, callback retries, and Git regression patterns. +- `src/foundation/compat_fs.c` and `src/foundation/subprocess.c`: current streamed command behavior, Windows handle isolation, and the limits of the non-capturing subprocess API. +- `src/mcp/mcp.c`, `src/mcp/mcp.h`, and `src/main.c`: live store ownership and watcher callback integration that shape same-session graph visibility. +- Apache Subversion 1.14 release notes: `https://subversion.apache.org/docs/release-notes/1.14.html`. +- Official `svn status` reference and 1.14 XML schema: `https://svnbook.red-bean.com/en/1.8/svn.ref.svn.c.status.html` and `https://github.com/apache/subversion/blob/1.14.x/subversion/svn/schema/status.rnc`. +- Official mixed-revision, sparse working-copy, externals, and peg-revision behavior: `https://svnbook.red-bean.com/en/1.8/svn.basic.in-action.html#svn.basic.in-action.mixedrevs`, `https://svnbook.red-bean.com/en/1.8/svn.advanced.sparsedirs.html`, `https://svnbook.red-bean.com/en/1.8/svn.advanced.externals.html`, and `https://svnbook.red-bean.com/en/1.8/svn.advanced.pegrevs.html`. + +### Sequencing + +U1 establishes the observable SVN state contract before U2 connects it to watcher acknowledgement and retry behavior. U3 proves graph and platform integration after both state layers exist. U4 updates the user contract only after behavior and prerequisites are verified. + +--- + +## Implementation Units + +### U1. Add secure SVN observation and semantic state probes + +- **Goal:** Provide a private, stable, local-only SVN observation leaf that detects clean revision movement and repeated content edits without coupling XML or CLI concerns to watcher scheduling. +- **Requirements:** R1, R3-R4, R7, R11-R13, R15; KTD2, KTD5-KTD6, KTD9. +- **Dependencies:** None. +- **Files:** `src/watcher/svn_state.c`, `src/watcher/svn_state.h`, `tests/test_svn_state.c`, `tests/test_main.c`, `Makefile.cbm`, `.github/workflows/_test.yml`. +- **Approach:** Add an internal tri-state SVN probe that resolves a trusted absolute CLI path, streams recursive verbose XML status through a schema-scoped parser, emits semantic/content signatures plus entry count, and owns XML entity/path normalization. Hash decoded per-entry revision/state fields into order-independent fixed-size digests; content-hash unique, contained, regular status-selected candidates only up to the existing indexing byte limit; coalesce overlapping unversioned/ignored directory walks without duplicating private discovery rules; and treat process, truncation, overflow, unsafe paths, or XML failure as uncertainty. Register the `svn_state` suite in the existing test runner and install `svn`/`svnadmin` only in CI jobs that execute native C suites. +- **Execution note:** Start with the standalone parser/probe suite and real `svnadmin` fixtures before integrating any watcher lifecycle state. +- **Patterns to follow:** Streaming Git signatures in `src/watcher/watcher.c`; isolated Windows `cbm_popen` behavior in `src/foundation/compat_fs.c`; temporary repository helpers in `tests/test_watcher.c`; `.svn` exclusion in `src/discover/discover.c`. +- **Test scenarios:** + - Parser fixtures cover normal, added, deleted, missing, replaced, moved, obstructed, ignored, unversioned, property, text/property/tree conflict, switched, copied, sparse, and external fields. + - Complete XML split across small input chunks produces the same observation as a single stream; truncated or malformed XML remains uncertain and retains no partial observation. + - The parser accepts predefined/numeric entities, ignores unknown schema extensions, and rejects DTD, external-entity, oversized-token, and structurally incomplete input. + - Reordered equivalent entries produce the same signature without retaining the full document; counters prove one entry finalization and at most one content fingerprint per unique candidate. + - A real clean update that modifies, adds, or deletes a child changes per-entry revision state even when the resulting working copy is clean. + - A real child commit changes mixed-revision state without relying on the root or aggregate revision. + - A tracked same-size rapid rewrite and restored-timestamp rewrite change the content signature while the path remains modified. + - A file inside an already unversioned or ignored directory changes the signature again; overlapping directory candidates visit each descendant once and never enter `.svn`. + - Materialized file/directory externals share the one local status traversal and are namespaced by root-relative path; a broken external retains its prior contribution without blocking a complete primary observation. + - Missing CLI, non-working-copy roots, nonzero exit, command-buffer truncation, overflow, malformed XML, and incomplete output are distinguished. + - A repository-local `svn`/`svn.exe`, hostile current directory, empty `PATH` component, or executable beneath the watched root cannot shadow the pinned system client. + - Spaces, Unicode, XML-special characters, `@`, leading dashes, long paths, Windows separators, and CRLF decode into stable file lookups. + - Absolute, drive-qualified, parent-traversing, symlink, junction/reparse-point, special-file, and external-escape candidates are rejected without file reads. + - Unsupported or oversized files do not enter the content-hash workload; synthetic 1K, 10K, and 50K entry streams keep parser buffering bounded and report non-gating time, RSS, and total bytes hashed. +- **Verification:** The focused SVN state suite proves a single pinned local process, streaming semantic correctness, bounded parser memory, `O(N + K + B)` traversal, path containment, and required real working-copy semantics without repository-network access. + +### U2. Integrate SVN observations with watcher baselines and retries + +- **Goal:** Apply SVN observations to the existing adaptive poll lifecycle without losing changes, repeatedly indexing stable states, or changing Git behavior. +- **Requirements:** R1, R3-R5, R9-R12, R14-R15; F1; AE1-AE4, AE6-AE7; KTD1-KTD5. +- **Dependencies:** U1. +- **Files:** `src/watcher/watcher.c`, `src/watcher/watcher.h`, `tests/test_watcher.c`. +- **Approach:** Replace the Git boolean with `none`, `git`, and `svn` strategies while keeping the SVN leaf behind a private boundary. Classify from root metadata before probing: Git metadata blocks SVN fallback even when Git probing is uncertain; `.svn` without Git remains retryable until the SVN probe confirms it; neither metadata type settles to `none`. Store acknowledged and pending SVN semantic/content signatures, establish revision state while leaving pre-existing dirt eligible for one sync, stage observations before the callback, acknowledge only on confirmed success, and recompute after failure. Reuse file-count-derived adaptive intervals, but schedule the next SVN eligibility window after the current probe/callback completes. +- **Execution note:** Add characterization coverage around existing Git baseline/retry behavior before generalizing shared lifecycle code. +- **Patterns to follow:** Pending Git HEAD/dirty signature acknowledgement in `src/watcher/watcher.c`; callback return contract in `src/watcher/watcher.h`; `auto_watch` registration coverage in `tests/test_mcp.c`. +- **Test scenarios:** + - A pre-existing dirty SVN working copy initializes without an immediate callback and triggers exactly once on the next eligible poll. + - An unchanged clean or dirty SVN state does not retrigger, while each later distinct edit triggers once. + - Callback success acknowledges the staged pre-callback state; an edit made during the callback remains visible on the next poll. + - Busy and failed callbacks preserve acknowledged state, recompute the latest observation on retry, and acknowledge only a later success. + - CLI or XML probe failure neither invokes the callback nor advances state; recovery coalesces intermediate changes into one latest-state sync. + - A temporarily unavailable SVN CLI at initial baseline remains retryable and later classifies as SVN; a definitive non-working-copy result settles to `none`. + - A mixed root whose Git probe temporarily fails remains pending as Git-first and never falls through to SVN; an ordinary root with neither `.git` nor `.svn` settles to `none` without an SVN command. + - File-count changes refresh the existing adaptive interval without adding an SVN-specific timer. + - A slow SVN probe receives one full adaptive interval after completion before it becomes eligible again and never overlaps itself. + - `auto_watch=false`, project deletion, explicit unwatch, missing-root grace pruning, uncertain root errors, and watcher shutdown retain existing behavior for SVN entries. + - Existing Git tests remain unchanged and green, including dirty-state deduplication and failed callback retry. +- **Verification:** The focused watcher suite proves one callback per distinct acknowledged state, retry safety, completion-relative SVN scheduling, watcher lifecycle behavior, and unchanged Git semantics. Existing MCP `auto_watch` tests remain green in the full suite as R14 registration evidence. + +### U3. Coordinate live MCP stores and prove graph synchronization + +- **Goal:** Make incremental and full SVN-triggered updates visible through the same running MCP server without stale cached stores. +- **Requirements:** R5-R13; F1; AE1-AE6; KTD3-KTD7. +- **Dependencies:** U1, U2. +- **Files:** `src/main.c`, `src/mcp/mcp.c`, `src/mcp/mcp.h`, `tests/test_integration.c`. +- **Approach:** Normalize supervised responses into the watcher callback contract so only an explicit non-error index result returns success. After that success, publish a thread-safe stale-store notification through callback `user_data` or a narrow public MCP interface; consume it on the MCP event-loop thread before the next store resolution, where the affected cached handle is closed and reopened. The watcher thread never closes the store directly. Use one real SVN integration sequence to pre-warm a query store, apply working-copy changes, run the real watcher/pipeline path, and query again through the same server instance; exercise full replacement through existing pipeline test setup rather than an SVN-specific routing threshold. +- **Patterns to follow:** MCP store resolution/cache ownership in `src/mcp/mcp.c`; watcher callback integration in `src/main.c`; pipeline-to-store-to-MCP flows in `tests/test_integration.c`. +- **Test scenarios:** + - Covers AE1. A tracked edit and an unversioned source addition become queryable after one successful watcher update in the same server lifecycle. + - Covers AE2. A deletion or rename removes stale graph state, and conflicted on-disk contents remain the pipeline input. + - Covers AE3. A clean update changing, adding, and deleting files refreshes graph results without another explicit `index_repository` call. + - Covers AE4. A commit after already-synchronized content may route through an incremental no-op while graph results remain correct. + - Covers AE5. An existing full-route fixture replaces the database, invalidates/reopens the pre-warmed store, and keeps graph queries correct through the same server instance. + - Covers AE6. A busy or failed callback leaves the prior graph queryable; a later successful retry exposes the latest state. + - MCP store coordination occurs only after successful indexing and does not invalidate a queryable prior graph on probe/callback failure. + - A non-null supervised `isError` response returns callback failure, preserves the pending observation, and publishes no stale-store notification. + - A query overlapping watcher indexing continues on its owned handle; the next event-loop query consumes the stale notification and reopens safely, with TSan coverage for the notification handoff. +- **Verification:** The focused integration suite proves same-session query visibility across incremental, no-op, retry, and database-replacing full routes. The full CI matrix proves the real SVN fixture has no platform skip path. + +### U4. Document SVN auto-watch and contribution constraints + +- **Goal:** Make the expanded auto-watch behavior, prerequisite, failure posture, and unchanged controls discoverable to users and contributors. +- **Requirements:** R1, R11-R15; KTD5-KTD8. +- **Dependencies:** U1-U3. +- **Files:** `README.md`, `CONTRIBUTING.md`. +- **Approach:** Replace Git-only watcher wording with Git-and-SVN behavior, document the system `svn` prerequisite and local-only polling, retain `auto_watch` as the only control, and keep implementation notes aligned with the approved tracking issue and DCO process. Update `CONTRIBUTING.md` only if its architecture map would otherwise remain factually Git-only. +- **Test scenarios:** Test expectation: none -- this unit changes documentation after U1-U3 provide executable evidence. +- **Verification:** Documentation matches the shipped behavior, names no unsupported SVN history capability, and introduces no new user control. + +--- + +## Verification Contract + +| Gate | Command | Proves | +|---|---|---| +| Focused SVN observation | `make -f Makefile.cbm test-focused TEST_SUITES=svn_state` | Streaming XML semantics, real working-copy state, content fingerprints, failure classification, and bounded traversal. | +| Focused watcher lifecycle | `make -f Makefile.cbm test-focused TEST_SUITES=watcher` | Strategy selection, acknowledgement, retry, scheduling, lifecycle, and Git regression behavior. | +| Focused graph integration | `make -f Makefile.cbm test-focused TEST_SUITES=integration` | Same-session MCP visibility across incremental, no-op, failure/retry, and full database replacement. | +| Full sanitized suite | `scripts/test.sh` | Repository-wide C behavior under the project's authoritative sanitizer and production builds. | +| Static and formatting checks | `scripts/lint.sh` | clang-tidy, cppcheck, clang-format, and no-skip policy. | +| Patch hygiene | `git diff --check` | Whitespace and patch integrity. | +| CI matrix | GitHub PR checks | Native Linux, macOS, Windows/MSYS2, and TSan test jobs use real SVN tooling; CodeQL, security, DCO, and license checks remain required without an SVN-tooling claim. | + +No browser test is required because this change has no UI or browser-facing behavior. + +--- + +## Definition of Done + +- Product Contract remains unchanged and every implementation decision traces to R/F/AE and KTD identifiers. +- U1 isolates and proves trusted machine-readable SVN state detection with streaming, bounded memory, content-sensitive bounded fingerprints, common/mixed/sparse/external semantics, path containment, and path-encoding cases. +- U2 proves acknowledged/pending baseline safety, one sync per distinct state, retry after probe/index failure, adaptive polling, lifecycle cleanup, and unchanged Git behavior. +- U3 proves only confirmed indexing success is acknowledged and the same running MCP server observes SVN-triggered incremental and database-replacing full updates without stale cached stores or cross-thread store closure on Linux, macOS, and Windows. +- U4 documents Git-and-SVN auto-watch, the system CLI prerequisite, retry behavior, unchanged `auto_watch` control, and excluded broader SVN capabilities. +- Focused tests, the full sanitized suite, lint, patch hygiene, and all required GitHub checks pass. +- The upstream tracking issue has maintainer approval, every commit includes a matching DCO sign-off, and the English PR references the issue. +- The final diff contains no abandoned parser, subprocess, filesystem-snapshot, or duplicate-watcher experiments and remains one focused feature PR. +- True recovery from a permanently hung `svn` process remains outside this PR; no claim or test may imply a timeout guarantee the existing process boundary does not provide. diff --git a/scripts/security-allowlist.txt b/scripts/security-allowlist.txt index ed329619b..7e38a10e4 100644 --- a/scripts/security-allowlist.txt +++ b/scripts/security-allowlist.txt @@ -23,6 +23,10 @@ src/watcher/watcher.c:cbm_popen:git working tree status (git_is_dirty) src/watcher/watcher.c:cbm_popen:git file count (git_file_count) src/watcher/watcher.c:popen:via cbm_popen wrapper calls +# ── Watcher: SVN status polling (trusted executable pinned during registration) ── +src/watcher/svn_state.c:cbm_popen:recursive local SVN status XML (executable pinned; args validated) +src/watcher/svn_state.c:popen:via cbm_popen wrapper call + # ── Git context: git metadata resolution (repo paths validated via cbm_validate_shell_arg) ── src/git/git_context.c:cbm_popen:git rev-parse/symbolic-ref/merge-base metadata lookup src/git/git_context.c:popen:via cbm_popen wrapper call diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c index 54c9e3c67..9e5006e9a 100644 --- a/src/foundation/compat_fs.c +++ b/src/foundation/compat_fs.c @@ -192,7 +192,8 @@ static wchar_t *cbm_resolve_comspec(void) { /* On failure returns NULL with *stage naming the failing step and *gle the * GetLastError value captured at that step (0 when errno is the signal). */ -static FILE *cbm_popen_isolated(const char *cmd, const char **stage, DWORD *gle) { +static FILE *cbm_popen_isolated(const char *cmd, const char *const *argv, const char **stage, + DWORD *gle) { *stage = ""; *gle = 0; InitOnceExecuteOnce(&g_popen_once, cbm_popen_init, NULL, NULL); @@ -246,12 +247,19 @@ static FILE *cbm_popen_isolated(const char *cmd, const char **stage, DWORD *gle) si.StartupInfo.hStdError = nul; si.lpAttributeList = attr; - /* Run through cmd.exe /c so command quoting and `2>NUL` behave as under - * _popen. The command line is heap-composed (no fixed-size truncation) - * and widened via UTF-8 so non-ASCII repo paths survive intact. */ - wchar_t *app = cbm_resolve_comspec(); + /* Shell commands run through cmd.exe /c for _popen compatibility. Argv + * callers bypass the shell and pin the requested executable directly. + * Both paths use heap-composed UTF-8 command lines so non-ASCII paths + * survive intact. */ + wchar_t *app = NULL; wchar_t *wcmdline = NULL; - if (app) { + if (argv) { + app = cbm_utf8_to_wide(argv[0]); + wcmdline = cbm_build_cmdline(argv); + } else { + app = cbm_resolve_comspec(); + } + if (app && !argv) { size_t u8len = strlen(cmd) + sizeof("cmd.exe /c "); char *u8 = (char *)malloc(u8len); if (u8) { @@ -334,7 +342,7 @@ FILE *cbm_popen(const char *cmd, const char *mode) { if (mode && mode[0] == 'r' && mode[1] == '\0') { const char *stage = ""; DWORD gle = 0; - FILE *fp = cbm_popen_isolated(cmd, &stage, &gle); + FILE *fp = cbm_popen_isolated(cmd, NULL, &stage, &gle); g_popen_last_isolated = (fp != NULL); if (!fp) { char glebuf[CBM_SZ_16]; @@ -350,6 +358,24 @@ FILE *cbm_popen(const char *cmd, const char *mode) { return _popen(cmd, mode); } +FILE *cbm_popen_argv(const char *const *argv) { + if (!argv || !argv[0] || !argv[0][0]) { + return NULL; + } + const char *stage = ""; + DWORD gle = 0; + FILE *fp = cbm_popen_isolated(NULL, argv, &stage, &gle); + g_popen_last_isolated = (fp != NULL); + if (!fp) { + char glebuf[CBM_SZ_16]; + char errnobuf[CBM_SZ_16]; + snprintf(glebuf, sizeof(glebuf), "%lu", (unsigned long)gle); + snprintf(errnobuf, sizeof(errnobuf), "%d", errno); + cbm_log_warn("compat.popen_argv_failed", "stage", stage, "gle", glebuf, "errno", errnobuf); + } + return fp; +} + int cbm_pclose(FILE *f) { InitOnceExecuteOnce(&g_popen_once, cbm_popen_init, NULL, NULL); diff --git a/src/foundation/compat_fs.h b/src/foundation/compat_fs.h index af89c795d..05a9f03f5 100644 --- a/src/foundation/compat_fs.h +++ b/src/foundation/compat_fs.h @@ -38,6 +38,11 @@ void cbm_closedir(cbm_dir_t *d); FILE *cbm_popen(const char *cmd, const char *mode); int cbm_pclose(FILE *f); +#ifdef _WIN32 +/* Spawn an argv vector directly while capturing stdout through the same + * isolated handle-inheritance path as cbm_popen. */ +FILE *cbm_popen_argv(const char *const *argv); +#endif /* ── File operations ──────────────────────────────────────────── */ diff --git a/src/main.c b/src/main.c index 01083e809..d1703398c 100644 --- a/src/main.c +++ b/src/main.c @@ -158,19 +158,25 @@ static void *http_thread(void *arg) { /* ── Index callback for watcher ─────────────────────────────────── */ static int watcher_index_fn(const char *project_name, const char *root_path, void *user_data) { - (void)user_data; + cbm_mcp_server_t *server = (cbm_mcp_server_t *)user_data; /* Skip indexing if shutdown is in progress (skipped, not indexed) */ if (atomic_load(&g_shutdown)) { return 1; } + /* Windows readers prevent atomic database replacement. Acquire the MCP + * store gate before the pipeline lock so tool-driven and watcher-driven + * indexing use one deadlock-free lock order. */ + cbm_mcp_server_begin_store_update(server); + /* Non-blocking: skip if another pipeline is already running. The * positive return keeps the watcher's baselines uncommitted so the * change is retried on the next poll cycle (5-60s) instead of being * recorded as seen and lost (#937). */ if (!cbm_pipeline_try_lock()) { cbm_log_info("watcher.skip", "project", project_name, "reason", "pipeline_busy"); + cbm_mcp_server_end_store_update(server, false); return 1; } @@ -186,8 +192,13 @@ static int watcher_index_fn(const char *project_name, const char *root_path, voi if (cbm_index_supervisor_should_wrap()) { char *resp = cbm_mcp_index_run_supervised_path(root_path); if (resp) { + bool succeeded = cbm_mcp_result_succeeded(resp); free(resp); cbm_pipeline_unlock(); + cbm_mcp_server_end_store_update(server, succeeded); + if (!succeeded) { + return CBM_NOT_FOUND; + } return 0; } /* resp == NULL → spawn-failure degrade → fall through to in-process. */ @@ -196,12 +207,14 @@ static int watcher_index_fn(const char *project_name, const char *root_path, voi cbm_pipeline_t *p = cbm_pipeline_new(root_path, NULL, CBM_MODE_FULL); if (!p) { cbm_pipeline_unlock(); + cbm_mcp_server_end_store_update(server, false); return CBM_NOT_FOUND; } int rc = cbm_pipeline_run(p); cbm_pipeline_free(p); cbm_pipeline_unlock(); + cbm_mcp_server_end_store_update(server, rc == 0); return rc; } @@ -794,7 +807,7 @@ int main(int argc, char **argv) { cbm_store_t *watch_store = NULL; if (!restricted_tool_profile) { watch_store = cbm_store_open_memory(); - g_watcher = cbm_watcher_new(watch_store, watcher_index_fn, NULL); + g_watcher = cbm_watcher_new(watch_store, watcher_index_fn, g_server); /* Wire watcher + config into MCP server for session auto-index. */ cbm_mcp_server_set_watcher(g_server, g_watcher); cbm_mcp_server_set_config(g_server, runtime_config); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 804222436..2be943b99 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -74,6 +74,7 @@ enum { #include #include #include // int64_t +#include #include #include #include @@ -292,6 +293,23 @@ char *cbm_mcp_text_result(const char *text, bool is_error) { return out; } +bool cbm_mcp_result_succeeded(const char *result_json) { + if (!result_json) { + return false; + } + + yyjson_doc *doc = yyjson_read(result_json, strlen(result_json), 0); + if (!doc) { + return false; + } + + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *is_error = yyjson_is_obj(root) ? yyjson_obj_get(root, "isError") : NULL; + bool succeeded = yyjson_is_bool(is_error) && !yyjson_get_bool(is_error); + yyjson_doc_free(doc); + return succeeded; +} + bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id, const char *active_id_str) { if (!params_json) { @@ -1358,10 +1376,14 @@ bool cbm_mcp_get_bool_arg(const char *args_json, const char *key) { * ══════════════════════════════════════════════════════════════════ */ struct cbm_mcp_server { - cbm_store_t *store; /* currently open project store (or NULL) */ - bool owns_store; /* true if we opened the store */ - char *current_project; /* which project store is open for (heap) */ - time_t store_last_used; /* last time resolve_store was called for a named project */ + cbm_store_t *store; /* currently open project store (or NULL) */ + bool owns_store; /* true if we opened the store */ + char *current_project; /* which project store is open for (heap) */ + time_t store_last_used; /* last time resolve_store was called for a named project */ + atomic_bool store_stale; /* background index completed; event thread must reopen */ +#ifdef _WIN32 + cbm_mutex_t store_update_gate; /* serialize cached readers with atomic DB replacement */ +#endif char update_notice[CBM_SZ_256]; /* one-shot update notice, cleared after first injection */ bool update_checked; /* true after background check has been launched */ cbm_thread_t update_tid; /* background update check thread */ @@ -1399,6 +1421,10 @@ cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) { } srv->owns_store = true; srv->tool_profile = CBM_MCP_TOOL_PROFILE_ALL; + atomic_init(&srv->store_stale, false); +#ifdef _WIN32 + cbm_mutex_init(&srv->store_update_gate); +#endif return srv; } @@ -1433,6 +1459,12 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } +void cbm_mcp_server_mark_store_stale(cbm_mcp_server_t *srv) { + if (srv) { + atomic_store_explicit(&srv->store_stale, true, memory_order_release); + } +} + void cbm_mcp_server_free(cbm_mcp_server_t *srv) { if (!srv) { return; @@ -1448,27 +1480,39 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv) { } free(srv->current_project); free(srv->active_request_id_str); +#ifdef _WIN32 + cbm_mutex_destroy(&srv->store_update_gate); +#endif free(srv); } -/* ── Idle store eviction ──────────────────────────────────────── */ - -void cbm_mcp_server_evict_idle(cbm_mcp_server_t *srv, int timeout_s) { - if (!srv || !srv->store) { - return; +static void store_update_gate_lock(cbm_mcp_server_t *srv) { +#ifdef _WIN32 + if (srv) { + cbm_mutex_lock(&srv->store_update_gate); } - /* Protect initial in-memory stores that were never accessed via a named project. - * store_last_used stays 0 until resolve_store is called with a non-NULL project. */ - if (srv->store_last_used == 0) { - return; +#else + (void)srv; +#endif +} + +static void store_update_gate_unlock(cbm_mcp_server_t *srv) { +#ifdef _WIN32 + if (srv) { + cbm_mutex_unlock(&srv->store_update_gate); } +#else + (void)srv; +#endif +} - time_t now = time(NULL); - if ((now - srv->store_last_used) < timeout_s) { +/* Callers must run on the request thread, or hold store_update_gate on + * Windows so no request can concurrently use the cached SQLite handle. */ +static void invalidate_cached_store(cbm_mcp_server_t *srv) { + if (!srv) { return; } - - if (srv->owns_store) { + if (srv->owns_store && srv->store) { cbm_store_close(srv->store); } srv->store = NULL; @@ -1477,6 +1521,51 @@ void cbm_mcp_server_evict_idle(cbm_mcp_server_t *srv, int timeout_s) { srv->store_last_used = 0; } +void cbm_mcp_server_begin_store_update(cbm_mcp_server_t *srv) { + if (!srv) { + return; + } + store_update_gate_lock(srv); +#ifdef _WIN32 + /* Windows cannot atomically replace a SQLite file while this process has + * an open reader. The gate makes cross-thread close safe and keeps new + * query handles from opening until publication completes. */ + invalidate_cached_store(srv); +#endif +} + +void cbm_mcp_server_end_store_update(cbm_mcp_server_t *srv, bool published) { + if (!srv) { + return; + } + if (published) { + cbm_mcp_server_mark_store_stale(srv); + } + store_update_gate_unlock(srv); +} + +static void consume_stale_store(cbm_mcp_server_t *srv) { + if (atomic_exchange_explicit(&srv->store_stale, false, memory_order_acq_rel)) { + invalidate_cached_store(srv); + } +} + +/* ── Idle store eviction ──────────────────────────────────────── */ + +void cbm_mcp_server_evict_idle(cbm_mcp_server_t *srv, int timeout_s) { + if (!srv) { + return; + } + store_update_gate_lock(srv); + if (srv->store && srv->store_last_used != 0) { + time_t now = time(NULL); + if ((now - srv->store_last_used) >= timeout_s) { + invalidate_cached_store(srv); + } + } + store_update_gate_unlock(srv); +} + bool cbm_mcp_server_has_cached_store(cbm_mcp_server_t *srv) { return (srv && srv->store != NULL) != 0; } @@ -1531,6 +1620,8 @@ static cbm_store_t *resolve_store_fallback_scan(const char *project); * Caches the connection — reopens only when project changes. * Tracks last-access time so the event loop can evict idle stores. */ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { + consume_stale_store(srv); + if (!project) { return NULL; /* project is required — no implicit fallback */ } @@ -5371,15 +5462,7 @@ static char *build_worker_failure_response(const char *args, cbm_proc_outcome_t * writes the DB and the parent only needs the return code, so there is nothing * to invalidate. */ static void supervisor_invalidate_store(cbm_mcp_server_t *srv) { - if (!srv) { - return; - } - if (srv->owns_store && srv->store) { - cbm_store_close(srv->store); - srv->store = NULL; - } - free(srv->current_project); - srv->current_project = NULL; + invalidate_cached_store(srv); } /* Resolve a per-supervisor-run temp path /logs/.supervisor- @@ -7822,7 +7905,8 @@ static char *handle_ingest_traces(cbm_mcp_server_t *srv, const char *args) { /* ── Tool dispatch ────────────────────────────────────────────── */ -char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { +static char *handle_tool_unlocked(cbm_mcp_server_t *srv, const char *tool_name, + const char *args_json) { if (!tool_name) { return cbm_mcp_text_result("missing tool name", true); } @@ -7885,6 +7969,13 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch return cbm_mcp_text_result(msg, true); } +char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { + store_update_gate_lock(srv); + char *result = handle_tool_unlocked(srv, tool_name, args_json); + store_update_gate_unlock(srv); + return result; +} + /* ── Session detection + auto-index ────────────────────────────── */ /* Detect session root from CWD (fallback: single indexed project from DB). */ @@ -7946,6 +8037,7 @@ static void *autoindex_thread(void *arg) { cbm_mcp_server_t *srv = (cbm_mcp_server_t *)arg; cbm_log_info("autoindex.start", "project", srv->session_project, "path", srv->session_root); + cbm_mcp_server_begin_store_update(srv); /* #832: prefer the supervised worker subprocess. Indexing the whole session in * this long-lived server thread ratchets RSS (mimalloc v3 does not reclaim the @@ -7953,9 +8045,17 @@ static void *autoindex_thread(void *arg) { * 100% of that memory back to the OS every cycle. Degrade to the in-process * pipeline below when the supervisor is off (kill switch) or the spawn fails. */ if (cbm_index_supervisor_should_wrap()) { - char *resp = index_run_supervised_path(srv, srv->session_root); + /* This is a background thread: use the srv-less runner so the + * supervisor cannot close the event loop's cached query handle. */ + char *resp = index_run_supervised_path(NULL, srv->session_root); if (resp) { + bool succeeded = cbm_mcp_result_succeeded(resp); free(resp); + cbm_mcp_server_end_store_update(srv, succeeded); + if (!succeeded) { + cbm_log_warn("autoindex.err", "msg", "supervised_index_failed"); + return NULL; + } cbm_log_info("autoindex.done", "project", srv->session_project, "mode", "supervised"); /* Register with watcher for ongoing change detection — gated on * auto_watch (#849), same as the in-process branch below. A bare @@ -7969,6 +8069,7 @@ static void *autoindex_thread(void *arg) { cbm_pipeline_t *p = cbm_pipeline_new(srv->session_root, NULL, CBM_MODE_FULL); if (!p) { + cbm_mcp_server_end_store_update(srv, false); cbm_log_warn("autoindex.err", "msg", "pipeline_create_failed"); return NULL; } @@ -7979,6 +8080,7 @@ static void *autoindex_thread(void *arg) { cbm_pipeline_unlock(); cbm_pipeline_free(p); + cbm_mcp_server_end_store_update(srv, rc == 0); cbm_mem_collect(); /* return mimalloc pages to OS after indexing (in-process only) */ if (rc == 0) { diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 09a7ed1ec..f42bf1f02 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -54,6 +54,10 @@ char *cbm_jsonrpc_format_error(int64_t id, int code, const char *message); /* Format an MCP tool result with text content. Returns heap-allocated JSON. */ char *cbm_mcp_text_result(const char *text, bool is_error); +/* Return true only for a valid MCP tool result that explicitly reports + * isError=false. Malformed or ambiguous results fail closed. */ +bool cbm_mcp_result_succeeded(const char *result_json); + /* Return true when notifications/cancelled params target the active request. */ bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id, const char *active_id_str); @@ -123,6 +127,17 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w); /* Set external config store reference (for auto_index setting). Not owned. */ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg); +/* Publish a thread-safe notification that the cached query store is stale. + * The owning MCP request thread consumes it before its next store resolution. */ +void cbm_mcp_server_mark_store_stale(cbm_mcp_server_t *srv); + +/* Bracket a background graph publication. On Windows this waits for active + * MCP tools, closes the cached SQLite reader, and blocks new tools until end; + * on POSIX the existing generation remains queryable during publication. + * Every successful begin must be paired with end on every exit path. */ +void cbm_mcp_server_begin_store_update(cbm_mcp_server_t *srv); +void cbm_mcp_server_end_store_update(cbm_mcp_server_t *srv, bool published); + /* Run the MCP server event loop on the given streams (typically stdin/stdout). * Blocks until EOF on input. Returns 0 on success, -1 on error. */ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 8b5766978..6a1b73c2a 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -643,11 +643,11 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil * Mode-skipped hash rows are preserved across the rebuild so subsequent * reindexes can correctly distinguish "never indexed" from "indexed but * not visited this pass". */ -static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, - cbm_file_info_t *files, int file_count, - const cbm_file_hash_t *mode_skipped, int mode_skipped_count, - const char *repo_path, const cbm_coverage_row_t *cov, int cov_count, - const cbm_coverage_meta_t *meta_template) { +static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, + cbm_file_info_t *files, int file_count, + const cbm_file_hash_t *mode_skipped, int mode_skipped_count, + const char *repo_path, const cbm_coverage_row_t *cov, int cov_count, + const cbm_coverage_meta_t *meta_template) { struct timespec t; cbm_clock_gettime(CLOCK_MONOTONIC, &t); @@ -662,6 +662,9 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char * int dump_rc = cbm_gbuf_dump_to_sqlite(gbuf, db_path); cbm_log_info("incremental.dump", "rc", itoa_buf(dump_rc), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); + if (dump_rc != 0) { + return dump_rc; + } cbm_store_t *hash_store = cbm_store_open_path(db_path); if (hash_store) { @@ -705,6 +708,7 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char * if (repo_path && cbm_artifact_exists(repo_path)) { cbm_artifact_export(db_path, repo_path, project, CBM_ARTIFACT_FAST); } + return 0; } /* ── Incremental pipeline entry point ────────────────────────────── */ @@ -1010,13 +1014,19 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .ignored_files_total = run_ignored_total, .coverage_version = 1, }; - dump_and_persist(existing, db_path, project, files, file_count, mode_skipped, - mode_skipped_count, cbm_pipeline_repo_path(p), cov, cov_n, &coverage_meta); + int dump_rc = + dump_and_persist(existing, db_path, project, files, file_count, mode_skipped, + mode_skipped_count, cbm_pipeline_repo_path(p), cov, cov_n, &coverage_meta); free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); cbm_gbuf_free(existing); + if (dump_rc != 0) { + cbm_log_error("incremental.err", "msg", "dump_failed", "rc", itoa_buf(dump_rc)); + return dump_rc; + } + cbm_log_info("incremental.done", "elapsed_ms", itoa_buf((int)elapsed_ms(t0))); return 0; } diff --git a/src/watcher/svn_state.c b/src/watcher/svn_state.c new file mode 100644 index 000000000..91f2c5f0a --- /dev/null +++ b/src/watcher/svn_state.c @@ -0,0 +1,1066 @@ +#include "watcher/svn_state.h" + +#include "discover/discover.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/hash_table.h" +#include "foundation/limits.h" +#include "foundation/platform.h" +#include "foundation/str_util.h" +#ifdef _WIN32 +#include "foundation/win_utf8.h" +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifndef _WIN32 +#include +#endif + +#ifdef _WIN32 +#define SVN_PATH_DELIM ';' +#define SVN_EXE_NAME "svn.exe" +#define SVN_NULDEV "NUL" +#else +#define SVN_PATH_DELIM ':' +#define SVN_EXE_NAME "svn" +#define SVN_NULDEV "/dev/null" +#endif + +#define SVN_FNV_OFFSET UINT64_C(1469598103934665603) +#define SVN_FNV_PRIME UINT64_C(1099511628211) +#define SVN_MAX_XML_DEPTH 64 +#define SVN_MAX_TAG_NAME 96 + +typedef struct { + char **paths; + size_t count; + size_t capacity; + CBMHashTable *seen; +} candidate_list_t; + +typedef struct { + char item[32]; + char props[32]; + char revision[64]; + char tree_conflicted[16]; + char switched[16]; + char copied[16]; + char wc_locked[16]; + char depth[32]; + char moved_from[CBM_SZ_4K]; + char moved_to[CBM_SZ_4K]; +} wc_fields_t; + +typedef struct { + char root[CBM_SZ_4K]; + char input_root[CBM_SZ_4K]; + char stack[SVN_MAX_XML_DEPTH][SVN_MAX_TAG_NAME]; + int depth; + bool xml_decl_seen; + bool status_seen; + bool status_closed; + bool target_seen; + bool entry_open; + bool entry_has_status; + char entry_path[CBM_SZ_4K]; + uint64_t semantic_xor; + uint64_t semantic_sum; + bool has_local_changes; + int entry_count; + candidate_list_t candidates; +} parser_t; + +static uint64_t hash_fold(uint64_t hash, const void *data, size_t length) { + const unsigned char *bytes = (const unsigned char *)data; + for (size_t i = 0; i < length; i++) { + hash ^= bytes[i]; + hash *= SVN_FNV_PRIME; + } + return hash; +} + +static uint64_t hash_string(uint64_t hash, const char *value) { + hash = hash_fold(hash, value, strlen(value)); + const unsigned char separator = 0; + return hash_fold(hash, &separator, sizeof(separator)); +} + +static uint64_t hash_mix(uint64_t value) { + value ^= value >> 30; + value *= UINT64_C(0xbf58476d1ce4e5b9); + value ^= value >> 27; + value *= UINT64_C(0x94d049bb133111eb); + return value ^ (value >> 31); +} + +static bool path_char_equal(char left, char right) { +#ifdef _WIN32 + return tolower((unsigned char)left) == tolower((unsigned char)right); +#else + return left == right; +#endif +} + +static void normalize_separators(char *path) { + cbm_normalize_path_sep(path); + size_t length = strlen(path); + while (length > 1 && path[length - 1] == '/') { + path[--length] = '\0'; + } +} + +static bool path_has_prefix(const char *path, const char *prefix) { + size_t prefix_length = strlen(prefix); + for (size_t i = 0; i < prefix_length; i++) { + if (!path[i] || !path_char_equal(path[i], prefix[i])) { + return false; + } + } + return path[prefix_length] == '\0' || path[prefix_length] == '/'; +} + +static bool path_is_absolute(const char *path) { + if (!path || !path[0]) { + return false; + } +#ifdef _WIN32 + return path[0] == '/' || path[0] == '\\' || (isalpha((unsigned char)path[0]) && path[1] == ':'); +#else + return path[0] == '/'; +#endif +} + +static bool relative_path_is_safe(const char *path) { + if (!path || path_is_absolute(path)) { + return false; + } + const char *segment = path; + for (const char *cursor = path;; cursor++) { + if (*cursor == ':' || *cursor == '\0' || *cursor == '/') { + size_t length = (size_t)(cursor - segment); + if (*cursor == ':' || (length == 2 && segment[0] == '.' && segment[1] == '.') || + (length == 1 && segment[0] == '.')) { + return false; + } + if (*cursor == '\0') { + break; + } + if (length == 0) { + return false; + } + segment = cursor + 1; + } + } + return true; +} + +static bool entry_relative_path(const char *root, const char *input_root, const char *entry, + char *out, size_t out_size) { + char normalized[CBM_SZ_4K]; + int written = snprintf(normalized, sizeof(normalized), "%s", entry ? entry : ""); + if (written < 0 || (size_t)written >= sizeof(normalized)) { + return false; + } + normalize_separators(normalized); + + const char *relative = normalized; + if (path_is_absolute(normalized)) { + const char *matched_root = root; + if (!path_has_prefix(normalized, matched_root)) { + matched_root = input_root; + } + if (!path_has_prefix(normalized, matched_root)) { + return false; + } + relative = normalized + strlen(matched_root); + if (*relative == '/') { + relative++; + } + } + if (!relative[0]) { + relative = "."; + } + if (strcmp(relative, ".") != 0 && !relative_path_is_safe(relative)) { + return false; + } + written = snprintf(out, out_size, "%s", relative); + return written >= 0 && (size_t)written < out_size; +} + +static void candidates_free(candidate_list_t *list) { + if (!list) { + return; + } + for (size_t i = 0; i < list->count; i++) { + free(list->paths[i]); + } + free(list->paths); + cbm_ht_free(list->seen); + memset(list, 0, sizeof(*list)); +} + +static bool candidates_add(candidate_list_t *list, const char *path) { + if (strcmp(path, ".") == 0 || cbm_ht_has(list->seen, path)) { + return true; + } + char *copy = cbm_strdup(path); + if (!copy) { + return false; + } + if (list->count == list->capacity) { + size_t next_capacity = list->capacity ? list->capacity * 2 : 16; + char **next = (char **)realloc(list->paths, next_capacity * sizeof(*next)); + if (!next) { + free(copy); + return false; + } + list->paths = next; + list->capacity = next_capacity; + } + list->paths[list->count++] = copy; + cbm_ht_set(list->seen, copy, copy); + return true; +} + +static int utf8_append(uint32_t codepoint, char *out, size_t out_size, size_t *used) { + unsigned char encoded[4]; + size_t count = 0; + if (codepoint <= 0x7f) { + encoded[count++] = (unsigned char)codepoint; + } else if (codepoint <= 0x7ff) { + encoded[count++] = (unsigned char)(0xc0 | (codepoint >> 6)); + encoded[count++] = (unsigned char)(0x80 | (codepoint & 0x3f)); + } else if (codepoint >= 0xd800 && codepoint <= 0xdfff) { + return -1; + } else if (codepoint <= 0xffff) { + encoded[count++] = (unsigned char)(0xe0 | (codepoint >> 12)); + encoded[count++] = (unsigned char)(0x80 | ((codepoint >> 6) & 0x3f)); + encoded[count++] = (unsigned char)(0x80 | (codepoint & 0x3f)); + } else if (codepoint <= 0x10ffff) { + encoded[count++] = (unsigned char)(0xf0 | (codepoint >> 18)); + encoded[count++] = (unsigned char)(0x80 | ((codepoint >> 12) & 0x3f)); + encoded[count++] = (unsigned char)(0x80 | ((codepoint >> 6) & 0x3f)); + encoded[count++] = (unsigned char)(0x80 | (codepoint & 0x3f)); + } else { + return -1; + } + if (*used + count >= out_size) { + return -1; + } + memcpy(out + *used, encoded, count); + *used += count; + return 0; +} + +static int decode_xml(const char *input, size_t length, char *out, size_t out_size) { + size_t used = 0; + for (size_t i = 0; i < length;) { + if (input[i] != '&') { + if (used + 1 >= out_size || input[i] == '<') { + return -1; + } + out[used++] = input[i++]; + continue; + } + size_t end = i + 1; + while (end < length && input[end] != ';' && end - i <= 16) { + end++; + } + if (end >= length || input[end] != ';') { + return -1; + } + const char *entity = input + i + 1; + size_t entity_length = end - i - 1; + uint32_t codepoint = 0; + if (entity_length == 3 && memcmp(entity, "amp", 3) == 0) { + codepoint = '&'; + } else if (entity_length == 2 && memcmp(entity, "lt", 2) == 0) { + codepoint = '<'; + } else if (entity_length == 2 && memcmp(entity, "gt", 2) == 0) { + codepoint = '>'; + } else if (entity_length == 4 && memcmp(entity, "quot", 4) == 0) { + codepoint = '"'; + } else if (entity_length == 4 && memcmp(entity, "apos", 4) == 0) { + codepoint = '\''; + } else if (entity_length >= 2 && entity[0] == '#') { + size_t digit = 1; + int base = 10; + if (entity[digit] == 'x' || entity[digit] == 'X') { + base = 16; + digit++; + } + if (digit == entity_length) { + return -1; + } + for (; digit < entity_length; digit++) { + int value; + if (entity[digit] >= '0' && entity[digit] <= '9') { + value = entity[digit] - '0'; + } else if (base == 16 && entity[digit] >= 'a' && entity[digit] <= 'f') { + value = entity[digit] - 'a' + 10; + } else if (base == 16 && entity[digit] >= 'A' && entity[digit] <= 'F') { + value = entity[digit] - 'A' + 10; + } else { + return -1; + } + if (codepoint > (UINT32_MAX - (uint32_t)value) / (uint32_t)base) { + return -1; + } + codepoint = codepoint * (uint32_t)base + (uint32_t)value; + } + } else { + return -1; + } + if ((codepoint < 0x20 && codepoint != '\t' && codepoint != '\n' && codepoint != '\r') || + utf8_append(codepoint, out, out_size, &used) != 0) { + return -1; + } + i = end + 1; + } + out[used] = '\0'; + return 0; +} + +static int copy_attribute(const char *name, const char *value, size_t value_length, + const char *wanted, char *out, size_t out_size) { + if (strcmp(name, wanted) != 0) { + char scratch[CBM_SZ_4K]; + return decode_xml(value, value_length, scratch, sizeof(scratch)); + } + return decode_xml(value, value_length, out, out_size); +} + +static int parse_attributes(const char *cursor, char *entry_path, wc_fields_t *fields) { + while (*cursor) { + while (isspace((unsigned char)*cursor)) { + cursor++; + } + if (!*cursor) { + return 0; + } + const char *name_start = cursor; + while (*cursor && !isspace((unsigned char)*cursor) && *cursor != '=') { + cursor++; + } + size_t name_length = (size_t)(cursor - name_start); + if (name_length == 0 || name_length >= SVN_MAX_TAG_NAME) { + return -1; + } + char name[SVN_MAX_TAG_NAME]; + memcpy(name, name_start, name_length); + name[name_length] = '\0'; + while (isspace((unsigned char)*cursor)) { + cursor++; + } + if (*cursor++ != '=') { + return -1; + } + while (isspace((unsigned char)*cursor)) { + cursor++; + } + char quote = *cursor++; + if (quote != '\'' && quote != '"') { + return -1; + } + const char *value = cursor; + while (*cursor && *cursor != quote) { + cursor++; + } + if (!*cursor) { + return -1; + } + size_t value_length = (size_t)(cursor - value); + cursor++; + + int result = 0; + if (entry_path) { + result = copy_attribute(name, value, value_length, "path", entry_path, CBM_SZ_4K); + } else if (fields) { + bool matched = false; +#define SVN_COPY_FIELD(attr, member) \ + do { \ + if (strcmp(name, attr) == 0) { \ + matched = true; \ + result = decode_xml(value, value_length, fields->member, sizeof(fields->member)); \ + } \ + } while (0) + SVN_COPY_FIELD("item", item); + SVN_COPY_FIELD("props", props); + SVN_COPY_FIELD("revision", revision); + SVN_COPY_FIELD("tree-conflicted", tree_conflicted); + SVN_COPY_FIELD("switched", switched); + SVN_COPY_FIELD("copied", copied); + SVN_COPY_FIELD("wc-locked", wc_locked); + SVN_COPY_FIELD("depth", depth); + SVN_COPY_FIELD("moved-from", moved_from); + SVN_COPY_FIELD("moved-to", moved_to); +#undef SVN_COPY_FIELD + if (result == 0 && !matched) { + char scratch[CBM_SZ_4K]; + result = decode_xml(value, value_length, scratch, sizeof(scratch)); + } + } else { + char scratch[CBM_SZ_4K]; + result = decode_xml(value, value_length, scratch, sizeof(scratch)); + } + if (result != 0) { + return -1; + } + } + return 0; +} + +static bool status_needs_content(const char *item) { + return strcmp(item, "modified") == 0 || strcmp(item, "added") == 0 || + strcmp(item, "replaced") == 0 || strcmp(item, "conflicted") == 0 || + strcmp(item, "obstructed") == 0 || strcmp(item, "unversioned") == 0 || + strcmp(item, "ignored") == 0; +} + +static bool status_has_local_changes(const wc_fields_t *fields) { + bool item_changed = + strcmp(fields->item, "normal") != 0 && strcmp(fields->item, "external") != 0; + bool props_changed = fields->props[0] && strcmp(fields->props, "none") != 0 && + strcmp(fields->props, "normal") != 0; + return item_changed || props_changed; +} + +static int finalize_status(parser_t *parser, const wc_fields_t *fields) { + if (!fields->item[0] || parser->entry_has_status) { + return -1; + } + char relative[CBM_SZ_4K]; + if (!entry_relative_path(parser->root, parser->input_root, parser->entry_path, relative, + sizeof(relative))) { + return -1; + } + + uint64_t entry_hash = SVN_FNV_OFFSET; + entry_hash = hash_string(entry_hash, relative); + entry_hash = hash_string(entry_hash, fields->item); + entry_hash = hash_string(entry_hash, fields->props); + entry_hash = hash_string(entry_hash, fields->revision); + entry_hash = hash_string(entry_hash, fields->tree_conflicted); + entry_hash = hash_string(entry_hash, fields->switched); + entry_hash = hash_string(entry_hash, fields->copied); + entry_hash = hash_string(entry_hash, fields->wc_locked); + entry_hash = hash_string(entry_hash, fields->depth); + entry_hash = hash_string(entry_hash, fields->moved_from); + entry_hash = hash_string(entry_hash, fields->moved_to); + parser->semantic_xor ^= hash_mix(entry_hash); + parser->semantic_sum += entry_hash * SVN_FNV_PRIME; + if (status_has_local_changes(fields)) { + parser->has_local_changes = true; + } + parser->entry_count++; + parser->entry_has_status = true; + + return !status_needs_content(fields->item) || candidates_add(&parser->candidates, relative) + ? 0 + : -1; +} + +static int process_open_tag(parser_t *parser, char *tag) { + char *cursor = tag; + while (isspace((unsigned char)*cursor)) { + cursor++; + } + if (*cursor == '?') { + size_t length = strlen(cursor); + if (parser->depth != 0 || parser->status_seen || parser->xml_decl_seen || length < 2 || + cursor[length - 1] != '?' || strncmp(cursor, "?xml", 4) != 0) { + return -1; + } + parser->xml_decl_seen = true; + return 0; + } + if (*cursor == '!') { + return -1; + } + + bool closing = *cursor == '/'; + if (closing) { + cursor++; + } + while (isspace((unsigned char)*cursor)) { + cursor++; + } + char *name = cursor; + while (*cursor && !isspace((unsigned char)*cursor) && *cursor != '/') { + cursor++; + } + size_t name_length = (size_t)(cursor - name); + if (name_length == 0 || name_length >= SVN_MAX_TAG_NAME) { + return -1; + } + char name_copy[SVN_MAX_TAG_NAME]; + memcpy(name_copy, name, name_length); + name_copy[name_length] = '\0'; + + if (closing) { + while (isspace((unsigned char)*cursor)) { + cursor++; + } + if (*cursor || parser->depth <= 0 || + strcmp(parser->stack[parser->depth - 1], name_copy) != 0) { + return -1; + } + if (strcmp(name_copy, "entry") == 0) { + if (!parser->entry_open || !parser->entry_has_status) { + return -1; + } + parser->entry_open = false; + parser->entry_has_status = false; + parser->entry_path[0] = '\0'; + } else if (strcmp(name_copy, "status") == 0) { + parser->status_closed = true; + } + parser->depth--; + return 0; + } + + char *end = cursor + strlen(cursor); + while (end > cursor && isspace((unsigned char)end[-1])) { + *--end = '\0'; + } + bool self_closing = end > cursor && end[-1] == '/'; + if (self_closing) { + *--end = '\0'; + while (end > cursor && isspace((unsigned char)end[-1])) { + *--end = '\0'; + } + } + if (parser->status_closed) { + return -1; + } + + if (strcmp(name_copy, "status") == 0) { + if (parser->depth != 0 || parser->status_seen || self_closing) { + return -1; + } + if (parse_attributes(cursor, NULL, NULL) != 0) { + return -1; + } + parser->status_seen = true; + } else if (strcmp(name_copy, "target") == 0) { + if (!parser->status_seen || parser->depth != 1 || + strcmp(parser->stack[parser->depth - 1], "status") != 0 || + parse_attributes(cursor, NULL, NULL) != 0) { + return -1; + } + parser->target_seen = true; + } else if (strcmp(name_copy, "entry") == 0) { + if (!parser->target_seen || parser->entry_open || self_closing || parser->depth < 2 || + strcmp(parser->stack[parser->depth - 1], "target") != 0) { + return -1; + } + parser->entry_path[0] = '\0'; + if (parse_attributes(cursor, parser->entry_path, NULL) != 0 || !parser->entry_path[0]) { + return -1; + } + parser->entry_open = true; + parser->entry_has_status = false; + } else if (strcmp(name_copy, "wc-status") == 0) { + if (!parser->entry_open || parser->depth < 3 || + strcmp(parser->stack[parser->depth - 1], "entry") != 0) { + return -1; + } + wc_fields_t fields = {0}; + if (parse_attributes(cursor, NULL, &fields) != 0 || finalize_status(parser, &fields) != 0) { + return -1; + } + } else if (parse_attributes(cursor, NULL, NULL) != 0) { + return -1; + } + + if (!self_closing) { + if (parser->depth >= SVN_MAX_XML_DEPTH) { + return -1; + } + snprintf(parser->stack[parser->depth], SVN_MAX_TAG_NAME, "%s", name_copy); + parser->depth++; + } + return 0; +} + +static int safe_stat_path(const char *root, const char *relative, char *absolute, + size_t absolute_size, struct stat *state) { + int written = snprintf(absolute, absolute_size, "%s", root); + if (written < 0 || (size_t)written >= absolute_size) { + return -1; + } + const char *cursor = relative; + while (*cursor) { + const char *slash = strchr(cursor, '/'); + size_t length = slash ? (size_t)(slash - cursor) : strlen(cursor); + size_t used = strlen(absolute); + if (used + 1 + length >= absolute_size) { + return -1; + } + absolute[used++] = '/'; + memcpy(absolute + used, cursor, length); + absolute[used + length] = '\0'; +#ifdef _WIN32 + wchar_t *wide = cbm_utf8_to_wide(absolute); + if (!wide) { + return -1; + } + DWORD attributes = GetFileAttributesW(wide); + free(wide); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return -1; + } +#else + struct stat component; + if (lstat(absolute, &component) != 0 || S_ISLNK(component.st_mode)) { + return -1; + } +#endif + cursor = slash ? slash + 1 : cursor + length; + } +#ifdef _WIN32 + wchar_t *wide = cbm_utf8_to_wide(absolute); + if (!wide) { + return -1; + } + struct _stat64 wide_state; + int result = _wstat64(wide, &wide_state); + free(wide); + if (result != 0) { + return -1; + } + memset(state, 0, sizeof(*state)); + state->st_mode = wide_state.st_mode; + state->st_size = wide_state.st_size; +#else + if (lstat(absolute, state) != 0) { + return -1; + } +#endif + return 0; +} + +typedef struct { + uint64_t xor_value; + uint64_t sum_value; + uint64_t bytes_hashed; + int file_count; +} content_accumulator_t; + +static int fingerprint_file(const char *absolute, const char *relative, const struct stat *state, + content_accumulator_t *content) { + if (!S_ISREG(state->st_mode) || state->st_size < 0 || + (uint64_t)state->st_size > (uint64_t)cbm_max_file_bytes()) { + return S_ISREG(state->st_mode) ? 0 : -1; + } + const char *name = strrchr(relative, '/'); + name = name ? name + 1 : relative; + if (cbm_language_for_filename(name) == CBM_LANG_COUNT || + cbm_has_ignored_suffix(name, CBM_MODE_FULL)) { + return 0; + } + + FILE *file = cbm_fopen(absolute, "rb"); + if (!file) { + return -1; + } + uint64_t file_hash = hash_string(SVN_FNV_OFFSET, relative); + unsigned char buffer[CBM_SZ_16K]; + size_t total = 0; + for (;;) { + size_t count = fread(buffer, 1, sizeof(buffer), file); + if (count > 0) { + file_hash = hash_fold(file_hash, buffer, count); + total += count; + } + if (count < sizeof(buffer)) { + if (ferror(file)) { + fclose(file); + return -1; + } + break; + } + } + fclose(file); + content->xor_value ^= hash_mix(file_hash); + content->sum_value += file_hash * SVN_FNV_PRIME; + content->bytes_hashed += total; + content->file_count++; + return 0; +} + +static int fingerprint_tree(const char *root, const char *relative, + content_accumulator_t *content) { + char absolute[CBM_SZ_4K]; + struct stat state; + if (safe_stat_path(root, relative, absolute, sizeof(absolute), &state) != 0) { + return -1; + } + if (S_ISREG(state.st_mode)) { + return fingerprint_file(absolute, relative, &state, content); + } + if (!S_ISDIR(state.st_mode)) { + return -1; + } + const char *name = strrchr(relative, '/'); + name = name ? name + 1 : relative; + if (cbm_should_skip_dir(name, CBM_MODE_FULL)) { + return 0; + } + + cbm_dir_t *directory = cbm_opendir(absolute); + if (!directory) { + return -1; + } + int result = 0; + cbm_dirent_t *entry; + while (result == 0 && (entry = cbm_readdir(directory)) != NULL) { + if (strcmp(entry->name, ".") == 0 || strcmp(entry->name, "..") == 0 || + strcmp(entry->name, ".svn") == 0) { + continue; + } + if (entry->is_dir && cbm_should_skip_dir(entry->name, CBM_MODE_FULL)) { + continue; + } + char child[CBM_SZ_4K]; + int written = snprintf(child, sizeof(child), "%s/%s", relative, entry->name); + if (written < 0 || (size_t)written >= sizeof(child)) { + result = -1; + break; + } + result = fingerprint_tree(root, child, content); + } + cbm_closedir(directory); + return result; +} + +static int candidate_compare(const void *left, const void *right) { + const char *left_path = *(const char *const *)left; + const char *right_path = *(const char *const *)right; + size_t left_length = strlen(left_path); + size_t right_length = strlen(right_path); + if (left_length != right_length) { + return left_length < right_length ? -1 : 1; + } + return strcmp(left_path, right_path); +} + +static bool candidate_has_ancestor(const CBMHashTable *scanned_directories, const char *candidate) { + char prefix[CBM_SZ_4K]; + int written = snprintf(prefix, sizeof(prefix), "%s", candidate); + if (written < 0 || (size_t)written >= sizeof(prefix)) { + return false; + } + char *cursor = prefix; + while ((cursor = strchr(cursor, '/')) != NULL) { + *cursor = '\0'; + if (cbm_ht_has(scanned_directories, prefix)) { + return true; + } + *cursor++ = '/'; + } + return false; +} + +static int fingerprint_candidates(parser_t *parser, cbm_svn_observation_t *observation) { + qsort(parser->candidates.paths, parser->candidates.count, sizeof(*parser->candidates.paths), + candidate_compare); + CBMHashTable *scanned_directories = + cbm_ht_create((uint32_t)(parser->candidates.count ? parser->candidates.count : 1)); + if (!scanned_directories) { + return -1; + } + content_accumulator_t content = {0}; + + for (size_t i = 0; i < parser->candidates.count; i++) { + const char *relative = parser->candidates.paths[i]; + if (candidate_has_ancestor(scanned_directories, relative)) { + continue; + } + char absolute[CBM_SZ_4K]; + struct stat state; + if (safe_stat_path(parser->root, relative, absolute, sizeof(absolute), &state) != 0) { + cbm_ht_free(scanned_directories); + return -1; + } + if (S_ISDIR(state.st_mode)) { + cbm_ht_set(scanned_directories, relative, parser->candidates.paths[i]); + } + if (fingerprint_tree(parser->root, relative, &content) != 0) { + cbm_ht_free(scanned_directories); + return -1; + } + } + cbm_ht_free(scanned_directories); + + observation->content_signature = + content.file_count + ? hash_mix(content.xor_value ^ content.sum_value ^ (uint64_t)content.file_count) + : 0; + observation->bytes_hashed = content.bytes_hashed; + observation->candidate_count = content.file_count; + return 0; +} + +static cbm_svn_probe_result_t parse_status_stream(FILE *stream, const char *root_path, + const char *input_root, + cbm_svn_observation_t *observation) { + if (!stream || !root_path || !input_root || !observation) { + return CBM_SVN_PROBE_UNCERTAIN; + } + parser_t parser = {0}; + parser.candidates.seen = cbm_ht_create(32); + if (!parser.candidates.seen || + !cbm_canonical_path(root_path, parser.root, sizeof(parser.root))) { + candidates_free(&parser.candidates); + return CBM_SVN_PROBE_UNCERTAIN; + } + normalize_separators(parser.root); + int input_root_length = + snprintf(parser.input_root, sizeof(parser.input_root), "%s", input_root); + if (input_root_length < 0 || (size_t)input_root_length >= sizeof(parser.input_root)) { + candidates_free(&parser.candidates); + return CBM_SVN_PROBE_UNCERTAIN; + } + normalize_separators(parser.input_root); + + char tag[CBM_SZ_16K]; + size_t tag_length = 0; + bool in_tag = false; + char attribute_quote = '\0'; + bool failed = false; + int character; + while ((character = fgetc(stream)) != EOF) { + if (!in_tag) { + if (character == '<') { + in_tag = true; + tag_length = 0; + attribute_quote = '\0'; + } else if (!isspace((unsigned char)character) && !parser.status_seen) { + failed = true; + break; + } + continue; + } + if (character == '\'' || character == '"') { + if (attribute_quote == '\0') { + attribute_quote = (char)character; + } else if (attribute_quote == character) { + attribute_quote = '\0'; + } + } + if (character == '>' && attribute_quote == '\0') { + tag[tag_length] = '\0'; + if (process_open_tag(&parser, tag) != 0) { + failed = true; + break; + } + in_tag = false; + continue; + } + if (tag_length + 1 >= sizeof(tag)) { + failed = true; + break; + } + tag[tag_length++] = (char)character; + } + if (ferror(stream) || in_tag || parser.depth != 0 || !parser.status_seen || + !parser.status_closed || !parser.target_seen || parser.entry_open) { + failed = true; + } + + cbm_svn_probe_result_t result = CBM_SVN_PROBE_UNCERTAIN; + cbm_svn_observation_t parsed = {0}; + if (!failed && parser.entry_count == 0) { + result = CBM_SVN_PROBE_NOT_WORKING_COPY; + } else if (!failed) { + parsed.semantic_signature = + hash_mix(parser.semantic_xor ^ parser.semantic_sum ^ (uint64_t)parser.entry_count); + parsed.has_local_changes = parser.has_local_changes; + parsed.entry_count = parser.entry_count; + if (fingerprint_candidates(&parser, &parsed) == 0) { + *observation = parsed; + result = CBM_SVN_PROBE_OK; + } + } + candidates_free(&parser.candidates); + return result; +} + +#ifdef CBM_SVN_STATE_ENABLE_TEST_API +cbm_svn_probe_result_t cbm_svn_parse_status_stream(FILE *stream, const char *root_path, + cbm_svn_observation_t *observation) { + return parse_status_stream(stream, root_path, root_path, observation); +} +#endif + +static bool executable_is_regular(const char *path) { +#ifdef _WIN32 + wchar_t *wide = cbm_utf8_to_wide(path); + if (!wide) { + return false; + } + DWORD attributes = GetFileAttributesW(wide); + free(wide); + return attributes != INVALID_FILE_ATTRIBUTES && + (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0; +#else + struct stat state; + return stat(path, &state) == 0 && S_ISREG(state.st_mode) && access(path, X_OK) == 0; +#endif +} + +#if defined(_WIN32) || defined(CBM_SVN_STATE_ENABLE_TEST_API) +static bool executable_uses_posix_paths(const char *path) { + static const char suffix[] = "/usr/bin/svn.exe"; + if (!path) { + return false; + } + size_t path_length = strlen(path); + size_t suffix_length = sizeof(suffix) - SKIP_ONE; + if (path_length < suffix_length) { + return false; + } + const char *tail = path + path_length - suffix_length; + return cbm_strcasestr(tail, suffix) == tail; +} +#endif + +#ifdef CBM_SVN_STATE_ENABLE_TEST_API +bool cbm_svn_test_executable_uses_posix_paths(const char *executable) { + return executable_uses_posix_paths(executable); +} +#endif + +bool cbm_svn_format_path_arg(const cbm_svn_client_t *client, const char *path, char *out, + size_t out_size) { + if (!client || !path || !out || out_size == 0) { + return false; + } +#ifdef _WIN32 + char normalized[CBM_SZ_4K]; + int normalized_length = snprintf(normalized, sizeof(normalized), "%s", path); + if (normalized_length < 0 || (size_t)normalized_length >= sizeof(normalized)) { + return false; + } + cbm_normalize_path_sep(normalized); + if (client->uses_posix_paths && isalpha((unsigned char)normalized[0]) && normalized[1] == ':' && + normalized[2] == '/') { + int written = + snprintf(out, out_size, "/%c%s", tolower((unsigned char)normalized[0]), normalized + 2); + return written >= 0 && (size_t)written < out_size; + } + const char *formatted = normalized; +#else + const char *formatted = path; +#endif + int written = snprintf(out, out_size, "%s", formatted); + return written >= 0 && (size_t)written < out_size; +} + +cbm_svn_probe_result_t cbm_svn_client_init(const char *root_path, cbm_svn_client_t *client) { + if (!root_path || !client) { + return CBM_SVN_PROBE_UNCERTAIN; + } + memset(client, 0, sizeof(*client)); + char root[CBM_SZ_4K]; + if (!cbm_canonical_path(root_path, root, sizeof(root))) { + return CBM_SVN_PROBE_UNCERTAIN; + } + normalize_separators(root); + char cwd[CBM_SZ_4K]; + if (!cbm_canonical_path(".", cwd, sizeof(cwd))) { + return CBM_SVN_PROBE_UNCERTAIN; + } + normalize_separators(cwd); + + char path_buffer[CBM_SZ_16K]; + if (!cbm_safe_getenv("PATH", path_buffer, sizeof(path_buffer), NULL)) { + return CBM_SVN_PROBE_UNCERTAIN; + } + char *cursor = path_buffer; + while (cursor) { + char *delimiter = strchr(cursor, SVN_PATH_DELIM); + if (delimiter) { + *delimiter = '\0'; + } + if (cursor[0] && path_is_absolute(cursor)) { + char candidate[CBM_SZ_4K]; + int written = snprintf(candidate, sizeof(candidate), "%s/%s", cursor, SVN_EXE_NAME); + if (written >= 0 && (size_t)written < sizeof(candidate)) { + char resolved[CBM_SZ_4K]; + if (cbm_canonical_path(candidate, resolved, sizeof(resolved))) { + normalize_separators(resolved); + if (!path_has_prefix(resolved, root) && !path_has_prefix(resolved, cwd) && + executable_is_regular(resolved)) { + snprintf(client->executable, sizeof(client->executable), "%s", resolved); +#ifdef _WIN32 + client->uses_posix_paths = executable_uses_posix_paths(resolved); +#endif + return CBM_SVN_PROBE_OK; + } + } + } + } + cursor = delimiter ? delimiter + 1 : NULL; + } + return CBM_SVN_PROBE_UNCERTAIN; +} + +cbm_svn_probe_result_t cbm_svn_probe(const cbm_svn_client_t *client, const char *root_path, + cbm_svn_observation_t *observation) { + if (!client || !client->executable[0] || !root_path || !observation) { + return CBM_SVN_PROBE_UNCERTAIN; + } + char status_root[CBM_SZ_4K]; + if (!cbm_svn_format_path_arg(client, root_path, status_root, sizeof(status_root))) { + return CBM_SVN_PROBE_UNCERTAIN; + } +#ifdef _WIN32 + char peg_root[CBM_SZ_8K]; + int peg_length = snprintf(peg_root, sizeof(peg_root), "%s@", status_root); + if (peg_length < 0 || (size_t)peg_length >= sizeof(peg_root)) { + return CBM_SVN_PROBE_UNCERTAIN; + } + const char *argv[] = { + client->executable, "status", "--xml", "--verbose", "--no-ignore", "--depth", "infinity", + "--non-interactive", "--", peg_root, NULL}; + FILE *process = cbm_popen_argv(argv); +#else + if (!cbm_validate_shell_arg(client->executable) || !cbm_validate_shell_arg(status_root)) { + return CBM_SVN_PROBE_UNCERTAIN; + } + char command[CBM_SZ_16K]; + int written = snprintf(command, sizeof(command), + "\"%s\" status --xml --verbose --no-ignore --depth infinity " + "--non-interactive -- \"%s@\" 2>%s", + client->executable, status_root, SVN_NULDEV); + if (written < 0 || (size_t)written >= sizeof(command)) { + return CBM_SVN_PROBE_UNCERTAIN; + } + FILE *process = cbm_popen(command, "r"); +#endif + if (!process) { + return CBM_SVN_PROBE_UNCERTAIN; + } + cbm_svn_observation_t parsed = {0}; + cbm_svn_probe_result_t result = parse_status_stream(process, root_path, status_root, &parsed); + int exit_code = cbm_pclose(process); + if (exit_code != 0 || result == CBM_SVN_PROBE_UNCERTAIN) { + return CBM_SVN_PROBE_UNCERTAIN; + } + if (result == CBM_SVN_PROBE_OK) { + *observation = parsed; + } + return result; +} diff --git a/src/watcher/svn_state.h b/src/watcher/svn_state.h new file mode 100644 index 000000000..9bd884e5b --- /dev/null +++ b/src/watcher/svn_state.h @@ -0,0 +1,51 @@ +#ifndef CBM_WATCHER_SVN_STATE_H +#define CBM_WATCHER_SVN_STATE_H + +#include +#include +#include +#ifdef CBM_SVN_STATE_ENABLE_TEST_API +#include +#endif + +#include "foundation/constants.h" + +typedef enum { + CBM_SVN_PROBE_UNCERTAIN = -1, + CBM_SVN_PROBE_NOT_WORKING_COPY = 0, + CBM_SVN_PROBE_OK = 1, +} cbm_svn_probe_result_t; + +typedef struct { + char executable[CBM_SZ_4K]; + bool uses_posix_paths; +} cbm_svn_client_t; + +typedef struct { + uint64_t semantic_signature; + uint64_t content_signature; + uint64_t bytes_hashed; + bool has_local_changes; + int entry_count; + int candidate_count; +} cbm_svn_observation_t; + +/* Resolve and pin a system SVN client without executing project-local code. */ +cbm_svn_probe_result_t cbm_svn_client_init(const char *root_path, cbm_svn_client_t *client); + +/* Format a local path for the pinned SVN client's runtime. */ +bool cbm_svn_format_path_arg(const cbm_svn_client_t *client, const char *path, char *out, + size_t out_size); + +/* Parse one local `svn status --xml --verbose` stream. */ +#ifdef CBM_SVN_STATE_ENABLE_TEST_API +cbm_svn_probe_result_t cbm_svn_parse_status_stream(FILE *stream, const char *root_path, + cbm_svn_observation_t *observation); +bool cbm_svn_test_executable_uses_posix_paths(const char *executable); +#endif + +/* Observe a working copy with one local, non-interactive SVN process. */ +cbm_svn_probe_result_t cbm_svn_probe(const cbm_svn_client_t *client, const char *root_path, + cbm_svn_observation_t *observation); + +#endif /* CBM_WATCHER_SVN_STATE_H */ diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 5459503a7..5cbadc6e5 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -1,8 +1,8 @@ /* - * watcher.c — Git-based file change watcher. + * watcher.c — VCS-based file change watcher. * - * Strategy: git status + HEAD tracking (the most reliable approach). - * For non-git projects, the watcher skips polling (no fsnotify/dirmtime yet). + * Strategy: Git HEAD/dirty tracking or local SVN status observations. + * For non-VCS projects, the watcher skips polling (no fsnotify/dirmtime yet). * * * Per-project state tracks: @@ -21,6 +21,7 @@ */ #include #include "watcher/watcher.h" +#include "watcher/svn_state.h" #include "store/store.h" #include "foundation/constants.h" #include "foundation/log.h" @@ -41,12 +42,18 @@ /* ── Per-project state ──────────────────────────────────────────── */ +typedef enum { + VCS_UNCLASSIFIED = 0, + VCS_NONE, + VCS_GIT, + VCS_SVN, +} vcs_strategy_t; + typedef struct { char *project_name; char *root_path; char last_head[CBM_SZ_64]; /* git HEAD hash (committed baseline) */ - bool is_git; /* false → skip polling */ - bool baseline_done; /* true after first poll */ + vcs_strategy_t strategy; int missing_root_count; /* consecutive polls where root was missing (ENOENT/ENOTDIR) */ uint64_t first_missing_ms; /* cbm_now_ms() of the streak's first miss (0 = no streak) */ int file_count; /* approximate, for interval calc */ @@ -60,6 +67,10 @@ typedef struct { uint64_t last_dirty_sig; /* committed dirty-state signature */ uint64_t pending_dirty_sig; /* observed at check time */ char pending_head[CBM_SZ_64]; /* HEAD observed at check time */ + cbm_svn_client_t svn_client; + cbm_svn_observation_t last_svn; + cbm_svn_observation_t pending_svn; + bool svn_initial_sync_pending; } project_state_t; /* ── Watcher struct ─────────────────────────────────────────────── */ @@ -329,6 +340,34 @@ static int git_file_count(const char *root_path) { return count; } +static bool vcs_metadata_exists(const char *root_path, const char *name) { + char path[CBM_SZ_4K]; + int written = snprintf(path, sizeof(path), "%s/%s", root_path, name); + if (written < 0 || (size_t)written >= sizeof(path)) { + return false; + } + return cbm_file_exists(path); +} + +static bool svn_observation_equal(const cbm_svn_observation_t *left, + const cbm_svn_observation_t *right) { + return left->semantic_signature == right->semantic_signature && + left->content_signature == right->content_signature; +} + +static void finish_git_baseline(project_state_t *s) { + s->strategy = VCS_GIT; + git_head(s->root_path, s->last_head, sizeof(s->last_head)); + /* A tree that is already dirty at baseline reindexes once on the next + * eligible poll, matching the established watcher contract. */ + s->last_dirty_sig = 0; + s->file_count = git_file_count(s->root_path); + s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count); + cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "git", "files", + s->file_count > 0 ? "yes" : "0"); + s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); +} + /* ── Project state lifecycle ────────────────────────────────────── */ static project_state_t *state_new(const char *name, const char *root_path) { @@ -517,8 +556,11 @@ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *r cbm_mutex_lock(&w->projects_lock); project_state_t *old = cbm_ht_get(w->projects, project_name); if (old) { + if (!defer_state_free(w, old)) { + cbm_mutex_unlock(&w->projects_lock); + return; + } cbm_ht_delete(w->projects, project_name); - state_free(old); } project_state_t *s = state_new(project_name, root_path); @@ -576,34 +618,69 @@ int cbm_watcher_watch_count(cbm_watcher_t *w) { /* ── Single poll cycle ──────────────────────────────────────────── */ -/* Init baseline for a project: check if git, get HEAD, count files */ +/* Initialize one immutable VCS strategy. Metadata selects precedence while + * command failures remain retryable instead of being mistaken for non-VCS. */ static void init_baseline(project_state_t *s) { struct stat st; if (stat(s->root_path, &st) != 0) { cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path); - s->baseline_done = true; - s->is_git = false; + s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); return; } - s->is_git = is_git_repo(s->root_path); - s->baseline_done = true; + bool has_git_metadata = vcs_metadata_exists(s->root_path, ".git"); + if (has_git_metadata) { + if (is_git_repo(s->root_path)) { + finish_git_baseline(s); + return; + } + cbm_log_warn("watcher.probe.pending", "project", s->project_name, "strategy", "git"); + s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); + return; + } - if (s->is_git) { - git_head(s->root_path, s->last_head, sizeof(s->last_head)); - /* last_dirty_sig stays 0 ("clean known"): a tree that is ALREADY - * dirty at baseline reindexes once on the first poll — the watcher - * cannot know whether that state made it into the DB (e.g. server - * restart with a stale artifact). At-least-once, then the signature - * gates further polls (#937). */ - s->file_count = git_file_count(s->root_path); + if (vcs_metadata_exists(s->root_path, ".svn")) { + if (!s->svn_client.executable[0] && + cbm_svn_client_init(s->root_path, &s->svn_client) != CBM_SVN_PROBE_OK) { + cbm_log_warn("watcher.probe.pending", "project", s->project_name, "strategy", "svn", + "reason", "client_unavailable"); + s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); + return; + } + cbm_svn_observation_t observation = {0}; + cbm_svn_probe_result_t probe = cbm_svn_probe(&s->svn_client, s->root_path, &observation); + if (probe == CBM_SVN_PROBE_NOT_WORKING_COPY) { + s->strategy = VCS_NONE; + cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "none"); + s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); + return; + } + if (probe != CBM_SVN_PROBE_OK) { + cbm_log_warn("watcher.probe.pending", "project", s->project_name, "strategy", "svn", + "reason", "status_failed"); + s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); + return; + } + s->strategy = VCS_SVN; + s->last_svn = observation; + s->svn_initial_sync_pending = observation.has_local_changes; + s->file_count = observation.entry_count; s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count); - cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "git", "files", + cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "svn", "files", s->file_count > 0 ? "yes" : "0"); - } else { - cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "none"); + s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); + return; } + /* Preserve Git-subdirectory registrations, but only after root SVN + * metadata has precedence over an inherited parent Git worktree. */ + if (is_git_repo(s->root_path)) { + finish_git_baseline(s); + return; + } + + s->strategy = VCS_NONE; + cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "none"); s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); } @@ -612,11 +689,7 @@ static void init_baseline(project_state_t *s) { * poll_project commits them only after a SUCCESSFUL reindex so that * busy-skips and failed runs retry instead of silently losing the change * (#937). Observations are staged in the pending_* fields. */ -static bool check_changes(project_state_t *s) { - if (!s->is_git) { - return false; - } - +static bool check_git_changes(project_state_t *s) { bool changed = false; /* Check HEAD movement (commit, checkout, pull) */ @@ -644,6 +717,26 @@ static bool check_changes(project_state_t *s) { return changed; } +typedef enum { + CHANGE_UNCERTAIN = -1, + CHANGE_NONE = 0, + CHANGE_DETECTED = 1, +} change_result_t; + +static change_result_t check_svn_changes(project_state_t *s) { + cbm_svn_observation_t observation = {0}; + if (cbm_svn_probe(&s->svn_client, s->root_path, &observation) != CBM_SVN_PROBE_OK) { + cbm_log_warn("watcher.probe.failed", "project", s->project_name, "strategy", "svn"); + return CHANGE_UNCERTAIN; + } + s->pending_svn = observation; + s->file_count = observation.entry_count; + s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count); + return s->svn_initial_sync_pending || !svn_observation_equal(&observation, &s->last_svn) + ? CHANGE_DETECTED + : CHANGE_NONE; +} + /* Context for poll_once foreach callback */ typedef struct { cbm_watcher_t *w; @@ -685,9 +778,8 @@ static void poll_project(const char *key, void *val, void *ud) { return; } - /* Stale-root pruning (#286): classify the root BEFORE the baseline / - * is_git / interval gates so vanished roots are noticed even for - * non-git projects and regardless of adaptive backoff. */ + /* Stale-root pruning (#286): classify the root before VCS/interval gates + * so vanished roots are noticed for every strategy. */ int stat_errno = 0; root_status_t rs = root_status(s->root_path, &stat_errno); if (rs == ROOT_UNCERTAIN) { @@ -723,31 +815,36 @@ static void poll_project(const char *key, void *val, void *ud) { s->first_missing_ms = 0; } - /* Initialize baseline on first poll */ - if (!s->baseline_done) { - init_baseline(s); + /* Retryable strategy probes use the same adaptive scheduling gate. */ + if (ctx->now < s->next_poll_ns) { return; } - /* Skip non-git projects */ - if (!s->is_git) { + /* Initialize baseline on first eligible poll. */ + if (s->strategy == VCS_UNCLASSIFIED) { + init_baseline(s); return; } - /* Respect adaptive interval */ - if (ctx->now < s->next_poll_ns) { + if (s->strategy == VCS_NONE) { return; } - /* Check for changes */ - bool changed = check_changes(s); - if (!changed) { - s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * US_PER_MS); + change_result_t change = CHANGE_NONE; + if (s->strategy == VCS_GIT) { + change = check_git_changes(s) ? CHANGE_DETECTED : CHANGE_NONE; + } else if (s->strategy == VCS_SVN) { + change = check_svn_changes(s); + } + int64_t completed_at = s->strategy == VCS_SVN ? now_ns() : ctx->now; + if (change != CHANGE_DETECTED) { + s->next_poll_ns = completed_at + ((int64_t)s->interval_ms * US_PER_MS); return; } /* Trigger reindex */ - cbm_log_info("watcher.changed", "project", s->project_name, "strategy", "git"); + const char *strategy_name = s->strategy == VCS_SVN ? "svn" : "git"; + cbm_log_info("watcher.changed", "project", s->project_name, "strategy", strategy_name); if (ctx->w->index_fn) { int rc = ctx->w->index_fn(s->project_name, s->root_path, ctx->w->user_data); if (rc == 0) { @@ -756,13 +853,17 @@ static void poll_project(const char *key, void *val, void *ud) { * reindex just succeeded. A commit/edit landing during the * reindex is deliberately not absorbed: the next poll sees it * as a new delta (at-least-once, never lost). */ - if (s->pending_head[0] != '\0') { - snprintf(s->last_head, sizeof(s->last_head), "%s", s->pending_head); + if (s->strategy == VCS_GIT) { + if (s->pending_head[0] != '\0') { + snprintf(s->last_head, sizeof(s->last_head), "%s", s->pending_head); + } + s->last_dirty_sig = s->pending_dirty_sig; + s->file_count = git_file_count(s->root_path); + s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count); + } else { + s->last_svn = s->pending_svn; + s->svn_initial_sync_pending = false; } - s->last_dirty_sig = s->pending_dirty_sig; - /* Refresh file count for interval */ - s->file_count = git_file_count(s->root_path); - s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count); } else if (rc > 0) { /* Busy-skip: baseline stays uncommitted, next poll retries. */ cbm_log_info("watcher.index.retry", "project", s->project_name); @@ -771,7 +872,8 @@ static void poll_project(const char *key, void *val, void *ud) { } } - s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * US_PER_MS); + completed_at = s->strategy == VCS_SVN ? now_ns() : ctx->now; + s->next_poll_ns = completed_at + ((int64_t)s->interval_ms * US_PER_MS); } /* Callback to snapshot project state pointers into an array. */ diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index 096d4e4a2..d6898e6fa 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -1,7 +1,7 @@ /* * watcher.h — File change watcher for auto-reindexing. * - * Polls indexed projects for git changes (HEAD movement or dirty working tree) + * Polls indexed Git and SVN projects for repository or working-copy changes * and triggers re-indexing via a callback. Uses adaptive polling intervals * based on project size (5s base + 1s per 500 files, capped at 60s). * diff --git a/tests/svn_test_helpers.h b/tests/svn_test_helpers.h new file mode 100644 index 000000000..d22b236b1 --- /dev/null +++ b/tests/svn_test_helpers.h @@ -0,0 +1,116 @@ +#ifndef SVN_TEST_HELPERS_H +#define SVN_TEST_HELPERS_H + +#include "../src/foundation/platform.h" +#include "../src/watcher/svn_state.h" +#include "test_helpers.h" + +typedef struct { + char root[CBM_SZ_4K]; + char repository[CBM_SZ_4K]; + char working_copy[CBM_SZ_4K]; + char source[CBM_SZ_4K]; + cbm_svn_client_t client; +} th_svn_fixture_t; + +static inline void th_svn_fixture_cleanup(th_svn_fixture_t *fixture) { + if (fixture) { + th_cleanup(fixture->root); + } +} + +static inline int th_svn_fixture_init(th_svn_fixture_t *fixture, const char *prefix, + const char *source_name, const char *source_content) { + if (!fixture || !prefix) { + return -1; + } + memset(fixture, 0, sizeof(*fixture)); + char *root = th_mktempdir(prefix); + if (!root) { + return -1; + } + snprintf(fixture->root, sizeof(fixture->root), "%s", root); + snprintf(fixture->repository, sizeof(fixture->repository), "%s/repository", fixture->root); + snprintf(fixture->working_copy, sizeof(fixture->working_copy), "%s/working-copy", + fixture->root); + if (cbm_svn_client_init(fixture->root, &fixture->client) != CBM_SVN_PROBE_OK) { + th_svn_fixture_cleanup(fixture); + return -1; + } + char repository_arg[CBM_SZ_4K]; + char working_copy_arg[CBM_SZ_4K]; + if (!cbm_svn_format_path_arg(&fixture->client, fixture->repository, repository_arg, + sizeof(repository_arg)) || + !cbm_svn_format_path_arg(&fixture->client, fixture->working_copy, working_copy_arg, + sizeof(working_copy_arg))) { + th_svn_fixture_cleanup(fixture); + return -1; + } + + char svnadmin[CBM_SZ_4K]; + snprintf(svnadmin, sizeof(svnadmin), "%s", fixture->client.executable); + cbm_normalize_path_sep(svnadmin); + char *slash = strrchr(svnadmin, '/'); + if (!slash) { + th_svn_fixture_cleanup(fixture); + return -1; + } +#ifdef _WIN32 + snprintf(slash + 1, (size_t)(svnadmin + sizeof(svnadmin) - slash - 1), "svnadmin.exe"); +#else + snprintf(slash + 1, (size_t)(svnadmin + sizeof(svnadmin) - slash - 1), "svnadmin"); +#endif + const char *create_args[] = {svnadmin, "create", repository_arg, NULL}; + if (cbm_exec_no_shell(create_args) != 0) { + th_svn_fixture_cleanup(fixture); + return -1; + } + + char repository_url[CBM_SZ_8K]; +#ifdef _WIN32 + const char *file_prefix = fixture->client.uses_posix_paths ? "file://" : "file:///"; +#else + const char *file_prefix = "file://"; +#endif + int url_length = + snprintf(repository_url, sizeof(repository_url), "%s%s", file_prefix, repository_arg); + if (url_length < 0 || (size_t)url_length >= sizeof(repository_url)) { + th_svn_fixture_cleanup(fixture); + return -1; + } + const char *checkout_args[] = {fixture->client.executable, "checkout", + "--non-interactive", repository_url, + working_copy_arg, NULL}; + if (cbm_exec_no_shell(checkout_args) != 0) { + th_svn_fixture_cleanup(fixture); + return -1; + } + if (!source_name) { + return 0; + } + + snprintf(fixture->source, sizeof(fixture->source), "%s/%s", fixture->working_copy, + source_name); + if (th_write_file(fixture->source, source_content) != 0) { + th_svn_fixture_cleanup(fixture); + return -1; + } + char source_arg[CBM_SZ_4K]; + if (!cbm_svn_format_path_arg(&fixture->client, fixture->source, source_arg, + sizeof(source_arg))) { + th_svn_fixture_cleanup(fixture); + return -1; + } + const char *add_args[] = {fixture->client.executable, "add", "--non-interactive", source_arg, + NULL}; + const char *commit_args[] = { + fixture->client.executable, "commit", "--non-interactive", "-m", "initial", + working_copy_arg, NULL}; + if (cbm_exec_no_shell(add_args) != 0 || cbm_exec_no_shell(commit_args) != 0) { + th_svn_fixture_cleanup(fixture); + return -1; + } + return 0; +} + +#endif /* SVN_TEST_HELPERS_H */ diff --git a/tests/test_integration.c b/tests/test_integration.c index b81ac378b..451664a12 100644 --- a/tests/test_integration.c +++ b/tests/test_integration.c @@ -8,12 +8,16 @@ * buffer → SQLite dump → query. No mocking — real files, real parsing. */ #include "../src/foundation/compat.h" +#include "svn_test_helpers.h" #include "test_framework.h" #include "test_helpers.h" #include #include #include #include +#include +#include +#include #include #include @@ -28,6 +32,36 @@ static char g_dbpath[512]; static cbm_mcp_server_t *g_srv = NULL; static char *g_project = NULL; +typedef struct { + cbm_mcp_server_t *server; + int calls; +} svn_watch_context_t; + +static int svn_watch_index(const char *project_name, const char *root_path, void *user_data) { + (void)project_name; + svn_watch_context_t *context = (svn_watch_context_t *)user_data; + cbm_mcp_server_begin_store_update(context->server); + cbm_pipeline_t *pipeline = cbm_pipeline_new(root_path, NULL, CBM_MODE_FULL); + if (!pipeline) { + cbm_mcp_server_end_store_update(context->server, false); + return -1; + } + int result = cbm_pipeline_run(pipeline); + cbm_pipeline_free(pipeline); + if (result == 0) { + context->calls++; + } + cbm_mcp_server_end_store_update(context->server, result == 0); + return result; +} + +static void remove_project_db(const char *project) { + char path[1024]; + snprintf(path, sizeof(path), "%s/%s.db", cbm_resolve_cache_dir(), project); + cbm_unlink(path); + cbm_remove_db_sidecars(path); +} + /* Create source files in temp directory */ static int create_test_project(void) { snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/cbm_integ_XXXXXX"); @@ -342,6 +376,119 @@ TEST(integ_mcp_query_graph_calls) { PASS(); } +TEST(integ_mcp_reopens_cached_store_after_full_route) { + char args[256]; + snprintf(args, sizeof(args), "{\"name_pattern\":\"greet\",\"project\":\"%s\"}", g_project); + + /* Pre-warm the server's read-only query handle before the pipeline replaces + * or rewrites the project database. */ + char *resp = call_tool("search_graph", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "greet")); + free(resp); + + /* The initial fixture has three indexed files. Four additions exceed the + * pipeline's existing incremental safety threshold and select its full route. */ + for (int i = 0; i < 4; i++) { + char path[512]; + snprintf(path, sizeof(path), "%s/full_refresh_%d.py", g_tmpdir, i); + FILE *f = fopen(path, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "def FullRefresh%d():\n return %d\n", i, i); + fclose(f); + } + + cbm_mcp_server_begin_store_update(g_srv); + cbm_pipeline_t *pipeline = cbm_pipeline_new(g_tmpdir, NULL, CBM_MODE_FULL); + if (!pipeline) { + cbm_mcp_server_end_store_update(g_srv, false); + } + ASSERT_NOT_NULL(pipeline); + int pipeline_result = cbm_pipeline_run(pipeline); + cbm_pipeline_free(pipeline); + cbm_mcp_server_end_store_update(g_srv, pipeline_result == 0); + ASSERT_EQ(pipeline_result, 0); + + /* The following query consumes the successful publication event and + * reopens the latest generation in the same server lifecycle. */ + snprintf(args, sizeof(args), "{\"name_pattern\":\"FullRefresh0\",\"project\":\"%s\"}", + g_project); + resp = call_tool("search_graph", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "FullRefresh0")); + free(resp); + PASS(); +} + +TEST(integ_svn_watcher_updates_graph_in_same_session) { + th_svn_fixture_t fixture; + ASSERT_EQ(th_svn_fixture_init(&fixture, "cbm_integ_svn", "service.py", + "def BeforeSvnWatch():\n return 1\n"), + 0); + const char *working_copy = fixture.working_copy; + char tracked_source[768]; + char added_source[768]; + snprintf(tracked_source, sizeof(tracked_source), "%s/service.py", working_copy); + + char *project = cbm_project_name_from_path(working_copy); + ASSERT_NOT_NULL(project); + remove_project_db(project); + cbm_mcp_server_t *server = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(server); + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", working_copy); + char *response = cbm_mcp_handle_tool(server, "index_repository", args); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "indexed")); + free(response); + + snprintf(args, sizeof(args), "{\"name_pattern\":\"BeforeSvnWatch\",\"project\":\"%s\"}", + project); + response = cbm_mcp_handle_tool(server, "search_graph", args); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "BeforeSvnWatch")); + free(response); + + cbm_store_t *watch_store = cbm_store_open_memory(); + ASSERT_NOT_NULL(watch_store); + svn_watch_context_t context = {.server = server, .calls = 0}; + cbm_watcher_t *watcher = cbm_watcher_new(watch_store, svn_watch_index, &context); + ASSERT_NOT_NULL(watcher); + cbm_watcher_watch(watcher, project, working_copy); + ASSERT_EQ(cbm_watcher_poll_once(watcher), 0); + + ASSERT_EQ(th_write_file(tracked_source, "def AfterSvnWatch():\n return 2\n"), 0); + cbm_watcher_touch(watcher, project); + ASSERT_EQ(cbm_watcher_poll_once(watcher), 1); + ASSERT_EQ(context.calls, 1); + snprintf(args, sizeof(args), "{\"name_pattern\":\"AfterSvnWatch\",\"project\":\"%s\"}", + project); + response = cbm_mcp_handle_tool(server, "search_graph", args); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "AfterSvnWatch")); + free(response); + + snprintf(added_source, sizeof(added_source), "%s/unversioned.py", working_copy); + ASSERT_EQ(th_write_file(added_source, "def UnversionedSvnWatch():\n return 3\n"), 0); + cbm_watcher_touch(watcher, project); + ASSERT_EQ(cbm_watcher_poll_once(watcher), 1); + ASSERT_EQ(context.calls, 2); + snprintf(args, sizeof(args), "{\"name_pattern\":\"UnversionedSvnWatch\",\"project\":\"%s\"}", + project); + response = cbm_mcp_handle_tool(server, "search_graph", args); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "UnversionedSvnWatch")); + free(response); + + cbm_watcher_free(watcher); + cbm_store_close(watch_store); + cbm_mcp_server_free(server); + remove_project_db(project); + free(project); + th_svn_fixture_cleanup(&fixture); + PASS(); +} + TEST(integ_mcp_get_graph_schema) { char args[128]; snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project); @@ -657,6 +804,8 @@ SUITE(integration) { RUN_TEST(integ_mcp_search_graph_by_name); RUN_TEST(integ_mcp_query_graph_functions); RUN_TEST(integ_mcp_query_graph_calls); + RUN_TEST(integ_mcp_reopens_cached_store_after_full_route); + RUN_TEST(integ_svn_watcher_updates_graph_in_same_session); RUN_TEST(integ_mcp_get_graph_schema); RUN_TEST(integ_mcp_get_architecture); RUN_TEST(integ_mcp_trace_path); diff --git a/tests/test_main.c b/tests/test_main.c index ffbb1fb5c..874c7630b 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -148,6 +148,20 @@ static int tf_maybe_run_socket_probe(int argc, char **argv) { #endif } +static int tf_maybe_run_argv_probe(int argc, char **argv) { +#ifdef _WIN32 + if (argc < 4 || strcmp(argv[1], "__cbm_argvprobe") != 0) { + return -1; + } + puts(argv[2]); + return atoi(argv[3]); +#else + (void)argc; + (void)argv; + return -1; +#endif +} + static int g_suite_argc = 0; static char **g_suite_argv = NULL; static bool *g_suite_arg_matched = NULL; @@ -207,6 +221,7 @@ extern void suite_index_resilience(void); extern void suite_fqn(void); extern void suite_route_canon(void); extern void suite_path_alias(void); +extern void suite_svn_state(void); extern void suite_watcher(void); extern void suite_lz4(void); extern void suite_zstd(void); @@ -283,6 +298,10 @@ extern void suite_dump_verify_io(void); extern void cbm_kind_in_set_free_cache(void); int main(int argc, char **argv) { + int argv_probe_rc = tf_maybe_run_argv_probe(argc, argv); + if (argv_probe_rc >= 0) { + return argv_probe_rc; + } /* #798 follow-up: if spawned as the socket-isolation probe, report whether an * inheritable socket handle crossed into this child and exit before any suite. */ int probe_rc = tf_maybe_run_socket_probe(argc, argv); @@ -374,6 +393,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(path_alias); /* Watcher (M10) */ + RUN_SELECTED_SUITE(svn_state); RUN_SELECTED_SUITE(watcher); /* LZ4 + zstd + SQLite writer */ diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 4e1d5eeb3..3c03709b2 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -6,6 +6,7 @@ #include "../src/foundation/compat.h" #include #include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */ +#include "../src/foundation/compat_thread.h" #include "../src/foundation/constants.h" #include "../src/foundation/log.h" #include "test_framework.h" @@ -596,6 +597,24 @@ TEST(mcp_text_result_error) { PASS(); } +TEST(mcp_result_success_requires_explicit_non_error) { + char *success = cbm_mcp_text_result("indexed", false); + char *failure = cbm_mcp_text_result("index failed", true); + + ASSERT_NOT_NULL(success); + ASSERT_NOT_NULL(failure); + ASSERT_TRUE(cbm_mcp_result_succeeded(success)); + ASSERT_FALSE(cbm_mcp_result_succeeded(failure)); + ASSERT_FALSE(cbm_mcp_result_succeeded("{}")); + ASSERT_FALSE(cbm_mcp_result_succeeded("{\"isError\":\"false\"}")); + ASSERT_FALSE(cbm_mcp_result_succeeded("not json")); + ASSERT_FALSE(cbm_mcp_result_succeeded(NULL)); + + free(success); + free(failure); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * ARGUMENT EXTRACTION * ══════════════════════════════════════════════════════════════════ */ @@ -3433,6 +3452,92 @@ TEST(tool_ingest_traces_empty) { * IDLE STORE EVICTION * ══════════════════════════════════════════════════════════════════ */ +static bool stale_store_make_db(const char *cache, const char *project, const char *function_name) { + char db_path[CBM_SZ_4K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return false; + } + + bool ok = cbm_store_upsert_project(store, project, cache) == CBM_STORE_OK; + if (ok) { + char qualified_name[CBM_SZ_256]; + snprintf(qualified_name, sizeof(qualified_name), "%s.%s", project, function_name); + cbm_node_t node = { + .project = project, + .label = "Function", + .name = function_name, + .qualified_name = qualified_name, + .file_path = "main.c", + .start_line = 1, + .end_line = 2, + }; + ok = cbm_store_upsert_node(store, &node) > 0; + } + cbm_store_close(store); + return ok; +} + +static void *mark_stale_store_thread(void *arg) { + cbm_mcp_server_mark_store_stale((cbm_mcp_server_t *)arg); + return NULL; +} + +TEST(store_stale_notification_is_consumed_by_resolve_thread) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-stale-store-XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "stale-store-project"; + ASSERT_TRUE(stale_store_make_db(cache, project, "BeforeRefresh")); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char args[512]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"name_pattern\":\"BeforeRefresh\"}", project); + char *resp = cbm_mcp_handle_tool(srv, "search_graph", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "BeforeRefresh")); + free(resp); + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + + cbm_thread_t tid; + ASSERT_EQ(cbm_thread_create(&tid, 0, mark_stale_store_thread, srv), 0); + ASSERT_EQ(cbm_thread_join(&tid), 0); + + /* Publishing from the background thread must not close the live handle. */ + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + + /* The next resolve runs on this request thread. A missing project leaves + * no replacement handle, making consumption directly observable. */ + resp = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"missing-after-stale\",\"name_pattern\":\".*\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "not found")); + free(resp); + ASSERT_FALSE(cbm_mcp_server_has_cached_store(srv)); + + /* A later resolve reopens the original project in the same server. */ + resp = cbm_mcp_handle_tool(srv, "search_graph", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "BeforeRefresh")); + free(resp); + ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv)); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + PASS(); +} + TEST(store_idle_eviction) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); cbm_mcp_server_set_project(srv, "test-evict"); @@ -6354,6 +6459,7 @@ SUITE(mcp) { RUN_TEST(mcp_text_result_wraps_plain_text_as_structured_content); RUN_TEST(mcp_cancel_matches_request_id); RUN_TEST(mcp_text_result_error); + RUN_TEST(mcp_result_success_requires_explicit_non_error); /* Argument extraction */ RUN_TEST(mcp_get_tool_name); @@ -6475,6 +6581,7 @@ SUITE(mcp) { RUN_TEST(readonly_query_succeeds_on_readonly_fs); /* Idle store eviction */ + RUN_TEST(store_stale_notification_is_consumed_by_resolve_thread); RUN_TEST(store_idle_eviction); RUN_TEST(store_idle_no_eviction_within_timeout); RUN_TEST(store_idle_evict_protects_initial_store); diff --git a/tests/test_security.c b/tests/test_security.c index 34676620e..9dc3cafc1 100644 --- a/tests/test_security.c +++ b/tests/test_security.c @@ -707,6 +707,22 @@ TEST(popen_isolated_propagates_exit_code) { PASS(); } +TEST(popen_argv_direct_spawn_propagates_exit_code) { + char self[MAX_PATH]; + ASSERT(GetModuleFileNameA(NULL, self, sizeof(self)) > 0); + const char *literal = "%CBM_POPEN_ARGV_LITERAL% & value"; + const char *argv[] = {self, "__cbm_argvprobe", literal, "37", NULL}; + FILE *fp = cbm_popen_argv(argv); + ASSERT_NOT_NULL(fp); + ASSERT_EQ(cbm_popen_last_was_isolated(), 1); + char line[128]; + ASSERT_NOT_NULL(fgets(line, sizeof(line), fp)); + line[strcspn(line, "\r\n")] = '\0'; + ASSERT_STR_EQ(line, literal); + ASSERT_EQ(cbm_pclose(fp), 37); + PASS(); +} + /* #798 follow-up (the full-repro gap flagged above): prove the EXACT handle class * that deadlocked git — an inheritable AFD/listening-socket handle, the kind the * UI HTTP server holds — does NOT cross into the cbm_popen child. Unlike the @@ -837,6 +853,7 @@ SUITE(security) { /* Isolated popen — handle-inheritance regression guard for #798 */ RUN_TEST(popen_isolated_git_version_round_trip); RUN_TEST(popen_isolated_propagates_exit_code); + RUN_TEST(popen_argv_direct_spawn_propagates_exit_code); RUN_TEST(popen_isolates_listening_socket); #endif } diff --git a/tests/test_svn_state.c b/tests/test_svn_state.c new file mode 100644 index 000000000..e2ae06a7a --- /dev/null +++ b/tests/test_svn_state.c @@ -0,0 +1,264 @@ +#include "../src/watcher/svn_state.h" +#include "../src/foundation/compat.h" +#include "svn_test_helpers.h" +#include "test_framework.h" +#include "test_helpers.h" + +#include +#include +#include +#include +#include + +static int parse_status(const char *root, const char *path, + cbm_svn_observation_t *observation) { + char xml[4096]; + int n = snprintf(xml, sizeof(xml), + "\n" + "" + "" + "", + root, path); + if (n < 0 || (size_t)n >= sizeof(xml)) { + return CBM_SVN_PROBE_UNCERTAIN; + } + + FILE *stream = tmpfile(); + if (!stream) { + return CBM_SVN_PROBE_UNCERTAIN; + } + fputs(xml, stream); + rewind(stream); + int result = cbm_svn_parse_status_stream(stream, root, observation); + fclose(stream); + return result; +} + +TEST(content_change_updates_observation_without_status_change) { + char *root = th_mktempdir("cbm_svn_state"); + ASSERT_NOT_NULL(root); + + char root_copy[1024]; + char path[1024]; + snprintf(root_copy, sizeof(root_copy), "%s", root); + snprintf(path, sizeof(path), "%s/source.c", root_copy); + ASSERT_EQ(th_write_file(path, "int value = 1;\n"), 0); + + cbm_svn_observation_t before = {0}; + ASSERT_EQ(parse_status(root_copy, path, &before), CBM_SVN_PROBE_OK); + + ASSERT_EQ(th_write_file(path, "int value = 2;\n"), 0); + cbm_svn_observation_t after = {0}; + ASSERT_EQ(parse_status(root_copy, path, &after), CBM_SVN_PROBE_OK); + + ASSERT_EQ(before.semantic_signature, after.semantic_signature); + ASSERT_NEQ(before.content_signature, after.content_signature); + ASSERT_TRUE(before.has_local_changes); + ASSERT_TRUE(after.has_local_changes); + ASSERT_EQ(before.entry_count, 1); + ASSERT_EQ(after.entry_count, 1); + + th_cleanup(root_copy); + PASS(); +} + +static int parse_xml(const char *root, const char *xml, cbm_svn_observation_t *observation) { + FILE *stream = tmpfile(); + if (!stream) { + return CBM_SVN_PROBE_UNCERTAIN; + } + fputs(xml, stream); + rewind(stream); + int result = cbm_svn_parse_status_stream(stream, root, observation); + fclose(stream); + return result; +} + +TEST(parser_rejects_unsafe_xml) { + char *root = th_mktempdir("cbm_svn_xml"); + ASSERT_NOT_NULL(root); + char root_copy[1024]; + snprintf(root_copy, sizeof(root_copy), "%s", root); + + cbm_svn_observation_t observation = {0}; + ASSERT_EQ(parse_xml(root_copy, + "]>", + &observation), + CBM_SVN_PROBE_UNCERTAIN); + ASSERT_EQ(parse_xml(root_copy, + "", + &observation), + CBM_SVN_PROBE_UNCERTAIN); + + th_cleanup(root_copy); + PASS(); +} + +TEST(clean_properties_and_externals_are_not_local_changes) { + char *root = th_mktempdir("cbm_svn_clean_state"); + ASSERT_NOT_NULL(root); + char xml[4096]; + snprintf(xml, sizeof(xml), + "" + "" + "", + root, root); + cbm_svn_observation_t observation = {0}; + ASSERT_EQ(parse_xml(root, xml, &observation), CBM_SVN_PROBE_OK); + ASSERT_FALSE(observation.has_local_changes); + ASSERT_EQ(observation.entry_count, 1); + + snprintf(xml, sizeof(xml), + "" + "" + "", + root, root); + memset(&observation, 0, sizeof(observation)); + ASSERT_EQ(parse_xml(root, xml, &observation), CBM_SVN_PROBE_OK); + ASSERT_FALSE(observation.has_local_changes); + th_cleanup(root); + PASS(); +} + +TEST(discovery_excluded_candidate_directory_is_not_hashed) { + char *root = th_mktempdir("cbm_svn_ignored_state"); + ASSERT_NOT_NULL(root); + char directory[1024]; + char source[1024]; + snprintf(directory, sizeof(directory), "%s/node_modules", root); + ASSERT_EQ(cbm_mkdir(directory), 0); + snprintf(source, sizeof(source), "%s/ignored.js", directory); + ASSERT_EQ(th_write_file(source, "function Hidden() {}\n"), 0); + + char xml[4096]; + snprintf(xml, sizeof(xml), + "" + "" + "", + root, directory); + cbm_svn_observation_t observation = {0}; + ASSERT_EQ(parse_xml(root, xml, &observation), CBM_SVN_PROBE_OK); + ASSERT_EQ(observation.candidate_count, 0); + ASSERT_EQ(observation.bytes_hashed, 0); + th_cleanup(root); + PASS(); +} + +TEST(client_init_skips_executable_in_current_directory) { + char *base = th_mktempdir("cbm_svn_client_cwd"); + ASSERT_NOT_NULL(base); + char watched[1024]; + char hostile[1024]; + char fake[1200]; + snprintf(watched, sizeof(watched), "%s/watched", base); + snprintf(hostile, sizeof(hostile), "%s/hostile", base); + ASSERT_EQ(cbm_mkdir(watched), 0); + ASSERT_EQ(cbm_mkdir(hostile), 0); +#ifdef _WIN32 + snprintf(fake, sizeof(fake), "%s/svn.exe", hostile); + const char delimiter = ';'; +#else + snprintf(fake, sizeof(fake), "%s/svn", hostile); + const char delimiter = ':'; +#endif + ASSERT_EQ(th_write_file(fake, "#!/bin/sh\nexit 99\n"), 0); + ASSERT_EQ(chmod(fake, 0755), 0); + + char original_cwd[4096]; + ASSERT_NOT_NULL(getcwd(original_cwd, sizeof(original_cwd))); + const char *path = getenv("PATH"); + ASSERT_NOT_NULL(path); + char *original_path = strdup(path); + ASSERT_NOT_NULL(original_path); + size_t path_size = strlen(hostile) + strlen(original_path) + 2; + char *test_path = malloc(path_size); + ASSERT_NOT_NULL(test_path); + snprintf(test_path, path_size, "%s%c%s", hostile, delimiter, original_path); + + bool changed_cwd = chdir(hostile) == 0; + if (changed_cwd) { + cbm_setenv("PATH", test_path, 1); + } + cbm_svn_client_t client = {0}; + cbm_svn_probe_result_t result = + changed_cwd ? cbm_svn_client_init(watched, &client) : CBM_SVN_PROBE_UNCERTAIN; + bool restored_cwd = chdir(original_cwd) == 0; + cbm_setenv("PATH", original_path, 1); + free(test_path); + free(original_path); + + ASSERT_TRUE(changed_cwd); + ASSERT_TRUE(restored_cwd); + ASSERT_EQ(result, CBM_SVN_PROBE_OK); + ASSERT_TRUE(strstr(client.executable, hostile) == NULL); + th_cleanup(base); + PASS(); +} + +TEST(path_arg_matches_client_runtime) { + cbm_svn_client_t client = {0}; + char path[128]; +#ifdef _WIN32 + client.uses_posix_paths = true; + ASSERT_TRUE( + cbm_svn_format_path_arg(&client, "D:\\R&D\\%project%", path, sizeof(path))); + ASSERT_STR_EQ(path, "/d/R&D/%project%"); +#else + ASSERT_TRUE(cbm_svn_format_path_arg(&client, "/workspace/project", path, sizeof(path))); + ASSERT_STR_EQ(path, "/workspace/project"); +#endif + PASS(); +} + +TEST(client_runtime_detection_is_case_insensitive) { + ASSERT_TRUE(cbm_svn_test_executable_uses_posix_paths("C:/MSYS64/USR/BIN/SVN.EXE")); + ASSERT_FALSE(cbm_svn_test_executable_uses_posix_paths("C:/MSYS64/mingw64/bin/svn.exe")); + PASS(); +} + +TEST(real_probe_tracks_repeated_working_copy_edits) { + th_svn_fixture_t fixture; +#ifdef _WIN32 + const char *prefix = "cbm_svn_probe_%_and&"; +#else + const char *prefix = "cbm_svn_probe"; +#endif + ASSERT_EQ(th_svn_fixture_init(&fixture, prefix, "source.c", "int value = 1;\n"), 0); + cbm_svn_observation_t non_working_copy = {0}; + ASSERT_EQ(cbm_svn_probe(&fixture.client, fixture.root, &non_working_copy), + CBM_SVN_PROBE_NOT_WORKING_COPY); + + cbm_svn_observation_t clean = {0}; + ASSERT_EQ(cbm_svn_probe(&fixture.client, fixture.working_copy, &clean), CBM_SVN_PROBE_OK); + ASSERT_FALSE(clean.has_local_changes); + ASSERT_EQ(th_write_file(fixture.source, "int value = 2;\n"), 0); + cbm_svn_observation_t first_edit = {0}; + ASSERT_EQ(cbm_svn_probe(&fixture.client, fixture.working_copy, &first_edit), CBM_SVN_PROBE_OK); + ASSERT_EQ(th_write_file(fixture.source, "int value = 3;\n"), 0); + cbm_svn_observation_t second_edit = {0}; + ASSERT_EQ(cbm_svn_probe(&fixture.client, fixture.working_copy, &second_edit), CBM_SVN_PROBE_OK); + + ASSERT_NEQ(clean.semantic_signature, first_edit.semantic_signature); + ASSERT_TRUE(first_edit.has_local_changes); + ASSERT_TRUE(second_edit.has_local_changes); + ASSERT_EQ(first_edit.semantic_signature, second_edit.semantic_signature); + ASSERT_NEQ(first_edit.content_signature, second_edit.content_signature); + ASSERT_GT(second_edit.bytes_hashed, 0); + + th_svn_fixture_cleanup(&fixture); + PASS(); +} + +SUITE(svn_state) { + RUN_TEST(content_change_updates_observation_without_status_change); + RUN_TEST(parser_rejects_unsafe_xml); + RUN_TEST(clean_properties_and_externals_are_not_local_changes); + RUN_TEST(discovery_excluded_candidate_directory_is_not_hashed); + RUN_TEST(client_init_skips_executable_in_current_directory); + RUN_TEST(path_arg_matches_client_runtime); + RUN_TEST(client_runtime_detection_is_case_insensitive); + RUN_TEST(real_probe_tracks_repeated_working_copy_edits); +} diff --git a/tests/test_watcher.c b/tests/test_watcher.c index 6e3c32f7b..44aaf54dd 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -8,6 +8,7 @@ #include "../src/foundation/platform.h" #include "test_framework.h" #include "test_helpers.h" +#include "svn_test_helpers.h" #include #include #include @@ -34,6 +35,17 @@ static const char *wt_path(char *buf, size_t n, const char *dir, const char *rel return buf; } +typedef th_svn_fixture_t wt_svn_fixture_t; + +static bool wt_svn_setup(wt_svn_fixture_t *fixture) { + return th_svn_fixture_init(fixture, "cbm_watcher_svn", "source.c", "int value = 1;\n") == + 0; +} + +static void wt_svn_teardown(wt_svn_fixture_t *fixture) { + th_svn_fixture_cleanup(fixture); +} + /* ══════════════════════════════════════════════════════════════════ * ADAPTIVE INTERVAL * ══════════════════════════════════════════════════════════════════ */ @@ -804,6 +816,150 @@ TEST(watcher_failed_reindex_retries_issue937) { PASS(); } +TEST(watcher_svn_clean_and_dirty_registration_baselines) { + wt_svn_fixture_t fixture; + if (!wt_svn_setup(&fixture)) { + FAIL("SVN fixture setup failed"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "svn-clean", fixture.working_copy); + index_call_count = 0; + + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + cbm_watcher_touch(w, "svn-clean"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + cbm_watcher_free(w); + + ASSERT_EQ(th_write_file(fixture.source, "int value = 2;\n"), 0); + w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "svn-dirty", fixture.working_copy); + index_call_count = 0; + + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + cbm_watcher_touch(w, "svn-dirty"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + cbm_watcher_touch(w, "svn-dirty"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + cbm_watcher_free(w); + cbm_store_close(store); + wt_svn_teardown(&fixture); + PASS(); +} + +TEST(watcher_svn_repeated_edits_are_distinct) { + wt_svn_fixture_t fixture; + if (!wt_svn_setup(&fixture)) { + FAIL("SVN fixture setup failed"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "svn-edits", fixture.working_copy); + index_call_count = 0; + cbm_watcher_poll_once(w); + + ASSERT_EQ(th_write_file(fixture.source, "int value = 2;\n"), 0); + cbm_watcher_touch(w, "svn-edits"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + cbm_watcher_touch(w, "svn-edits"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + ASSERT_EQ(th_write_file(fixture.source, "int value = 3;\n"), 0); + cbm_watcher_touch(w, "svn-edits"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 2); + + cbm_watcher_free(w); + cbm_store_close(store); + wt_svn_teardown(&fixture); + PASS(); +} + +static int svn_retry_calls = 0; +static int svn_retry_callback(const char *name, const char *path, void *ud) { + (void)name; + (void)path; + (void)ud; + static const int results[] = {1, -1, 0}; + int index = svn_retry_calls++; + return index < (int)(sizeof(results) / sizeof(results[0])) ? results[index] : 0; +} + +TEST(watcher_svn_callback_failures_preserve_pending_observation) { + wt_svn_fixture_t fixture; + if (!wt_svn_setup(&fixture)) { + FAIL("SVN fixture setup failed"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, svn_retry_callback, NULL); + cbm_watcher_watch(w, "svn-retry", fixture.working_copy); + svn_retry_calls = 0; + cbm_watcher_poll_once(w); + + ASSERT_EQ(th_write_file(fixture.source, "int value = 2;\n"), 0); + cbm_watcher_touch(w, "svn-retry"); + cbm_watcher_poll_once(w); + ASSERT_EQ(svn_retry_calls, 1); + cbm_watcher_touch(w, "svn-retry"); + cbm_watcher_poll_once(w); + ASSERT_EQ(svn_retry_calls, 2); + cbm_watcher_touch(w, "svn-retry"); + cbm_watcher_poll_once(w); + ASSERT_EQ(svn_retry_calls, 3); + cbm_watcher_touch(w, "svn-retry"); + cbm_watcher_poll_once(w); + ASSERT_EQ(svn_retry_calls, 3); + + cbm_watcher_free(w); + cbm_store_close(store); + wt_svn_teardown(&fixture); + PASS(); +} + +TEST(watcher_svn_root_wins_over_inherited_parent_git) { + wt_svn_fixture_t fixture; + if (!wt_svn_setup(&fixture)) { + FAIL("SVN fixture setup failed"); + } + if (wt_git(fixture.root, "init -q") != 0) { + wt_svn_teardown(&fixture); + FAIL("parent Git setup failed"); + } + char gitignore[CBM_SZ_2K]; + snprintf(gitignore, sizeof(gitignore), "%s/.gitignore", fixture.root); + ASSERT_EQ(th_write_file(gitignore, "repository/\nworking-copy/\n"), 0); + ASSERT_EQ(wt_git(fixture.root, "add .gitignore"), 0); + ASSERT_EQ(wt_git(fixture.root, "commit -q -m parent"), 0); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "nested-svn", fixture.working_copy); + index_call_count = 0; + cbm_watcher_poll_once(w); + + ASSERT_EQ(th_write_file(fixture.source, "int value = 2;\n"), 0); + cbm_watcher_touch(w, "nested-svn"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + cbm_watcher_free(w); + cbm_store_close(store); + wt_svn_teardown(&fixture); + PASS(); +} + TEST(watcher_multiple_projects) { /* Create two temporary git repos */ char tmpdirA[256]; @@ -2091,6 +2247,10 @@ SUITE(watcher) { RUN_TEST(watcher_no_change_no_reindex); RUN_TEST(watcher_dirty_state_reindexes_once_issue937); RUN_TEST(watcher_failed_reindex_retries_issue937); + RUN_TEST(watcher_svn_clean_and_dirty_registration_baselines); + RUN_TEST(watcher_svn_repeated_edits_are_distinct); + RUN_TEST(watcher_svn_callback_failures_preserve_pending_observation); + RUN_TEST(watcher_svn_root_wins_over_inherited_parent_git); RUN_TEST(watcher_multiple_projects); /* Non-git project */