Skip to content

refactor(gitsync): rebuild on GitHub Contents API for managed-hosting compatibility (#38)#42

Merged
chubes4 merged 1 commit intomainfrom
feat/gitsync-api-first
Apr 20, 2026
Merged

refactor(gitsync): rebuild on GitHub Contents API for managed-hosting compatibility (#38)#42
chubes4 merged 1 commit intomainfrom
feat/gitsync-api-first

Conversation

@chubes4
Copy link
Copy Markdown
Member

@chubes4 chubes4 commented Apr 20, 2026

Replaces the shell-git GitSync from #39 + #41 with an API-first implementation that works on every WordPress host — self-hosted, WordPress.com Business, WP Engine, Pantheon, Kinsta, local dev. Same ability contracts; completely different implementation.

Why

Phase 1 + 2 (shipped in #39 + #41) shelled out to the local `git` binary. That ruled out every managed host — `exec()` disabled, no `git` in PATH, no writable dirs outside `wp-content`. Not a viable long-term substrate for Intelligence's wiki sync or Matt's personal-agent scenarios.

DMC is already hybrid:

  • `Workspace` = shell git (for agent code-editing workflows where interactive rebase, multi-file staging, and local branching matter)
  • `GitHubAbilities` = REST API (issues, PRs, file CRUD via `apiGet`/`apiRequest`)

GitSync's actual use cases — wiki content, agent definitions — are file-level content sync that maps cleanly to `PUT /repos/:slug/contents/:path`. The shell-git inheritance from Workspace was the wrong primary layer. Rebuilding on GitHubAbilities' surface collapses ~2000 LOC of stash/branch/rebase orchestration to straight HTTP calls.

What changes

Gone

  • `inc/GitSync/GitSyncSubmitter.php` — the shell-git orchestrator (stash → reset → checkout -B → pop → push). Replaced by 5 HTTP calls.
  • `inc/GitSync/GitRepo.php` — local-git readback helpers. No local git state to read.
  • `tests/smoke-gitsync-write.php` — merged into unified smoke.
  • 2 abilities: `gitsync-add`, `gitsync-commit` — no staging concept in API-first.
  • `GitHubRemote::pushUrlWithPat()` — no URL-based push anymore.

New

  • `inc/GitSync/GitSyncFetcher.php` — pull via `GET /git/trees/:branch` → compare git-blob SHAs → `GET /contents/:path` for mismatches. Computes `sha1("blob " + len + "\0" + content)` to match GitHub's own blob SHAs, so no git binary needed for comparison.
  • `inc/GitSync/GitSyncProposer.php` — submit (feature branch + PR) and push (direct to pinned) via `createOrUpdateFile` + ref management.

Rewritten

  • `GitSync` facade — delegates pull to Fetcher, submit/push to Proposer. No internal git ops.
  • `GitSyncBinding` — adds `pulled_paths` (delete detection), tightens `remote_url` validation to GitHub URLs only.
  • `GitSyncAbilities` — 8 abilities (down from 10).
  • `GitSyncCommand` — 8 matching CLI subcommands.
  • `docs/gitsync.md` — full rewrite explaining API-first model.

Smokes

  • `tests/smoke-gitsync.php` — 45 assertions, all green. Zero network (GitHub API mocked via `wp_remote_request` stub). Covers:
    • Input validation (slug, remote URL, traversal, sensitive path)
    • Bind (registry-only, no disk materialization)
    • Pull: initial, idempotent, upstream-deletion propagation, untracked-file preservation, conflict under `fail` policy, conflict under `upstream_wins`
    • Submit: gate enforcement, feature branch creation, PR open, PR update (re-submit), nothing-to-submit
    • Push: two-key auth enforcement, direct commit on both keys set
    • Status / list / unbind / purge round-trip
    • Policy validation (unknown key, orphan `safe_direct_push`, bad conflict strategy)
  • `tests/smoke-worktree-handles.php` — 32/32 green (Workspace untouched).

Surface summary

Ability CLI REST Purpose
`gitsync-list` `gitsync list` List bindings
`gitsync-status` `gitsync status ` Binding detail
`gitsync-bind` `gitsync bind ` Register binding (no disk work)
`gitsync-unbind` `gitsync unbind [--purge]` Remove binding
`gitsync-pull` `gitsync pull ` Download files via Contents API
`gitsync-submit` `gitsync submit --message=…` PR flow on `gitsync/`
`gitsync-push` `gitsync push --message=…` Direct to pinned (two-key auth)
`gitsync-policy-update` `gitsync policy --flag=…` Update policy fields

Known limitations

  • GitHub-only. Non-GitHub remotes refused at bind time. Pluggable backends (GitLab, Gitea) are a future concern — the Proposer is the only class that needs to grow backend awareness.
  • Per-file commits on submit. A submit with N changed files produces N commits. PR reviewers still see the aggregate diff; collapsing to one commit per submit via the Git Data API (single tree + commit + ref update) is a follow-up optimization.
  • No deletion propagation on submit. Modifications and additions propagate; deletions don't yet. Manual workaround documented.
  • Scheduled sync not implemented. `auto_pull` / `pull_interval` are stored metadata that a future task can consume.

Files

  • +1796 / -2597 — net simplification.
  • Touches: 12 files (6 modified, 3 new, 3 deleted).

Next

With this landed, GitSync is ready to be consumed — but (per the separate concern Chris raised in the discussion thread) the DB ↔ disk bridge for each consumer CPT lives outside this PR. Intelligence#31 and Intelligence#125 each need their own bridge that turns DB records into disk files (for submit) and vice versa (after pull). That's a consumer concern; GitSync's contract is disk ↔ upstream only. Deliberate boundary.

Refs: #38

… compatibility (#38)

Rebuilds GitSync on top of the GitHub REST API (Contents + Git Data)
instead of shelling out to a local git binary. Same ability contracts,
completely different implementation — works identically on self-hosted,
WordPress.com Business, WP Engine, Pantheon, and local dev.

## Why

Phase 1 + 2 shelled out to git via Workspace-style primitives. That
locked GitSync out of every managed host (exec disabled, no git binary,
no writable dirs outside wp-content). Since the actual consumer use
cases — wiki content sync, agent definition sync — are file-level
operations that map cleanly to 'PUT /repos/:slug/contents/:path',
shell-git was the wrong tool for the job.

DMC is already hybrid:
  - Workspace = shell git (for agent code-editing workflows)
  - GitHubAbilities = REST API (for issues, PRs, file CRUD)

GitSync fits firmly in the second category — its needs were never
the shell-git ones (no interactive rebase, no multi-file staging,
no local branches). Rebuilding on GitHubAbilities' surface collapses
~2000 LOC of stash/branch/rebase orchestration to ~1100 LOC of
straight HTTP calls, and works on every WordPress host.

## Changes

New files:
  - inc/GitSync/GitSyncFetcher.php — pull implementation. Reads the
    recursive tree via GET git/trees/:branch, compares each blob's
    SHA to a local file's git-blob-sha (sha1('blob '+len+'\0'+content),
    the same formula GitHub uses), GETs mismatched blobs via the
    Contents API, writes to disk. Tracks pulled_paths so upstream
    deletions propagate locally while consumer-added files stay
    untouched. Handles the Contents API's 1MB truncation by falling
    back to GET /git/blobs/:sha.
  - inc/GitSync/GitSyncProposer.php — submit + push implementation.
    Submit: GET /git/ref/heads/:pinned for base SHA, ensure
    gitsync/<slug> exists at base SHA via POST or PATCH /git/refs,
    PUT /contents/:path for each changed file on the feature branch,
    open or PATCH the PR via /pulls. Push: same but targets pinned
    branch directly (gated by two-key auth: write_enabled AND
    safe_direct_push).

Deleted files:
  - inc/GitSync/GitSyncSubmitter.php — the shell-git orchestrator
    with its stash/reset/checkout-B/pop/push dance. Replaced by
    GitSyncProposer which does the same job via five HTTP calls.
  - inc/GitSync/GitRepo.php — readHead/readBranch/countDirty shell
    helpers. No local git state to read anymore; upstream state
    comes from the API directly.

Rewritten:
  - inc/GitSync/GitSync.php — facade slimmed to bind/unbind/pull/
    submit/push/status/list/updatePolicy. Delegates pull to Fetcher,
    submit/push to Proposer. No internal git ops.
  - inc/GitSync/GitSyncBinding.php — policy shape slightly simplified:
    add pulled_paths (list of files this binding owns on disk, for
    delete detection); tighten remote_url validation to GitHub URLs
    only (file:// dropped — no local-remote test case anymore, smoke
    uses HTTP mocking instead). DEFAULT_POLICY retains write_enabled,
    safe_direct_push, allowed_paths, conflict; auto_pull/pull_interval
    stay as future-scheduler hints.
  - inc/Abilities/GitSyncAbilities.php — 8 abilities (was 10). Dropped
    gitsync-add and gitsync-commit since the API-first flow has no
    separate staging step — consumer writes files to disk, calls
    submit directly, Proposer diffs and uploads. Kept bind, unbind,
    list, status, pull, submit, push, policy-update.
  - inc/Cli/Commands/GitSyncCommand.php — corresponding CLI surface.
  - inc/Support/GitHubRemote.php — pushUrlWithPat() dropped (no URL
    push happens anymore). Kept isGitHubRemote, slug, apiUrl.
  - tests/smoke-gitsync.php — 45-assertion end-to-end smoke using
    wp_remote_request mocking. Covers: validation, bind, pull,
    idempotent re-pull, upstream-deletion propagation with untracked-
    file preservation, conflict policies (fail/upstream_wins),
    submit gate enforcement, submit creates PR, submit updates PR,
    nothing-to-submit, push two-key auth, status/list/unbind/purge,
    policy validation (unknown keys, orphan safe_direct_push, bad
    conflict strategy). Runs in ~100ms with zero network.

Deleted:
  - tests/smoke-gitsync-write.php — merged into tests/smoke-gitsync.php
    since the API-first implementation doesn't have separate read/write
    test paths.

## Smokes

  tests/smoke-gitsync.php         45/45 passing
  tests/smoke-worktree-handles.php 32/32 passing (Workspace unchanged)

## Binding shape migration

Phase 1+2 shipped briefly between #39 and this PR. Any binding saved
by those versions rehydrates cleanly here via GitSyncBinding::fromArray
(pulled_paths defaults to empty, file:// remotes get rejected on first
re-save). No explicit migration — fresh installs are unaffected.

## Known limitations

  - GitHub-only. Non-GitHub remotes refused at bind time.
  - Per-file commits on submit (N files = N commits). PR reviewers
    see the aggregate diff; Git Data API optimization (single
    tree + commit + ref update) is a follow-up.
  - No deletion propagation on submit. Deleting a file upstream is
    a manual step, then pull.
  - Scheduled sync not implemented. auto_pull/pull_interval are
    stored metadata that a future task can consume.

Refs: #38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant