Skip to content

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

Merged
chubes4 merged 1 commit intomainfrom
feat/gitsync-phase-2
Apr 20, 2026
Merged

feat(gitsync): Phase 2 — write path + sticky proposal branch submit (#38)#41
chubes4 merged 1 commit intomainfrom
feat/gitsync-phase-2

Conversation

@chubes4
Copy link
Copy Markdown
Member

@chubes4 chubes4 commented Apr 20, 2026

Phase 2 of GitSync (DMC#38) — the write path. Originally PR #40; that PR auto-closed when its base branch was deleted after #39 merged. Same commit, retargeted to main.

See the original PR #40 thread for full discussion and review.

Summary

Layers a full write path on top of Phase 1's read surface:

  • add / commit / push / submit / policy-update — 5 new abilities, all CLI-only, all gated by PermissionHelper::can_manage().
  • Four-flag policy surface: write_enabled, push_enabled, safe_direct_push (two-key auth for direct push to pinned branch), allowed_paths (staging allowlist).
  • submit = sticky proposal branch — each binding gets one gitsync/<slug> feature branch on the remote, force-pushed in place on every submit, single PR updated via GitHub API.
  • Clean layering — shared Support/GitHubRemote (host detection, slug parsing, PAT injection, API URL template) used by GitSync, GitSyncSubmitter, AND refactored back into Workspace. Zero duplication across the codebase.
  • Zero new HTTP wrapping — Submitter routes through existing GitHubAbilities::apiGet/apiRequest.

Smokes

  • tests/smoke-gitsync-write.php35/35 green (local bare repo as fake remote, GitHub API mocked via wp_remote_request stub)
  • tests/smoke-gitsync.php (Phase 1) — 32/32 green
  • tests/smoke-worktree-handles.php — 32/32 green (Workspace refactor clean)

All 10 datamachine/gitsync-* abilities verified registered in Studio.

Files

New: inc/GitSync/GitRepo.php, inc/GitSync/GitSyncSubmitter.php, inc/Support/GitHubRemote.php, tests/smoke-gitsync-write.php.
Modified: inc/GitSync/GitSync.php, inc/GitSync/GitSyncBinding.php, inc/Abilities/GitSyncAbilities.php, inc/Cli/Commands/GitSyncCommand.php, inc/Support/PathSecurity.php, inc/Workspace/Workspace.php, tests/smoke-gitsync.php, docs/gitsync.md.

Issue #38 stays open — Phase 3 (scheduled sync) is deferred until a real consumer tells us what cadence pattern they actually need.

)

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