feat(gitsync): Phase 2 — write path + sticky proposal branch submit (#38)#41
Merged
feat(gitsync): Phase 2 — write path + sticky proposal branch submit (#38)#41
Conversation
) 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 byPermissionHelper::can_manage().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 onegitsync/<slug>feature branch on the remote, force-pushed in place on every submit, single PR updated via GitHub API.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.GitHubAbilities::apiGet/apiRequest.Smokes
tests/smoke-gitsync-write.php— 35/35 green (local bare repo as fake remote, GitHub API mocked viawp_remote_requeststub)tests/smoke-gitsync.php(Phase 1) — 32/32 greentests/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.