Skip to content

feat(gitsync): Phase 2 — write path + sticky proposal branch submit (#38)#40

Closed
chubes4 wants to merge 1 commit intofeat/gitsync-phase-1from
feat/gitsync-phase-2
Closed

feat(gitsync): Phase 2 — write path + sticky proposal branch submit (#38)#40
chubes4 wants to merge 1 commit intofeat/gitsync-phase-1from
feat/gitsync-phase-2

Conversation

@chubes4
Copy link
Copy Markdown
Member

@chubes4 chubes4 commented Apr 20, 2026

Stacked on #39. Review #39 first; this PR's diff is only Phase 2 changes.

Summary

Ships Phase 2 of GitSync (DMC#38) — the write path. Layers on top of Phase 1's read surface (bind/unbind/pull/status/list) with five new abilities that let a binding propose changes upstream. Default posture stays conservative: freshly-bound bindings are read-only until writes are explicitly opened up per-binding.

Implements Model B per design discussion: add/commit/push primitives plus a blessed submit that encodes the PR-based flow.

New abilities (5)

Ability CLI Description
datamachine/gitsync-add gitsync add <slug> <paths>… Stage paths. allowed_paths + sensitive-file filter enforced.
datamachine/gitsync-commit gitsync commit <slug> --message=… Commit staged changes. 8–200 char message.
datamachine/gitsync-push gitsync push <slug> [--force] Direct push to pinned branch. Two-key auth required.
datamachine/gitsync-submit gitsync submit <slug> --message=… Blessed PR flow on the sticky proposal branch.
datamachine/gitsync-policy-update gitsync policy <slug> --flag=… Modify policy fields on an existing binding.

All CLI-only (show_in_rest=false), all gated by PermissionHelper::can_manage().

Policy gates

Four flags on each binding now control the write surface:

Flag Default Gates
write_enabled false add + commit
push_enabled false push + submit
safe_direct_push false Second key for direct push to the pinned branch
allowed_paths [] Every staged path must sit under one of these roots

safe_direct_push=true requires push_enabled=true — orphan combinations refused at updatePolicy time.

Typical progression:

# Start 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

# Open writes + submit (PR flow), restricted to articles/
wp datamachine-code gitsync policy wiki \
  --write-enabled=true --push-enabled=true \
  --allowed-paths=articles/,images/

# Propose a change upstream via PR
wp datamachine-code gitsync submit wiki \
  --message="Add CIAB kickoff article"

submit — sticky proposal branch

Each binding gets one feature branch on the remote: gitsync/<slug>. Every submit rewrites it from fresh upstream + user's edits and opens (or updates) a single PR. The branch represents the latest proposal from this binding — never accumulates per-submit branches.

Orchestration (GitSyncSubmitter::submit):

  1. git fetch origin --prune
  2. Stash dirty + untracked files on the pinned branch
  3. git reset --hard origin/<pinned>
  4. git checkout -B gitsync/<slug>
  5. git stash pop
  6. Stage allowed paths (explicit --paths= list, or derived from dirty set under allowed_paths)
  7. git commit
  8. git push --force origin gitsync/<slug> (we own this branch exclusively)
  9. Open/update PR via GitHubAbilities (existing PAT)
  10. git checkout <pinned> — always, even on failure

Stash-pop conflicts leave the stash in place with a logged warning so edits are recoverable via git stash list.

Phase 2 supports github.com remotes only for submit — non-GitHub remotes error with unsupported_remote. Pluggable PR backends (GitLab MR, Gitea) are Phase 3+.

Why two keys on direct push?

Bindings model a read-synchronized mirror that tracks upstream. Default posture says "changes go upstream via PR review" (submit). push_enabled alone lets submit work while keeping direct push to the tracked branch gated behind a second, deliberate flag. Half-opting-in (push via submit only) stays safe by default; full direct-push requires explicit intent.

Auth

  • github.com remotes: existing GitHubAbilities::getPat() injected into the push URL as https://<token>@github.com/.... Same pattern as homeboy and other DMC git-writing code.
  • Other https remotes: fall back to system credential.helper.
  • SSH + non-GitHub PRs: Phase 3+.

Shared helper extension

Support/PathSecurity gains isPathAllowed(relative, roots). Keeps the empty allowlist = nothing allowed contract in the shared helper so Workspace and GitSync can converge on it if useful later.

Binding shape addition

  • GitSyncBinding::DEFAULT_POLICY adds safe_direct_push => false.
  • Existing Phase 1 bindings rehydrate with the default via fromArray's array_merge; no migration required.
  • GitSyncBinding::create now also accepts file:// remote URLs alongside https://, http://, and git@. Enables local bare-repo smoke testing without meaningfully loosening production validation (git clone refuses non-repo file:// paths).

Testing

tests/smoke-gitsync-write.php — 35 assertions, all green.

Uses a local bare repo as the fake remote (zero network, zero credentials). GitHub API calls are mocked via a wp_remote_request stub that captures request shape for assertion.

Coverage:

  • Policy gate enforcement: write_disabled, push_disabled, direct_push_blocked, no_allowed_paths, unknown_policy_key, policy_conflict for orphan safe_direct_push, path_not_allowed, sensitive_path
  • add + commit + direct push against the bare remote (verifies commit lands on remote)
  • submit orchestration: PAT injection into push URL, GET → POST on first run, GET → PATCH on idempotent re-run, sticky gitsync/<slug> branch on remote, working tree returned to pinned after both success and failure paths
  • Empty-submit refusal when nothing is staged under allowed_paths

Uses git config url.<path>.insteadOf scoped to each clone (no global pollution) to route PAT-injected https://github.com/... URLs back to the bare repo.

Regression check: Phase 1 smokes stay green (32/32 + 32/32).

CLI registration confirmed in Studio:

$ studio wp help datamachine-code gitsync
SUBCOMMANDS
  add     bind    commit  list    policy
  pull    push    status  submit  unbind

All 10 abilities confirmed registered.

Files

  • New: 2 (GitSyncSubmitter.php, smoke-gitsync-write.php)
  • Modified: 6 (GitSync.php, GitSyncBinding.php, GitSyncAbilities.php, GitSyncCommand.php, PathSecurity.php, docs/gitsync.md)

What's next

Phase 3GitSyncPullTask extends SystemTask + datamachine_gitsync_tick recurring schedule (hourly, opt-in PluginSetting), honoring per-binding auto_pull + pull_interval. Issue #38 stays open until Phase 3 lands.

)

Adds the full write path to GitSync, layering onto Phase 1's read
surface: add, commit, push, submit (the blessed PR flow), and
policy-update. Every mutating op runs through policy gates, so a
freshly-bound binding stays read-only until writes are explicitly
opened up per-binding.

## New abilities (all CLI-only)

- datamachine/gitsync-add — stage paths, allowlist enforced
- datamachine/gitsync-commit — 8–200 char message, refuses empty
- datamachine/gitsync-push — direct push, two-key auth required
- datamachine/gitsync-submit — sticky proposal branch + PR
- datamachine/gitsync-policy-update — modify policy fields

## Policy gates

Four flags on each binding control the write surface:

- write_enabled        — gates add + commit
- push_enabled         — gates push + submit
- safe_direct_push     — second key for push to the pinned branch.
                         push_enabled alone isn't enough — intentional
                         friction so direct push requires deliberate
                         intent. submit() bypasses this because it
                         pushes to a feature branch, not the tracked
                         one.
- allowed_paths        — empty allowlist = nothing stageable. Every
                         staged path must sit under one of the listed
                         roots. Sensitive-file filter (.env, *.key,
                         credentials.json, etc.) always applies on top.

updatePolicy validates:
  - unknown keys refused
  - safe_direct_push without push_enabled refused
  - invalid conflict strategies refused
  - allowed_paths must be an array

## Submit — sticky proposal branch

Each binding gets exactly one feature branch on the remote:
`gitsync/<slug>`. Submit rewrites it from fresh upstream + user's
edits and opens (or updates) a single PR. No per-submit branches
accumulating; the branch represents 'the latest proposal from this
binding' and updates in place.

Orchestration (GitSyncSubmitter):

  1. fetch origin --prune
  2. stash dirty + untracked
  3. reset --hard origin/<pinned>
  4. checkout -B gitsync/<slug>
  5. stash pop
  6. stage allowed paths (explicit --paths or derived from dirty)
  7. commit
  8. push --force origin gitsync/<slug>  (exclusive ownership)
  9. open/update PR via GitHubAbilities
 10. checkout <pinned>  (always, even on failure)

stash-pop conflicts leave the stash in place with a logged warning
so edits are recoverable via `git stash list`.

Phase 2 supports github.com remotes only for submit (PR backend is
GitHubAbilities). Non-GitHub remotes error with unsupported_remote;
pluggable backends are Phase 3+.

## Auth

github.com push URL is rewritten to include the existing
GitHubAbilities PAT (same pattern as homeboy and other DMC git-
writing code). Non-GitHub remotes rely on the system's
credential.helper. SSH is Phase 3+.

## Shared helper extension

Support/PathSecurity gains isPathAllowed(relative, roots) — the
empty-allowlist-is-empty-whitelist contract is central to how
allowed_paths works, so keeping it in the shared helper means
Workspace and GitSync can converge on it if needed later.

## Smoke — local bare repo as fake remote

tests/smoke-gitsync-write.php — 35-assertion pure-PHP e2e covering:

  - Policy gate enforcement (write_disabled, push_disabled,
    direct_push_blocked, no_allowed_paths, unknown_policy_key,
    policy_conflict for orphan safe_direct_push, path_not_allowed,
    sensitive_path)
  - add + commit + direct push against file:// bare repo
  - submit orchestration with GitHub API mocked via wp_remote_request
    stub — verifies PAT header, GET→POST on first run, GET→PATCH on
    idempotent re-run, sticky gitsync/<slug> branch on remote,
    working tree returned to pinned after success and failure
  - Empty-submit refusal when nothing is staged under allowed_paths

Uses git's url.<path>.insteadOf config scoped to the clone (no global
pollution) to route PAT-injected https URLs back to the bare repo.

Phase 1 smokes (32/32 + 32/32) stay green — no regressions.

## Binding shape addition

GitSyncBinding::DEFAULT_POLICY adds `safe_direct_push => false`.
Existing Phase 1 bindings rehydrate with the default via fromArray's
array_merge; no migration required.

GitSyncBinding::create also accepts file:// remote_url in addition
to https://, http://, and git@. Enables local bare-repo testing
without loosening production validation meaningfully — git clone
refuses non-repo file:// paths, so no attack surface added.

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