diff --git a/docs/gitsync.md b/docs/gitsync.md index 443e590..afbf1cf 100644 --- a/docs/gitsync.md +++ b/docs/gitsync.md @@ -1,226 +1,177 @@ # GitSync -GitSync binds a **site-owned local directory** under `ABSPATH` to a **remote git repository**, then keeps the two in lockstep via pull (Phase 1), push (Phase 2), and scheduled sync (Phase 3). +GitSync binds a **site-owned directory** under `ABSPATH` to a **GitHub repository**, keeping them in lockstep via pull/submit/push — all through the GitHub Contents + Git Data APIs. **No git binary, no `.git/` directory, no shell.** Works identically on self-hosted VPS, WordPress.com Business, WP Engine, Pantheon, and local dev. -It is the layer *above* the existing [`Workspace`](../inc/Workspace/Workspace.php) system: +It is parallel to the [`Workspace`](../inc/Workspace/Workspace.php) system: -| System | Owns | Lives under | Typical user | -|--------------|--------------------------------------|---------------------------------------|-------------------------------| -| `Workspace` | Agent-owned code checkouts | `~/.datamachine/workspace/` | The coding agent itself | -| `GitSync` | Site-owned subtrees (content, data) | `ABSPATH/` of the site's choice | Plugins (Intelligence, etc.) | +| System | Owns | Storage | Transport | Typical user | +|--------------|-------------------------------------|---------------------------------------|--------------------|-------------------------------| +| `Workspace` | Agent-owned code checkouts | `~/.datamachine/workspace/` | Shell git + binary | Coding agent | +| `GitSync` | Site-owned subtrees (content, data) | `ABSPATH/` of the site's choice | GitHub REST API | Plugins (Intelligence, etc.) | -The two systems share low-level primitives — `Support\GitRunner` for shelling out to git, `Support\PathSecurity` for containment and sensitive-file checks — but their scopes stay separate. Workspace knows about `@` handles and worktrees; GitSync knows about user-named bindings pinned to a single branch. +The two systems share low-level helpers (`Support/PathSecurity`, `Support/GitHubRemote`) but don't share implementations. Workspace needs shell git for `git checkout`, `git rebase`, multi-file staging, local branching. GitSync doesn't — content sync is a per-file operation that maps cleanly to `PUT /repos/:slug/contents/:path`. -First consumers (filed, not yet wired): +Consumers (filed, not yet wired): - [Automattic/intelligence#31](https://github.com/Automattic/intelligence/issues/31) — Git-synced wiki content subtrees (WooCommerce wiki, Jetpack wiki, etc.). - [Automattic/intelligence#125](https://github.com/Automattic/intelligence/issues/125) — Git-tracked wiki-generator agent definitions. Both map cleanly to one GitSync binding per synced subtree. +## Why API-first + +Earlier iterations of this primitive shelled out to `git`. That ruled out every managed host (exec disabled, no git binary) and added ~2000 lines of stash/branch/rebase orchestration. The API-first rebuild: + +- Works anywhere `wp_remote_request` works. +- No local `.git/` state means pulls are idempotent reads + writes; no stash conflicts. +- Each file upload is one atomic commit via `createOrUpdateFile` — the feature-branch push dance collapses to a sequence of PUT requests. +- Reuses `GitHubAbilities::apiGet` / `apiRequest` so auth, headers, and error envelopes match the rest of DMC's GitHub surface. + +Trade-offs: GitHub-only for now (no GitLab/Gitea yet), per-file commits rather than one atomic multi-file commit. Both are addressable later without reshaping the surface. + ## Binding shape -Bindings are stored as an associative array in the `datamachine_gitsync_bindings` WordPress option: +Bindings are stored in the `datamachine_gitsync_bindings` option: ```php array( - 'slug' => 'intelligence-wiki', // unique identifier - 'local_path' => '/wp-content/uploads/markdown/wiki/', // ABSPATH-relative, leading slash - 'remote_url' => 'https://github.com/Automattic/a8c-wiki-woocommerce', - 'branch' => 'main', // pinned branch - 'policy' => array( - 'auto_pull' => false, // Phase 3 honors this - 'pull_interval' => 'hourly', // reuses DM SchedulerIntervals - 'write_enabled' => false, // Phase 2 honors this - 'push_enabled' => false, // Phase 2 honors this - 'allowed_paths' => array(), // Phase 2 write containment - 'conflict' => 'fail', // fail | upstream_wins | manual + 'slug' => 'intelligence-wiki', + 'local_path' => '/wp-content/uploads/markdown/wiki/', // ABSPATH-relative + 'remote_url' => 'https://github.com/Automattic/a8c-wiki-woocommerce', + 'branch' => 'main', + 'policy' => array( + 'write_enabled' => false, // gates submit + push + 'safe_direct_push' => false, // second key for push + 'allowed_paths' => array(), // staging allowlist + 'conflict' => 'fail', // fail | upstream_wins | manual + 'auto_pull' => false, // future-scheduler hint + 'pull_interval' => 'hourly', // future-scheduler hint ), - 'created_at' => '2026-04-20T12:00:00+00:00', - 'last_pulled' => '2026-04-20T12:15:00+00:00', - 'last_commit' => 'abc1234', + 'created_at' => '2026-04-20T12:00:00+00:00', + 'last_pulled' => '2026-04-20T12:15:00+00:00', + 'last_commit' => 'abc1234', + 'pulled_paths' => array( 'articles/a.md', 'articles/b.md' ), ) ``` -Defaults are deliberately conservative: no writes, no push, no auto-pull, `conflict = fail`. Every destructive behavior must be explicitly enabled per binding. +`pulled_paths` tracks which files this binding materialized so pulls can detect upstream deletions (files in the list that vanish from the next tree get removed from disk). Files *not* in the list are treated as consumer-owned and left alone. -## CLI (Phase 1) +## CLI ```bash # List bindings wp datamachine-code gitsync list -# Bind + clone (or adopt an existing matching checkout) +# Bind (registry only — doesn't touch disk) wp datamachine-code gitsync bind intelligence-wiki \ --local=/wp-content/uploads/markdown/wiki/ \ - --remote=https://github.com/Automattic/a8c-wiki-woocommerce \ - --branch=main + --remote=https://github.com/Automattic/a8c-wiki-woocommerce + +# First pull materializes files on disk +wp datamachine-code gitsync pull intelligence-wiki -# Status for a single binding +# Status snapshot wp datamachine-code gitsync status intelligence-wiki wp datamachine-code gitsync status intelligence-wiki --format=json -# Pull latest (fast-forward, conflict policy applies) -wp datamachine-code gitsync pull intelligence-wiki -wp datamachine-code gitsync pull intelligence-wiki --allow-dirty +# Open writes + submit via PR (PR flow) +wp datamachine-code gitsync policy intelligence-wiki \ + --write-enabled=true --allowed-paths=articles/,images/ +wp datamachine-code gitsync submit intelligence-wiki \ + --message="Add CIAB kickoff article" + +# Direct push (personal-wiki case — two-key auth) +wp datamachine-code gitsync policy personal-wiki \ + --write-enabled=true --safe-direct-push=true \ + --allowed-paths=notes/ +wp datamachine-code gitsync push personal-wiki --message="Daily notes" -# Unbind (keeps the directory by default) +# Unbind (directory preserved) wp datamachine-code gitsync unbind intelligence-wiki +# Unbind + delete directory wp datamachine-code gitsync unbind intelligence-wiki --purge --yes ``` ## Abilities -Every CLI subcommand is a thin shell over the matching WordPress ability. Consumers should call the ability, not the service class. - -| Ability | Category | REST? | Purpose | -|-----------------------------------|-----------------------------|-------|-------------------------------------------------------------| -| `datamachine/gitsync-list` | `datamachine-code-gitsync` | yes | List all bindings + lightweight status. | -| `datamachine/gitsync-status` | `datamachine-code-gitsync` | yes | Detailed status for one binding (branch/HEAD/dirty/ahead/behind). | -| `datamachine/gitsync-bind` | `datamachine-code-gitsync` | no | Register a binding and clone or adopt the working tree. | -| `datamachine/gitsync-unbind` | `datamachine-code-gitsync` | no | Remove a binding; optional `--purge` deletes the dir. | -| `datamachine/gitsync-pull` | `datamachine-code-gitsync` | no | Fast-forward pull, honoring conflict policy. | - -Mutating abilities are CLI-only (`show_in_rest = false`) in Phase 1 — they change filesystem state and should stay behind explicit operator action. +Every CLI subcommand is a thin shell over the matching ability. Consumers should call abilities directly, not the service class. -```php -$ability = wp_get_ability( 'datamachine/gitsync-pull' ); -$result = $ability->execute( array( - 'slug' => 'intelligence-wiki', - 'allow_dirty' => false, -) ); -``` - -## `bind` semantics - -When you call `bind`, GitSync inspects the target path and chooses one of four paths: - -| Path state | Action | -|----------------------------------------|---------------------------------------------------------------| -| Doesn't exist | `git clone --branch ` | -| Exists, empty | `git clone` into it | -| Exists, has `.git/` with matching origin | **Adopt** — register the binding, no clone | -| Exists, has `.git/` but origin mismatch | Error (`origin_mismatch`, HTTP 409) | -| Exists, non-empty, no `.git/` | Error (`dirty_target`, HTTP 409) — refuses to overlay | - -Adopt semantics mean `bind` is **idempotent**: running it twice against the same path with the same remote is safe. This is important when bindings are provisioned declaratively (e.g. by a plugin on activation). - -## `pull` semantics +| Ability | Category | REST? | Purpose | +|--------------------------------------|-----------------------------|-------|-------------------------------------------------------------| +| `datamachine/gitsync-list` | `datamachine-code-gitsync` | yes | List all bindings. | +| `datamachine/gitsync-status` | `datamachine-code-gitsync` | yes | Detailed status for one binding. | +| `datamachine/gitsync-bind` | `datamachine-code-gitsync` | no | Register a binding. Doesn't touch disk. | +| `datamachine/gitsync-unbind` | `datamachine-code-gitsync` | no | Remove a binding; optional `--purge` deletes the dir. | +| `datamachine/gitsync-pull` | `datamachine-code-gitsync` | no | Download upstream files via Contents API. | +| `datamachine/gitsync-submit` | `datamachine-code-gitsync` | no | Upload changed files to `gitsync/`, open or update PR. | +| `datamachine/gitsync-push` | `datamachine-code-gitsync` | no | Direct commits to pinned branch. Two-key auth. | +| `datamachine/gitsync-policy-update` | `datamachine-code-gitsync` | no | Modify policy fields on an existing binding. | -- Pull is **fast-forward only** (`git pull --ff-only origin `). -- If the working tree's current branch drifted from the binding's pinned branch, pull **refuses** rather than silently switching. -- The binding's `conflict` policy drives dirty-tree behavior: +## Pull semantics -| Policy | Dirty tree behavior | -|-----------------|----------------------------------------------------------------------------| -| `fail` (default) | Refuse pull with `dirty_working_tree` (HTTP 400). | -| `upstream_wins` | `git reset --hard HEAD` before pull, discarding local changes. | -| `manual` | Attempt the pull; git surfaces the conflict and the admin resolves it. | +`pull` executes: -`--allow-dirty` on the CLI overrides `fail` for a single invocation without changing the policy. +1. `GET /git/trees/:branch?recursive=1` — list every blob on the pinned branch. +2. For each blob: compute the git-blob SHA (`sha1("blob " + len + "\0" + content)`) of the local file, compare to the tree entry's SHA. Match → skip. Mismatch → `GET /contents/:path` and write. +3. For each path in the binding's `pulled_paths` that's *not* in the tree: delete it locally. Paths never pulled are untouched. -## Security posture - -Containment is enforced at every mutation point: - -- `local_path` must be ABSPATH-relative (leading slash, no `..`, no `.`). -- Sensitive filename fragments (`.env`, `id_rsa`, `.pem`, `.key`, `credentials.json`, `secrets`) are refused outright. -- `purge` re-validates containment via `realpath()` immediately before `rm -rf`, defending against symlink escapes. -- All abilities require `PermissionHelper::can_manage()`. +The binding's `conflict` policy handles the case where a tracked file was edited locally *and* upstream changed: -Shared with the Workspace system via `DataMachineCode\Support\PathSecurity` — the block list and traversal detection have one canonical source of truth. +| Policy | Behavior | +|-----------------|------------------------------------------------------------| +| `fail` (default) | Abort with conflict list. Local edits preserved. | +| `upstream_wins` | Overwrite local files with upstream content. | +| `manual` | Skip conflicting files; surface them in the result. | -## Write path (Phase 2) +`--allow-dirty` on the CLI overrides `fail` for a single call without changing policy. -Five more abilities/CLI commands let a binding propose changes upstream. +Large files (>1MB, where the Contents API truncates the response) are transparently refetched via `GET /git/blobs/:sha` — the fallback path most GitHub clients implement. -| Ability | CLI | Purpose | -|---|---|---| -| `datamachine/gitsync-add` | `gitsync add …` | Stage paths. Enforces `allowed_paths` + sensitive-file filter. | -| `datamachine/gitsync-commit` | `gitsync commit --message=…` | Commit staged changes. 8–200 char message. | -| `datamachine/gitsync-push` | `gitsync push [--force]` | Direct push to pinned branch. Two-key auth. | -| `datamachine/gitsync-submit` | `gitsync submit --message=…` | Blessed PR flow on the sticky proposal branch. | -| `datamachine/gitsync-policy-update` | `gitsync policy --flag=…` | Modify policy fields on an existing binding. | +## Submit semantics — the sticky proposal branch -All mutating, all CLI-only (`show_in_rest = false`), all gated by `PermissionHelper::can_manage()`. - -### Policy gates - -Every write passes through policy checkpoints: - -| Policy | Gates | -|---|---| -| `write_enabled` (default: false) | `add` + `commit` | -| `push_enabled` (default: false) | `push` + `submit` | -| `safe_direct_push` (default: false) | `push` (direct push to pinned branch). Second key — `push_enabled` alone isn't enough. | -| `allowed_paths` (default: `[]`) | Every staged path must sit under one of these roots. Empty allowlist = nothing stageable. | - -`safe_direct_push=true` requires `push_enabled=true` — the policy-update validator refuses the orphan combination. - -Typical progression for a wiki-content binding: - -```bash -# 1. Bind read-only (Phase 1 default) -wp datamachine-code gitsync bind wiki \ - --local=/wp-content/uploads/markdown/wiki/ \ - --remote=https://github.com/Automattic/a8c-wiki-woocommerce - -# 2. Open writes + submit, restricted to the articles subtree -wp datamachine-code gitsync policy wiki \ - --write-enabled=true --push-enabled=true \ - --allowed-paths=articles/,images/ - -# 3. Stage + commit + propose via PR -wp datamachine-code gitsync submit wiki \ - --message="Add CIAB kickoff article" -``` - -Personal-wiki (single-owner) bindings can flip the third key for direct push: - -```bash -wp datamachine-code gitsync policy personal-wiki \ - --push-enabled=true --safe-direct-push=true \ - --allowed-paths=notes/,daily/ -``` - -### `submit` — the sticky proposal branch - -Each binding gets exactly one feature branch on the remote: `gitsync/`. Every submit rewrites it from upstream + user's edits and opens (or updates) a single PR. +Each binding gets exactly one feature branch on the remote: `gitsync/`. Every submit rewrites it from fresh upstream + user's edits and opens (or updates) a single PR. Algorithm: -1. `git fetch origin --prune` — refresh upstream refs. -2. Stash any dirty/untracked files on the pinned branch. -3. `git reset --hard origin/` — align local pinned branch with upstream. -4. `git checkout -B gitsync/` — create/reset the feature branch. -5. `git stash pop` — restore edits on the feature branch. -6. Stage (explicit `--paths=` list, or every dirty file under `allowed_paths`). -7. `git commit`. -8. `git push --force origin gitsync/` — we own this branch exclusively. -9. Open/update PR via GitHubAbilities (using existing PAT). -10. `git checkout ` — leave the working tree clean. +1. `GET /git/ref/heads/` — current SHA of the pinned branch. +2. Walk the binding's local directory (or the explicit `--paths=` list), diff each file's git-blob SHA against upstream. +3. Ensure `gitsync/` exists and points at pinned's current SHA: + - Branch missing → `POST /git/refs` to create. + - Branch at a different SHA → `PATCH /git/refs/heads/gitsync-` with `force=true` to rewind. +4. For each changed file: `PUT /contents/:path` with `branch=gitsync/` and `sha=` (so GitHub creates a commit on the feature branch correctly referencing the old blob). +5. `GET /pulls?head=:gitsync/&state=open` — find existing PR. +6. `PATCH /pulls/:n` (update) or `POST /pulls` (create). + +The branch represents *the latest proposal from this binding* and updates in place. No per-submit branches accumulating. -Any step can fail and the `finally`-shaped cleanup always tries to return you to the pinned branch. A failed `stash pop` leaves the stash in place and logs a warning so your edits are recoverable via `git stash list`. +## Push semantics — direct to pinned -Phase 2 supports `github.com` remotes only for `submit` — the PR backend is DMC's `GitHubAbilities`. Non-GitHub remotes error with `unsupported_remote`; pluggable backends (GitLab MR, Gitea) are Phase 3+. +`push` skips the feature branch and PR — commits go straight to the pinned branch. Requires **both** `policy.write_enabled=true` AND `policy.safe_direct_push=true`. The second key is intentional friction: bindings model a *read-synchronized mirror that tracks upstream*, and the default posture says "changes go upstream via PR review." Direct push is valid for single-owner scenarios (personal wikis, config repos you own) but requires deliberate intent. -### Direct push — why two keys? +## Security posture -`push_enabled` alone doesn't authorize direct push to the pinned branch. `safe_direct_push` must also be true. Rationale: bindings model a *read-synchronized mirror that tracks upstream*. The default posture says "changes go upstream via PR review" (`submit`), and anyone wanting to bypass that flow has to flip two explicit keys. Half-opting-in (write_enabled + push_enabled + push to feature branch via submit) stays safe by default; full direct-push to the tracked branch requires deliberate intent. +- `local_path` must be ABSPATH-relative (leading slash, no `..`, no `.`). +- Sensitive filename fragments (`.env`, `id_rsa`, `.pem`, `.key`, `credentials.json`, `secrets`) are refused outright at both bind and submit time. +- `purge` re-validates containment via `realpath()` immediately before `rm -rf`, defending against symlink escapes. +- Staging (submit/push) enforces `policy.allowed_paths` as an allowlist prefix match. Empty allowlist = nothing uploadable. +- All abilities require `PermissionHelper::can_manage()`. +- GitHub PAT stored via DMC settings (`GitHubAbilities::getPat()`); submitter does not invent its own credential storage. -### Auth +## Known limitations (Phase 1) -- **github.com remotes:** the existing GitHubAbilities PAT is injected into the push URL as `https://@github.com/...`. Standard pattern, matches DMC's other git-writing code. -- **Other https remotes:** fall back to the system's `credential.helper`. Works out of the box on macOS/Linux; may need config in Studio WASM. -- **SSH / non-GitHub PRs:** Phase 3+. +- **GitHub-only.** Non-GitHub remotes are refused at bind time. A pluggable PR backend for GitLab/Gitea is a future concern. +- **Per-file commits on submit.** A submit with N changed files produces N commits. PR reviewers see the aggregate diff; a future optimization using the Git Data API (single tree + single commit + single ref update) would collapse to one commit per submit. +- **No deletion propagation on submit.** Files *added* to or *modified* locally propagate upstream. Files *deleted* locally do not yet delete upstream. Workaround: delete upstream manually, then pull. +- **Scheduled sync not implemented.** `auto_pull` / `pull_interval` are accepted and stored, but nothing honors them. Revisit once a consumer actually needs it. -## What's next +## Consumers -**Phase 3 — scheduled sync:** +Intelligence (`#31`, `#125`) is the first consumer. Integration pattern: -- `GitSyncPullTask extends SystemTask` (mirror of `WorktreeCleanupTask`). -- Registered via `datamachine_tasks` + `datamachine_recurring_schedules` filters. -- Honors each binding's `auto_pull` + `pull_interval` policy. -- Opt-in via a PluginSetting gate. +1. Bind on plugin activation (or admin action). +2. Call `pull` on a hook that makes sense (cron, admin action, webhook) to import upstream content to disk. +3. A consumer-specific bridge imports disk content into the CPT (e.g. Intelligence's wiki importer reads markdown files and creates/updates wiki posts). +4. When the CPT is edited in wp-admin, the bridge exports changes back to disk. +5. Call `submit` to propose disk changes as a PR upstream. -See [data-machine-code#38](https://github.com/Extra-Chill/data-machine-code/issues/38) for the full design + acceptance checklist. +The disk↔DB bridge is consumer-owned — GitSync only cares about disk↔upstream. That boundary is deliberate: each consumer's CPT has its own frontmatter shape, content model, and edit story. GitSync doesn't need to know. diff --git a/inc/Abilities/GitSyncAbilities.php b/inc/Abilities/GitSyncAbilities.php index 8691448..2d2be17 100644 --- a/inc/Abilities/GitSyncAbilities.php +++ b/inc/Abilities/GitSyncAbilities.php @@ -2,14 +2,14 @@ /** * GitSync Abilities * - * WordPress Abilities API surface for the Phase 1 GitSync primitive: - * bind, unbind, pull, status, list. Push, policy-update, and the - * scheduled pull task land in later phases (see DMC#38). + * WordPress Abilities API surface for the API-first GitSync primitive. + * 7 abilities cover the full lifecycle: bind/unbind, list/status, + * pull, submit (PR flow), push (direct-to-pinned), policy-update. * - * Read-only abilities (status, list) are exposed via REST; mutating - * abilities (bind, unbind, pull) are CLI-only (`show_in_rest = false`) - * since they change filesystem state and should be gated behind - * manual review. + * Read-only abilities (list, status) are exposed via REST; every + * mutating call is CLI-only — they either hit the filesystem (pull) + * or spend a GitHub PAT (submit/push/policy), so they stay behind + * explicit operator action. * * @package DataMachineCode\Abilities * @since 0.7.0 @@ -30,11 +30,9 @@ public function __construct() { if ( ! class_exists( 'WP_Ability' ) ) { return; } - if ( self::$registered ) { return; } - $this->registerAbilities(); self::$registered = true; } @@ -43,14 +41,14 @@ private function registerAbilities(): void { $register_callback = function () { // ----------------------------------------------------------------- - // Read-only abilities (show_in_rest = true). + // Read-only (show_in_rest = true). // ----------------------------------------------------------------- wp_register_ability( 'datamachine/gitsync-list', array( 'label' => 'List GitSync Bindings', - 'description' => 'List every registered GitSync binding and its on-disk status snapshot.', + 'description' => 'List every registered GitSync binding with a lightweight summary.', 'category' => 'datamachine-code-gitsync', 'input_schema' => array( 'type' => 'object', @@ -60,25 +58,7 @@ private function registerAbilities(): void { 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean' ), - 'bindings' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'slug' => array( 'type' => 'string' ), - 'local_path' => array( 'type' => 'string' ), - 'absolute_path' => array( 'type' => 'string' ), - 'remote_url' => array( 'type' => 'string' ), - 'branch' => array( 'type' => 'string' ), - 'exists' => array( 'type' => 'boolean' ), - 'is_repo' => array( 'type' => 'boolean' ), - 'auto_pull' => array( 'type' => 'boolean' ), - 'pull_interval' => array( 'type' => 'string' ), - 'last_pulled' => array( 'type' => array( 'string', 'null' ) ), - 'last_commit' => array( 'type' => array( 'string', 'null' ) ), - ), - ), - ), + 'bindings' => array( 'type' => 'array' ), ), ), 'execute_callback' => array( self::class, 'listBindings' ), @@ -91,16 +71,13 @@ private function registerAbilities(): void { 'datamachine/gitsync-status', array( 'label' => 'GitSync Binding Status', - 'description' => 'Report on-disk status for a single GitSync binding: branch, HEAD, dirty count, ahead/behind vs upstream.', + 'description' => 'Report on-disk + upstream status for a single GitSync binding.', 'category' => 'datamachine-code-gitsync', 'input_schema' => array( 'type' => 'object', 'required' => array( 'slug' ), 'properties' => array( - 'slug' => array( - 'type' => 'string', - 'description' => 'Binding slug.', - ), + 'slug' => array( 'type' => 'string' ), ), ), 'output_schema' => array( @@ -112,13 +89,9 @@ private function registerAbilities(): void { 'remote_url' => array( 'type' => 'string' ), 'tracked_branch' => array( 'type' => 'string' ), 'exists' => array( 'type' => 'boolean' ), - 'is_repo' => array( 'type' => 'boolean' ), - 'branch' => array( 'type' => array( 'string', 'null' ) ), - 'head' => array( 'type' => array( 'string', 'null' ) ), - 'dirty' => array( 'type' => 'integer' ), - 'ahead' => array( 'type' => array( 'integer', 'null' ) ), - 'behind' => array( 'type' => array( 'integer', 'null' ) ), 'last_pulled' => array( 'type' => array( 'string', 'null' ) ), + 'last_commit' => array( 'type' => array( 'string', 'null' ) ), + 'pulled_count' => array( 'type' => 'integer' ), 'policy' => array( 'type' => 'object' ), ), ), @@ -129,49 +102,32 @@ private function registerAbilities(): void { ); // ----------------------------------------------------------------- - // Mutating abilities (show_in_rest = false). + // Mutating (show_in_rest = false). // ----------------------------------------------------------------- wp_register_ability( 'datamachine/gitsync-bind', array( 'label' => 'Bind GitSync Path', - 'description' => 'Bind a site-owned directory (relative to ABSPATH) to a remote git repository. Clones the remote or adopts an existing matching checkout.', + 'description' => 'Register a binding between a site-owned local directory (relative to ABSPATH) and a GitHub repository. First pull materializes files.', 'category' => 'datamachine-code-gitsync', 'input_schema' => array( 'type' => 'object', 'required' => array( 'slug', 'local_path', 'remote_url' ), 'properties' => array( - 'slug' => array( - 'type' => 'string', - 'description' => 'Unique binding slug. Lowercase letters, digits, hyphen, underscore.', - ), - 'local_path' => array( - 'type' => 'string', - 'description' => 'Path relative to ABSPATH, e.g. "/wp-content/uploads/markdown/wiki/".', - ), - 'remote_url' => array( - 'type' => 'string', - 'description' => 'Git remote URL (https:// or git@).', - ), - 'branch' => array( - 'type' => 'string', - 'description' => 'Branch to track. Defaults to "main".', - ), - 'policy' => array( - 'type' => 'object', - 'description' => 'Policy overrides (auto_pull, pull_interval, conflict, write_enabled, push_enabled, allowed_paths).', - ), + 'slug' => array( 'type' => 'string' ), + 'local_path' => array( 'type' => 'string' ), + 'remote_url' => array( 'type' => 'string' ), + 'branch' => array( 'type' => 'string' ), + 'policy' => array( 'type' => 'object' ), ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'binding' => array( 'type' => 'object' ), - 'cloned' => array( 'type' => 'boolean' ), - 'adopted' => array( 'type' => 'boolean' ), - 'local_path' => array( 'type' => 'string' ), + 'success' => array( 'type' => 'boolean' ), + 'binding' => array( 'type' => 'object' ), + 'message' => array( 'type' => 'string' ), ), ), 'execute_callback' => array( self::class, 'bind' ), @@ -184,20 +140,14 @@ private function registerAbilities(): void { 'datamachine/gitsync-unbind', array( 'label' => 'Unbind GitSync Path', - 'description' => 'Remove a GitSync binding. By default the on-disk directory is preserved; pass purge=true to delete it.', + 'description' => 'Remove a binding. Directory preserved by default; pass purge=true to delete it.', 'category' => 'datamachine-code-gitsync', 'input_schema' => array( 'type' => 'object', 'required' => array( 'slug' ), 'properties' => array( - 'slug' => array( - 'type' => 'string', - 'description' => 'Binding slug.', - ), - 'purge' => array( - 'type' => 'boolean', - 'description' => 'Also remove the on-disk directory. Default: false.', - ), + 'slug' => array( 'type' => 'string' ), + 'purge' => array( 'type' => 'boolean' ), ), ), 'output_schema' => array( @@ -219,31 +169,31 @@ private function registerAbilities(): void { 'datamachine/gitsync-pull', array( 'label' => 'Pull GitSync Binding', - 'description' => 'Fast-forward pull the remote into a bound directory, honoring the binding\'s conflict policy.', + 'description' => 'Download all files from the pinned branch to the local directory. Uses GitHub Contents API — no git binary required.', 'category' => 'datamachine-code-gitsync', 'input_schema' => array( 'type' => 'object', 'required' => array( 'slug' ), 'properties' => array( - 'slug' => array( - 'type' => 'string', - 'description' => 'Binding slug.', - ), + 'slug' => array( 'type' => 'string' ), 'allow_dirty' => array( 'type' => 'boolean', - 'description' => 'Bypass dirty-working-tree safety for this pull. Default: false.', + 'description' => 'Override the conflict policy for this pull only.', ), ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'slug' => array( 'type' => 'string' ), - 'branch' => array( 'type' => 'string' ), - 'previous_head' => array( 'type' => array( 'string', 'null' ) ), - 'head' => array( 'type' => array( 'string', 'null' ) ), - 'message' => array( 'type' => 'string' ), + 'success' => array( 'type' => 'boolean' ), + 'slug' => array( 'type' => 'string' ), + 'branch' => array( 'type' => 'string' ), + 'tree_sha' => array( 'type' => 'string' ), + 'updated' => array( 'type' => 'array' ), + 'unchanged' => array( 'type' => 'integer' ), + 'deleted' => array( 'type' => 'array' ), + 'conflicts' => array( 'type' => 'array' ), + 'truncated' => array( 'type' => 'boolean' ), ), ), 'execute_callback' => array( self::class, 'pull' ), @@ -252,47 +202,11 @@ private function registerAbilities(): void { ) ); - // ----------------------------------------------------------------- - // Phase 2 — write path (all CLI-only). - // ----------------------------------------------------------------- - wp_register_ability( - 'datamachine/gitsync-add', - array( - 'label' => 'Stage Paths in GitSync Binding', - 'description' => 'Stage one or more relative paths in a binding\'s working tree. Paths must sit under policy.allowed_paths.', - 'category' => 'datamachine-code-gitsync', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'slug', 'paths' ), - 'properties' => array( - 'slug' => array( 'type' => 'string' ), - 'paths' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'slug' => array( 'type' => 'string' ), - 'paths' => array( 'type' => 'array' ), - 'message' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( self::class, 'add' ), - 'permission_callback' => fn() => PermissionHelper::can_manage(), - 'meta' => array( 'show_in_rest' => false ), - ) - ); - - wp_register_ability( - 'datamachine/gitsync-commit', + 'datamachine/gitsync-submit', array( - 'label' => 'Commit Staged Changes in GitSync Binding', - 'description' => 'Commit the currently-staged changes on a binding\'s working tree. Requires policy.write_enabled=true.', + 'label' => 'Submit GitSync Binding as Pull Request', + 'description' => 'Upload changed local files to the sticky proposal branch (gitsync/) and open or update a PR against the pinned branch.', 'category' => 'datamachine-code-gitsync', 'input_schema' => array( 'type' => 'object', @@ -300,38 +214,13 @@ private function registerAbilities(): void { 'properties' => array( 'slug' => array( 'type' => 'string' ), 'message' => array( 'type' => 'string' ), - ), - ), - 'output_schema' => array( - 'type' => 'object', - 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'slug' => array( 'type' => 'string' ), - 'commit' => array( 'type' => array( 'string', 'null' ) ), - 'message' => array( 'type' => 'string' ), - ), - ), - 'execute_callback' => array( self::class, 'commit' ), - 'permission_callback' => fn() => PermissionHelper::can_manage(), - 'meta' => array( 'show_in_rest' => false ), - ) - ); - - wp_register_ability( - 'datamachine/gitsync-push', - array( - 'label' => 'Push GitSync Binding to Pinned Branch', - 'description' => 'Direct push to the pinned branch on origin. Requires policy.push_enabled=true AND policy.safe_direct_push=true (two-key authorization). Use submit() for PR-based flow.', - 'category' => 'datamachine-code-gitsync', - 'input_schema' => array( - 'type' => 'object', - 'required' => array( 'slug' ), - 'properties' => array( - 'slug' => array( 'type' => 'string' ), - 'force' => array( - 'type' => 'boolean', - 'description' => 'Use --force-with-lease for the push. Default: false.', + 'paths' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => 'Optional explicit list of relative paths. If omitted, every file with a SHA mismatch against upstream (filtered by allowed_paths) is submitted.', ), + 'title' => array( 'type' => 'string' ), + 'body' => array( 'type' => 'string' ), ), ), 'output_schema' => array( @@ -340,21 +229,22 @@ private function registerAbilities(): void { 'success' => array( 'type' => 'boolean' ), 'slug' => array( 'type' => 'string' ), 'branch' => array( 'type' => 'string' ), - 'head' => array( 'type' => array( 'string', 'null' ) ), + 'commits' => array( 'type' => 'array' ), + 'pr' => array( 'type' => 'object' ), 'message' => array( 'type' => 'string' ), ), ), - 'execute_callback' => array( self::class, 'push' ), + 'execute_callback' => array( self::class, 'submit' ), 'permission_callback' => fn() => PermissionHelper::can_manage(), 'meta' => array( 'show_in_rest' => false ), ) ); wp_register_ability( - 'datamachine/gitsync-submit', + 'datamachine/gitsync-push', array( - 'label' => 'Submit GitSync Binding as Pull Request', - 'description' => 'Stage + commit + push the sticky proposal branch (gitsync/) and open or update a PR upstream. Phase 2 requires a github.com remote.', + 'label' => 'Push GitSync Binding Directly', + 'description' => 'Commit changed local files directly to the pinned branch — no PR. Requires policy.write_enabled=true AND policy.safe_direct_push=true (two-key authorization).', 'category' => 'datamachine-code-gitsync', 'input_schema' => array( 'type' => 'object', @@ -363,12 +253,9 @@ private function registerAbilities(): void { 'slug' => array( 'type' => 'string' ), 'message' => array( 'type' => 'string' ), 'paths' => array( - 'type' => 'array', - 'items' => array( 'type' => 'string' ), - 'description' => 'Optional explicit list of relative paths to stage. If omitted, every dirty file under allowed_paths is staged.', + 'type' => 'array', + 'items' => array( 'type' => 'string' ), ), - 'title' => array( 'type' => 'string' ), - 'body' => array( 'type' => 'string' ), ), ), 'output_schema' => array( @@ -377,13 +264,11 @@ private function registerAbilities(): void { 'success' => array( 'type' => 'boolean' ), 'slug' => array( 'type' => 'string' ), 'branch' => array( 'type' => 'string' ), - 'commit' => array( 'type' => array( 'string', 'null' ) ), - 'staged' => array( 'type' => 'array' ), - 'pr' => array( 'type' => 'object' ), + 'commits' => array( 'type' => 'array' ), 'message' => array( 'type' => 'string' ), ), ), - 'execute_callback' => array( self::class, 'submit' ), + 'execute_callback' => array( self::class, 'push' ), 'permission_callback' => fn() => PermissionHelper::can_manage(), 'meta' => array( 'show_in_rest' => false ), ) @@ -393,17 +278,14 @@ private function registerAbilities(): void { 'datamachine/gitsync-policy-update', array( 'label' => 'Update GitSync Binding Policy', - 'description' => 'Update one or more policy fields on an existing binding (write_enabled, push_enabled, safe_direct_push, allowed_paths, conflict, auto_pull, pull_interval).', + 'description' => 'Update one or more policy fields on an existing binding (write_enabled, safe_direct_push, allowed_paths, conflict, auto_pull, pull_interval).', 'category' => 'datamachine-code-gitsync', 'input_schema' => array( 'type' => 'object', 'required' => array( 'slug', 'policy' ), 'properties' => array( 'slug' => array( 'type' => 'string' ), - 'policy' => array( - 'type' => 'object', - 'description' => 'Subset of policy keys to update.', - ), + 'policy' => array( 'type' => 'object' ), ), ), 'output_schema' => array( @@ -421,9 +303,6 @@ private function registerAbilities(): void { ); }; - // Matches the WorkspaceAbilities lifecycle: register now if we're - // inside the init action, defer if it hasn't fired yet, skip if it - // already fired without us (registration missed the window). if ( doing_action( 'wp_abilities_api_init' ) ) { $register_callback(); } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { @@ -455,39 +334,25 @@ public static function unbind( array $input ): array|\WP_Error { } public static function pull( array $input ): array|\WP_Error { - return ( new GitSync() )->pull( - (string) ( $input['slug'] ?? '' ), - ! empty( $input['allow_dirty'] ) - ); - } - - public static function add( array $input ): array|\WP_Error { - $paths = $input['paths'] ?? array(); - if ( ! is_array( $paths ) ) { - $paths = array(); + $args = array(); + if ( ! empty( $input['allow_dirty'] ) ) { + $args['allow_dirty'] = true; } - return ( new GitSync() )->add( (string) ( $input['slug'] ?? '' ), $paths ); + return ( new GitSync() )->pull( (string) ( $input['slug'] ?? '' ), $args ); } - public static function commit( array $input ): array|\WP_Error { - return ( new GitSync() )->commit( - (string) ( $input['slug'] ?? '' ), - (string) ( $input['message'] ?? '' ) - ); + public static function submit( array $input ): array|\WP_Error { + $slug = (string) ( $input['slug'] ?? '' ); + $args = $input; + unset( $args['slug'] ); + return ( new GitSync() )->submit( $slug, $args ); } public static function push( array $input ): array|\WP_Error { - return ( new GitSync() )->push( - (string) ( $input['slug'] ?? '' ), - ! empty( $input['force'] ) - ); - } - - public static function submit( array $input ): array|\WP_Error { $slug = (string) ( $input['slug'] ?? '' ); $args = $input; unset( $args['slug'] ); - return ( new GitSync() )->submit( $slug, $args ); + return ( new GitSync() )->push( $slug, $args ); } public static function policyUpdate( array $input ): array|\WP_Error { diff --git a/inc/Cli/Commands/GitSyncCommand.php b/inc/Cli/Commands/GitSyncCommand.php index 8d3fc80..608b711 100644 --- a/inc/Cli/Commands/GitSyncCommand.php +++ b/inc/Cli/Commands/GitSyncCommand.php @@ -2,10 +2,10 @@ /** * WP-CLI GitSync Command * - * Thin CLI shell over the `datamachine/gitsync-*` abilities. Every - * subcommand resolves the corresponding ability via `wp_get_ability()`, - * maps CLI args into the ability's input schema, and formats the output - * — no business logic lives here. + * Thin shell over the `datamachine/gitsync-*` abilities. Every subcommand + * resolves the matching ability via `wp_get_ability()`, maps CLI args + * into the ability's input schema, and formats the response — no + * business logic in the CLI layer. * * @package DataMachineCode\Cli\Commands * @since 0.7.0 @@ -39,7 +39,6 @@ class GitSyncCommand extends BaseCommand { * ## EXAMPLES * * wp datamachine-code gitsync list - * wp datamachine-code gitsync list --format=json * * @subcommand list */ @@ -55,15 +54,15 @@ public function list_bindings( array $args, array $assoc_args ): void { // phpcs $items = array_map( function ( array $b ): array { return array( - 'slug' => $b['slug'], - 'local_path' => $b['local_path'], - 'remote_url' => $b['remote_url'], - 'branch' => $b['branch'], - 'is_repo' => ! empty( $b['is_repo'] ) ? 'yes' : 'no', - 'auto_pull' => ! empty( $b['auto_pull'] ) ? 'yes' : 'no', - 'interval' => (string) ( $b['pull_interval'] ?? '-' ), - 'last_pulled' => (string) ( $b['last_pulled'] ?? '-' ), - 'last_commit' => (string) ( $b['last_commit'] ?? '-' ), + 'slug' => $b['slug'], + 'local_path' => $b['local_path'], + 'remote_url' => $b['remote_url'], + 'branch' => $b['branch'], + 'exists' => ! empty( $b['exists'] ) ? 'yes' : 'no', + 'write' => ! empty( $b['write_enabled'] ) ? 'yes' : 'no', + 'direct' => ! empty( $b['push_enabled'] ) ? 'yes' : 'no', + 'pulled' => (int) ( $b['pulled_count'] ?? 0 ), + 'last_pulled' => (string) ( $b['last_pulled'] ?? '-' ), ); }, $result['bindings'] @@ -71,17 +70,17 @@ function ( array $b ): array { $this->format_items( $items, - array( 'slug', 'local_path', 'remote_url', 'branch', 'is_repo', 'auto_pull', 'interval', 'last_pulled', 'last_commit' ), + array( 'slug', 'local_path', 'remote_url', 'branch', 'exists', 'write', 'direct', 'pulled', 'last_pulled' ), $assoc_args, 'slug' ); } /** - * Bind a site-owned directory to a remote git repository. + * Bind a site-owned directory to a GitHub repository. * - * Clones the remote into the target path, or adopts an existing - * checkout whose origin matches `--remote`. + * Registers the binding but does NOT clone files. Run `gitsync pull` + * after bind to materialize upstream content. * * ## OPTIONS * @@ -89,16 +88,16 @@ function ( array $b ): array { * : Unique binding identifier. Lowercase letters, digits, hyphen, underscore. * * --local= - * : Path relative to ABSPATH (leading slash). e.g. /wp-content/uploads/markdown/wiki/ + * : ABSPATH-relative path (leading slash). e.g. /wp-content/uploads/markdown/wiki/ * * --remote= - * : Git remote URL (https:// or git@). + * : GitHub URL (https://github.com/owner/repo or git@github.com:owner/repo). * * [--branch=] * : Branch to track. Default: main. * * [--conflict=] - * : Conflict policy. + * : Conflict policy for pull. * --- * default: fail * options: @@ -107,50 +106,24 @@ function ( array $b ): array { * - manual * --- * - * [--auto-pull] - * : Opt the binding into scheduled sync (Phase 3 honors this flag). - * - * [--pull-interval=] - * : Scheduled pull cadence. Default: hourly. - * * ## EXAMPLES * - * # Bind the wiki content directory * wp datamachine-code gitsync bind intelligence-wiki \ * --local=/wp-content/uploads/markdown/wiki/ \ * --remote=https://github.com/Automattic/a8c-wiki-woocommerce * - * # Bind + auto-pull hourly - * wp datamachine-code gitsync bind wg-agent-def \ - * --local=/wp-content/uploads/datamachine-files/agents/wiki-generator/ \ - * --remote=https://github.com/Automattic/a8c-wiki-generator \ - * --auto-pull --pull-interval=hourly - * * @subcommand bind */ public function bind( array $args, array $assoc_args ): void { $slug = $args[0] ?? ''; if ( '' === $slug ) { - WP_CLI::error( 'Binding slug is required (first positional argument).' ); - return; + WP_CLI::error( 'Binding slug is required.' ); } $local = (string) ( $assoc_args['local'] ?? '' ); $remote = (string) ( $assoc_args['remote'] ?? '' ); if ( '' === $local || '' === $remote ) { WP_CLI::error( 'Both --local and --remote are required.' ); - return; - } - - $policy = array(); - if ( isset( $assoc_args['conflict'] ) ) { - $policy['conflict'] = (string) $assoc_args['conflict']; - } - if ( ! empty( $assoc_args['auto-pull'] ) ) { - $policy['auto_pull'] = true; - } - if ( isset( $assoc_args['pull-interval'] ) ) { - $policy['pull_interval'] = (string) $assoc_args['pull-interval']; } $input = array( @@ -161,28 +134,18 @@ public function bind( array $args, array $assoc_args ): void { if ( ! empty( $assoc_args['branch'] ) ) { $input['branch'] = (string) $assoc_args['branch']; } - if ( ! empty( $policy ) ) { - $input['policy'] = $policy; + if ( isset( $assoc_args['conflict'] ) ) { + $input['policy'] = array( 'conflict' => (string) $assoc_args['conflict'] ); } $result = $this->execute_ability( 'datamachine/gitsync-bind', $input ); - $verb = ! empty( $result['adopted'] ) ? 'Adopted existing' : 'Cloned and bound'; - WP_CLI::success( sprintf( '%s: %s → %s', $verb, $slug, $result['local_path'] ?? '' ) ); - WP_CLI::log( sprintf( ' remote: %s', $result['binding']['remote_url'] ?? '' ) ); - WP_CLI::log( sprintf( ' branch: %s', $result['binding']['branch'] ?? '' ) ); - if ( ! empty( $result['binding']['last_commit'] ) ) { - WP_CLI::log( sprintf( ' head: %s', $result['binding']['last_commit'] ) ); - } + WP_CLI::success( (string) ( $result['message'] ?? 'Bound.' ) ); } /** * Unbind a GitSync binding. * - * By default the on-disk directory is preserved — the binding is - * metadata, not ownership of the filesystem. Pass `--purge` to also - * delete the working tree. - * * ## OPTIONS * * @@ -205,11 +168,8 @@ public function unbind( array $args, array $assoc_args ): void { $slug = $args[0] ?? ''; if ( '' === $slug ) { WP_CLI::error( 'Binding slug is required.' ); - return; } - $purge = ! empty( $assoc_args['purge'] ); - if ( $purge && empty( $assoc_args['yes'] ) ) { WP_CLI::confirm( sprintf( 'Purge the on-disk directory for binding "%s"? This permanently deletes files.', $slug ) ); } @@ -222,7 +182,7 @@ public function unbind( array $args, array $assoc_args ): void { ) ); - if ( $result['purged'] ?? false ) { + if ( ! empty( $result['purged'] ) ) { WP_CLI::success( sprintf( 'Unbound "%s" and purged %s', $slug, $result['local_path'] ?? '' ) ); } else { WP_CLI::success( sprintf( 'Unbound "%s" (directory preserved: %s)', $slug, $result['local_path'] ?? '' ) ); @@ -230,7 +190,7 @@ public function unbind( array $args, array $assoc_args ): void { } /** - * Pull the remote into a bound directory. + * Pull the remote into a bound directory via GitHub Contents API. * * ## OPTIONS * @@ -238,12 +198,11 @@ public function unbind( array $args, array $assoc_args ): void { * : Binding slug. * * [--allow-dirty] - * : Bypass dirty-working-tree safety for this pull only. + * : Bypass the conflict policy for this pull only. * * ## EXAMPLES * * wp datamachine-code gitsync pull intelligence-wiki - * wp datamachine-code gitsync pull intelligence-wiki --allow-dirty * * @subcommand pull */ @@ -251,7 +210,6 @@ public function pull( array $args, array $assoc_args ): void { $slug = $args[0] ?? ''; if ( '' === $slug ) { WP_CLI::error( 'Binding slug is required.' ); - return; } $result = $this->execute_ability( @@ -262,18 +220,28 @@ public function pull( array $args, array $assoc_args ): void { ) ); - $previous = $result['previous_head'] ?? '-'; - $head = $result['head'] ?? '-'; + $updated = (array) ( $result['updated'] ?? array() ); + $deleted = (array) ( $result['deleted'] ?? array() ); + $conflicts = (array) ( $result['conflicts'] ?? array() ); + $unchanged = (int) ( $result['unchanged'] ?? 0 ); - if ( $previous === $head ) { - WP_CLI::success( sprintf( 'Already up to date: %s @ %s', $slug, (string) $head ) ); - } else { - WP_CLI::success( sprintf( 'Pulled %s: %s → %s', $slug, (string) $previous, (string) $head ) ); - } + WP_CLI::success( sprintf( + 'Pulled %s: %d updated, %d unchanged, %d deleted, %d conflicts', + $slug, + count( $updated ), + $unchanged, + count( $deleted ), + count( $conflicts ) + ) ); - $message = trim( (string) ( $result['message'] ?? '' ) ); - if ( '' !== $message ) { - WP_CLI::log( $message ); + if ( ! empty( $conflicts ) ) { + WP_CLI::warning( 'Conflicts (run with --allow-dirty to override, or change conflict policy):' ); + foreach ( $conflicts as $c ) { + WP_CLI::log( sprintf( ' %s — %s', $c['path'] ?? '?', $c['reason'] ?? '?' ) ); + } + } + if ( ! empty( $result['truncated'] ) ) { + WP_CLI::warning( 'GitHub tree response was truncated. Some paths may be missing. Consider narrowing the repo or upgrading to Git Data API pagination (not yet implemented).' ); } } @@ -295,20 +263,13 @@ public function pull( array $args, array $assoc_args ): void { * - yaml * --- * - * ## EXAMPLES - * - * wp datamachine-code gitsync status intelligence-wiki - * wp datamachine-code gitsync status intelligence-wiki --format=json - * * @subcommand status */ public function status( array $args, array $assoc_args ): void { $slug = $args[0] ?? ''; if ( '' === $slug ) { WP_CLI::error( 'Binding slug is required.' ); - return; } - $result = $this->execute_ability( 'datamachine/gitsync-status', array( 'slug' => $slug ) ); $format = $assoc_args['format'] ?? 'table'; @@ -316,90 +277,52 @@ public function status( array $args, array $assoc_args ): void { WP_CLI::log( wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); return; } - if ( 'yaml' === $format ) { - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r - WP_CLI::log( (string) print_r( $result, true ) ); - return; - } - // Default table-like summary — a single record flattened into key/value rows. $rows = array( array( 'field' => 'slug', 'value' => (string) ( $result['slug'] ?? '' ) ), array( 'field' => 'local_path', 'value' => (string) ( $result['local_path'] ?? '' ) ), array( 'field' => 'remote_url', 'value' => (string) ( $result['remote_url'] ?? '' ) ), array( 'field' => 'tracked_branch', 'value' => (string) ( $result['tracked_branch'] ?? '' ) ), array( 'field' => 'exists', 'value' => ! empty( $result['exists'] ) ? 'yes' : 'no' ), - array( 'field' => 'is_repo', 'value' => ! empty( $result['is_repo'] ) ? 'yes' : 'no' ), - array( 'field' => 'branch', 'value' => (string) ( $result['branch'] ?? '-' ) ), - array( 'field' => 'head', 'value' => (string) ( $result['head'] ?? '-' ) ), - array( 'field' => 'dirty', 'value' => (string) ( (int) ( $result['dirty'] ?? 0 ) ) ), - array( 'field' => 'ahead', 'value' => null === ( $result['ahead'] ?? null ) ? '-' : (string) $result['ahead'] ), - array( 'field' => 'behind', 'value' => null === ( $result['behind'] ?? null ) ? '-' : (string) $result['behind'] ), + array( 'field' => 'pulled_count', 'value' => (string) ( (int) ( $result['pulled_count'] ?? 0 ) ) ), array( 'field' => 'last_pulled', 'value' => (string) ( $result['last_pulled'] ?? '-' ) ), + array( 'field' => 'last_commit', 'value' => (string) ( $result['last_commit'] ?? '-' ) ), ); - $this->format_items( $rows, array( 'field', 'value' ), $assoc_args, 'field' ); } /** - * Stage paths for commit in a binding's working tree. + * Submit local edits as a pull request. + * + * Uploads changed files to the sticky proposal branch (gitsync/) + * and opens or updates a PR against the pinned branch. * * ## OPTIONS * * * : Binding slug. * - * ... - * : Relative paths inside the binding to stage. Must live under - * policy.allowed_paths; sensitive-file patterns are always refused. - * - * ## EXAMPLES - * - * wp datamachine-code gitsync add intelligence-wiki articles/new-article.md - * wp datamachine-code gitsync add intelligence-wiki articles/a.md articles/b.md - * - * @subcommand add - */ - public function add( array $args, array $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found - $slug = $args[0] ?? ''; - if ( '' === $slug ) { - WP_CLI::error( 'Binding slug is required.' ); - } - $paths = array_slice( $args, 1 ); - if ( empty( $paths ) ) { - WP_CLI::error( 'At least one path is required.' ); - } - - $result = $this->execute_ability( - 'datamachine/gitsync-add', - array( - 'slug' => $slug, - 'paths' => $paths, - ) - ); - - WP_CLI::success( (string) ( $result['message'] ?? 'Staged.' ) ); - } - - /** - * Commit staged changes in a binding's working tree. + * --message= + * : Commit / PR title (8–200 chars). * - * ## OPTIONS + * [--paths=] + * : Comma-separated list of relative paths to submit. If omitted, + * every changed file under allowed_paths is included. * - * - * : Binding slug. + * [--title=] + * : PR title. Defaults to --message. * - * --message=<message> - * : Commit message (8–200 characters). + * [--body=<body>] + * : PR body. Defaults to a generated summary. * * ## EXAMPLES * - * wp datamachine-code gitsync commit intelligence-wiki \ - * --message="Add new CIAB article" + * wp datamachine-code gitsync submit intelligence-wiki \ + * --message="Add CIAB kickoff article" * - * @subcommand commit + * @subcommand submit */ - public function commit( array $args, array $assoc_args ): void { + public function submit( array $args, array $assoc_args ): void { $slug = $args[0] ?? ''; if ( '' === $slug ) { WP_CLI::error( 'Binding slug is required.' ); @@ -409,69 +332,37 @@ public function commit( array $args, array $assoc_args ): void { WP_CLI::error( '--message is required.' ); } - $result = $this->execute_ability( - 'datamachine/gitsync-commit', - array( - 'slug' => $slug, - 'message' => $message, - ) + $input = array( + 'slug' => $slug, + 'message' => $message, ); - - WP_CLI::success( (string) ( $result['message'] ?? 'Committed.' ) ); - if ( ! empty( $result['commit'] ) ) { - WP_CLI::log( sprintf( ' head: %s', $result['commit'] ) ); + if ( isset( $assoc_args['paths'] ) && '' !== $assoc_args['paths'] ) { + $input['paths'] = array_values( array_filter( array_map( 'trim', explode( ',', (string) $assoc_args['paths'] ) ) ) ); } - } - - /** - * Direct-push a binding to its pinned branch on origin. - * - * Requires policy.push_enabled=true AND policy.safe_direct_push=true. - * For most workflows you want `submit` instead, which pushes to a - * feature branch and opens a PR. - * - * ## OPTIONS - * - * <slug> - * : Binding slug. - * - * [--force] - * : Use git push --force-with-lease. Default: false. - * - * ## EXAMPLES - * - * wp datamachine-code gitsync push personal-wiki - * wp datamachine-code gitsync push personal-wiki --force - * - * @subcommand push - */ - public function push( array $args, array $assoc_args ): void { - $slug = $args[0] ?? ''; - if ( '' === $slug ) { - WP_CLI::error( 'Binding slug is required.' ); + if ( isset( $assoc_args['title'] ) ) { + $input['title'] = (string) $assoc_args['title']; + } + if ( isset( $assoc_args['body'] ) ) { + $input['body'] = (string) $assoc_args['body']; } - $result = $this->execute_ability( - 'datamachine/gitsync-push', - array( - 'slug' => $slug, - 'force' => ! empty( $assoc_args['force'] ), - ) - ); + $result = $this->execute_ability( 'datamachine/gitsync-submit', $input ); - WP_CLI::success( sprintf( 'Pushed %s → %s @ %s', $slug, (string) ( $result['branch'] ?? '' ), (string) ( $result['head'] ?? '-' ) ) ); - $output = trim( (string) ( $result['message'] ?? '' ) ); - if ( '' !== $output ) { - WP_CLI::log( $output ); - } + $pr = $result['pr'] ?? array(); + WP_CLI::success( sprintf( + '%s PR #%d on %s: %s', + 'updated' === ( $pr['action'] ?? '' ) ? 'Updated' : 'Opened', + (int) ( $pr['number'] ?? 0 ), + (string) ( $result['branch'] ?? '' ), + (string) ( $pr['html_url'] ?? '' ) + ) ); + WP_CLI::log( sprintf( ' commits: %d', count( (array) ( $result['commits'] ?? array() ) ) ) ); } /** - * Submit local edits as a pull request. + * Commit changes directly to the pinned branch — no PR. * - * Orchestrates the sticky proposal branch flow: align with upstream, - * push gitsync/<slug> with --force-with-lease, open or update a PR. - * Phase 2 supports github.com remotes only. + * Requires policy.write_enabled=true AND policy.safe_direct_push=true. * * ## OPTIONS * @@ -479,31 +370,20 @@ public function push( array $args, array $assoc_args ): void { * : Binding slug. * * --message=<message> - * : Commit message (8–200 characters). + * : Commit message base. * * [--paths=<paths>] - * : Comma-separated list of relative paths to stage. If omitted, - * every dirty file under policy.allowed_paths is staged. - * - * [--title=<title>] - * : PR title. Defaults to the commit message. - * - * [--body=<body>] - * : PR body. Defaults to a generated summary. + * : Comma-separated list of relative paths. If omitted, every changed + * file under allowed_paths is committed. * * ## EXAMPLES * - * wp datamachine-code gitsync submit intelligence-wiki \ - * --message="Add CIAB kickoff article" - * - * wp datamachine-code gitsync submit intelligence-wiki \ - * --message="Update two articles" \ - * --paths=articles/a.md,articles/b.md \ - * --title="docs: update a + b" + * wp datamachine-code gitsync push personal-wiki \ + * --message="Daily notes" * - * @subcommand submit + * @subcommand push */ - public function submit( array $args, array $assoc_args ): void { + public function push( array $args, array $assoc_args ): void { $slug = $args[0] ?? ''; if ( '' === $slug ) { WP_CLI::error( 'Binding slug is required.' ); @@ -520,25 +400,10 @@ public function submit( array $args, array $assoc_args ): void { if ( isset( $assoc_args['paths'] ) && '' !== $assoc_args['paths'] ) { $input['paths'] = array_values( array_filter( array_map( 'trim', explode( ',', (string) $assoc_args['paths'] ) ) ) ); } - if ( isset( $assoc_args['title'] ) ) { - $input['title'] = (string) $assoc_args['title']; - } - if ( isset( $assoc_args['body'] ) ) { - $input['body'] = (string) $assoc_args['body']; - } - $result = $this->execute_ability( 'datamachine/gitsync-submit', $input ); + $result = $this->execute_ability( 'datamachine/gitsync-push', $input ); - $pr = $result['pr'] ?? array(); - WP_CLI::success( sprintf( - '%s PR #%d on %s: %s', - 'updated' === ( $pr['action'] ?? '' ) ? 'Updated' : 'Opened', - (int) ( $pr['number'] ?? 0 ), - (string) ( $result['branch'] ?? '' ), - (string) ( $pr['html_url'] ?? '' ) - ) ); - WP_CLI::log( sprintf( ' commit: %s', (string) ( $result['commit'] ?? '-' ) ) ); - WP_CLI::log( sprintf( ' files: %d staged', count( (array) ( $result['staged'] ?? array() ) ) ) ); + WP_CLI::success( sprintf( 'Pushed %d file(s) to %s on "%s"', count( (array) ( $result['commits'] ?? array() ) ), $result['branch'] ?? '', $slug ) ); } /** @@ -550,17 +415,14 @@ public function submit( array $args, array $assoc_args ): void { * : Binding slug. * * [--write-enabled=<bool>] - * : Gate add + commit. - * - * [--push-enabled=<bool>] - * : Gate push + submit. + * : Gate submit + push. * * [--safe-direct-push=<bool>] * : Second key required for direct push to the pinned branch. * * [--allowed-paths=<list>] - * : Comma-separated list of relative path prefixes that may be staged. - * Use "clear" to reset to an empty allowlist. + * : Comma-separated relative path prefixes that may be uploaded. Use + * "clear" to empty the allowlist. * * [--conflict=<strategy>] * : Conflict strategy for pull. @@ -572,21 +434,15 @@ public function submit( array $args, array $assoc_args ): void { * --- * * [--auto-pull=<bool>] - * : Enroll in scheduled sync (Phase 3). + * : Mark binding for scheduled sync (honored when a future task consumes it). * * [--pull-interval=<interval>] - * : Scheduled pull cadence. + * : Scheduled pull cadence hint. * * ## EXAMPLES * - * # Open up writes + submit for a wiki binding * wp datamachine-code gitsync policy intelligence-wiki \ - * --write-enabled=true --push-enabled=true \ - * --allowed-paths=articles/,images/ - * - * # Allow direct push for a personal (single-owner) binding - * wp datamachine-code gitsync policy personal-wiki \ - * --push-enabled=true --safe-direct-push=true + * --write-enabled=true --allowed-paths=articles/,images/ * * @subcommand policy */ @@ -599,7 +455,6 @@ public function policy( array $args, array $assoc_args ): void { $patch = array(); $bool_keys = array( 'write-enabled' => 'write_enabled', - 'push-enabled' => 'push_enabled', 'safe-direct-push' => 'safe_direct_push', 'auto-pull' => 'auto_pull', ); @@ -629,10 +484,7 @@ public function policy( array $args, array $assoc_args ): void { $result = $this->execute_ability( 'datamachine/gitsync-policy-update', - array( - 'slug' => $slug, - 'policy' => $patch, - ) + array( 'slug' => $slug, 'policy' => $patch ) ); WP_CLI::success( sprintf( 'Policy updated for "%s".', $slug ) ); @@ -643,39 +495,22 @@ public function policy( array $args, array $assoc_args ): void { // Helpers // ========================================================================= - /** - * Coerce a CLI-provided string into a boolean. Treats common truthy - * strings (`true`, `1`, `yes`, `on`) as true; everything else as false. - */ - private function coerce_bool( string $value ): bool { - return in_array( strtolower( trim( $value ) ), array( '1', 'true', 'yes', 'on' ), true ); - } - - /** - * Resolve + execute an ability, erroring out on missing or failed calls. - * - * Centralized so every subcommand gets the same "ability missing → CLI - * error" and "WP_Error result → CLI error" behavior without boilerplate. - * - * @param string $ability_name Fully qualified ability name. - * @param array<string, mixed> $input Ability input payload. - * @return array<string, mixed> On success. Exits via WP_CLI::error otherwise. - */ private function execute_ability( string $ability_name, array $input ): array { if ( ! function_exists( 'wp_get_ability' ) ) { WP_CLI::error( 'WordPress Abilities API unavailable — requires WP 6.9+ or the Abilities API plugin.' ); } - $ability = wp_get_ability( $ability_name ); if ( ! $ability ) { WP_CLI::error( sprintf( 'Ability "%s" is not registered.', $ability_name ) ); } - $result = $ability->execute( $input ); if ( is_wp_error( $result ) ) { WP_CLI::error( $result->get_error_message() ); } - return is_array( $result ) ? $result : array(); } + + private function coerce_bool( string $value ): bool { + return in_array( strtolower( trim( $value ) ), array( '1', 'true', 'yes', 'on' ), true ); + } } diff --git a/inc/GitSync/GitRepo.php b/inc/GitSync/GitRepo.php deleted file mode 100644 index 4f9664e..0000000 --- a/inc/GitSync/GitRepo.php +++ /dev/null @@ -1,75 +0,0 @@ -<?php -/** - * Git Repo - * - * Read-only helpers for inspecting a local working tree — HEAD, branch, - * dirty count. Shared by GitSync + GitSyncSubmitter so these three - * operations have one canonical shell invocation. - * - * Parallel to Support/GitRunner (which wraps `git -C <path> <args>`): - * this class builds on GitRunner for the narrow set of queries GitSync - * needs repeatedly, returning plain scalars instead of the runner's - * `{success, output}` envelope. - * - * @package DataMachineCode\GitSync - * @since 0.7.0 - */ - -namespace DataMachineCode\GitSync; - -use DataMachineCode\Support\GitRunner; - -defined( 'ABSPATH' ) || exit; - -final class GitRepo { - - /** - * Short HEAD SHA, or null on any failure. - * - * Explicitly tolerates errors — callers use this to annotate - * bindings with their current commit for the registry, and a null - * value there just means "we couldn't read it this time." - */ - public static function head( string $path ): ?string { - $result = GitRunner::run( $path, 'rev-parse --short HEAD' ); - if ( is_wp_error( $result ) ) { - return null; - } - $head = trim( (string) $result['output'] ); - return '' === $head ? null : $head; - } - - /** - * Current branch name, or null on failure/detached HEAD. - * - * `git rev-parse --abbrev-ref HEAD` returns `HEAD` for detached - * checkouts; callers treat that as null here so a detached tree - * doesn't silently pass branch-match checks. - */ - public static function branch( string $path ): ?string { - $result = GitRunner::run( $path, 'rev-parse --abbrev-ref HEAD' ); - if ( is_wp_error( $result ) ) { - return null; - } - $branch = trim( (string) $result['output'] ); - if ( '' === $branch || 'HEAD' === $branch ) { - return null; - } - return $branch; - } - - /** - * Count entries in `git status --porcelain`. - * - * Zero on any error (shell-out failed, path isn't a repo, etc.) — - * the conservative default that keeps callers from treating a - * readback failure as "dirty." - */ - public static function dirtyCount( string $path ): int { - $result = GitRunner::run( $path, 'status --porcelain' ); - if ( is_wp_error( $result ) ) { - return 0; - } - return count( array_filter( array_map( 'trim', explode( "\n", (string) $result['output'] ) ) ) ); - } -} diff --git a/inc/GitSync/GitSync.php b/inc/GitSync/GitSync.php index 1729c2e..21461bd 100644 --- a/inc/GitSync/GitSync.php +++ b/inc/GitSync/GitSync.php @@ -2,36 +2,32 @@ /** * GitSync * - * Binds site-owned local directories under ABSPATH to remote git - * repositories, keeping them in lockstep via pull (and, in later phases, - * push + scheduled sync). Parallel to `Workspace\Workspace` — Workspace - * manages agent-owned checkouts under `~/.datamachine/workspace/`; - * GitSync manages site-owned subtrees (wiki content, synced agent - * definitions, etc.) where the site chooses the path. + * API-first primitive for syncing site-owned directories with GitHub + * repositories. Uses the GitHub Contents + Git Data APIs instead of a + * local git binary, so bindings work identically on self-hosted, + * managed hosts (WordPress.com Business, WP Engine, Pantheon, etc.) + * and local dev. * - * Phase 1 surface (this file): - * - bind() — register a binding and either clone or adopt an - * existing git checkout at the target path. - * - unbind() — remove binding metadata; directory is preserved by - * default (pass `$purge = true` to wipe it). - * - pull() — fast-forward pull, honoring conflict policy. - * - status() — dirty count, branch, HEAD, upstream gap. - * - list_bindings() — every registered binding. + * Scope: + * - bind/unbind → register/remove a binding (no disk work done at + * bind time; the first pull materializes files). + * - list/status → read-only queries over the registry. + * - pull → delegates to GitSyncFetcher. + * - submit → delegates to GitSyncProposer::submit (PR flow). + * - push → delegates to GitSyncProposer::push (direct-to-pinned). + * - updatePolicy → modify policy fields on an existing binding. * - * Push, policy-update, and the scheduled pull task land in follow-up - * PRs (DMC#38 phases 2 and 3). + * Deliberately smaller than the shell-git iteration: no staging (`add`, + * `commit`) — consumers write files to disk and call submit/push + * directly. The API flow has no separate stage step. * * @package DataMachineCode\GitSync - * @since 0.7.0 + * @since 0.8.0 * @see https://github.com/Extra-Chill/data-machine-code/issues/38 */ namespace DataMachineCode\GitSync; -use DataMachineCode\GitSync\GitRepo; -use DataMachineCode\GitSync\GitSyncSubmitter; -use DataMachineCode\Support\GitHubRemote; -use DataMachineCode\Support\GitRunner; use DataMachineCode\Support\PathSecurity; defined( 'ABSPATH' ) || exit; @@ -45,29 +41,19 @@ public function __construct( ?GitSyncRegistry $registry = null ) { } // ========================================================================= - // Public API + // Registry operations // ========================================================================= /** - * Register a new binding and materialize it on disk. + * Register a new binding. * - * On disk behavior: - * - Path missing → git clone into it. - * - Path exists, empty → git clone into it. - * - Path exists, has `.git` → adopt if origin matches the binding's - * remote_url; error otherwise. - * - Path exists, non-empty, no `.git` → refuse. Safer to make the - * user decide than to overlay a clone - * on whatever's already there. + * Bind does NOT materialize files — it just records the binding. The + * first `pull` call fetches upstream content to disk. This lets + * managed-host installs bind bindings in wp-admin without blocking + * on a potentially slow tree-wide download. * - * @param array<string, mixed> $input { - * @type string $slug Unique binding identifier. - * @type string $local_path ABSPATH-relative path (leading slash). - * @type string $remote_url Git remote URL (https:// or git@). - * @type string $branch Branch to track. Default 'main'. - * @type array $policy Policy overrides (see GitSyncBinding::DEFAULT_POLICY). - * } - * @return array{success: true, binding: array<string, mixed>, cloned: bool, adopted: bool, local_path: string}|\WP_Error + * @param array<string, mixed> $input See GitSyncBinding::create(). + * @return array<string, mixed>|\WP_Error */ public function bind( array $input ): array|\WP_Error { $binding = GitSyncBinding::create( $input ); @@ -83,41 +69,29 @@ public function bind( array $input ): array|\WP_Error { ); } - $containment = $this->validateBindingPath( $binding ); - if ( is_wp_error( $containment ) ) { - return $containment; - } - $absolute = $containment; - - $materialize = $this->materializeClone( $binding, $absolute ); - if ( is_wp_error( $materialize ) ) { - return $materialize; + $path_err = $this->validateLocalPath( $binding ); + if ( is_wp_error( $path_err ) ) { + return $path_err; } - $binding->last_commit = GitRepo::head( $absolute ); - $binding->last_pulled = gmdate( 'c' ); $this->registry->save( $binding ); return array( - 'success' => true, - 'binding' => $binding->toArray(), - 'cloned' => ! $materialize['adopted'], - 'adopted' => $materialize['adopted'], - 'local_path' => $absolute, + 'success' => true, + 'binding' => $binding->toArray(), + 'message' => sprintf( 'Bound "%s" → %s. Run `gitsync pull %s` to materialize files.', $binding->slug, $binding->remote_url, $binding->slug ), ); } /** * Remove a binding. * - * By default the on-disk working tree is preserved — the binding is - * metadata, not ownership of the directory. `$purge = true` opts in - * to deleting the directory (with strict containment re-validation - * at the blast radius). + * Directory is preserved by default — the binding is metadata, not + * ownership of the filesystem. `$purge = true` removes the directory + * too, with a re-validated realpath containment check right before + * `rm -rf` to defend against symlink escapes. * - * @param string $slug Binding slug. - * @param bool $purge If true, remove the on-disk directory too. - * @return array{success: true, slug: string, purged: bool, local_path: string}|\WP_Error + * @return array<string, mixed>|\WP_Error */ public function unbind( string $slug, bool $purge = false ): array|\WP_Error { $binding = $this->registry->get( $slug ); @@ -129,7 +103,6 @@ public function unbind( string $slug, bool $purge = false ): array|\WP_Error { $purged = false; if ( $purge && is_dir( $absolute ) ) { - // Belt-and-suspenders: re-validate containment right before rm. $abspath = rtrim( ABSPATH, '/' ); $validation = PathSecurity::validateContainment( $absolute, $abspath ); if ( ! $validation['valid'] ) { @@ -140,11 +113,12 @@ public function unbind( string $slug, bool $purge = false ): array|\WP_Error { ); } - $escaped = escapeshellarg( $validation['real_path'] ); - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec( sprintf( 'rm -rf %s 2>&1', $escaped ), $_unused, $exit_code ); - if ( 0 !== $exit_code ) { - return new \WP_Error( 'purge_failed', sprintf( 'Failed to remove directory %s (exit %d).', $absolute, $exit_code ), array( 'status' => 500 ) ); + // Native PHP recursion so this works on managed hosts where + // exec() is disabled. RecursiveIteratorIterator with + // CHILD_FIRST lets unlink/rmdir happen depth-first. + $purge_err = $this->removeTree( $validation['real_path'] ); + if ( is_wp_error( $purge_err ) ) { + return $purge_err; } $purged = true; } @@ -159,104 +133,6 @@ public function unbind( string $slug, bool $purge = false ): array|\WP_Error { ); } - /** - * Fast-forward pull a binding. - * - * Conflict handling: - * - `fail` (default): refuse pull when working tree is dirty. - * - `upstream_wins`: reset hard to the remote tip, discarding local - * changes. Non-reversible; opt-in per binding. - * - `manual`: allow the dirty pull to run and surface the - * failure for admin review. - * - * `$allow_dirty` can override a `fail` policy one-off (CLI convenience). - * - * @param string $slug Binding slug. - * @param bool $allow_dirty Bypass dirty-tree safety for this pull. - * @return array{success: true, slug: string, branch: string, previous_head: ?string, head: ?string, message: string}|\WP_Error - */ - public function pull( string $slug, bool $allow_dirty = false ): array|\WP_Error { - $binding = $this->registry->get( $slug ); - if ( null === $binding ) { - return $this->notFound( $slug ); - } - - $absolute = $binding->resolveAbsolutePath(); - $repo_err = $this->assertRepo( $absolute, $slug ); - if ( is_wp_error( $repo_err ) ) { - return $repo_err; - } - - $previous_head = GitRepo::head( $absolute ); - $dirty_count = GitRepo::dirtyCount( $absolute ); - $conflict = (string) ( $binding->policy['conflict'] ?? 'fail' ); - - if ( $dirty_count > 0 && ! $allow_dirty ) { - if ( 'fail' === $conflict ) { - return new \WP_Error( - 'dirty_working_tree', - sprintf( 'Working tree for "%s" is dirty (%d file(s)). Pass allow_dirty=true or change conflict policy.', $slug, $dirty_count ), - array( 'status' => 400, 'dirty' => $dirty_count ) - ); - } - if ( 'upstream_wins' === $conflict ) { - // Reset hard before fetching to ensure a clean fast-forward. - $reset = GitRunner::run( $absolute, 'reset --hard HEAD' ); - if ( is_wp_error( $reset ) ) { - return $reset; - } - } - // 'manual' — fall through and let the pull attempt happen; git - // will refuse on conflict and the error surfaces to the caller. - } - - // Refuse any pull that would change branches. - $current_branch = GitRepo::branch( $absolute ); - if ( null !== $current_branch && $current_branch !== $binding->branch ) { - return new \WP_Error( - 'branch_mismatch', - sprintf( - 'Binding "%s" is pinned to branch "%s" but the working tree is on "%s". Refusing to pull.', - $slug, - $binding->branch, - $current_branch - ), - array( 'status' => 409 ) - ); - } - - $pull = GitRunner::run( $absolute, 'pull --ff-only origin ' . escapeshellarg( $binding->branch ) ); - if ( is_wp_error( $pull ) ) { - return $pull; - } - - $new_head = GitRepo::head( $absolute ); - $binding->last_pulled = gmdate( 'c' ); - $binding->last_commit = $new_head; - $this->registry->save( $binding ); - - return array( - 'success' => true, - 'slug' => $slug, - 'branch' => $binding->branch, - 'previous_head' => $previous_head, - 'head' => $new_head, - 'message' => trim( (string) ( $pull['output'] ?? '' ) ), - ); - } - - /** - * Status snapshot for a binding. - * - * Reports the working-tree state (branch, HEAD, dirty files) and any - * drift between local HEAD and the tracked upstream ref. Drift is - * computed against `origin/<binding->branch>` via `rev-list --count`, - * so a stale local ref produces a conservative number rather than - * failing the call. - * - * @param string $slug Binding slug. - * @return array<string, mixed>|\WP_Error - */ public function status( string $slug ): array|\WP_Error { $binding = $this->registry->get( $slug ); if ( null === $binding ) { @@ -264,69 +140,38 @@ public function status( string $slug ): array|\WP_Error { } $absolute = $binding->resolveAbsolutePath(); - $exists = is_dir( $absolute ); - $is_repo = $exists && ( is_dir( $absolute . '/.git' ) || is_file( $absolute . '/.git' ) ); - - $data = array( - 'success' => true, - 'slug' => $slug, - 'local_path' => $absolute, - 'remote_url' => $binding->remote_url, + return array( + 'success' => true, + 'slug' => $slug, + 'local_path' => $absolute, + 'remote_url' => $binding->remote_url, 'tracked_branch' => $binding->branch, - 'exists' => $exists, - 'is_repo' => $is_repo, - 'branch' => null, - 'head' => null, - 'dirty' => 0, - 'ahead' => null, - 'behind' => null, - 'last_pulled' => $binding->last_pulled, - 'policy' => $binding->policy, + 'exists' => is_dir( $absolute ), + 'last_pulled' => $binding->last_pulled, + 'last_commit' => $binding->last_commit, + 'pulled_count' => count( $binding->pulled_paths ), + 'policy' => $binding->policy, ); - - if ( ! $is_repo ) { - return $data; - } - - $data['branch'] = GitRepo::branch( $absolute ); - $data['head'] = GitRepo::head( $absolute ); - $data['dirty'] = GitRepo::dirtyCount( $absolute ); - - $upstream = 'origin/' . $binding->branch; - $ahead = GitRunner::run( $absolute, 'rev-list --count ' . escapeshellarg( $upstream ) . '..HEAD' ); - $behind = GitRunner::run( $absolute, 'rev-list --count HEAD..' . escapeshellarg( $upstream ) ); - $data['ahead'] = is_wp_error( $ahead ) ? null : (int) trim( (string) $ahead['output'] ); - $data['behind'] = is_wp_error( $behind ) ? null : (int) trim( (string) $behind['output'] ); - - return $data; } - /** - * List every registered binding with a lightweight status snapshot. - * - * @return array{success: true, bindings: array<int, array<string, mixed>>} - */ public function list_bindings(): array { - $bindings = $this->registry->all(); - $out = array(); - foreach ( $bindings as $binding ) { + $out = array(); + foreach ( $this->registry->all() as $binding ) { $absolute = $binding->resolveAbsolutePath(); - $is_repo = is_dir( $absolute ) && ( is_dir( $absolute . '/.git' ) || is_file( $absolute . '/.git' ) ); $out[] = array( - 'slug' => $binding->slug, - 'local_path' => $binding->local_path, - 'absolute_path' => $absolute, - 'remote_url' => $binding->remote_url, - 'branch' => $binding->branch, - 'exists' => is_dir( $absolute ), - 'is_repo' => $is_repo, - 'auto_pull' => (bool) ( $binding->policy['auto_pull'] ?? false ), - 'pull_interval' => (string) ( $binding->policy['pull_interval'] ?? 'hourly' ), - 'last_pulled' => $binding->last_pulled, - 'last_commit' => $binding->last_commit, + 'slug' => $binding->slug, + 'local_path' => $binding->local_path, + 'absolute_path' => $absolute, + 'remote_url' => $binding->remote_url, + 'branch' => $binding->branch, + 'exists' => is_dir( $absolute ), + 'write_enabled' => (bool) ( $binding->policy['write_enabled'] ?? false ), + 'push_enabled' => (bool) ( $binding->policy['safe_direct_push'] ?? false ), + 'last_pulled' => $binding->last_pulled, + 'last_commit' => $binding->last_commit, + 'pulled_count' => count( $binding->pulled_paths ), ); } - return array( 'success' => true, 'bindings' => $out, @@ -334,265 +179,38 @@ public function list_bindings(): array { } // ========================================================================= - // Phase 2 — write path + // Delegating operations // ========================================================================= - /** - * Stage one or more relative paths for commit on a binding's working tree. - * - * Every path runs through the same checkpoint: - * 1. Not absolute, no traversal, not sensitive. - * 2. Under at least one of the binding's `allowed_paths` roots. - * 3. Actually exists under the binding's working tree. - * - * A single rejected path fails the whole call — we never partially stage. - * This keeps the outcome predictable: either all requested paths are - * staged, or none are. - * - * @param string $slug Binding slug. - * @param string[] $paths Relative paths inside the binding. - * @return array{success: true, slug: string, paths: string[], message: string}|\WP_Error - */ - public function add( string $slug, array $paths ): array|\WP_Error { + public function pull( string $slug, array $args = array() ): array|\WP_Error { $binding = $this->registry->get( $slug ); if ( null === $binding ) { return $this->notFound( $slug ); } - - if ( empty( $binding->policy['write_enabled'] ) ) { - return new \WP_Error( - 'write_disabled', - sprintf( 'Writes are disabled for binding "%s" (policy.write_enabled=false).', $slug ), - array( 'status' => 403 ) - ); - } - - $allowed_roots = is_array( $binding->policy['allowed_paths'] ?? null ) - ? $binding->policy['allowed_paths'] - : array(); - if ( empty( $allowed_roots ) ) { - return new \WP_Error( - 'no_allowed_paths', - sprintf( 'Binding "%s" has no allowed_paths configured — nothing can be staged.', $slug ), - array( 'status' => 403 ) - ); - } - - $absolute = $binding->resolveAbsolutePath(); - $repo_err = $this->assertRepo( $absolute, $slug ); - if ( is_wp_error( $repo_err ) ) { - return $repo_err; - } - - $clean = array(); - foreach ( $paths as $raw ) { - $relative = ltrim( trim( (string) $raw ), '/' ); - if ( '' === $relative ) { - continue; - } - - if ( PathSecurity::hasTraversal( $relative ) ) { - return new \WP_Error( 'path_traversal', sprintf( 'Invalid path (traversal): %s', $relative ), array( 'status' => 400 ) ); - } - if ( PathSecurity::isSensitivePath( $relative ) ) { - return new \WP_Error( 'sensitive_path', sprintf( 'Refusing to stage sensitive path: %s', $relative ), array( 'status' => 403 ) ); - } - if ( ! PathSecurity::isPathAllowed( $relative, $allowed_roots ) ) { - return new \WP_Error( - 'path_not_allowed', - sprintf( 'Path "%s" is outside the binding\'s allowed_paths allowlist.', $relative ), - array( 'status' => 403, 'allowed_paths' => $allowed_roots ) - ); - } - - $clean[] = $relative; - } - - if ( empty( $clean ) ) { - return new \WP_Error( 'no_paths', 'At least one non-empty path is required.', array( 'status' => 400 ) ); - } - - $escaped = array_map( 'escapeshellarg', $clean ); - $result = GitRunner::run( $absolute, 'add -- ' . implode( ' ', $escaped ) ); - if ( is_wp_error( $result ) ) { - return $result; - } - - return array( - 'success' => true, - 'slug' => $slug, - 'paths' => $clean, - 'message' => sprintf( 'Staged %d path(s) on "%s".', count( $clean ), $slug ), - ); + return ( new GitSyncFetcher( $this->registry ) )->pull( $binding, $args ); } - /** - * Commit staged changes on a binding's working tree. - * - * Message constraints mirror Workspace for consistency: 8–200 characters. - * Refuses when nothing is staged so empty commits never sneak through. - * - * @param string $slug Binding slug. - * @param string $message Commit message. - * @return array{success: true, slug: string, commit: ?string, message: string}|\WP_Error - */ - public function commit( string $slug, string $message ): array|\WP_Error { - $binding = $this->registry->get( $slug ); - if ( null === $binding ) { - return $this->notFound( $slug ); - } - - if ( empty( $binding->policy['write_enabled'] ) ) { - return new \WP_Error( 'write_disabled', sprintf( 'Writes are disabled for binding "%s".', $slug ), array( 'status' => 403 ) ); - } - - $message = trim( $message ); - if ( '' === $message ) { - return new \WP_Error( 'missing_message', 'Commit message is required.', array( 'status' => 400 ) ); - } - if ( strlen( $message ) < 8 ) { - return new \WP_Error( 'message_too_short', 'Commit message must be at least 8 characters.', array( 'status' => 400 ) ); - } - if ( strlen( $message ) > 200 ) { - return new \WP_Error( 'message_too_long', 'Commit message must be 200 characters or fewer.', array( 'status' => 400 ) ); - } - - $absolute = $binding->resolveAbsolutePath(); - $repo_err = $this->assertRepo( $absolute, $slug ); - if ( is_wp_error( $repo_err ) ) { - return $repo_err; - } - - $staged = GitRunner::run( $absolute, 'diff --cached --name-only' ); - if ( is_wp_error( $staged ) ) { - return $staged; - } - $staged_files = array_filter( array_map( 'trim', explode( "\n", (string) ( $staged['output'] ?? '' ) ) ) ); - if ( empty( $staged_files ) ) { - return new \WP_Error( 'nothing_staged', 'No staged changes to commit.', array( 'status' => 400 ) ); - } - - $result = GitRunner::run( $absolute, 'commit -m ' . escapeshellarg( $message ) ); - if ( is_wp_error( $result ) ) { - return $result; - } - - return array( - 'success' => true, - 'slug' => $slug, - 'commit' => GitRepo::head( $absolute ), - 'message' => sprintf( 'Committed %d file(s) on "%s".', count( $staged_files ), $slug ), - ); - } - - /** - * Push the binding's pinned branch to origin. - * - * Direct push requires two keys: `push_enabled=true` AND - * `safe_direct_push=true`. Missing either refuses — use submit() for - * the PR-based flow if you don't want to flip both. - * - * `--force` is honored but uses `--force-with-lease` under the hood so - * a concurrent upstream move isn't silently overwritten. - * - * @param string $slug Binding slug. - * @param bool $force Force push (uses --force-with-lease). - * @return array{success: true, slug: string, branch: string, head: ?string, message: string}|\WP_Error - */ - public function push( string $slug, bool $force = false ): array|\WP_Error { + public function submit( string $slug, array $args ): array|\WP_Error { $binding = $this->registry->get( $slug ); if ( null === $binding ) { return $this->notFound( $slug ); } - - if ( empty( $binding->policy['push_enabled'] ) ) { - return new \WP_Error( 'push_disabled', sprintf( 'Pushes are disabled for binding "%s" (policy.push_enabled=false).', $slug ), array( 'status' => 403 ) ); - } - - if ( empty( $binding->policy['safe_direct_push'] ) ) { - return new \WP_Error( - 'direct_push_blocked', - sprintf( - 'Direct push to the pinned branch is blocked for binding "%s". Set policy.safe_direct_push=true, or use submit() to open a PR instead.', - $slug - ), - array( 'status' => 403 ) - ); - } - - $absolute = $binding->resolveAbsolutePath(); - $repo_err = $this->assertRepo( $absolute, $slug ); - if ( is_wp_error( $repo_err ) ) { - return $repo_err; - } - - $current = GitRepo::branch( $absolute ); - if ( null !== $current && $current !== $binding->branch ) { - return new \WP_Error( - 'branch_mismatch', - sprintf( - 'Binding "%s" is pinned to "%s" but working tree is on "%s". Refusing to push.', - $slug, $binding->branch, $current - ), - array( 'status' => 409 ) - ); - } - - $push_url = $this->resolveAuthenticatedPushUrl( $binding ); - $flag = $force ? '--force-with-lease ' : ''; - $cmd = sprintf( - 'push %s%s %s', - $flag, - escapeshellarg( $push_url ), - escapeshellarg( $binding->branch . ':' . $binding->branch ) - ); - - $result = GitRunner::run( $absolute, $cmd ); - if ( is_wp_error( $result ) ) { - return $result; - } - - $binding->last_commit = GitRepo::head( $absolute ); - $this->registry->save( $binding ); - - return array( - 'success' => true, - 'slug' => $slug, - 'branch' => $binding->branch, - 'head' => $binding->last_commit, - 'message' => trim( (string) ( $result['output'] ?? '' ) ), - ); + return ( new GitSyncProposer( $this->registry ) )->submit( $binding, $args ); } - /** - * Submit local edits as a pull request. - * - * Delegates the orchestration to GitSyncSubmitter — this method is a - * thin wrapper so the ability callback has a short spelling. - * - * @param string $slug Binding slug. - * @param array<string, mixed> $args Submit args (message, paths, title, body). - * @return array<string, mixed>|\WP_Error - */ - public function submit( string $slug, array $args ): array|\WP_Error { + public function push( string $slug, array $args ): array|\WP_Error { $binding = $this->registry->get( $slug ); if ( null === $binding ) { return $this->notFound( $slug ); } - - return ( new GitSyncSubmitter( $this->registry ) )->submit( $binding, $args ); + return ( new GitSyncProposer( $this->registry ) )->push( $binding, $args ); } /** - * Update policy fields on an existing binding. - * - * Accepts a subset of policy keys and merges them into the current - * policy. Validates the same way `GitSyncBinding::create()` does so - * bad values are refused before they hit storage. + * Update a subset of policy fields. * - * @param string $slug Binding slug. - * @param array<string, mixed> $patch Policy keys to update. - * @return array{success: true, slug: string, policy: array<string, mixed>}|\WP_Error + * Whitelisted keys only; unknown keys refused. Same validation + * applied at bind time runs here so bad states never reach storage. */ public function updatePolicy( string $slug, array $patch ): array|\WP_Error { $binding = $this->registry->get( $slug ); @@ -600,7 +218,7 @@ public function updatePolicy( string $slug, array $patch ): array|\WP_Error { return $this->notFound( $slug ); } - $merged = array_merge( $binding->policy, array() ); + $merged = $binding->policy; foreach ( $patch as $key => $value ) { if ( ! array_key_exists( $key, GitSyncBinding::DEFAULT_POLICY ) ) { return new \WP_Error( 'unknown_policy_key', sprintf( 'Unknown policy key: %s', $key ), array( 'status' => 400 ) ); @@ -608,25 +226,9 @@ public function updatePolicy( string $slug, array $patch ): array|\WP_Error { $merged[ $key ] = $value; } - if ( ! in_array( $merged['conflict'] ?? 'fail', GitSyncBinding::CONFLICT_STRATEGIES, true ) ) { - return new \WP_Error( - 'invalid_conflict_strategy', - sprintf( 'conflict must be one of: %s.', implode( ', ', GitSyncBinding::CONFLICT_STRATEGIES ) ), - array( 'status' => 400 ) - ); - } - - if ( ! is_array( $merged['allowed_paths'] ?? null ) ) { - return new \WP_Error( 'invalid_allowed_paths', 'allowed_paths must be an array.', array( 'status' => 400 ) ); - } - - // Safety coupling: safe_direct_push only meaningful with push_enabled. - if ( ! empty( $merged['safe_direct_push'] ) && empty( $merged['push_enabled'] ) ) { - return new \WP_Error( - 'policy_conflict', - 'safe_direct_push=true requires push_enabled=true.', - array( 'status' => 400 ) - ); + $validation = GitSyncBinding::validatePolicy( $merged ); + if ( is_wp_error( $validation ) ) { + return $validation; } $binding->policy = $merged; @@ -639,175 +241,85 @@ public function updatePolicy( string $slug, array $patch ): array|\WP_Error { ); } - /** - * Resolve the push URL, injecting a GitHub PAT when the remote is - * github.com and DMC has one registered. Non-GitHub remotes pass - * through unchanged and rely on the system's credential.helper. - * - * Thin wrapper around Support\GitHubRemote — the actual rewriting - * and host detection live there so every GitHub-aware caller - * (GitSync, GitSyncSubmitter, future write paths) stays consistent. - */ - private function resolveAuthenticatedPushUrl( GitSyncBinding $binding ): string { - if ( ! GitHubRemote::isGitHubRemote( $binding->remote_url ) ) { - return $binding->remote_url; - } - if ( ! class_exists( '\DataMachineCode\Abilities\GitHubAbilities' ) ) { - return $binding->remote_url; - } - return GitHubRemote::pushUrlWithPat( $binding->remote_url, \DataMachineCode\Abilities\GitHubAbilities::getPat() ); - } - // ========================================================================= // Internal helpers // ========================================================================= /** - * Validate the binding's `local_path` against ABSPATH containment rules. - * - * Pre-write validation — the target may not exist yet, so we check the - * relative segments first (no `..`, no absolute prefix after stripping - * the leading slash) and then compute the absolute path without calling - * `realpath()` on a non-existent target. Existing targets get an extra - * `realpath`-based containment check as a belt-and-suspenders. + * Pre-bind validation of a local path. * - * @return string|\WP_Error Absolute path on success. + * The path doesn't have to exist yet (pull materializes it), so we + * can't rely on realpath containment. Check segments instead: must + * be leading-slash, no traversal, not sensitive. */ - private function validateBindingPath( GitSyncBinding $binding ): string|\WP_Error { + private function validateLocalPath( GitSyncBinding $binding ): true|\WP_Error { $relative = ltrim( str_replace( '\\', '/', $binding->local_path ), '/' ); - if ( '' === $relative ) { return new \WP_Error( 'invalid_local_path', 'local_path cannot resolve to ABSPATH itself.', array( 'status' => 400 ) ); } - if ( PathSecurity::hasTraversal( $relative ) ) { return new \WP_Error( 'path_traversal', 'local_path contains traversal segments (`.`, `..`).', array( 'status' => 400 ) ); } - if ( PathSecurity::isSensitivePath( $relative ) ) { return new \WP_Error( 'sensitive_path', sprintf( 'Refusing to bind sensitive path: %s', $relative ), array( 'status' => 403 ) ); } + // If the target already exists, run the symlink-safe check too. $abspath = rtrim( ABSPATH, '/' ); $absolute = $abspath . '/' . rtrim( $relative, '/' ); - - // If it already exists, confirm it really resolves under ABSPATH via - // realpath (defends against symlink escapes). if ( is_dir( $absolute ) ) { - $validation = PathSecurity::validateContainment( $absolute, $abspath ); - if ( ! $validation['valid'] ) { - return new \WP_Error( - 'path_outside_abspath', - sprintf( 'local_path resolves outside ABSPATH: %s', $validation['message'] ?? '' ), - array( 'status' => 403 ) - ); + $containment = PathSecurity::validateContainment( $absolute, $abspath ); + if ( ! $containment['valid'] ) { + return new \WP_Error( 'path_outside_abspath', sprintf( 'local_path resolves outside ABSPATH: %s', $containment['message'] ?? '' ), array( 'status' => 403 ) ); } - $absolute = $validation['real_path']; } - return $absolute; + return true; } /** - * Either clone the remote into `$absolute` or adopt an existing checkout. + * Recursively remove a directory using native PHP — no shell. * - * Returns `['adopted' => bool]` on success so the caller can report - * whether the working tree was materialized fresh or already present. - * - * @return array{adopted: bool}|\WP_Error + * `$path` is expected to have already cleared `validateContainment` + * by the caller; this method trusts that and only performs the + * recursive unlink/rmdir. Returns a WP_Error if any step fails, + * leaving the partially-removed tree behind for the admin to + * inspect rather than attempting further cleanup. */ - private function materializeClone( GitSyncBinding $binding, string $absolute ): array|\WP_Error { - $git_path = $absolute . '/.git'; - $exists = is_dir( $absolute ); - - if ( $exists && ( is_dir( $git_path ) || is_file( $git_path ) ) ) { - $remote = GitRunner::run( $absolute, 'config --get remote.origin.url' ); - if ( is_wp_error( $remote ) ) { - return $remote; - } - $current_origin = trim( (string) $remote['output'] ); - if ( $current_origin !== $binding->remote_url ) { - return new \WP_Error( - 'origin_mismatch', - sprintf( - 'Existing checkout at "%s" has origin "%s", but binding expects "%s". Refusing to adopt.', - $absolute, - $current_origin, - $binding->remote_url - ), - array( 'status' => 409 ) - ); - } - - // Optional: verify branch alignment. Not fatal (caller can pull/fetch - // to re-align), but we warn by returning success so the CLI can report. - return array( 'adopted' => true ); - } - - if ( $exists && ! $this->isDirEmpty( $absolute ) ) { - return new \WP_Error( - 'dirty_target', - sprintf( - 'Target "%s" exists and is non-empty without a .git directory. Refusing to clone over it.', - $absolute - ), - array( 'status' => 409 ) - ); - } - - // Create parent directory if missing. - $parent = dirname( $absolute ); - if ( ! is_dir( $parent ) && ! wp_mkdir_p( $parent ) ) { - return new \WP_Error( 'parent_mkdir_failed', sprintf( 'Failed to create parent directory: %s', $parent ), array( 'status' => 500 ) ); + private function removeTree( string $path ): true|\WP_Error { + if ( ! is_dir( $path ) ) { + return true; } - $cmd = sprintf( - 'git clone --branch %s %s %s 2>&1', - escapeshellarg( $binding->branch ), - escapeshellarg( $binding->remote_url ), - escapeshellarg( $absolute ) + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $path, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST ); - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec( $cmd, $output, $exit_code ); + foreach ( $iterator as $entry ) { + /** @var \SplFileInfo $entry */ + $target = $entry->getPathname(); + $ok = $entry->isDir() ? @rmdir( $target ) : @unlink( $target ); + if ( ! $ok ) { + return new \WP_Error( + 'purge_failed', + sprintf( 'Failed to remove %s during purge of %s.', $target, $path ), + array( 'status' => 500 ) + ); + } + } - if ( 0 !== $exit_code ) { + if ( ! @rmdir( $path ) ) { return new \WP_Error( - 'clone_failed', - sprintf( 'Git clone failed (exit %d): %s', $exit_code, implode( "\n", $output ) ), + 'purge_failed', + sprintf( 'Failed to remove top-level directory %s.', $path ), array( 'status' => 500 ) ); } - return array( 'adopted' => false ); - } - - private function isDirEmpty( string $path ): bool { - $handle = @opendir( $path ); - if ( false === $handle ) { - return false; - } - while ( false !== ( $entry = readdir( $handle ) ) ) { - if ( '.' !== $entry && '..' !== $entry ) { - closedir( $handle ); - return false; - } - } - closedir( $handle ); return true; } - private function assertRepo( string $absolute, string $slug ): ?\WP_Error { - if ( ! is_dir( $absolute ) ) { - return new \WP_Error( 'missing_working_tree', sprintf( 'Binding "%s" has no working tree on disk at %s.', $slug, $absolute ), array( 'status' => 404 ) ); - } - $git_path = $absolute . '/.git'; - if ( ! is_dir( $git_path ) && ! is_file( $git_path ) ) { - return new \WP_Error( 'not_a_repo', sprintf( 'Binding "%s" is not a git repository (no .git at %s).', $slug, $absolute ), array( 'status' => 409 ) ); - } - return null; - } - private function notFound( string $slug ): \WP_Error { return new \WP_Error( 'binding_not_found', diff --git a/inc/GitSync/GitSyncBinding.php b/inc/GitSync/GitSyncBinding.php index a317d30..523c462 100644 --- a/inc/GitSync/GitSyncBinding.php +++ b/inc/GitSync/GitSyncBinding.php @@ -2,13 +2,11 @@ /** * GitSync Binding * - * Value object representing a single binding between a site-owned local - * directory (relative to ABSPATH) and a remote git repository. + * Value object for a single binding between a site-owned local directory + * (relative to ABSPATH) and a GitHub repository. * - * Bindings are stored serialized inside the `datamachine_gitsync_bindings` - * option by `GitSyncRegistry`. This class exists so the serialization - * shape has one canonical source of truth and validation lives alongside - * the data it validates. + * Storage: serialized inside the `datamachine_gitsync_bindings` option. + * Validation lives here so the shape has one authoritative definition. * * @package DataMachineCode\GitSync * @since 0.7.0 @@ -21,31 +19,40 @@ final class GitSyncBinding { /** - * Default policy shape applied to freshly created bindings. + * Default policy for freshly-created bindings. * - * Deliberately conservative: no writes, no push, no auto-pull. The - * caller has to explicitly opt each of those in. `conflict = fail` - * means a dirty pull aborts instead of destroying local state. + * Deliberately conservative: read-only until writes are explicitly + * enabled; direct push to the pinned branch requires its own second + * key (`safe_direct_push`) on top of `push_enabled`; conflicts fail + * instead of silently overwriting. * * @var array<string, mixed> */ public const DEFAULT_POLICY = array( - 'auto_pull' => false, - 'pull_interval' => 'hourly', + // Gates submit (propose changes as a PR) and push (direct commit + // to pinned branch). Neither ability is reachable without this + // flipped to true. 'write_enabled' => false, - 'push_enabled' => false, - // Second key for direct push to the pinned branch. Even with - // push_enabled=true, pushes straight to the tracked branch are - // refused unless this is also true. submit() pushes to a feature - // branch and does NOT require this flag — PR flow is the - // intended default for bindings that allow writes. + // Second key: even with write_enabled=true, direct push to the + // pinned branch is blocked unless this is also true. submit() + // always opens a PR, so it doesn't need this flag. 'safe_direct_push' => false, + // Staging allowlist. Every file uploaded by submit or push must + // live under one of these path prefixes. Empty = nothing uploadable. 'allowed_paths' => array(), + // Conflict strategy when local state diverges from upstream during + // a pull: fail (abort), upstream_wins (overwrite local), manual + // (surface the conflict; leave files alone). 'conflict' => 'fail', + // Scheduled-sync hints, ignored until a future task consumes them. + // Kept on the shape so bindings created today don't need migration + // when scheduling lands. + 'auto_pull' => false, + 'pull_interval' => 'hourly', ); /** - * Allowed conflict-resolution strategies. + * Conflict strategies allowed on `policy.conflict`. * * @var string[] */ @@ -65,25 +72,36 @@ final class GitSyncBinding { public ?string $last_pulled; public ?string $last_commit; + /** + * Relative paths this binding pulled at least once. Used to detect + * upstream deletions: on pull, any path in this list not in the + * remote tree is removed from disk and from this list. Paths not + * in the list are assumed consumer-owned and left alone. + * + * @var string[] + */ + public array $pulled_paths; + /** * @param array<string, mixed> $data */ private function __construct( array $data ) { - $this->slug = (string) $data['slug']; - $this->local_path = (string) $data['local_path']; - $this->remote_url = (string) $data['remote_url']; - $this->branch = (string) ( $data['branch'] ?? 'main' ); - $this->policy = is_array( $data['policy'] ?? null ) ? $data['policy'] : array(); - $this->policy = array_merge( self::DEFAULT_POLICY, $this->policy ); - $this->created_at = (string) ( $data['created_at'] ?? gmdate( 'c' ) ); - $this->last_pulled = isset( $data['last_pulled'] ) ? (string) $data['last_pulled'] : null; - $this->last_commit = isset( $data['last_commit'] ) ? (string) $data['last_commit'] : null; + $this->slug = (string) $data['slug']; + $this->local_path = (string) $data['local_path']; + $this->remote_url = (string) $data['remote_url']; + $this->branch = (string) ( $data['branch'] ?? 'main' ); + $this->policy = is_array( $data['policy'] ?? null ) ? $data['policy'] : array(); + $this->policy = array_merge( self::DEFAULT_POLICY, $this->policy ); + $this->created_at = (string) ( $data['created_at'] ?? gmdate( 'c' ) ); + $this->last_pulled = isset( $data['last_pulled'] ) ? (string) $data['last_pulled'] : null; + $this->last_commit = isset( $data['last_commit'] ) ? (string) $data['last_commit'] : null; + $this->pulled_paths = is_array( $data['pulled_paths'] ?? null ) ? array_values( array_filter( array_map( 'strval', $data['pulled_paths'] ) ) ) : array(); } /** - * Build a binding from a raw input array, validating each field. + * Build a binding from raw input, validating each field. * - * @param array<string, mixed> $input Raw user input. + * @param array<string, mixed> $input * @return self|\WP_Error */ public static function create( array $input ): self|\WP_Error { @@ -106,14 +124,13 @@ public static function create( array $input ): self|\WP_Error { return new \WP_Error( 'missing_remote_url', 'remote_url is required.', array( 'status' => 400 ) ); } - // Accept https://, http:// (for self-hosted git), git@ SSH, and - // file:// (useful for local bare-repo testing). Anything outside - // these schemes is refused — guards against accidental bindings - // to filesystem paths that git's URL-sniffing might still accept. - if ( ! preg_match( '#^(https?://|git@|file://)#', $remote ) ) { + // API-first GitSync speaks GitHub only — accept https://github.com/... + // and git@github.com:... shapes. Non-GitHub remotes are rejected at + // bind time so nothing with an unsupported backend gets into storage. + if ( ! preg_match( '#^(https://github\.com/|git@github\.com:)#', $remote ) ) { return new \WP_Error( 'invalid_remote_url', - 'remote_url must be an https://, http://, git@, or file:// URL.', + 'remote_url must be a https://github.com/... or git@github.com:... URL. GitSync talks to GitHub\'s Contents API — non-GitHub backends are not yet supported.', array( 'status' => 400 ) ); } @@ -126,40 +143,51 @@ public static function create( array $input ): self|\WP_Error { $policy_in = is_array( $input['policy'] ?? null ) ? $input['policy'] : array(); $policy = array_merge( self::DEFAULT_POLICY, $policy_in ); - if ( ! in_array( $policy['conflict'] ?? 'fail', self::CONFLICT_STRATEGIES, true ) ) { - return new \WP_Error( - 'invalid_conflict_strategy', - sprintf( 'conflict must be one of: %s.', implode( ', ', self::CONFLICT_STRATEGIES ) ), - array( 'status' => 400 ) - ); - } - - if ( ! is_array( $policy['allowed_paths'] ?? null ) ) { - $policy['allowed_paths'] = array(); + $validation = self::validatePolicy( $policy ); + if ( is_wp_error( $validation ) ) { + return $validation; } return new self( array( - 'slug' => $slug, - 'local_path' => $local, - 'remote_url' => $remote, - 'branch' => $branch, - 'policy' => $policy, - 'created_at' => gmdate( 'c' ), + 'slug' => $slug, + 'local_path' => $local, + 'remote_url' => $remote, + 'branch' => $branch, + 'policy' => $policy, + 'created_at' => gmdate( 'c' ), + 'pulled_paths' => array(), ) ); } /** - * Restore a binding from its stored array form. + * Validate a policy array. Shared by create() and updatePolicy(). * - * Unlike `create()` this does NOT re-validate — callers trust the - * option store. If storage is ever migrated to a stricter format, - * add validation here. - * - * @param array<string, mixed> $data - * @return self + * @param array<string, mixed> $policy + * @return true|\WP_Error */ + public static function validatePolicy( array $policy ): true|\WP_Error { + if ( ! in_array( $policy['conflict'] ?? 'fail', self::CONFLICT_STRATEGIES, true ) ) { + return new \WP_Error( + 'invalid_conflict_strategy', + sprintf( 'conflict must be one of: %s.', implode( ', ', self::CONFLICT_STRATEGIES ) ), + array( 'status' => 400 ) + ); + } + if ( ! is_array( $policy['allowed_paths'] ?? null ) ) { + return new \WP_Error( 'invalid_allowed_paths', 'allowed_paths must be an array.', array( 'status' => 400 ) ); + } + if ( ! empty( $policy['safe_direct_push'] ) && empty( $policy['write_enabled'] ) ) { + return new \WP_Error( + 'policy_conflict', + 'safe_direct_push=true requires write_enabled=true.', + array( 'status' => 400 ) + ); + } + return true; + } + public static function fromArray( array $data ): self { return new self( $data ); } @@ -169,25 +197,25 @@ public static function fromArray( array $data ): self { */ public function toArray(): array { return array( - 'slug' => $this->slug, - 'local_path' => $this->local_path, - 'remote_url' => $this->remote_url, - 'branch' => $this->branch, - 'policy' => $this->policy, - 'created_at' => $this->created_at, - 'last_pulled' => $this->last_pulled, - 'last_commit' => $this->last_commit, + 'slug' => $this->slug, + 'local_path' => $this->local_path, + 'remote_url' => $this->remote_url, + 'branch' => $this->branch, + 'policy' => $this->policy, + 'created_at' => $this->created_at, + 'last_pulled' => $this->last_pulled, + 'last_commit' => $this->last_commit, + 'pulled_paths' => $this->pulled_paths, ); } /** * Resolve `local_path` to an absolute filesystem path under ABSPATH. * - * `local_path` convention: leading-slash string, interpreted relative - * to ABSPATH (e.g. `/wp-content/uploads/markdown/wiki/` → - * `ABSPATH/wp-content/uploads/markdown/wiki/`). This matches the shape - * documented in Extra-Chill/data-machine-code#38 and keeps bindings - * portable across installs with different ABSPATHs. + * `local_path` is stored leading-slash (e.g. `/wp-content/uploads/wiki/`) + * and interpreted relative to ABSPATH. Keeping the stored form portable + * lets a binding move between installs with different ABSPATHs without + * a migration step. * * @return string Absolute path with no trailing slash. */ diff --git a/inc/GitSync/GitSyncFetcher.php b/inc/GitSync/GitSyncFetcher.php new file mode 100644 index 0000000..c365305 --- /dev/null +++ b/inc/GitSync/GitSyncFetcher.php @@ -0,0 +1,257 @@ +<?php +/** + * GitSync Fetcher + * + * Pulls files from a GitHub repository onto the local filesystem using + * the Contents API — no git binary, no .git/ directory, works on any + * WordPress host that can make outbound HTTPS. + * + * Strategy: + * 1. Ask GitHub for the recursive tree of the pinned branch. + * 2. For each blob, compare its git-blob SHA to the one we'd compute + * from the local file (if any). Download + write only on mismatch. + * 3. After the sync, any path the binding's `pulled_paths` recorded + * that's no longer in the tree gets deleted locally — upstream + * removals propagate down, but consumer-added local files (paths + * we never pulled) stay untouched. + * + * Conflict handling (when local file exists with a different SHA than + * both upstream and what we last pulled — i.e. the consumer edited a + * tracked file locally between pulls): + * - fail: abort, surface the conflict list, leave files alone + * - upstream_wins: overwrite the local file from upstream + * - manual: skip the conflicting file, surface it in the result + * + * @package DataMachineCode\GitSync + * @since 0.8.0 + */ + +namespace DataMachineCode\GitSync; + +use DataMachineCode\Abilities\GitHubAbilities; +use DataMachineCode\Support\GitHubRemote; +use DataMachineCode\Support\PathSecurity; + +defined( 'ABSPATH' ) || exit; + +final class GitSyncFetcher { + + private GitSyncRegistry $registry; + + public function __construct( GitSyncRegistry $registry ) { + $this->registry = $registry; + } + + /** + * Pull the binding's pinned branch to local disk. + * + * @param GitSyncBinding $binding + * @param array<string, mixed> $args Optional: `allow_dirty` (override + * conflict policy for this call). + * @return array<string, mixed>|\WP_Error + */ + public function pull( GitSyncBinding $binding, array $args = array() ): array|\WP_Error { + $slug = GitHubRemote::slug( $binding->remote_url ); + if ( null === $slug ) { + return new \WP_Error( 'unparseable_remote', sprintf( 'Cannot parse GitHub owner/repo from %s.', $binding->remote_url ), array( 'status' => 400 ) ); + } + + $pat = (string) GitHubAbilities::getPat(); + if ( '' === $pat ) { + return new \WP_Error( 'missing_pat', 'GitHub PAT not configured.', array( 'status' => 500 ) ); + } + + $absolute = $binding->resolveAbsolutePath(); + if ( ! is_dir( $absolute ) && ! wp_mkdir_p( $absolute ) ) { + return new \WP_Error( 'mkdir_failed', sprintf( 'Could not create local directory %s.', $absolute ), array( 'status' => 500 ) ); + } + + // Containment belt-and-suspenders: once the dir exists, re-validate + // that it really lives under ABSPATH (symlink-safe). + $abspath_root = rtrim( ABSPATH, '/' ); + $containment = PathSecurity::validateContainment( $absolute, $abspath_root ); + if ( ! $containment['valid'] ) { + return new \WP_Error( 'path_outside_abspath', $containment['message'] ?? 'containment failed', array( 'status' => 403 ) ); + } + $absolute = $containment['real_path']; + + $tree = GitHubRemote::fetchTree( $slug, $binding->branch, $pat ); + if ( is_wp_error( $tree ) ) { + return $tree; + } + $upstream = $tree['blobs']; + $tree_sha = $tree['tree_sha']; + $truncated = $tree['truncated']; + + $conflict_policy = (string) ( $binding->policy['conflict'] ?? 'fail' ); + $allow_dirty = ! empty( $args['allow_dirty'] ); + + $updated = array(); + $unchanged = array(); + $conflicts = array(); + $deleted = array(); + + // Walk upstream blobs first — write new + updated files, detect conflicts. + foreach ( $upstream as $path => $remote_sha ) { + $dest = $absolute . '/' . $path; + + if ( PathSecurity::isSensitivePath( $path ) ) { + // Upstream shouldn't ship credentials, but defend anyway. + $conflicts[] = array( 'path' => $path, 'reason' => 'sensitive_path' ); + continue; + } + + $local_exists = is_file( $dest ); + $local_sha = $local_exists ? GitHubRemote::blobSha( (string) file_get_contents( $dest ) ) : null; + + if ( $local_exists && $local_sha === $remote_sha ) { + $unchanged[] = $path; + continue; + } + + // Conflict detection: a tracked file exists locally with a SHA + // that differs from both the previous upstream (implied by our + // `pulled_paths` list) and the current remote. The consumer + // edited it between pulls. + $tracked = in_array( $path, $binding->pulled_paths, true ); + if ( $local_exists && $tracked && 'fail' === $conflict_policy && ! $allow_dirty ) { + $conflicts[] = array( 'path' => $path, 'reason' => 'local_modified' ); + continue; + } + if ( $local_exists && $tracked && 'manual' === $conflict_policy && ! $allow_dirty ) { + $conflicts[] = array( 'path' => $path, 'reason' => 'local_modified_manual' ); + continue; + } + // upstream_wins or allow_dirty or untracked: fall through and overwrite. + + $content = $this->fetchBlobContent( $slug, $path, $binding->branch, $pat ); + if ( is_wp_error( $content ) ) { + return $content; + } + + $parent = dirname( $dest ); + if ( ! is_dir( $parent ) && ! wp_mkdir_p( $parent ) ) { + return new \WP_Error( 'mkdir_failed', sprintf( 'Could not create %s.', $parent ), array( 'status' => 500 ) ); + } + + $bytes = file_put_contents( $dest, $content ); + if ( false === $bytes ) { + return new \WP_Error( 'write_failed', sprintf( 'Could not write %s.', $dest ), array( 'status' => 500 ) ); + } + + $updated[] = $path; + } + + // Delete files that were pulled previously but are no longer upstream. + // Untracked local files (never pulled) are left alone — they're + // assumed consumer-owned proposals. + foreach ( $binding->pulled_paths as $path ) { + if ( array_key_exists( $path, $upstream ) ) { + continue; + } + $victim = $absolute . '/' . $path; + if ( is_file( $victim ) ) { + unlink( $victim ); + } + $deleted[] = $path; + } + + // Rebuild pulled_paths from the upstream we just saw. Anything in + // conflicts stays in the list — we didn't touch those files, so + // they remain "tracked" for the next pull's conflict check. + $new_pulled = array_keys( $upstream ); + foreach ( $conflicts as $conflict ) { + if ( ! in_array( $conflict['path'], $new_pulled, true ) ) { + $new_pulled[] = $conflict['path']; + } + } + + $binding->pulled_paths = array_values( $new_pulled ); + $binding->last_pulled = gmdate( 'c' ); + $binding->last_commit = $tree_sha ?: $binding->last_commit; + $this->registry->save( $binding ); + + return array( + 'success' => empty( $conflicts ) || 'manual' === $conflict_policy, + 'slug' => $binding->slug, + 'branch' => $binding->branch, + 'tree_sha' => $tree_sha, + 'updated' => $updated, + 'unchanged' => count( $unchanged ), + 'deleted' => $deleted, + 'conflicts' => $conflicts, + 'truncated' => $truncated, + 'last_pulled' => $binding->last_pulled, + ); + } + + /** + * Fetch a single blob's content via the Contents API. + * + * Returns raw decoded file contents (not the normalize envelope) since + * callers want to write bytes to disk directly. + * + * @return string|\WP_Error + */ + private function fetchBlobContent( string $slug, string $path, string $branch, string $pat ): string|\WP_Error { + $response = GitHubAbilities::apiGet( + GitHubRemote::apiUrl( $slug, 'contents/' . ltrim( $path, '/' ) ), + array( 'ref' => $branch ), + $pat + ); + if ( is_wp_error( $response ) ) { + return $response; + } + + $data = is_array( $response['data'] ?? null ) ? $response['data'] : array(); + + // The Contents API truncates file payloads > 1MB — when that + // happens the response omits `content` and the caller must fall + // back to the Git Data API (blobs endpoint). Do that transparently. + if ( empty( $data['content'] ) && ! empty( $data['sha'] ) ) { + return $this->fetchLargeBlob( $slug, (string) $data['sha'], $pat ); + } + + $encoding = (string) ( $data['encoding'] ?? 'base64' ); + if ( 'base64' !== $encoding ) { + return new \WP_Error( + 'unexpected_encoding', + sprintf( 'Unexpected encoding "%s" for %s.', $encoding, $path ), + array( 'status' => 500 ) + ); + } + + $decoded = base64_decode( (string) $data['content'], true ); + if ( false === $decoded ) { + return new \WP_Error( 'base64_decode_failed', sprintf( 'Could not decode base64 content for %s.', $path ), array( 'status' => 500 ) ); + } + + return $decoded; + } + + /** + * Fetch a blob by its SHA via the Git Data API. + * + * Used as a fallback for files larger than the Contents API's 1MB + * response cap. + * + * @return string|\WP_Error Raw decoded blob content. + */ + private function fetchLargeBlob( string $slug, string $sha, string $pat ): string|\WP_Error { + $response = GitHubAbilities::apiGet( + GitHubRemote::apiUrl( $slug, 'git/blobs/' . rawurlencode( $sha ) ), + array(), + $pat + ); + if ( is_wp_error( $response ) ) { + return $response; + } + $data = is_array( $response['data'] ?? null ) ? $response['data'] : array(); + $decoded = base64_decode( (string) ( $data['content'] ?? '' ), true ); + if ( false === $decoded ) { + return new \WP_Error( 'base64_decode_failed', sprintf( 'Could not decode large blob %s.', $sha ), array( 'status' => 500 ) ); + } + return $decoded; + } + +} diff --git a/inc/GitSync/GitSyncProposer.php b/inc/GitSync/GitSyncProposer.php new file mode 100644 index 0000000..8c400fb --- /dev/null +++ b/inc/GitSync/GitSyncProposer.php @@ -0,0 +1,620 @@ +<?php +/** + * GitSync Proposer + * + * Pushes local changes upstream via the GitHub Contents + Git Data APIs. + * Two modes: + * + * submit() — writes changes to the binding's sticky proposal branch + * (`gitsync/<slug>`) and opens or updates a PR against the + * pinned branch. Default flow for content sync. + * + * push() — writes changes directly to the pinned branch (no PR). + * Two-key auth required: policy.write_enabled AND + * policy.safe_direct_push must both be true. Meant for + * personal-wiki / single-owner scenarios. + * + * No git binary involved. Every operation is `wp_remote_request` + + * `file_get_contents` on local files, so this works identically on + * WordPress.com, VPS, or laptop. + * + * Per-file commits — one `createOrUpdateFile` per changed file means N + * commits per submit. PR reviewers still see the aggregate diff; the + * extra commits are tolerable. Optimization path (single tree + commit + * via the Git Data API) noted for a future pass. + * + * @package DataMachineCode\GitSync + * @since 0.8.0 + */ + +namespace DataMachineCode\GitSync; + +use DataMachineCode\Abilities\GitHubAbilities; +use DataMachineCode\Support\GitHubRemote; +use DataMachineCode\Support\PathSecurity; + +defined( 'ABSPATH' ) || exit; + +final class GitSyncProposer { + + public const BRANCH_PREFIX = 'gitsync/'; + + private GitSyncRegistry $registry; + + public function __construct( GitSyncRegistry $registry ) { + $this->registry = $registry; + } + + /** + * Submit local edits as a PR on the sticky proposal branch. + * + * @param GitSyncBinding $binding + * @param array<string, mixed> $args { + * @type string $message Commit-message base (also used as PR title default). + * @type string[] $paths Optional explicit path list. If omitted, + * every changed file under allowed_paths + * is included. + * @type string $title PR title. Defaults to $message. + * @type string $body PR body. Defaults to an auto-summary. + * } + * @return array<string, mixed>|\WP_Error + */ + public function submit( GitSyncBinding $binding, array $args ): array|\WP_Error { + $gate = $this->commonGates( $binding ); + if ( is_wp_error( $gate ) ) { + return $gate; + } + + $message = trim( (string) ( $args['message'] ?? '' ) ); + $message_err = $this->validateMessage( $message ); + if ( is_wp_error( $message_err ) ) { + return $message_err; + } + + $ctx = $this->buildContext( $binding ); + if ( is_wp_error( $ctx ) ) { + return $ctx; + } + + $changes = $this->resolveChanges( $binding, $ctx, $args ); + if ( is_wp_error( $changes ) ) { + return $changes; + } + if ( empty( $changes ) ) { + return new \WP_Error( + 'nothing_to_submit', + sprintf( 'Binding "%s" has no changes under its allowed_paths.', $binding->slug ), + array( 'status' => 400 ) + ); + } + + $feature_branch = self::BRANCH_PREFIX . $binding->slug; + + // Ensure the feature branch exists and points at the current + // pinned-branch HEAD. If it already existed (previous submit), + // force it back to upstream so per-file SHAs line up and the PR + // diff stays focused on this submit's changes. + $reset = $this->ensureFeatureBranchAtBase( $ctx['slug'], $feature_branch, $ctx['base_sha'], $ctx['pat'] ); + if ( is_wp_error( $reset ) ) { + return $reset; + } + + // When we just reset the feature branch to base, every file's SHA + // on the feature branch matches its SHA on base. We already have + // those SHAs in $ctx['upstream']. + $commits = array(); + foreach ( $changes as $change ) { + $result = $this->putFile( + $ctx['slug'], + $change['path'], + $change['content'], + sprintf( '%s: %s', $message, $change['path'] ), + $feature_branch, + $ctx['upstream'][ $change['path'] ] ?? null, + $ctx['pat'] + ); + if ( is_wp_error( $result ) ) { + return new \WP_Error( + 'upload_failed', + sprintf( 'Commit of %s to %s failed: %s', $change['path'], $feature_branch, $result->get_error_message() ), + array( 'status' => 502, 'path' => $change['path'], 'branch' => $feature_branch ) + ); + } + $commits[] = array( + 'path' => $change['path'], + 'commit' => (string) ( $result['commit']['sha'] ?? '' ), + ); + } + + $pr = $this->openOrUpdatePullRequest( $ctx['slug'], $binding, $feature_branch, $message, $args, array_column( $changes, 'path' ), $ctx['pat'] ); + if ( is_wp_error( $pr ) ) { + return new \WP_Error( + 'pr_failed', + sprintf( 'Branch "%s" updated, but PR open/update failed: %s', $feature_branch, $pr->get_error_message() ), + array( 'status' => 502, 'branch' => $feature_branch, 'commits' => $commits, 'pr_error' => $pr->get_error_code() ) + ); + } + + $binding->last_commit = end( $commits )['commit'] ?? $binding->last_commit; + $this->registry->save( $binding ); + + return array( + 'success' => true, + 'slug' => $binding->slug, + 'branch' => $feature_branch, + 'commits' => $commits, + 'pr' => $pr, + 'message' => sprintf( 'Proposed %d file(s) on "%s" via PR #%d.', count( $commits ), $binding->slug, (int) ( $pr['number'] ?? 0 ) ), + ); + } + + /** + * Commit local changes directly to the pinned branch — no PR. + * + * Requires both policy.write_enabled and policy.safe_direct_push. + * The second key is intentional friction: bindings default to the + * PR flow, and direct-to-pinned push should require a deliberate + * second opt-in. + * + * @param GitSyncBinding $binding + * @param array<string, mixed> $args { + * @type string $message Commit message base. + * @type string[] $paths Optional explicit path list. + * } + * @return array<string, mixed>|\WP_Error + */ + public function push( GitSyncBinding $binding, array $args ): array|\WP_Error { + $gate = $this->commonGates( $binding ); + if ( is_wp_error( $gate ) ) { + return $gate; + } + if ( empty( $binding->policy['safe_direct_push'] ) ) { + return new \WP_Error( + 'direct_push_blocked', + sprintf( + 'Direct push to the pinned branch is blocked for binding "%s". Set policy.safe_direct_push=true, or use submit() to open a PR instead.', + $binding->slug + ), + array( 'status' => 403 ) + ); + } + + $message = trim( (string) ( $args['message'] ?? '' ) ); + $message_err = $this->validateMessage( $message ); + if ( is_wp_error( $message_err ) ) { + return $message_err; + } + + $ctx = $this->buildContext( $binding ); + if ( is_wp_error( $ctx ) ) { + return $ctx; + } + + $changes = $this->resolveChanges( $binding, $ctx, $args ); + if ( is_wp_error( $changes ) ) { + return $changes; + } + if ( empty( $changes ) ) { + return new \WP_Error( 'nothing_to_push', sprintf( 'Binding "%s" has no changes under its allowed_paths.', $binding->slug ), array( 'status' => 400 ) ); + } + + $commits = array(); + foreach ( $changes as $change ) { + $result = $this->putFile( + $ctx['slug'], + $change['path'], + $change['content'], + sprintf( '%s: %s', $message, $change['path'] ), + $binding->branch, + $ctx['upstream'][ $change['path'] ] ?? null, + $ctx['pat'] + ); + if ( is_wp_error( $result ) ) { + return new \WP_Error( + 'upload_failed', + sprintf( 'Commit of %s to %s failed: %s', $change['path'], $binding->branch, $result->get_error_message() ), + array( 'status' => 502, 'path' => $change['path'], 'branch' => $binding->branch ) + ); + } + $commits[] = array( + 'path' => $change['path'], + 'commit' => (string) ( $result['commit']['sha'] ?? '' ), + ); + } + + $binding->last_commit = end( $commits )['commit'] ?? $binding->last_commit; + $this->registry->save( $binding ); + + return array( + 'success' => true, + 'slug' => $binding->slug, + 'branch' => $binding->branch, + 'commits' => $commits, + 'message' => sprintf( 'Pushed %d file(s) directly to "%s" on "%s".', count( $commits ), $binding->branch, $binding->slug ), + ); + } + + // ========================================================================= + // Internal helpers + // ========================================================================= + + private function commonGates( GitSyncBinding $binding ): true|\WP_Error { + if ( empty( $binding->policy['write_enabled'] ) ) { + return new \WP_Error( + 'write_disabled', + sprintf( 'Writes are disabled for binding "%s" (policy.write_enabled=false).', $binding->slug ), + array( 'status' => 403 ) + ); + } + $allowed = is_array( $binding->policy['allowed_paths'] ?? null ) ? $binding->policy['allowed_paths'] : array(); + if ( empty( $allowed ) ) { + return new \WP_Error( + 'no_allowed_paths', + sprintf( 'Binding "%s" has no allowed_paths — nothing can be uploaded.', $binding->slug ), + array( 'status' => 403 ) + ); + } + if ( ! GitHubRemote::isGitHubRemote( $binding->remote_url ) ) { + return new \WP_Error( + 'unsupported_remote', + sprintf( 'GitSync requires a github.com remote (got %s).', $binding->remote_url ), + array( 'status' => 400 ) + ); + } + return true; + } + + private function validateMessage( string $message ): true|\WP_Error { + if ( '' === $message ) { + return new \WP_Error( 'missing_message', 'Commit/PR message is required.', array( 'status' => 400 ) ); + } + if ( strlen( $message ) < 8 ) { + return new \WP_Error( 'message_too_short', 'Message must be at least 8 characters.', array( 'status' => 400 ) ); + } + if ( strlen( $message ) > 200 ) { + return new \WP_Error( 'message_too_long', 'Message must be 200 characters or fewer.', array( 'status' => 400 ) ); + } + return true; + } + + /** + * Assemble the context every write path needs up front: + * - resolved absolute path under ABSPATH + * - GitHub PAT + * - owner/repo slug + * - pinned-branch HEAD SHA + * - upstream tree (path → blob sha) for diffing against local disk + * + * @return array{absolute:string,pat:string,slug:string,base_sha:string,upstream:array<string,string>}|\WP_Error + */ + private function buildContext( GitSyncBinding $binding ): array|\WP_Error { + $slug = GitHubRemote::slug( $binding->remote_url ); + if ( null === $slug ) { + return new \WP_Error( 'unparseable_remote', sprintf( 'Cannot parse GitHub owner/repo from %s.', $binding->remote_url ), array( 'status' => 400 ) ); + } + + $pat = (string) GitHubAbilities::getPat(); + if ( '' === $pat ) { + return new \WP_Error( 'missing_pat', 'GitHub PAT not configured — cannot write.', array( 'status' => 500 ) ); + } + + $absolute = $binding->resolveAbsolutePath(); + if ( ! is_dir( $absolute ) ) { + return new \WP_Error( 'missing_working_dir', sprintf( 'Local directory %s does not exist. Pull first.', $absolute ), array( 'status' => 404 ) ); + } + + $ref = GitHubAbilities::apiGet( + GitHubRemote::apiUrl( $slug, 'git/ref/heads/' . rawurlencode( $binding->branch ) ), + array(), + $pat + ); + if ( is_wp_error( $ref ) ) { + return $ref; + } + $base_sha = (string) ( $ref['data']['object']['sha'] ?? '' ); + if ( '' === $base_sha ) { + return new \WP_Error( 'missing_base_sha', sprintf( 'Could not resolve SHA for branch "%s".', $binding->branch ), array( 'status' => 502 ) ); + } + + $tree = GitHubRemote::fetchTree( $slug, $binding->branch, $pat ); + if ( is_wp_error( $tree ) ) { + return $tree; + } + $upstream = $tree['blobs']; + + return array( + 'absolute' => $absolute, + 'pat' => $pat, + 'slug' => $slug, + 'base_sha' => $base_sha, + 'upstream' => $upstream, + ); + } + + /** + * Determine which files to upload. + * + * Explicit `$args['paths']` → validate + load each; otherwise scan the + * binding's local path recursively, filter to `allowed_paths`, keep + * only files whose git-blob SHA differs from upstream. + * + * @param array{absolute:string,upstream:array<string,string>} $ctx + * @return array<int,array{path:string,content:string}>|\WP_Error + */ + private function resolveChanges( GitSyncBinding $binding, array $ctx, array $args ): array|\WP_Error { + $allowed = is_array( $binding->policy['allowed_paths'] ?? null ) ? $binding->policy['allowed_paths'] : array(); + + $explicit = isset( $args['paths'] ) && is_array( $args['paths'] ) ? $args['paths'] : null; + if ( null !== $explicit ) { + $changes = array(); + foreach ( $explicit as $raw ) { + $rel = ltrim( trim( (string) $raw ), '/' ); + if ( '' === $rel ) { + continue; + } + if ( PathSecurity::hasTraversal( $rel ) ) { + return new \WP_Error( 'path_traversal', sprintf( 'Invalid path: %s', $rel ), array( 'status' => 400 ) ); + } + if ( PathSecurity::isSensitivePath( $rel ) ) { + return new \WP_Error( 'sensitive_path', sprintf( 'Refusing to upload sensitive path: %s', $rel ), array( 'status' => 403 ) ); + } + if ( ! PathSecurity::isPathAllowed( $rel, $allowed ) ) { + return new \WP_Error( 'path_not_allowed', sprintf( 'Path "%s" is outside allowed_paths.', $rel ), array( 'status' => 403 ) ); + } + $content = $this->readLocal( $ctx['absolute'], $rel ); + if ( is_wp_error( $content ) ) { + return $content; + } + if ( GitHubRemote::blobSha( $content ) === ( $ctx['upstream'][ $rel ] ?? null ) ) { + // No diff against upstream; skip this explicitly-requested file. + continue; + } + $changes[] = array( 'path' => $rel, 'content' => $content ); + } + return $changes; + } + + // Derived mode: scan the local dir, filter by allowed_paths + sensitive, + // pick files whose SHA differs from upstream. + $changes = array(); + foreach ( $this->iterateLocalFiles( $ctx['absolute'] ) as $rel ) { + if ( PathSecurity::hasTraversal( $rel ) || PathSecurity::isSensitivePath( $rel ) ) { + continue; + } + if ( ! PathSecurity::isPathAllowed( $rel, $allowed ) ) { + continue; + } + $content = $this->readLocal( $ctx['absolute'], $rel ); + if ( is_wp_error( $content ) ) { + continue; + } + $local_sha = GitHubRemote::blobSha( $content ); + if ( $local_sha === ( $ctx['upstream'][ $rel ] ?? null ) ) { + continue; + } + $changes[] = array( 'path' => $rel, 'content' => $content ); + } + return $changes; + } + + private function readLocal( string $absolute, string $rel ): string|\WP_Error { + $full = $absolute . '/' . $rel; + if ( ! is_file( $full ) ) { + return new \WP_Error( 'missing_file', sprintf( 'Local file %s does not exist.', $full ), array( 'status' => 404 ) ); + } + $content = file_get_contents( $full ); + if ( false === $content ) { + return new \WP_Error( 'read_failed', sprintf( 'Could not read %s.', $full ), array( 'status' => 500 ) ); + } + return $content; + } + + /** + * Iterate every file under $absolute recursively, yielding paths + * relative to $absolute with forward slashes. + * + * @return iterable<string> + */ + private function iterateLocalFiles( string $absolute ): iterable { + if ( ! is_dir( $absolute ) ) { + return array(); + } + $rii = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $absolute, \RecursiveDirectoryIterator::SKIP_DOTS ) ); + foreach ( $rii as $file ) { + if ( ! $file->isFile() ) { + continue; + } + $path = str_replace( '\\', '/', $file->getPathname() ); + $rel = ltrim( substr( $path, strlen( $absolute ) ), '/' ); + if ( '' === $rel ) { + continue; + } + yield $rel; + } + } + + /** + * Ensure the sticky feature branch exists and points at $base_sha. + * + * - Branch missing → create it via POST git/refs. + * - Branch exists at other SHA → force-update via PATCH git/refs/:ref. + * - Branch already at $base_sha → no-op. + */ + private function ensureFeatureBranchAtBase( string $slug, string $feature_branch, string $base_sha, string $pat ): true|\WP_Error { + $ref = GitHubAbilities::apiGet( + GitHubRemote::apiUrl( $slug, 'git/ref/heads/' . rawurlencode( $feature_branch ) ), + array(), + $pat + ); + + if ( is_wp_error( $ref ) ) { + // Not-found → create it fresh. + if ( 'github_not_found' === $ref->get_error_code() ) { + $created = GitHubAbilities::apiRequest( + 'POST', + GitHubRemote::apiUrl( $slug, 'git/refs' ), + array( + 'ref' => 'refs/heads/' . $feature_branch, + 'sha' => $base_sha, + ), + $pat + ); + if ( is_wp_error( $created ) ) { + return $created; + } + return true; + } + return $ref; + } + + $current = (string) ( $ref['data']['object']['sha'] ?? '' ); + if ( $current === $base_sha ) { + return true; + } + + $patched = GitHubAbilities::apiRequest( + 'PATCH', + GitHubRemote::apiUrl( $slug, 'git/refs/heads/' . rawurlencode( $feature_branch ) ), + array( + 'sha' => $base_sha, + 'force' => true, + ), + $pat + ); + if ( is_wp_error( $patched ) ) { + return $patched; + } + return true; + } + + /** + * PUT a single file's contents via the Contents API, creating or + * updating as appropriate. Returns the API response body. + */ + private function putFile( string $slug, string $path, string $content, string $message, string $branch, ?string $existing_sha, string $pat ): array|\WP_Error { + $body = array( + 'message' => $message, + 'content' => base64_encode( $content ), + 'branch' => $branch, + ); + if ( null !== $existing_sha ) { + $body['sha'] = $existing_sha; + } + + $response = GitHubAbilities::apiRequest( + 'PUT', + GitHubRemote::apiUrl( $slug, 'contents/' . ltrim( $path, '/' ) ), + $body, + $pat + ); + if ( is_wp_error( $response ) ) { + return $response; + } + return is_array( $response['data'] ?? null ) ? $response['data'] : array(); + } + + /** + * Open a new PR or update the existing one on the feature branch. + * + * @param string[] $changed_paths Paths that went into the submit (for + * the default PR body summary). + */ + private function openOrUpdatePullRequest( + string $slug, + GitSyncBinding $binding, + string $feature_branch, + string $commit_message, + array $args, + array $changed_paths, + string $pat + ): array|\WP_Error { + $owner = explode( '/', $slug )[0]; + $head = $owner . ':' . $feature_branch; + $title = trim( (string) ( $args['title'] ?? '' ) ); + if ( '' === $title ) { + $title = $commit_message; + } + $body = trim( (string) ( $args['body'] ?? '' ) ); + if ( '' === $body ) { + $body = $this->buildDefaultBody( $binding, $commit_message, $changed_paths ); + } + + $existing = GitHubAbilities::apiGet( + GitHubRemote::apiUrl( $slug, 'pulls' ), + array( + 'head' => $head, + 'state' => 'open', + 'per_page' => 5, + ), + $pat + ); + if ( is_wp_error( $existing ) ) { + return $existing; + } + $existing_list = is_array( $existing['data'] ?? null ) ? $existing['data'] : array(); + $existing_pr = ! empty( $existing_list ) ? $existing_list[0] : null; + + if ( null !== $existing_pr ) { + $patched = GitHubAbilities::apiRequest( + 'PATCH', + GitHubRemote::apiUrl( $slug, 'pulls/' . (int) $existing_pr['number'] ), + array( 'title' => $title, 'body' => $body ), + $pat + ); + if ( is_wp_error( $patched ) ) { + return $patched; + } + $data = is_array( $patched['data'] ?? null ) ? $patched['data'] : array(); + return array( + 'number' => (int) ( $data['number'] ?? $existing_pr['number'] ), + 'html_url' => (string) ( $data['html_url'] ?? $existing_pr['html_url'] ), + 'state' => (string) ( $data['state'] ?? 'open' ), + 'action' => 'updated', + ); + } + + $created = GitHubAbilities::apiRequest( + 'POST', + GitHubRemote::apiUrl( $slug, 'pulls' ), + array( + 'title' => $title, + 'body' => $body, + 'head' => $feature_branch, + 'base' => $binding->branch, + ), + $pat + ); + if ( is_wp_error( $created ) ) { + return $created; + } + $data = is_array( $created['data'] ?? null ) ? $created['data'] : array(); + return array( + 'number' => (int) ( $data['number'] ?? 0 ), + 'html_url' => (string) ( $data['html_url'] ?? '' ), + 'state' => (string) ( $data['state'] ?? 'open' ), + 'action' => 'opened', + ); + } + + private function buildDefaultBody( GitSyncBinding $binding, string $message, array $paths ): string { + $files = ''; + foreach ( array_slice( $paths, 0, 25 ) as $rel ) { + $files .= "- `{$rel}`\n"; + } + if ( count( $paths ) > 25 ) { + $files .= sprintf( "- …and %d more\n", count( $paths ) - 25 ); + } + return <<<BODY +Proposed by GitSync binding **{$binding->slug}** from local edits. + +## Commit +{$message} + +## Files +{$files} +--- +*Opened via `datamachine/gitsync-submit`. Re-running submit updates this PR in place.* +BODY; + } + +} diff --git a/inc/GitSync/GitSyncSubmitter.php b/inc/GitSync/GitSyncSubmitter.php deleted file mode 100644 index 22c8a0a..0000000 --- a/inc/GitSync/GitSyncSubmitter.php +++ /dev/null @@ -1,545 +0,0 @@ -<?php -/** - * GitSync Submitter - * - * Orchestrates the submit() flow — the blessed path for sending local - * edits upstream as a pull request. Keeps submit logic out of GitSync - * proper so GitSync stays a clean CRUD surface over bindings. - * - * Model: **sticky proposal branch.** Each binding gets exactly one - * feature branch on the remote (`gitsync/<slug>`). Every submit - * rebases that branch onto fresh upstream, applies the user's edits, - * force-pushes with lease, and opens or updates a single PR. The - * branch represents "the latest proposal from this binding" — it - * doesn't accumulate stale per-submit branches. - * - * Algorithm: - * 1. Fetch origin so upstream and gitsync/<slug> refs are current. - * 2. Stash any dirty/staged changes on the pinned branch. - * 3. Hard-reset the pinned branch to origin/<pinned> so local state - * matches upstream exactly. - * 4. Create (or reset) local gitsync/<slug> at origin/<pinned>. - * 5. Check out gitsync/<slug>, pop the stash to restore edits. - * 6. Stage the requested paths (or the full allowed subset if none - * specified) and commit. - * 7. Push gitsync/<slug> to origin with --force-with-lease. - * 8. Open or update the PR via GitHubAbilities. - * 9. Switch back to the pinned branch and reset it to upstream so - * the working tree is clean and idempotent across runs. - * - * Errors at any step trigger best-effort cleanup — we always try to - * return the working tree to the pinned branch so a failed submit - * doesn't leave the binding in a half-migrated state. - * - * Phase 2 only supports github.com remotes for submit (PR backend is - * GitHubAbilities). Non-GitHub remotes error with a clear message; - * a pluggable PR backend is Phase 3+. - * - * @package DataMachineCode\GitSync - * @since 0.8.0 - * @see https://github.com/Extra-Chill/data-machine-code/issues/38 - */ - -namespace DataMachineCode\GitSync; - -use DataMachineCode\Support\GitHubRemote; -use DataMachineCode\Support\GitRunner; -use DataMachineCode\Support\PathSecurity; - -defined( 'ABSPATH' ) || exit; - -final class GitSyncSubmitter { - - public const BRANCH_PREFIX = 'gitsync/'; - - private GitSyncRegistry $registry; - - public function __construct( GitSyncRegistry $registry ) { - $this->registry = $registry; - } - - /** - * Submit local edits as a PR on the sticky proposal branch. - * - * @param GitSyncBinding $binding Binding to submit from. - * @param array<string, mixed> $args { - * @type string $message Commit message (required). - * @type string[] $paths Relative paths to stage. If omitted, - * stage every dirty file that sits under - * the binding's allowed_paths roots. - * @type string $title PR title. Defaults to the commit subject. - * @type string $body PR body. Defaults to a short note. - * } - * @return array{success: true, slug: string, branch: string, pr: array<string, mixed>, commit: ?string, message: string}|\WP_Error - */ - public function submit( GitSyncBinding $binding, array $args ): array|\WP_Error { - $gate = $this->checkGates( $binding ); - if ( is_wp_error( $gate ) ) { - return $gate; - } - - $absolute = $binding->resolveAbsolutePath(); - if ( ! is_dir( $absolute . '/.git' ) && ! is_file( $absolute . '/.git' ) ) { - return new \WP_Error( 'not_a_repo', sprintf( 'Binding "%s" has no git working tree at %s.', $binding->slug, $absolute ), array( 'status' => 409 ) ); - } - - $message = trim( (string) ( $args['message'] ?? '' ) ); - if ( strlen( $message ) < 8 ) { - return new \WP_Error( 'message_too_short', 'Commit message must be at least 8 characters.', array( 'status' => 400 ) ); - } - if ( strlen( $message ) > 200 ) { - return new \WP_Error( 'message_too_long', 'Commit message must be 200 characters or fewer.', array( 'status' => 400 ) ); - } - - $feature_branch = self::BRANCH_PREFIX . $binding->slug; - $pinned_branch = $binding->branch; - - // Paths to stage: explicit list, or derived from the dirty set. - $paths = $this->resolveStagingPaths( $absolute, $binding, $args ); - if ( is_wp_error( $paths ) ) { - return $paths; - } - if ( empty( $paths ) ) { - return new \WP_Error( - 'nothing_to_submit', - sprintf( 'Binding "%s" has no changes under its allowed_paths.', $binding->slug ), - array( 'status' => 400 ) - ); - } - - // --- Begin orchestration. Everything from here until the final - // restore runs inside a try-finally-shaped block so partial - // failures always attempt to leave the working tree clean. - $state = array( - 'stashed' => false, - 'switched_to_feat' => false, - ); - - $result = $this->runOrchestration( $absolute, $binding, $feature_branch, $pinned_branch, $paths, $message, $args, $state ); - - // Best-effort restore: always return to the pinned branch, aligned - // with upstream, working tree clean. - $this->restorePinned( $absolute, $pinned_branch, $state ); - - if ( is_wp_error( $result ) ) { - return $result; - } - - $binding->last_commit = $result['commit'] ?? null; - $this->registry->save( $binding ); - - return $result; - } - - /** - * Check submit-time policy gates. - * - * Separate from inline checks so tests can exercise each gate cleanly. - * - * @return true|\WP_Error - */ - private function checkGates( GitSyncBinding $binding ): true|\WP_Error { - if ( empty( $binding->policy['write_enabled'] ) ) { - return new \WP_Error( 'write_disabled', sprintf( 'Writes are disabled for binding "%s".', $binding->slug ), array( 'status' => 403 ) ); - } - if ( empty( $binding->policy['push_enabled'] ) ) { - return new \WP_Error( 'push_disabled', sprintf( 'Pushes are disabled for binding "%s".', $binding->slug ), array( 'status' => 403 ) ); - } - - $allowed = is_array( $binding->policy['allowed_paths'] ?? null ) ? $binding->policy['allowed_paths'] : array(); - if ( empty( $allowed ) ) { - return new \WP_Error( 'no_allowed_paths', sprintf( 'Binding "%s" has no allowed_paths — nothing can be submitted.', $binding->slug ), array( 'status' => 403 ) ); - } - - if ( ! GitHubRemote::isGitHubRemote( $binding->remote_url ) ) { - return new \WP_Error( - 'unsupported_remote', - sprintf( 'submit() requires a github.com remote (got %s). Non-GitHub backends are Phase 3+.', $binding->remote_url ), - array( 'status' => 400 ) - ); - } - - return true; - } - - /** - * The sticky-branch orchestration, factored out so submit() can run - * restorePinned() as a finally even when steps mid-flow fail. - * - * @param array<string, bool> $state By-ref state flags updated so the - * restore path knows what to undo. - * @return array<string, mixed>|\WP_Error - */ - private function runOrchestration( - string $absolute, - GitSyncBinding $binding, - string $feature_branch, - string $pinned_branch, - array $paths, - string $message, - array $args, - array &$state - ): array|\WP_Error { - $pat = $this->resolvePat(); - $push_url = GitHubRemote::pushUrlWithPat( $binding->remote_url, $pat ); - - // 1. Fetch so origin refs are current (pinned branch + feature branch). - $fetch = GitRunner::run( $absolute, 'fetch origin --prune' ); - if ( is_wp_error( $fetch ) ) { - return $fetch; - } - - // 2. Ensure we're on the pinned branch before stashing, so a later - // stash-pop lands the changes on the feature branch cleanly. - $current = GitRepo::branch( $absolute ); - if ( null !== $current && $current !== $pinned_branch ) { - $checkout = GitRunner::run( $absolute, 'checkout ' . escapeshellarg( $pinned_branch ) ); - if ( is_wp_error( $checkout ) ) { - return $checkout; - } - } - - // 3. Stash all changes (including untracked) so reset --hard doesn't - // nuke them. Skip the stash when the working tree is already clean. - $dirty = GitRepo::dirtyCount( $absolute ); - if ( $dirty > 0 ) { - $stash = GitRunner::run( $absolute, 'stash push --include-untracked -m ' . escapeshellarg( 'gitsync-submit-' . $binding->slug ) ); - if ( is_wp_error( $stash ) ) { - return $stash; - } - $state['stashed'] = true; - } - - // 4. Align local pinned branch with upstream. Fast-forward would be - // sufficient when clean, but reset --hard guarantees alignment even - // after we've stashed divergent local state. - $reset = GitRunner::run( $absolute, 'reset --hard ' . escapeshellarg( 'origin/' . $pinned_branch ) ); - if ( is_wp_error( $reset ) ) { - return $reset; - } - - // 5. Create (or reset) the feature branch from the freshly-aligned - // upstream tip. `checkout -B` replaces any stale local ref. - $feat = GitRunner::run( $absolute, 'checkout -B ' . escapeshellarg( $feature_branch ) ); - if ( is_wp_error( $feat ) ) { - return $feat; - } - $state['switched_to_feat'] = true; - - // 6. Re-apply the stashed edits onto the feature branch. If pop - // fails (conflict against upstream move), abort and surface. - if ( $state['stashed'] ) { - $pop = GitRunner::run( $absolute, 'stash pop' ); - if ( is_wp_error( $pop ) ) { - // Leave the stash in place so the user can recover manually. - return new \WP_Error( - 'stash_conflict', - 'Upstream moved in a way that conflicts with local edits. Your changes are preserved in the stash — resolve manually and retry.', - array( 'status' => 409, 'detail' => $pop->get_error_message() ) - ); - } - $state['stashed'] = false; - } - - // 7. Stage the requested paths. - foreach ( $paths as $rel ) { - $stage = GitRunner::run( $absolute, 'add -- ' . escapeshellarg( $rel ) ); - if ( is_wp_error( $stage ) ) { - return $stage; - } - } - - $staged = GitRunner::run( $absolute, 'diff --cached --name-only' ); - if ( is_wp_error( $staged ) ) { - return $staged; - } - $staged_lines = array_filter( array_map( 'trim', explode( "\n", (string) ( $staged['output'] ?? '' ) ) ) ); - if ( empty( $staged_lines ) ) { - return new \WP_Error( 'nothing_staged', 'After staging, no diff detected. Nothing to submit.', array( 'status' => 400 ) ); - } - - // 8. Commit. - $commit = GitRunner::run( $absolute, 'commit -m ' . escapeshellarg( $message ) ); - if ( is_wp_error( $commit ) ) { - return $commit; - } - $head = GitRepo::head( $absolute ); - - // 9. Push the feature branch with --force. We own gitsync/<slug> - // exclusively (no other writer touches it by convention), so a - // plain force is safe and matches the sticky-proposal-branch - // model. --force-with-lease would be belt-and-suspenders but it - // can't compute a lease when pushing to a URL rather than a - // named remote, which produces a "stale info" refusal. - $push_cmd = sprintf( - 'push --force %s %s', - escapeshellarg( $push_url ), - escapeshellarg( $feature_branch . ':' . $feature_branch ) - ); - $push = GitRunner::run( $absolute, $push_cmd ); - if ( is_wp_error( $push ) ) { - return $push; - } - - // 10. Open or update the PR. PR failure is surfaced but doesn't undo - // the push — the branch is upstream, the PR can be opened manually. - $pr = $this->openOrUpdatePullRequest( $binding, $feature_branch, $pinned_branch, $message, $args, $staged_lines, $pat ); - if ( is_wp_error( $pr ) ) { - return new \WP_Error( - 'pr_failed', - sprintf( - 'Branch "%s" pushed, but PR open/update failed: %s', - $feature_branch, - $pr->get_error_message() - ), - array( - 'status' => 502, - 'branch' => $feature_branch, - 'head' => $head, - 'staged' => array_values( $staged_lines ), - 'pr_error' => $pr->get_error_code(), - ) - ); - } - - return array( - 'success' => true, - 'slug' => $binding->slug, - 'branch' => $feature_branch, - 'commit' => $head, - 'staged' => array_values( $staged_lines ), - 'pr' => $pr, - 'message' => sprintf( 'Submitted %d file(s) from "%s" via PR #%d.', count( $staged_lines ), $binding->slug, (int) ( $pr['number'] ?? 0 ) ), - ); - } - - /** - * Best-effort restore to the pinned branch after submit finishes or - * fails partway through. - */ - private function restorePinned( string $absolute, string $pinned_branch, array $state ): void { - if ( $state['switched_to_feat'] ) { - // Checkout the pinned branch. Ignore errors — we're already in - // cleanup mode and the user can recover manually if this fails. - GitRunner::run( $absolute, 'checkout ' . escapeshellarg( $pinned_branch ) ); - } - if ( $state['stashed'] ) { - // We never popped the stash — leave it in place so the user can - // decide what to do. A noisy log keeps them from losing track. - do_action( - 'datamachine_log', - 'warning', - sprintf( 'GitSync submit left a stash on binding working tree (%s). Recover via `git stash list`.', $absolute ), - array( 'context' => 'gitsync-submit' ) - ); - } - } - - /** - * Resolve the stage set for a submit call. - * - * If the caller passed an explicit `paths` array, validate each one. - * Otherwise, derive the stage set from the current dirty list filtered - * by the binding's allowed_paths — the "submit everything I've touched - * that the policy lets me touch" convenience. - * - * @param array<string, mixed> $args - * @return string[]|\WP_Error - */ - private function resolveStagingPaths( string $absolute, GitSyncBinding $binding, array $args ): array|\WP_Error { - $allowed = is_array( $binding->policy['allowed_paths'] ?? null ) ? $binding->policy['allowed_paths'] : array(); - - $explicit = isset( $args['paths'] ) && is_array( $args['paths'] ) ? $args['paths'] : null; - if ( null !== $explicit ) { - $clean = array(); - foreach ( $explicit as $raw ) { - $rel = ltrim( trim( (string) $raw ), '/' ); - if ( '' === $rel ) { - continue; - } - if ( PathSecurity::hasTraversal( $rel ) ) { - return new \WP_Error( 'path_traversal', sprintf( 'Invalid path: %s', $rel ), array( 'status' => 400 ) ); - } - if ( PathSecurity::isSensitivePath( $rel ) ) { - return new \WP_Error( 'sensitive_path', sprintf( 'Refusing to submit sensitive path: %s', $rel ), array( 'status' => 403 ) ); - } - if ( ! PathSecurity::isPathAllowed( $rel, $allowed ) ) { - return new \WP_Error( 'path_not_allowed', sprintf( 'Path "%s" is outside allowed_paths.', $rel ), array( 'status' => 403 ) ); - } - $clean[] = $rel; - } - return $clean; - } - - // Derive from git status --porcelain on the pinned branch. - $status = GitRunner::run( $absolute, 'status --porcelain' ); - if ( is_wp_error( $status ) ) { - return $status; - } - $lines = array_filter( array_map( 'trim', explode( "\n", (string) ( $status['output'] ?? '' ) ) ) ); - - $derived = array(); - foreach ( $lines as $line ) { - // Porcelain lines: `XY path[ -> newpath]`. Keep it simple — take - // the tail after the first whitespace, ignoring rename arrows. - $parts = preg_split( '/\s+/', $line, 2 ); - if ( count( $parts ) < 2 ) { - continue; - } - $rel = trim( $parts[1] ); - if ( str_contains( $rel, ' -> ' ) ) { - $rel = trim( explode( ' -> ', $rel, 2 )[1] ); - } - if ( '' === $rel ) { - continue; - } - - // Silently skip files outside the policy — this is the - // convenience path, not a validation path. - if ( PathSecurity::hasTraversal( $rel ) || PathSecurity::isSensitivePath( $rel ) ) { - continue; - } - if ( ! PathSecurity::isPathAllowed( $rel, $allowed ) ) { - continue; - } - $derived[] = $rel; - } - - return array_values( array_unique( $derived ) ); - } - - /** - * Open a PR on the feature branch, or update the existing one. - * - * Delegates every HTTP call to `GitHubAbilities::apiGet` / - * `apiRequest` so the auth headers, JSON decoding, and error - * envelope stay identical to the rest of DMC's GitHub surface. - * - * Phase 2 only targets github.com — caller has already gated. - * - * @param string[] $staged Relative paths we just committed. - * @return array<string, mixed>|\WP_Error - */ - private function openOrUpdatePullRequest( - GitSyncBinding $binding, - string $feature_branch, - string $pinned_branch, - string $commit_message, - array $args, - array $staged, - string $pat - ): array|\WP_Error { - if ( '' === $pat ) { - return new \WP_Error( 'missing_pat', 'GitHub PAT not configured — cannot open PR. Configure via data-machine-code settings.', array( 'status' => 500 ) ); - } - - $slug = GitHubRemote::slug( $binding->remote_url ); - if ( null === $slug ) { - return new \WP_Error( 'unparseable_remote', sprintf( 'Could not parse GitHub owner/repo from %s.', $binding->remote_url ), array( 'status' => 400 ) ); - } - - $owner = explode( '/', $slug )[0]; - $head = $owner . ':' . $feature_branch; - $title = trim( (string) ( $args['title'] ?? '' ) ); - if ( '' === $title ) { - $title = $commit_message; - } - $body = trim( (string) ( $args['body'] ?? '' ) ); - if ( '' === $body ) { - $body = $this->buildDefaultBody( $binding, $commit_message, $staged ); - } - - // Look up existing PR on this head. - $existing = \DataMachineCode\Abilities\GitHubAbilities::apiGet( - GitHubRemote::apiUrl( $slug, 'pulls' ), - array( - 'head' => $head, - 'state' => 'open', - 'per_page' => 5, - ), - $pat - ); - if ( is_wp_error( $existing ) ) { - return $existing; - } - $existing_pr = is_array( $existing['data'] ?? null ) && ! empty( $existing['data'] ) ? $existing['data'][0] : null; - - if ( null !== $existing_pr ) { - $patched = \DataMachineCode\Abilities\GitHubAbilities::apiRequest( - 'PATCH', - GitHubRemote::apiUrl( $slug, 'pulls/' . (int) $existing_pr['number'] ), - array( - 'title' => $title, - 'body' => $body, - ), - $pat - ); - if ( is_wp_error( $patched ) ) { - return $patched; - } - $patched_data = is_array( $patched['data'] ?? null ) ? $patched['data'] : array(); - return array( - 'number' => (int) ( $patched_data['number'] ?? $existing_pr['number'] ), - 'html_url' => (string) ( $patched_data['html_url'] ?? $existing_pr['html_url'] ), - 'state' => (string) ( $patched_data['state'] ?? 'open' ), - 'action' => 'updated', - ); - } - - $created = \DataMachineCode\Abilities\GitHubAbilities::apiRequest( - 'POST', - GitHubRemote::apiUrl( $slug, 'pulls' ), - array( - 'title' => $title, - 'body' => $body, - 'head' => $feature_branch, - 'base' => $pinned_branch, - ), - $pat - ); - if ( is_wp_error( $created ) ) { - return $created; - } - $created_data = is_array( $created['data'] ?? null ) ? $created['data'] : array(); - return array( - 'number' => (int) ( $created_data['number'] ?? 0 ), - 'html_url' => (string) ( $created_data['html_url'] ?? '' ), - 'state' => (string) ( $created_data['state'] ?? 'open' ), - 'action' => 'opened', - ); - } - - private function buildDefaultBody( GitSyncBinding $binding, string $commit_message, array $staged ): string { - $files = ''; - foreach ( array_slice( $staged, 0, 25 ) as $rel ) { - $files .= "- `{$rel}`\n"; - } - if ( count( $staged ) > 25 ) { - $files .= sprintf( "- …and %d more\n", count( $staged ) - 25 ); - } - return <<<BODY -Proposed by GitSync binding **{$binding->slug}** from local edits. - -## Commit -{$commit_message} - -## Files -{$files} ---- -*Opened via `datamachine/gitsync-submit`. Re-running submit updates this PR in place.* -BODY; - } - - /** - * Fetch the GitHub PAT from DMC's settings, or empty string if - * the GitHubAbilities class isn't loaded. The caller decides how - * to react to an empty PAT — push may still succeed via system - * credential.helper, but PR creation requires one. - */ - private function resolvePat(): string { - if ( ! class_exists( '\DataMachineCode\Abilities\GitHubAbilities' ) ) { - return ''; - } - return (string) \DataMachineCode\Abilities\GitHubAbilities::getPat(); - } -} diff --git a/inc/Support/GitHubRemote.php b/inc/Support/GitHubRemote.php index 1b4ec42..5415004 100644 --- a/inc/Support/GitHubRemote.php +++ b/inc/Support/GitHubRemote.php @@ -46,23 +46,82 @@ public static function slug( string $url ): ?string { } /** - * Rewrite an https://github.com/... URL to include a PAT for auth. + * Compute a git-blob SHA for a string of content. * - * Non-https URLs (git@ SSH, file://, non-GitHub) pass through untouched - * — SSH uses ssh-agent, file:// has no auth surface, and non-GitHub - * hosts should rely on system credential.helper. + * Git hashes blobs as `sha1("blob " + len + "\0" + content)`. Matching + * this lets us compare local files against the `sha` field GitHub's + * tree API returns without needing a git binary, so both read (Fetcher + * deciding which files to download) and write (Proposer deciding + * which files to upload) can short-circuit when SHAs already match. + */ + public static function blobSha( string $content ): string { + return sha1( 'blob ' . strlen( $content ) . "\0" . $content ); + } + + /** + * Fetch a branch's recursive tree and flatten it into a path → sha map. + * + * Pulls `GET /repos/:slug/git/trees/:branch?recursive=1`, skips + * non-blob entries and paths containing traversal segments, and + * returns the blob map plus metadata (raw tree SHA + truncation flag). + * + * Used by both Fetcher (pull) and Proposer (submit/push context) so + * the tree-fetch-and-flatten step has one implementation. * - * The PAT is rawurlencoded before injection so exotic token characters - * don't break URL parsing downstream. + * **Why this exists next to `GitHubAbilities::getRepoTree`.** That + * ability returns the normalized `{success, files, count}` shape + * designed for MCP / REST callers, and strips `tree_sha` + the + * `truncated` flag during normalization. GitSync needs both: the + * tree SHA seeds `binding->last_commit` so we can detect upstream + * drift between pulls, and the truncation flag drives a CLI warning + * when the repo is too large for a single-response tree. Keeping + * this helper alongside the ability (rather than widening the + * ability's output schema and churning a public contract) is the + * smaller blast radius. + * + * @return array{blobs: array<string, string>, tree_sha: string, truncated: bool}|\WP_Error */ - public static function pushUrlWithPat( string $url, string $pat ): string { - if ( '' === $pat ) { - return $url; + public static function fetchTree( string $slug, string $branch, string $pat ): array|\WP_Error { + if ( ! class_exists( '\DataMachineCode\Abilities\GitHubAbilities' ) ) { + return new \WP_Error( 'github_abilities_unavailable', 'GitHubAbilities class is not loaded.', array( 'status' => 500 ) ); + } + + $response = \DataMachineCode\Abilities\GitHubAbilities::apiGet( + self::apiUrl( $slug, 'git/trees/' . rawurlencode( $branch ) ), + array( 'recursive' => '1' ), + $pat + ); + if ( is_wp_error( $response ) ) { + return $response; } - if ( ! str_starts_with( $url, 'https://github.com/' ) ) { - return $url; + + $data = is_array( $response['data'] ?? null ) ? $response['data'] : array(); + $tree_sha = (string) ( $data['sha'] ?? '' ); + $truncated = ! empty( $data['truncated'] ); + $blobs = array(); + + foreach ( (array) ( $data['tree'] ?? array() ) as $entry ) { + if ( 'blob' !== ( $entry['type'] ?? '' ) ) { + continue; + } + $path = (string) ( $entry['path'] ?? '' ); + if ( '' === $path ) { + continue; + } + // Defense-in-depth: refuse traversal even though GitHub would + // never ship such a path. Keeping the filter here means both + // Fetcher and Proposer inherit it without having to remember. + if ( PathSecurity::hasTraversal( $path ) ) { + continue; + } + $blobs[ $path ] = (string) ( $entry['sha'] ?? '' ); } - return 'https://' . rawurlencode( $pat ) . '@' . substr( $url, strlen( 'https://' ) ); + + return array( + 'blobs' => $blobs, + 'tree_sha' => $tree_sha, + 'truncated' => $truncated, + ); } /** diff --git a/tests/smoke-gitsync-write.php b/tests/smoke-gitsync-write.php deleted file mode 100644 index 9ec3e67..0000000 --- a/tests/smoke-gitsync-write.php +++ /dev/null @@ -1,450 +0,0 @@ -<?php -/** - * Pure-PHP smoke test for GitSync Phase 2 — the write path. - * - * Exercises policy gates, add, commit, push, submit (sans GitHub call), - * and updatePolicy against a **local bare repo as the fake remote**. - * Zero network, zero credentials — everything runs under a temp - * directory that's torn down on exit. - * - * Run: php tests/smoke-gitsync-write.php - * - * The `submit` flow is exercised up to the `git push` step; the - * GitHubAbilities PR call is stubbed via the `pre_http_request` filter - * so we can assert request shape without hitting the network. - */ - -declare( strict_types=1 ); - -namespace { - - if ( ! defined( 'ABSPATH' ) ) { - $scratch = sys_get_temp_dir() . '/dmc-gitsync-write-' . getmypid(); - @mkdir( $scratch, 0755, true ); - define( 'ABSPATH', $scratch . '/' ); - } - - $GLOBALS['__dmc_options'] = array(); - $GLOBALS['__dmc_http_mock'] = array(); - $GLOBALS['__dmc_http_capture'] = array(); - - if ( ! class_exists( 'WP_Error' ) ) { - class WP_Error { - public string $code; - public string $message; - public array $data; - public function __construct( string $code = '', string $message = '', array $data = array() ) { - $this->code = $code; - $this->message = $message; - $this->data = $data; - } - public function get_error_code(): string { return $this->code; } - public function get_error_message(): string { return $this->message; } - } - } - - if ( ! function_exists( 'is_wp_error' ) ) { - function is_wp_error( $thing ): bool { return $thing instanceof \WP_Error; } - } - - if ( ! function_exists( 'wp_mkdir_p' ) ) { - function wp_mkdir_p( string $path ): bool { return is_dir( $path ) || mkdir( $path, 0755, true ); } - } - - if ( ! function_exists( 'get_option' ) ) { - function get_option( string $name, $default = false ) { - return $GLOBALS['__dmc_options'][ $name ] ?? $default; - } - } - - if ( ! function_exists( 'update_option' ) ) { - function update_option( string $name, $value, $autoload = null ): bool { - $GLOBALS['__dmc_options'][ $name ] = $value; - return true; - } - } - - if ( ! function_exists( 'wp_json_encode' ) ) { - function wp_json_encode( $data, int $options = 0 ) { return json_encode( $data, $options ); } - } - - if ( ! function_exists( 'add_query_arg' ) ) { - function add_query_arg( array $args, string $url ): string { - $qs = http_build_query( $args ); - return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . $qs; - } - } - - // wp_remote_request stub — routes through the in-memory mock table so - // tests can assert request shape without network access. - if ( ! function_exists( 'wp_remote_request' ) ) { - function wp_remote_request( string $url, array $args = array() ) { - $GLOBALS['__dmc_http_capture'][] = array( - 'url' => $url, - 'method' => $args['method'] ?? 'GET', - 'body' => $args['body'] ?? null, - 'headers' => $args['headers'] ?? array(), - ); - $method = strtoupper( $args['method'] ?? 'GET' ); - $key = $method . ' ' . strtok( $url, '?' ); - if ( isset( $GLOBALS['__dmc_http_mock'][ $key ] ) ) { - return $GLOBALS['__dmc_http_mock'][ $key ]; - } - return array( - 'response' => array( 'code' => 404 ), - 'body' => json_encode( array( 'message' => 'Not mocked: ' . $key ) ), - ); - } - } - - if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { - function wp_remote_retrieve_response_code( $response ): int { - return (int) ( $response['response']['code'] ?? 0 ); - } - } - - if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { - function wp_remote_retrieve_body( $response ): string { - return (string) ( $response['body'] ?? '' ); - } - } - - if ( ! function_exists( 'do_action' ) ) { - function do_action( ...$args ): void {} - } - - require __DIR__ . '/../inc/Support/GitRunner.php'; - require __DIR__ . '/../inc/Support/PathSecurity.php'; - require __DIR__ . '/../inc/Support/GitHubRemote.php'; - require __DIR__ . '/../inc/GitSync/GitRepo.php'; - require __DIR__ . '/../inc/GitSync/GitSyncBinding.php'; - require __DIR__ . '/../inc/GitSync/GitSyncRegistry.php'; - require __DIR__ . '/../inc/GitSync/GitSyncSubmitter.php'; - require __DIR__ . '/../inc/GitSync/GitSync.php'; - - $failures = 0; - $total = 0; - - $assert = function ( $cond, string $message ) use ( &$failures, &$total ): void { - $total++; - if ( $cond ) { echo " ✓ {$message}\n"; return; } - $failures++; - echo " ✗ {$message}\n"; - }; - - $cleanup = function () use ( $scratch ): void { - if ( is_dir( $scratch ) ) { - exec( 'rm -rf ' . escapeshellarg( $scratch ) ); - } - }; - register_shutdown_function( $cleanup ); - - echo "GitSync Phase 2 — write-path smoke\n"; - echo "Scratch ABSPATH: " . ABSPATH . "\n\n"; - - // ------------------------------------------------------------------------- - // Fake remote: a local bare repo seeded with an initial commit on `main`. - // ------------------------------------------------------------------------- - $fake_remote_path = sys_get_temp_dir() . '/dmc-gitsync-fake-remote-' . getmypid() . '.git'; - $fake_remote = 'file://' . $fake_remote_path; - $seed_dir = sys_get_temp_dir() . '/dmc-gitsync-fake-seed-' . getmypid(); - exec( 'git init --bare ' . escapeshellarg( $fake_remote_path ) . ' 2>&1', $_out, $init_ok ); - if ( 0 !== $init_ok ) { echo " ! could not init bare repo\n"; exit( 1 ); } - register_shutdown_function( fn() => exec( 'rm -rf ' . escapeshellarg( $fake_remote_path ) ) ); - register_shutdown_function( fn() => exec( 'rm -rf ' . escapeshellarg( $seed_dir ) ) ); - - // Seed with one commit so origin/main exists. - mkdir( $seed_dir, 0755, true ); - exec( 'git -C ' . escapeshellarg( $seed_dir ) . ' init -b main 2>&1' ); - exec( 'git -C ' . escapeshellarg( $seed_dir ) . ' -c user.email=smoke@example.com -c user.name=Smoke commit --allow-empty -m "init" 2>&1' ); - exec( 'git -C ' . escapeshellarg( $seed_dir ) . ' remote add origin ' . escapeshellarg( $fake_remote ) . ' 2>&1' ); - exec( 'git -C ' . escapeshellarg( $seed_dir ) . ' push -u origin main 2>&1', $_out, $seed_ok ); - if ( 0 !== $seed_ok ) { echo " ! could not seed bare repo\n"; exit( 1 ); } - - // Configure git identity on the test binding so commits don't fail in - // CI-like envs where user.email isn't set globally. - putenv( 'GIT_AUTHOR_NAME=Smoke' ); - putenv( 'GIT_AUTHOR_EMAIL=smoke@example.com' ); - putenv( 'GIT_COMMITTER_NAME=Smoke' ); - putenv( 'GIT_COMMITTER_EMAIL=smoke@example.com' ); - - $gs = new \DataMachineCode\GitSync\GitSync(); - - // ------------------------------------------------------------------------- - // 1. Bind (reuses Phase 1 code path). - // ------------------------------------------------------------------------- - echo "Bind against fake remote\n"; - $bind = $gs->bind( array( - 'slug' => 'w', - 'local_path' => '/bound/', - 'remote_url' => $fake_remote, - ) ); - $assert( ! is_wp_error( $bind ), 'bind succeeded' ); - $absolute = $bind['local_path'] ?? ''; - - // Configure local git identity inside the clone. - exec( 'git -C ' . escapeshellarg( $absolute ) . ' config user.email smoke@example.com' ); - exec( 'git -C ' . escapeshellarg( $absolute ) . ' config user.name Smoke' ); - - // ------------------------------------------------------------------------- - // 2. add/commit/push refuse when write_enabled=false (Phase 1 default). - // ------------------------------------------------------------------------- - echo "\nPolicy gates (write_enabled=false)\n"; - file_put_contents( $absolute . '/foo.md', "hello\n" ); - $add_blocked = $gs->add( 'w', array( 'foo.md' ) ); - $assert( is_wp_error( $add_blocked ) && 'write_disabled' === $add_blocked->get_error_code(), 'add refused without write_enabled' ); - - $commit_blocked = $gs->commit( 'w', 'nope nope nope' ); - $assert( is_wp_error( $commit_blocked ) && 'write_disabled' === $commit_blocked->get_error_code(), 'commit refused without write_enabled' ); - - $push_blocked = $gs->push( 'w' ); - $assert( is_wp_error( $push_blocked ) && 'push_disabled' === $push_blocked->get_error_code(), 'push refused without push_enabled' ); - - // ------------------------------------------------------------------------- - // 3. updatePolicy: turn on writes but not allowed_paths yet. - // ------------------------------------------------------------------------- - echo "\nupdatePolicy\n"; - $p1 = $gs->updatePolicy( 'w', array( 'write_enabled' => true ) ); - $assert( ! is_wp_error( $p1 ), 'policy update succeeded' ); - $assert( true === ( $p1['policy']['write_enabled'] ?? false ), 'write_enabled now true' ); - - $add_noroots = $gs->add( 'w', array( 'foo.md' ) ); - $assert( is_wp_error( $add_noroots ) && 'no_allowed_paths' === $add_noroots->get_error_code(), 'add refused without allowed_paths' ); - - // Policy key whitelist. - $unknown = $gs->updatePolicy( 'w', array( 'bogus' => true ) ); - $assert( is_wp_error( $unknown ) && 'unknown_policy_key' === $unknown->get_error_code(), 'rejects unknown policy key' ); - - // safe_direct_push without push_enabled errors. - $sdp_orphan = $gs->updatePolicy( 'w', array( 'safe_direct_push' => true ) ); - $assert( is_wp_error( $sdp_orphan ) && 'policy_conflict' === $sdp_orphan->get_error_code(), 'rejects safe_direct_push without push_enabled' ); - - // ------------------------------------------------------------------------- - // 4. Set allowed_paths + push_enabled and exercise add/commit path restrictions. - // ------------------------------------------------------------------------- - echo "\nallowed_paths enforcement\n"; - mkdir( $absolute . '/articles', 0755, true ); - file_put_contents( $absolute . '/articles/a.md', "aaa\n" ); - file_put_contents( $absolute . '/secrets.env', "KEY=val\n" ); - file_put_contents( $absolute . '/README.md', "readme\n" ); - - $p2 = $gs->updatePolicy( 'w', array( - 'allowed_paths' => array( 'articles/' ), - 'push_enabled' => true, - ) ); - $assert( ! is_wp_error( $p2 ), 'allowed_paths + push_enabled set' ); - - $add_outside = $gs->add( 'w', array( 'README.md' ) ); - $assert( is_wp_error( $add_outside ) && 'path_not_allowed' === $add_outside->get_error_code(), 'add refuses path outside allowed_paths' ); - - $add_sensitive = $gs->add( 'w', array( 'secrets.env' ) ); - $assert( is_wp_error( $add_sensitive ) && 'sensitive_path' === $add_sensitive->get_error_code(), 'add refuses sensitive filename' ); - - $add_ok = $gs->add( 'w', array( 'articles/a.md' ) ); - $assert( ! is_wp_error( $add_ok ) && array( 'articles/a.md' ) === $add_ok['paths'], 'add accepts allowed path' ); - - // ------------------------------------------------------------------------- - // 5. Commit validation. - // ------------------------------------------------------------------------- - echo "\nCommit\n"; - $short = $gs->commit( 'w', 'nope' ); - $assert( is_wp_error( $short ) && 'message_too_short' === $short->get_error_code(), 'rejects short commit message' ); - - $too_long = $gs->commit( 'w', str_repeat( 'x', 201 ) ); - $assert( is_wp_error( $too_long ) && 'message_too_long' === $too_long->get_error_code(), 'rejects overlong commit message' ); - - $commit = $gs->commit( 'w', 'Add first article' ); - $assert( ! is_wp_error( $commit ), 'commit succeeded' ); - $assert( is_string( $commit['commit'] ?? null ) && '' !== $commit['commit'], 'commit returns hash' ); - - // Clean-tree commit refuses. - $empty = $gs->commit( 'w', 'another commit message' ); - $assert( is_wp_error( $empty ) && 'nothing_staged' === $empty->get_error_code(), 'refuses commit when nothing staged' ); - - // ------------------------------------------------------------------------- - // 6. Direct push: push_enabled alone still refuses. - // ------------------------------------------------------------------------- - echo "\nDirect push (two-key auth)\n"; - $dp_blocked = $gs->push( 'w' ); - $assert( is_wp_error( $dp_blocked ) && 'direct_push_blocked' === $dp_blocked->get_error_code(), 'direct push needs safe_direct_push' ); - - $gs->updatePolicy( 'w', array( 'safe_direct_push' => true ) ); - $push_ok = $gs->push( 'w' ); - $assert( ! is_wp_error( $push_ok ), 'direct push with both keys set' ); - - // Verify the commit actually landed on the fake remote. - exec( 'git -C ' . escapeshellarg( $fake_remote_path ) . ' rev-parse HEAD', $remote_head ); - $assert( ! empty( $remote_head[0] ), 'bare remote advanced after push' ); - - // ------------------------------------------------------------------------- - // 7. Submit flow — fake GitHub remote slug via a fs path. We can't run - // openOrUpdatePullRequest against an fs path, so point submit at a - // pretend github.com URL that pushes to the same fs bare repo via a - // separate mock. Instead: verify the pre-GitHub steps complete and - // the PR call hits the mock table. - // ------------------------------------------------------------------------- - echo "\nSubmit (mocked PR API)\n"; - - // Re-bind to a github.com-shaped URL for submit — but actually push to - // our fake remote by monkey-patching via environment: we rewrite the - // binding's remote_url after bind so it passes the github.com gate while - // pointing at the real bare repo for git operations. - $gs2 = $gs; // reuse - $gs->unbind( 'w', true ); - - $bind2 = $gs->bind( array( - 'slug' => 's', - 'local_path' => '/submitbound/', - 'remote_url' => $fake_remote, - ) ); - $assert( ! is_wp_error( $bind2 ), 'submit binding created' ); - $absolute2 = $bind2['local_path']; - exec( 'git -C ' . escapeshellarg( $absolute2 ) . ' config user.email smoke@example.com' ); - exec( 'git -C ' . escapeshellarg( $absolute2 ) . ' config user.name Smoke' ); - - // Swap remote_url to github-looking value so submit() passes the host - // check. The git remote stays pointed at the bare repo (origin). - $opts = $GLOBALS['__dmc_options']['datamachine_gitsync_bindings']; - $opts['s']['remote_url'] = 'https://github.com/example/repo'; - $opts['s']['policy']['write_enabled'] = true; - $opts['s']['policy']['push_enabled'] = true; - $opts['s']['policy']['allowed_paths'] = array( 'articles/' ); - $GLOBALS['__dmc_options']['datamachine_gitsync_bindings'] = $opts; - - // Route the PAT-injected push URL back to the local bare repo via git's - // insteadOf config. Scoped to this clone only (no global pollution). - $injected_url = 'https://test-pat@github.com/example/repo'; - exec( sprintf( - 'git -C %s config url.%s.insteadOf %s', - escapeshellarg( $absolute2 ), - escapeshellarg( $fake_remote ), - escapeshellarg( $injected_url ) - ) ); - // Also the un-injected URL, in case PAT resolution short-circuits. - exec( sprintf( - 'git -C %s config --add url.%s.insteadOf %s', - escapeshellarg( $absolute2 ), - escapeshellarg( $fake_remote ), - escapeshellarg( 'https://github.com/example/repo' ) - ) ); - - if ( ! is_dir( $absolute2 . '/articles' ) ) { - mkdir( $absolute2 . '/articles', 0755, true ); - } - file_put_contents( $absolute2 . '/articles/new.md', "new\n" ); - - // Mock GitHub API: GET existing pulls returns [], POST creates PR #42. - $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/example/repo/pulls'] = array( - 'response' => array( 'code' => 200 ), - 'body' => '[]', - ); - $GLOBALS['__dmc_http_mock']['POST https://api.github.com/repos/example/repo/pulls'] = array( - 'response' => array( 'code' => 201 ), - 'body' => json_encode( array( - 'number' => 42, - 'html_url' => 'https://github.com/example/repo/pull/42', - 'state' => 'open', - ) ), - ); - - // Stub GitHubAbilities so the PAT resolves and apiGet/apiRequest - // route through the mocked wp_remote_request. Mirrors the real class's - // response envelope (`['success' => true, 'data' => $decoded_body]`) - // so callers don't need test-specific unwrapping. - if ( ! class_exists( '\DataMachineCode\Abilities\GitHubAbilities' ) ) { - eval( 'namespace DataMachineCode\\Abilities; - class GitHubAbilities { - public static function getPat(): string { return "test-pat"; } - public static function apiGet( string $url, array $query, string $pat ) { - if ( ! empty( $query ) ) { $url = add_query_arg( $query, $url ); } - $resp = wp_remote_request( $url, array( "method" => "GET", "headers" => array( "Authorization" => "token " . $pat ) ) ); - $code = (int) wp_remote_retrieve_response_code( $resp ); - $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true ); - if ( $code >= 400 ) { return new \\WP_Error( "github_api_error", "stub error", array( "status" => $code ) ); } - return array( "success" => true, "data" => $body ); - } - public static function apiRequest( string $method, string $url, array $body, string $pat ) { - $resp = wp_remote_request( $url, array( "method" => $method, "headers" => array( "Authorization" => "token " . $pat ), "body" => json_encode( $body ) ) ); - $code = (int) wp_remote_retrieve_response_code( $resp ); - $decoded = json_decode( (string) wp_remote_retrieve_body( $resp ), true ); - if ( $code >= 400 ) { return new \\WP_Error( "github_api_error", "stub error", array( "status" => $code ) ); } - return array( "success" => true, "data" => $decoded ); - } - }' ); - } - - $submit = $gs->submit( 's', array( - 'message' => 'Propose a new article', - ) ); - - $assert( ! is_wp_error( $submit ), 'submit succeeded — ' . ( is_wp_error( $submit ) ? $submit->get_error_message() : '' ) ); - $assert( 42 === ( $submit['pr']['number'] ?? null ), 'submit returned PR #42' ); - $assert( 'opened' === ( $submit['pr']['action'] ?? null ), 'submit reports PR as opened' ); - $assert( 'gitsync/s' === ( $submit['branch'] ?? null ), 'feature branch is gitsync/s' ); - - // Verify working tree returned to pinned branch (main). - exec( 'git -C ' . escapeshellarg( $absolute2 ) . ' rev-parse --abbrev-ref HEAD', $br_out ); - $assert( 'main' === trim( (string) ( $br_out[0] ?? '' ) ), 'working tree returned to pinned branch after submit' ); - - // Verify feature branch pushed to bare remote. - $feat_out = array(); - exec( 'git -C ' . escapeshellarg( $fake_remote_path ) . ' rev-parse refs/heads/gitsync/s 2>&1', $feat_out, $feat_exit ); - $assert( 0 === $feat_exit, 'feature branch gitsync/s exists on bare remote' ); - - // Verify API was called in the expected order. - $captured = $GLOBALS['__dmc_http_capture']; - $assert( count( $captured ) >= 2, 'at least 2 API calls captured' ); - $assert( 'GET' === ( $captured[0]['method'] ?? '' ), 'first API call is GET' ); - $assert( 'POST' === ( $captured[1]['method'] ?? '' ), 'second API call is POST' ); - $assert( str_contains( (string) ( $captured[0]['headers']['Authorization'] ?? '' ), 'test-pat' ), 'PAT sent in Authorization header' ); - - // ------------------------------------------------------------------------- - // 8. Submit is idempotent — second run updates PR in place. - // ------------------------------------------------------------------------- - echo "\nSubmit update (second run)\n"; - $GLOBALS['__dmc_http_capture'] = array(); - $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/example/repo/pulls'] = array( - 'response' => array( 'code' => 200 ), - 'body' => json_encode( array( - array( - 'number' => 42, - 'html_url' => 'https://github.com/example/repo/pull/42', - 'state' => 'open', - ), - ) ), - ); - $GLOBALS['__dmc_http_mock']['PATCH https://api.github.com/repos/example/repo/pulls/42'] = array( - 'response' => array( 'code' => 200 ), - 'body' => json_encode( array( - 'number' => 42, - 'html_url' => 'https://github.com/example/repo/pull/42', - 'state' => 'open', - ) ), - ); - - if ( ! is_dir( $absolute2 . '/articles' ) ) { - mkdir( $absolute2 . '/articles', 0755, true ); - } - file_put_contents( $absolute2 . '/articles/new.md', "new v2\n" ); - $submit2 = $gs->submit( 's', array( 'message' => 'Update the new article' ) ); - $assert( - ! is_wp_error( $submit2 ), - 'second submit succeeded' . ( is_wp_error( $submit2 ) ? ' — ' . $submit2->get_error_code() . ': ' . $submit2->get_error_message() : '' ) - ); - if ( is_wp_error( $submit2 ) ) { - // Skip remaining assertions to keep the run readable. - echo "\nResult: " . ( $total - $failures ) . "/{$total} passed\n"; - exit( $failures > 0 ? 1 : 0 ); - } - $assert( 'updated' === ( $submit2['pr']['action'] ?? null ), 'second submit reports PR as updated' ); - - // ------------------------------------------------------------------------- - // 9. Submit with no changes under allowed_paths errors cleanly. - // ------------------------------------------------------------------------- - echo "\nSubmit with nothing to propose\n"; - $clean_submit = $gs->submit( 's', array( 'message' => 'nothing changed here' ) ); - $assert( is_wp_error( $clean_submit ) && 'nothing_to_submit' === $clean_submit->get_error_code(), 'submit refuses when nothing is staged' ); - - echo "\nResult: " . ( $total - $failures ) . "/{$total} passed\n"; - exit( $failures > 0 ? 1 : 0 ); -} diff --git a/tests/smoke-gitsync.php b/tests/smoke-gitsync.php index 5a00434..61414b0 100644 --- a/tests/smoke-gitsync.php +++ b/tests/smoke-gitsync.php @@ -1,18 +1,14 @@ <?php /** - * Pure-PHP end-to-end smoke test for GitSync Phase 1. + * Pure-PHP smoke for the API-first GitSync. * - * Exercises bind (clone) → status → pull → unbind against a real public - * remote. Runs on native macOS/Linux PHP + git — does not require a full - * WordPress bootstrap. Needed because Studio WASM can't spawn git clone - * into its mounted /wordpress filesystem (Studio#3082), so the CLI smoke - * lives here instead. + * Zero git binary, zero network: GitHub API responses are mocked via + * wp_remote_request, and local file I/O happens inside a scratch temp + * directory that plays the role of ABSPATH. The same harness covers + * Phase 1 (bind/pull/status/list/unbind) and Phase 2 (submit/push/ + * policy-update) because the API-first rebuild unifies them. * * Run: php tests/smoke-gitsync.php - * - * The test uses `mcp-context-wporg` as a small, stable public remote. If - * you don't have network, skip this smoke and rely on the handle/binding - * unit checks plus manual in-Studio verification for the non-git surface. */ declare( strict_types=1 ); @@ -20,15 +16,14 @@ namespace { if ( ! defined( 'ABSPATH' ) ) { - // Point ABSPATH at a scratch directory we control so bindings - // actually land in real writable space during the smoke run. - $scratch = sys_get_temp_dir() . '/dmc-gitsync-smoke-' . getmypid(); + $scratch = sys_get_temp_dir() . '/dmc-gitsync-api-' . getmypid(); @mkdir( $scratch, 0755, true ); define( 'ABSPATH', $scratch . '/' ); } - // Minimal WordPress polyfills. Options are held in-memory for the run. - $GLOBALS['__dmc_options'] = array(); + $GLOBALS['__dmc_options'] = array(); + $GLOBALS['__dmc_http_mock'] = array(); + $GLOBALS['__dmc_http_capture'] = array(); if ( ! class_exists( 'WP_Error' ) ) { class WP_Error { @@ -66,13 +61,105 @@ function update_option( string $name, $value, $autoload = null ): bool { } } - require __DIR__ . '/../inc/Support/GitRunner.php'; + if ( ! function_exists( 'wp_json_encode' ) ) { + function wp_json_encode( $data, int $options = 0 ) { return json_encode( $data, $options ); } + } + + if ( ! function_exists( 'add_query_arg' ) ) { + function add_query_arg( array $args, string $url ): string { + $qs = http_build_query( $args ); + return $url . ( str_contains( $url, '?' ) ? '&' : '?' ) . $qs; + } + } + + if ( ! function_exists( 'wp_remote_request' ) ) { + function wp_remote_request( string $url, array $args = array() ) { + $method = strtoupper( $args['method'] ?? 'GET' ); + $GLOBALS['__dmc_http_capture'][] = array( + 'url' => $url, + 'method' => $method, + 'body' => $args['body'] ?? null, + 'headers' => $args['headers'] ?? array(), + ); + // Match on "METHOD <url-without-query>" first, then on exact URL + // with query so tests can be specific when needed. + $clean_url = strtok( $url, '?' ); + $keys = array( + $method . ' ' . $url, + $method . ' ' . $clean_url, + ); + foreach ( $keys as $key ) { + if ( isset( $GLOBALS['__dmc_http_mock'][ $key ] ) ) { + $mock = $GLOBALS['__dmc_http_mock'][ $key ]; + // Allow mocks to be callables for dynamic responses. + if ( is_callable( $mock ) ) { + return $mock( $url, $args ); + } + return $mock; + } + } + return array( + 'response' => array( 'code' => 404 ), + 'body' => json_encode( array( 'message' => 'Not mocked: ' . $method . ' ' . $clean_url ) ), + ); + } + } + + if ( ! function_exists( 'wp_remote_get' ) ) { + function wp_remote_get( string $url, array $args = array() ) { + $args['method'] = 'GET'; + return wp_remote_request( $url, $args ); + } + } + + if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { + function wp_remote_retrieve_response_code( $response ): int { + return (int) ( $response['response']['code'] ?? 0 ); + } + } + + if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { + function wp_remote_retrieve_body( $response ): string { + return (string) ( $response['body'] ?? '' ); + } + } + + // Stub GitHubAbilities — real class has too many dependencies to load + // here. We mirror just the static surface GitSync's path calls. + if ( ! class_exists( '\DataMachineCode\Abilities\GitHubAbilities' ) ) { + eval( 'namespace DataMachineCode\\Abilities; + class GitHubAbilities { + public static function getPat(): string { return "test-pat"; } + public static function apiGet( string $url, array $query, string $pat ) { + if ( ! empty( $query ) ) { $url = add_query_arg( $query, $url ); } + $resp = wp_remote_get( $url, array( "headers" => array( "Authorization" => "token " . $pat ) ) ); + $code = (int) wp_remote_retrieve_response_code( $resp ); + $body = json_decode( (string) wp_remote_retrieve_body( $resp ), true ); + if ( $code >= 400 ) { + $err_code = 404 === $code ? "github_not_found" : "github_api_error"; + return new \\WP_Error( $err_code, is_array( $body ) && isset( $body["message"] ) ? (string) $body["message"] : "stub error", array( "status" => $code ) ); + } + return array( "success" => true, "data" => $body ); + } + public static function apiRequest( string $method, string $url, array $body, string $pat ) { + $resp = wp_remote_request( $url, array( "method" => $method, "headers" => array( "Authorization" => "token " . $pat ), "body" => json_encode( $body ) ) ); + $code = (int) wp_remote_retrieve_response_code( $resp ); + $decoded = json_decode( (string) wp_remote_retrieve_body( $resp ), true ); + if ( $code >= 400 ) { + $err_code = 404 === $code ? "github_not_found" : "github_api_error"; + return new \\WP_Error( $err_code, is_array( $decoded ) && isset( $decoded["message"] ) ? (string) $decoded["message"] : "stub error", array( "status" => $code ) ); + } + return array( "success" => true, "data" => $decoded ); + } + }' ); + } + require __DIR__ . '/../inc/Support/PathSecurity.php'; require __DIR__ . '/../inc/Support/GitHubRemote.php'; - require __DIR__ . '/../inc/GitSync/GitRepo.php'; require __DIR__ . '/../inc/GitSync/GitSyncBinding.php'; require __DIR__ . '/../inc/GitSync/GitSyncRegistry.php'; - require __DIR__ . '/../inc/GitSync/GitSyncSubmitter.php'; + require __DIR__ . '/../inc/GitSync/GitSyncFetcher.php'; + require __DIR__ . '/../inc/GitSync/GitSyncProposer.php'; require __DIR__ . '/../inc/GitSync/GitSync.php'; $failures = 0; @@ -80,184 +167,314 @@ function update_option( string $name, $value, $autoload = null ): bool { $assert = function ( $cond, string $message ) use ( &$failures, &$total ): void { $total++; - if ( $cond ) { - echo " ✓ {$message}\n"; - return; - } + if ( $cond ) { echo " ✓ {$message}\n"; return; } $failures++; echo " ✗ {$message}\n"; }; - $cleanup = function () use ( $scratch ): void { - if ( is_dir( $scratch ) ) { - exec( 'rm -rf ' . escapeshellarg( $scratch ) ); - } - }; - - register_shutdown_function( $cleanup ); + register_shutdown_function( fn() => is_dir( $scratch ) && exec( 'rm -rf ' . escapeshellarg( $scratch ) ) ); - echo "GitSync Phase 1 — end-to-end smoke\n"; + echo "GitSync API-first — smoke\n"; echo "Scratch ABSPATH: " . ABSPATH . "\n\n"; - $gs = new \DataMachineCode\GitSync\GitSync(); + // Helper: build a git-blob SHA for mocked tree responses. + $blob_sha = fn( string $content ): string => sha1( 'blob ' . strlen( $content ) . "\0" . $content ); - // --------------------------------------------------------------------- - // 1. Input validation rejects bad inputs. - // --------------------------------------------------------------------- + // ========================================================================= + // 1. Input validation at bind time. + // ========================================================================= echo "Input validation\n"; + $gs = new \DataMachineCode\GitSync\GitSync(); - $bad_slug = $gs->bind( array( - 'slug' => 'Bad Slug!', - 'local_path' => '/scratch/repo/', - 'remote_url' => 'https://github.com/example/repo', - ) ); - $assert( is_wp_error( $bad_slug ) && 'invalid_slug' === $bad_slug->get_error_code(), 'rejects invalid slug' ); - - $bad_url = $gs->bind( array( - 'slug' => 'repo', - 'local_path' => '/scratch/repo/', - 'remote_url' => 'not-a-url', - ) ); - $assert( is_wp_error( $bad_url ) && 'invalid_remote_url' === $bad_url->get_error_code(), 'rejects invalid remote URL' ); + $r = $gs->bind( array( 'slug' => 'Bad Slug!', 'local_path' => '/x/', 'remote_url' => 'https://github.com/a/b' ) ); + $assert( is_wp_error( $r ) && 'invalid_slug' === $r->get_error_code(), 'rejects invalid slug' ); - $traversal = $gs->bind( array( - 'slug' => 'repo', - 'local_path' => '/../../etc/', - 'remote_url' => 'https://github.com/example/repo', - ) ); - $assert( is_wp_error( $traversal ) && 'path_traversal' === $traversal->get_error_code(), 'rejects traversal in local_path' ); + $r = $gs->bind( array( 'slug' => 's', 'local_path' => '/x/', 'remote_url' => 'https://gitlab.com/a/b' ) ); + $assert( is_wp_error( $r ) && 'invalid_remote_url' === $r->get_error_code(), 'rejects non-GitHub remote' ); - $sensitive = $gs->bind( array( - 'slug' => 'repo', - 'local_path' => '/.env/', - 'remote_url' => 'https://github.com/example/repo', - ) ); - $assert( is_wp_error( $sensitive ) && 'sensitive_path' === $sensitive->get_error_code(), 'rejects sensitive local_path' ); - - // --------------------------------------------------------------------- - // 2. Real clone against a public repo. - // --------------------------------------------------------------------- - echo "\nBind + clone (public repo)\n"; - - $remote = 'https://github.com/Automattic/mcp-context-wporg'; - $result = $gs->bind( array( - 'slug' => 'smoke', - 'local_path' => '/sync/mcp-context-wporg/', - 'remote_url' => $remote, - ) ); + $r = $gs->bind( array( 'slug' => 's', 'local_path' => '/../etc/', 'remote_url' => 'https://github.com/a/b' ) ); + $assert( is_wp_error( $r ) && 'path_traversal' === $r->get_error_code(), 'rejects traversal in local_path' ); - if ( is_wp_error( $result ) ) { - echo " ! clone failed: " . $result->get_error_message() . "\n"; - echo " ! skipping remaining smoke (network or git unavailable)\n"; - exit( $failures > 0 ? 1 : 0 ); - } + $r = $gs->bind( array( 'slug' => 's', 'local_path' => '/.env/', 'remote_url' => 'https://github.com/a/b' ) ); + $assert( is_wp_error( $r ) && 'sensitive_path' === $r->get_error_code(), 'rejects sensitive local_path' ); - $assert( true === ( $result['success'] ?? false ), 'bind returned success' ); - $assert( true === ( $result['cloned'] ?? false ), 'bind marked path as newly cloned' ); - $assert( false === ( $result['adopted'] ?? true ), 'bind did not mark path as adopted' ); - $assert( is_dir( $result['local_path'] . '/.git' ), '.git/ present after clone' ); - - // --------------------------------------------------------------------- - // 3. Duplicate bind is rejected. - // --------------------------------------------------------------------- - echo "\nDuplicate bind\n"; - $dup = $gs->bind( array( - 'slug' => 'smoke', - 'local_path' => '/sync/mcp-context-wporg/', - 'remote_url' => $remote, + // ========================================================================= + // 2. Bind (registry only — no HTTP, no disk). + // ========================================================================= + echo "\nBind\n"; + $bound = $gs->bind( array( + 'slug' => 'wiki', + 'local_path' => '/content/wiki/', + 'remote_url' => 'https://github.com/Automattic/a8c-wiki-woocommerce', ) ); - $assert( is_wp_error( $dup ) && 'binding_exists' === $dup->get_error_code(), 'refuses duplicate slug' ); + $assert( ! is_wp_error( $bound ), 'bind succeeded' ); + $assert( false === is_dir( ABSPATH . 'content/wiki/' ), 'bind did not create directory' ); - // --------------------------------------------------------------------- - // 4. Adopt: new binding slug against already-cloned path + matching origin. - // --------------------------------------------------------------------- - echo "\nAdopt existing checkout\n"; - $adopt = $gs->bind( array( - 'slug' => 'smoke-adopt', - 'local_path' => '/sync/mcp-context-wporg/', - 'remote_url' => $remote, - ) ); - $assert( ! is_wp_error( $adopt ), 'adopt bind succeeded' ); - $assert( true === ( $adopt['adopted'] ?? false ), 'adopt flagged as adopted' ); - $assert( false === ( $adopt['cloned'] ?? true ), 'adopt did not re-clone' ); - - // Cleanup adopt entry so status/list results stay readable. - $gs->unbind( 'smoke-adopt' ); - - // --------------------------------------------------------------------- - // 5. Adopt rejection: mismatched origin. - // --------------------------------------------------------------------- - echo "\nReject adopt on origin mismatch\n"; - $mismatch = $gs->bind( array( - 'slug' => 'smoke-mismatch', - 'local_path' => '/sync/mcp-context-wporg/', - 'remote_url' => 'https://github.com/example/other-repo', - ) ); - $assert( is_wp_error( $mismatch ) && 'origin_mismatch' === $mismatch->get_error_code(), 'rejects adopt on origin mismatch' ); - - // --------------------------------------------------------------------- - // 6. Status reports repo + branch + head. - // --------------------------------------------------------------------- - echo "\nStatus\n"; - $status = $gs->status( 'smoke' ); - $assert( ! is_wp_error( $status ), 'status succeeded' ); - $assert( true === ( $status['exists'] ?? false ), 'status reports exists' ); - $assert( true === ( $status['is_repo'] ?? false ), 'status reports is_repo' ); - $assert( is_string( $status['head'] ?? null ) && '' !== $status['head'], 'status has HEAD hash' ); - $assert( 0 === ( $status['ahead'] ?? -1 ), 'status ahead=0 on fresh clone' ); - - // --------------------------------------------------------------------- - // 7. Pull on an already-up-to-date tree is idempotent and succeeds. - // --------------------------------------------------------------------- - echo "\nPull (idempotent on clean tree)\n"; - $pull = $gs->pull( 'smoke' ); - $assert( ! is_wp_error( $pull ), 'pull succeeded' ); - $assert( ( $pull['previous_head'] ?? null ) === ( $pull['head'] ?? null ), 'pull did not advance HEAD on clean tree' ); - - // --------------------------------------------------------------------- - // 8. Dirty-tree pull fails under default policy. - // --------------------------------------------------------------------- - echo "\nDirty-tree pull refusal\n"; - file_put_contents( $result['local_path'] . '/SMOKE_DIRTY_FILE', 'dirty' ); - $dirty = $gs->pull( 'smoke' ); - $assert( is_wp_error( $dirty ) && 'dirty_working_tree' === $dirty->get_error_code(), 'pull refuses on dirty tree with fail policy' ); - - $allow = $gs->pull( 'smoke', true ); - $assert( ! is_wp_error( $allow ), 'pull succeeds with allow_dirty=true' ); - - unlink( $result['local_path'] . '/SMOKE_DIRTY_FILE' ); - - // --------------------------------------------------------------------- - // 9. List reports both bindings before + only remaining after unbind. - // --------------------------------------------------------------------- - echo "\nList + unbind\n"; - $list = $gs->list_bindings(); - $assert( 1 === count( $list['bindings'] ?? array() ), 'list reports 1 binding' ); - - $unbind = $gs->unbind( 'smoke' ); - $assert( ! is_wp_error( $unbind ), 'unbind succeeded' ); - $assert( false === ( $unbind['purged'] ?? true ), 'unbind preserved directory by default' ); - $assert( is_dir( $result['local_path'] . '/.git' ), 'working tree preserved after unbind' ); - - $list_after = $gs->list_bindings(); - $assert( 0 === count( $list_after['bindings'] ?? array() ), 'list empty after unbind' ); - - // --------------------------------------------------------------------- - // 10. Re-bind + purge wipes the directory. - // --------------------------------------------------------------------- - echo "\nRebind + purge\n"; - $rebind = $gs->bind( array( - 'slug' => 'smoke', - 'local_path' => '/sync/mcp-context-wporg/', - 'remote_url' => $remote, - ) ); - $assert( ! is_wp_error( $rebind ), 'rebind adopted existing tree' ); - $assert( true === ( $rebind['adopted'] ?? false ), 'rebind flagged as adopted' ); + $dup = $gs->bind( array( 'slug' => 'wiki', 'local_path' => '/x/', 'remote_url' => 'https://github.com/a/b' ) ); + $assert( is_wp_error( $dup ) && 'binding_exists' === $dup->get_error_code(), 'refuses duplicate slug' ); - $purge = $gs->unbind( 'smoke', true ); - $assert( ! is_wp_error( $purge ), 'purge unbind succeeded' ); - $assert( true === ( $purge['purged'] ?? false ), 'purge flag set' ); - $assert( ! is_dir( $result['local_path'] ), 'working tree removed by purge' ); + // ========================================================================= + // 3. Pull — initial sync materializes files on disk. + // ========================================================================= + echo "\nPull (initial)\n"; + $content_a = "article a v1\n"; + $content_b = "article b v1\n"; + $sha_a = $blob_sha( $content_a ); + $sha_b = $blob_sha( $content_b ); + + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/trees/main'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'sha' => 'tree-sha-1', + 'truncated' => false, + 'tree' => array( + array( 'path' => 'articles/a.md', 'type' => 'blob', 'sha' => $sha_a ), + array( 'path' => 'articles/b.md', 'type' => 'blob', 'sha' => $sha_b ), + ), + ) ), + ); + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/contents/articles/a.md'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'content' => base64_encode( $content_a ), 'encoding' => 'base64', 'sha' => $sha_a ) ), + ); + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/contents/articles/b.md'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'content' => base64_encode( $content_b ), 'encoding' => 'base64', 'sha' => $sha_b ) ), + ); + + $pull = $gs->pull( 'wiki' ); + $assert( ! is_wp_error( $pull ), 'pull succeeded — ' . ( is_wp_error( $pull ) ? $pull->get_error_message() : '' ) ); + $assert( 2 === count( (array) ( $pull['updated'] ?? array() ) ), 'pulled 2 files' ); + $assert( file_get_contents( ABSPATH . 'content/wiki/articles/a.md' ) === $content_a, 'articles/a.md content matches' ); + $assert( file_get_contents( ABSPATH . 'content/wiki/articles/b.md' ) === $content_b, 'articles/b.md content matches' ); + + // ========================================================================= + // 4. Pull again — nothing should change (SHAs match on disk). + // ========================================================================= + echo "\nPull (idempotent)\n"; + $GLOBALS['__dmc_http_capture'] = array(); + $pull2 = $gs->pull( 'wiki' ); + $assert( ! is_wp_error( $pull2 ), 'second pull succeeded' ); + $assert( 0 === count( (array) ( $pull2['updated'] ?? array() ) ), 'second pull updated 0 files (already in sync)' ); + $assert( 2 === ( $pull2['unchanged'] ?? 0 ), 'second pull reports 2 unchanged' ); + + // ========================================================================= + // 5. Upstream delete → local file removed, pulled_paths shrinks. + // ========================================================================= + echo "\nPull (upstream deleted a file)\n"; + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/trees/main'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'sha' => 'tree-sha-2', + 'tree' => array( + array( 'path' => 'articles/a.md', 'type' => 'blob', 'sha' => $sha_a ), + // b.md removed upstream + ), + ) ), + ); + $pull3 = $gs->pull( 'wiki' ); + $assert( ! is_wp_error( $pull3 ), 'pull after upstream delete succeeded' ); + $assert( in_array( 'articles/b.md', (array) ( $pull3['deleted'] ?? array() ), true ), 'reports articles/b.md deleted' ); + $assert( ! is_file( ABSPATH . 'content/wiki/articles/b.md' ), 'articles/b.md removed from disk' ); + + // Untracked local file (consumer-owned proposal) is left alone. + file_put_contents( ABSPATH . 'content/wiki/articles/local-only.md', "proposal\n" ); + $pull4 = $gs->pull( 'wiki' ); + $assert( is_file( ABSPATH . 'content/wiki/articles/local-only.md' ), 'untracked local file preserved across pull' ); + + // ========================================================================= + // 6. Conflict: tracked file modified locally, upstream also changed. + // ========================================================================= + echo "\nPull conflict (fail policy)\n"; + file_put_contents( ABSPATH . 'content/wiki/articles/a.md', "article a local-edit\n" ); + $content_a_v2 = "article a v2\n"; + $sha_a_v2 = $blob_sha( $content_a_v2 ); + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/trees/main'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'sha' => 'tree-sha-3', + 'tree' => array( + array( 'path' => 'articles/a.md', 'type' => 'blob', 'sha' => $sha_a_v2 ), + ), + ) ), + ); + $conflicted = $gs->pull( 'wiki' ); + $assert( is_array( $conflicted['conflicts'] ?? null ) && 1 === count( $conflicted['conflicts'] ), 'conflict surfaced under fail policy' ); + $assert( false === ( $conflicted['success'] ?? true ), 'conflicted pull reports success=false' ); + $assert( "article a local-edit\n" === file_get_contents( ABSPATH . 'content/wiki/articles/a.md' ), 'local edit preserved under fail policy' ); + + // upstream_wins overrides. + $gs->updatePolicy( 'wiki', array( 'conflict' => 'upstream_wins' ) ); + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/contents/articles/a.md'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'content' => base64_encode( $content_a_v2 ), 'encoding' => 'base64', 'sha' => $sha_a_v2 ) ), + ); + $wins = $gs->pull( 'wiki' ); + $assert( ! is_wp_error( $wins ), 'pull under upstream_wins succeeded' ); + $assert( $content_a_v2 === file_get_contents( ABSPATH . 'content/wiki/articles/a.md' ), 'upstream_wins overwrote local edit' ); + + // ========================================================================= + // 7. Submit — write path, gated by policy. + // ========================================================================= + echo "\nSubmit (gated)\n"; + $r = $gs->submit( 'wiki', array( 'message' => 'update article a content' ) ); + $assert( is_wp_error( $r ) && 'write_disabled' === $r->get_error_code(), 'submit refused without write_enabled' ); + + $gs->updatePolicy( 'wiki', array( 'write_enabled' => true ) ); + $r = $gs->submit( 'wiki', array( 'message' => 'update article a content' ) ); + $assert( is_wp_error( $r ) && 'no_allowed_paths' === $r->get_error_code(), 'submit refused without allowed_paths' ); + + $gs->updatePolicy( 'wiki', array( 'allowed_paths' => array( 'articles/' ) ) ); + // Clean up the untracked-probe file from the earlier pull test — it + // would otherwise become a valid "new file" candidate for submit and + // require its own PUT mock. + @unlink( ABSPATH . 'content/wiki/articles/local-only.md' ); + // Edit locally, then submit. + file_put_contents( ABSPATH . 'content/wiki/articles/a.md', "article a v3 proposal\n" ); + $sha_a_v3 = $blob_sha( "article a v3 proposal\n" ); + + // Mock GitHub ref + branch + PR endpoints for submit flow. + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/ref/heads/main'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'object' => array( 'sha' => 'base-sha-1' ) ) ), + ); + // Tree for submit's context — same as last pull. + // (Already mocked above.) + // Feature branch doesn't exist yet → 404 + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/ref/heads/gitsync/wiki'] = array( + 'response' => array( 'code' => 404 ), + 'body' => json_encode( array( 'message' => 'Not Found' ) ), + ); + $GLOBALS['__dmc_http_mock']['POST https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/refs'] = array( + 'response' => array( 'code' => 201 ), + 'body' => json_encode( array( 'ref' => 'refs/heads/gitsync/wiki', 'object' => array( 'sha' => 'base-sha-1' ) ) ), + ); + $GLOBALS['__dmc_http_mock']['PUT https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/contents/articles/a.md'] = array( + 'response' => array( 'code' => 201 ), + 'body' => json_encode( array( 'content' => array( 'sha' => $sha_a_v3 ), 'commit' => array( 'sha' => 'commit-1' ) ) ), + ); + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/pulls'] = array( + 'response' => array( 'code' => 200 ), + 'body' => '[]', + ); + $GLOBALS['__dmc_http_mock']['POST https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/pulls'] = array( + 'response' => array( 'code' => 201 ), + 'body' => json_encode( array( 'number' => 7, 'html_url' => 'https://github.com/a/b/pull/7', 'state' => 'open' ) ), + ); + + $submit = $gs->submit( 'wiki', array( 'message' => 'update article a content' ) ); + $assert( ! is_wp_error( $submit ), 'submit succeeded — ' . ( is_wp_error( $submit ) ? $submit->get_error_message() : '' ) ); + $assert( 'gitsync/wiki' === ( $submit['branch'] ?? null ), 'feature branch is gitsync/wiki' ); + $assert( 7 === ( $submit['pr']['number'] ?? null ), 'PR #7 opened' ); + $assert( 'opened' === ( $submit['pr']['action'] ?? null ), 'PR reported as opened' ); + + // ========================================================================= + // 8. Submit again — existing branch + PR is updated in place. + // ========================================================================= + echo "\nSubmit (update existing PR)\n"; + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/ref/heads/gitsync/wiki'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'object' => array( 'sha' => 'feature-old-sha' ) ) ), + ); + $GLOBALS['__dmc_http_mock']['PATCH https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/refs/heads/gitsync/wiki'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'object' => array( 'sha' => 'base-sha-1' ) ) ), + ); + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/pulls'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + array( 'number' => 7, 'html_url' => 'https://github.com/a/b/pull/7', 'state' => 'open' ), + ) ), + ); + $GLOBALS['__dmc_http_mock']['PATCH https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/pulls/7'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'number' => 7, 'html_url' => 'https://github.com/a/b/pull/7', 'state' => 'open' ) ), + ); + file_put_contents( ABSPATH . 'content/wiki/articles/a.md', "article a v4 proposal\n" ); + $submit2 = $gs->submit( 'wiki', array( 'message' => 'further update article a' ) ); + $assert( ! is_wp_error( $submit2 ), 'second submit succeeded' ); + $assert( 'updated' === ( $submit2['pr']['action'] ?? null ), 'PR reported as updated' ); + + // ========================================================================= + // 9. Submit with nothing changed → nothing_to_submit. + // ========================================================================= + echo "\nSubmit (nothing to propose)\n"; + // File's current SHA must match upstream tree's reported SHA. + $current = file_get_contents( ABSPATH . 'content/wiki/articles/a.md' ); + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/trees/main'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'sha' => 'tree-sha-4', + 'tree' => array( + array( 'path' => 'articles/a.md', 'type' => 'blob', 'sha' => $blob_sha( $current ) ), + ), + ) ), + ); + $nothing = $gs->submit( 'wiki', array( 'message' => 'should have nothing' ) ); + $assert( is_wp_error( $nothing ) && 'nothing_to_submit' === $nothing->get_error_code(), 'submit refuses when nothing changed' ); + + // ========================================================================= + // 10. Direct push — two-key auth. + // ========================================================================= + echo "\nPush (two-key auth)\n"; + file_put_contents( ABSPATH . 'content/wiki/articles/a.md', "article a v5 direct\n" ); + $r = $gs->push( 'wiki', array( 'message' => 'direct update' ) ); + $assert( is_wp_error( $r ) && 'direct_push_blocked' === $r->get_error_code(), 'push refused without safe_direct_push' ); + + $gs->updatePolicy( 'wiki', array( 'safe_direct_push' => true ) ); + $GLOBALS['__dmc_http_mock']['PUT https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/contents/articles/a.md'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( 'content' => array( 'sha' => 'sha-after-direct' ), 'commit' => array( 'sha' => 'commit-2' ) ) ), + ); + // Submit refreshed upstream tree mock so push's context reads it as changed. + $sha_old_on_upstream = $blob_sha( "article a v3 proposal\n" ); // still v3 upstream + $GLOBALS['__dmc_http_mock']['GET https://api.github.com/repos/Automattic/a8c-wiki-woocommerce/git/trees/main'] = array( + 'response' => array( 'code' => 200 ), + 'body' => json_encode( array( + 'sha' => 'tree-sha-5', + 'tree' => array( + array( 'path' => 'articles/a.md', 'type' => 'blob', 'sha' => $sha_old_on_upstream ), + ), + ) ), + ); + $push = $gs->push( 'wiki', array( 'message' => 'direct update' ) ); + $assert( ! is_wp_error( $push ), 'push with both keys succeeded — ' . ( is_wp_error( $push ) ? $push->get_error_message() : '' ) ); + $assert( 1 === count( (array) ( $push['commits'] ?? array() ) ), 'push recorded 1 commit' ); + + // ========================================================================= + // 11. Status + list + unbind round-trip. + // ========================================================================= + echo "\nStatus + list + unbind\n"; + $st = $gs->status( 'wiki' ); + $assert( ! is_wp_error( $st ) && 'wiki' === $st['slug'], 'status returns binding' ); + $assert( true === ( $st['exists'] ?? false ), 'status reports exists=true' ); + + $l = $gs->list_bindings(); + $assert( 1 === count( $l['bindings'] ?? array() ), 'list returns 1 binding' ); + + $u = $gs->unbind( 'wiki' ); + $assert( ! is_wp_error( $u ) && false === ( $u['purged'] ?? true ), 'unbind preserved directory by default' ); + $assert( is_dir( ABSPATH . 'content/wiki/' ), 'directory still exists after non-purge unbind' ); + + // Re-bind and purge. + $gs->bind( array( 'slug' => 'wiki', 'local_path' => '/content/wiki/', 'remote_url' => 'https://github.com/Automattic/a8c-wiki-woocommerce' ) ); + $p = $gs->unbind( 'wiki', true ); + $assert( ! is_wp_error( $p ) && true === ( $p['purged'] ?? false ), 'purge unbind succeeded' ); + $assert( ! is_dir( ABSPATH . 'content/wiki/' ), 'directory removed by purge' ); + + // ========================================================================= + // 12. Policy validation. + // ========================================================================= + echo "\nPolicy validation\n"; + $gs->bind( array( 'slug' => 'p', 'local_path' => '/content/p/', 'remote_url' => 'https://github.com/Automattic/a8c-wiki-woocommerce' ) ); + $bad = $gs->updatePolicy( 'p', array( 'bogus' => true ) ); + $assert( is_wp_error( $bad ) && 'unknown_policy_key' === $bad->get_error_code(), 'rejects unknown policy key' ); + + $orphan = $gs->updatePolicy( 'p', array( 'safe_direct_push' => true ) ); + $assert( is_wp_error( $orphan ) && 'policy_conflict' === $orphan->get_error_code(), 'rejects safe_direct_push without write_enabled' ); + + $bad_conflict = $gs->updatePolicy( 'p', array( 'conflict' => 'nuke' ) ); + $assert( is_wp_error( $bad_conflict ) && 'invalid_conflict_strategy' === $bad_conflict->get_error_code(), 'rejects unknown conflict strategy' ); echo "\nResult: " . ( $total - $failures ) . "/{$total} passed\n"; exit( $failures > 0 ? 1 : 0 );