feat(agentctl): push to a caller-named remote and branch, not just origin (#3558)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-13 06:01:44 +08:00
committed by GitHub
parent 2dc1596667
commit df3c9142ab
26 changed files with 3883 additions and 146 deletions
+276
View File
@@ -0,0 +1,276 @@
---
created: 2026-09-08
status: implemented
requirements:
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
system_design:
- ../../specs/workspaces/system-design/configured-push-target.md
legacy_specs: []
---
# Implementation Plan: Configured Push Target
## Overview
The workspace push contract can publish only to `origin` or to a remote that a
contribution binding selects, and push preflight reports success without
contacting a remote at all on the ordinary path. This work package adds two
optional inputs to the push and push-preflight contracts, an explicit push
target and an expected branch, and makes preflight validate the destination it
would actually use.
Implementation runs in four work orders. Task 01 lands the vocabulary the rest
of the package needs: the request options carried into the operator, the eleven
error codes, the five result fields, and the pure resolution and validation
helpers, with no behavior change. Task 02 rewires the push path onto those
helpers, which is where the refusal ordering and the two-point expected-branch
verification take effect. Task 04 carries the two inputs across the HTTP
surface, the runtime client, and the backend action handler; it touches files
disjoint from Task 02 and depends only on Task 01's signatures, so the two run
in the same wave. Task 03 lands last because preflight consumes the shared
resolution and guard call sites that Task 02 establishes.
This order exists so that no work order changes a destination decision and its
transport in the same pass: the operator owns every new decision, and the
transport work carries values it does not interpret.
## Scope
### In scope
- An optional explicit push target on the push and push-preflight contracts,
accepted as a configured remote name or as a remote URL resolved against
already-configured remotes.
- An optional expected branch on both contracts, verified twice on the push
path and once on preflight.
- The fixed refusal ordering and the eleven stable error codes.
- Push preflight validating the destination-branch refspec against the resolved
remote, including the `origin` path that reports blanket success today.
- The five optional result fields, with the push/preflight asymmetry the design
requires.
- Transport plumbing for both inputs across agentctl HTTP, the runtime client,
and the backend workspace Git action handler.
### Out of scope
- Exact-OID `--force-with-lease=<ref>:<oid>`. Force keeps emitting the bare
lease and stays refused under contribution routing.
- Auto-push policy. This package only makes such a caller expressible.
- Creating, renaming, retargeting, or deleting a remote.
- Any change to remote-contribution or contribution-destination routing, their
force refusal, or their existing preflight behavior.
- Any web, desktop, or mobile control. The existing push control keeps sending
neither input, so this package ships no user-visible surface.
- Preflighting the empty-remote baseline refspec.
- Pushing tags, multiple refspecs, or deleting a remote branch.
## Technical approach
### Operator primitives (`internal/agentctl/server/process`)
A new `git_push_target.go` owns everything the resolution and guard need, so the
decision logic sits in one file rather than growing `Push`:
- `PushOptions{Force, SetUpstream bool; Remote, ExpectedBranch string}` replaces
the two positional booleans on `GitOperator.Push` and is added to
`GitOperator.PushPreflight`, which takes no options today.
- Error-code constants for the eleven codes the design tables, alongside the
existing `emptyRemote*ErrorCode` constants.
- `configuredRemotes(ctx)` enumerates names with `git remote`, then reads each
name's `--get-all remote.<name>.pushurl` and `--get-all remote.<name>.url`.
The effective push URL set is the push URL list when non-empty, otherwise the
single-element fetch URL list, otherwise empty. A read failure yields
`push_remote_config_unreadable` rather than an absent or unmatched target.
- `resolvePushTarget` applies the trim, discriminates name from URL with
`securityutil.IsValidBranchName`, and returns a remote **name**. A
caller-supplied URL never reaches a Git command line, which is what keeps
`runGitCommand`'s existing argument allowlist unchanged.
- `currentBranch(ctx)` reads `git symbolic-ref HEAD` and trims `refs/heads/`.
A non-zero exit is the detached state, reported as an empty branch. This
replaces `getCurrentBranch`'s `rev-parse --abbrev-ref HEAD` on the new paths,
which returns the literal `HEAD` when detached and cannot satisfy
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.11. `symbolic-ref HEAD` needs no
flag and `HEAD` is already a safe literal, so the allowlist is untouched.
### Expected-branch validation (`internal/common/securityutil`)
The strict allowlist the expected branch must pass is `IsValidBranchName` plus
a reject on a trailing `/`, on `//`, and on the four symbolic pseudo-refs. The
existing `IsValidDefaultBranchName` composes exactly those checks but reaches
them through `IsValidBaseBranchRef`, which first strips an `origin/` prefix, so
an expected branch of `origin/main` would validate as `main` and pass. A new
`IsValidExpectedBranchName` applies the same checks without that strip.
### Push path (`Push`, `internal/agentctl/server/process/git.go`)
`Push` gains a `resolvePushPlan` step that evaluates the whole refusal list
before any remote is contacted, then keeps the existing structure. Two ordering
changes are load-bearing:
- Empty-remote first publication runs only when the resolved remote is `origin`
and no contribution routing applies.
- `getUpstreamRef` is read today after first publication and inside the argument
build. It moves ahead of the second expected-branch verification, so that the
branch read is the last Git command before the push
(AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.12).
Refspec selection is `HEAD:refs/heads/<expected>` with an explicit target and an
expected branch, `HEAD:refs/heads/<current>` with an explicit target alone, and
unchanged on every path that names no target. The `HEAD:refs/heads/` form is
already admitted by `runGitCommand`.
### Preflight (`PushPreflight`)
The contribution-destination and remote-contribution branches keep their current
bodies and result shape. The `g.remoteContribution == nil` early `result.Success
= true` return is replaced by the resolved-remote dry-run, defaulting to
`origin`, reporting `push_no_remote_configured` when no explicit target, no
contribution routing, and no `origin` remote exist. A successful preflight to
which contribution routing did not apply reports `pushed_remote` and
`pushed_branch`.
### Transport
- `internal/agentctl/server/api/git.go`: `GitPushRequest` and
`GitPushPreflightRequest` gain `remote` and `expected_branch`, bound and
forwarded without interpretation.
- `internal/agent/runtime/agentctl/git.go`: both result structs gain the five
fields; `GitPush` and `GitPushPreflight` carry the two inputs.
- `internal/agent/handlers/git_handlers.go`: the `worktree.push` payload gains
both fields and forwards them.
- `internal/agent/runtime/lifecycle/manager_startup.go` keeps passing neither,
so the startup remote-contribution preflight is unchanged.
### Output redaction
`runGitCommand` already routes every `push` invocation, dry-run included,
through `sanitizeGitPushOutput`, so the redaction
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18 requires is satisfied by
construction on both new paths. No second sanitization is added; see
[Open questions](#open-questions).
## Tests
| Acceptance criteria | Evidence |
| --- | --- |
| 001.1, 004.7 | `TestResolvePushTargetTrimsAndTreatsEmptyAsAbsent` in `internal/agentctl/server/process/git_push_target_test.go` |
| 001.3, 001.15 | `TestResolvePushTargetDiscriminatesNameFromURL`, `TestResolvePushTargetRejectsCaseAndPrefixVariants` in `git_push_target_test.go` |
| 001.4 | `TestResolvePushTargetReportsUnknownRemoteName` in `git_push_target_test.go` |
| 001.5, 001.6 | `TestResolvePushTargetMatchesSingleEntryPushURL`, `TestResolvePushTargetPicksFirstByByteOrder` in `git_push_target_test.go` |
| 001.7 | `TestResolvePushTargetReportsUnmatchedURL` in `git_push_target_test.go` |
| 001.16 | `TestResolvePushTargetReportsUnreadableRemoteConfig` in `git_push_target_test.go` |
| 001.17 | `TestResolvePushTargetReportsFanoutRemote`, `TestGitOperatorPushNamedFanoutRemotePublishesToEveryPushURL` in `git_push_target_test.go` |
| 002.2 | `TestIsValidExpectedBranchName` in `internal/common/securityutil/git_test.go` |
| 002.11 | `TestGitOperatorCurrentBranchReportsDetachedHeadAsEmpty` in `git_push_target_test.go` |
| 001.2 | `TestGitOperatorPushWithoutOptionsIsUnchanged` in `internal/agentctl/server/process/git_push_expected_branch_test.go` |
| 001.8 | `TestGitOperatorPushNeverCreatesRemote` in `git_push_expected_branch_test.go` |
| 001.9, 001.10 | `TestGitOperatorPushRefusesTargetUnderContributionRouting`, `TestGitOperatorPushRefusesTargetWithSetUpstream` in `git_push_expected_branch_test.go` |
| 001.11, 001.12 | `TestGitOperatorPushToNamedRemoteLeavesUpstreamUnset`, `TestGitOperatorPushToNamedRemoteSkipsBaselinePublication` in `git_push_expected_branch_test.go` |
| 001.13 | `TestGitOperatorPushToNamedRemoteStaysNonForce` in `git_push_expected_branch_test.go` |
| 001.14 | `TestGitOperatorPushResultAndLogsCarryNoRemoteURL` in `git_push_expected_branch_test.go` |
| 001.18 | `TestGitOperatorPushRedactsCredentialsOnExplicitTargetPath` in `git_push_expected_branch_test.go`; `TestPushPreflightRedactsCredentialsInRemoteOutput` in `git_push_preflight_test.go` |
| 002.1, 002.9 | `TestGitOperatorPushExpectedBranchAloneOnlyGates` in `git_push_expected_branch_test.go` |
| 002.3, 002.4 | `TestGitOperatorPushRefusesExpectedBranchMismatch`, `TestGitOperatorPushRefusesDetachedHeadAsMismatch` in `git_push_expected_branch_test.go` |
| 002.5, 002.12 | `TestGitOperatorPushVerifiesBranchAsLastReadBeforePush` in `git_push_expected_branch_test.go` |
| 002.6 | `TestGitOperatorPushReportsMismatchAfterBaselinePublication` in `git_push_expected_branch_test.go` |
| 002.7, 002.8, 002.10 | `TestGitOperatorPushPublishesHeadToExpectedBranch`, `TestGitOperatorPushRefusesDetachedHeadWithTargetOnly`, `TestGitOperatorPushCreatesMissingDestinationBranch` in `git_push_expected_branch_test.go` |
| 003.1, 003.7 | `TestPushPreflightAppliesSameRefusalOrdering`, `TestPushPreflightReportsValidatedRemoteAndBranch` in `internal/agentctl/server/process/git_push_preflight_test.go` |
| 003.2, 003.3 | `TestPushPreflightValidatesNamedRemote`, `TestPushPreflightValidatesOriginWithoutTarget` in `git_push_preflight_test.go` |
| 003.4 | `TestPushPreflightReportsNoRemoteConfigured` in `git_push_preflight_test.go` |
| 003.5 | `TestPushPreflightMutatesNothing` in `git_push_preflight_test.go` |
| 003.6 | `TestPushPreflightReportsRemoteRefusalOutput` in `git_push_preflight_test.go` |
| 004.1, 004.2 | `TestGitOperatorPushRefusalOrdering`, `TestGitOperatorPushRefusalsAreNoOps` in `git_push_expected_branch_test.go` |
| 004.3 | `TestGitOperatorPushReportsOperationInProgress` in `git_push_expected_branch_test.go` |
| 004.4, 004.5 | `TestGitOperatorPushRepeatedIdenticalRequestSucceeds`, `TestGitOperatorPushDoesNotEscalateRejectedNonForcePush` in `git_push_expected_branch_test.go` |
| 004.6 | `TestGitOperatorPushOmitsTargetFieldsWithoutExplicitTarget` in `git_push_expected_branch_test.go` |
| 004.8 | `TestGitPushRequestScopesTargetToSelectedRepo` in `internal/agentctl/server/api/git_handlers_test.go` |
| 001.1, 002.1 transport | `TestHandleGitPushBindsRemoteAndExpectedBranch` in `internal/agentctl/server/api/git_handlers_test.go`; `TestGitPushSendsPushOptions` in `internal/agent/runtime/agentctl/git_test.go`; `TestWsPushForwardsPushOptions` in `internal/agent/handlers/git_handlers_test.go` |
## E2E tests
None. The design places every web, desktop, and mobile surface out of scope,
and the existing push control keeps sending neither input, so this package adds
no user-visible behavior for a browser test to exercise. Evidence is the Go
unit and integration coverage above, which drives real Git repositories through
the existing `setupEmptyRemoteTaskRepo` fixture pattern.
## Work orders
- [x] [Task 01: Push-target and expected-branch primitives](task-01-resolution-primitives.md)
- [x] [Task 02: Explicit push target and expected-branch verification on the push path](task-02-push-path.md)
- [x] [Task 03: Push preflight validates the resolved destination](task-03-preflight.md)
- [x] [Task 04: Carry both inputs across the push transport](task-04-transport.md)
Dependency order: Task 01, then Tasks 02 and 04 together, then Task 03.
## Verification results
All four work orders are implemented and verified on the neo SSH runner.
- `make -C apps/backend lint` — 0 issues.
- `go test -tags fts5 ./internal/agentctl/server/process/...` — all push, preflight,
resolution and empty-remote tests pass.
- `go test -tags fts5 ./internal/agentctl/server/api/... ./internal/agent/runtime/agentctl/
./internal/agent/handlers/... ./internal/agent/runtime/lifecycle/...
./internal/common/securityutil/...` — all new and existing push tests pass.
Four tests fail identically on this runner both with these changes and on a
clean worktree at `HEAD`, so they are pre-existing and environment-specific, not
regressions: `TestRepairManagedRuntimeCacheClearsPreviousStderr`,
`TestManagedRuntimeCacheRepairUsesAgentEnvironmentAndExactTree`, the Kubernetes
PVC and `WorktreePreparer` cases in `internal/agent/runtime/lifecycle`, and
`TestBuildAuthMethodsIdentityAgentOverridesEnvironment`.
`TestWorkspaceGitAdmissionWaitDoesNotConsumeTimeout` is load-sensitive and fails
under parallel suite runs on clean `HEAD` too.
Two ordering guarantees are proved directly rather than by inspection. A PATH
shim records every git invocation, so
`TestGitOperatorPushVerifiesBranchAsLastReadBeforePush` asserts that
`symbolic-ref HEAD` is the command immediately preceding `push` on both the
explicit-target and default paths. A second shim moves `HEAD` during
empty-remote first publication, so
`TestGitOperatorPushReportsMismatchAfterBaselinePublication` exercises the
second-verification window that the first verification cannot cover.
## Risks
- `Push` is already long and carries contribution routing, force refusal, and
empty-remote publication. Adding resolution and two verification points
in-line would breach the backend function-length limit; the plan therefore
puts resolution and the guard in `git_push_target.go` and has `Push` call a
single `resolvePushPlan`.
- Moving `getUpstreamRef` ahead of the second verification changes the order of
existing Git reads on the path that names no target. The existing
`git_empty_remote_test.go` cases are the regression net for that path and must
stay green unmodified.
- Replacing preflight's blanket `result.Success = true` makes a previously
inert call contact `origin`. The startup remote-contribution preflight is not
affected because it runs only when a binding exists, but any future caller on
the ordinary path now performs network I/O.
- A configured remote whose name fails `IsValidBranchName` cannot be addressed
by the name form and is skipped during URL matching. This is consistent in
both directions, but it means such a remote is unreachable through this
contract.
- The five result fields are `omitempty`, so `current_branch` reported as empty
for a detached `HEAD` is omitted from the JSON rather than present and empty.
Consumers must read a missing key as the detached state.
## Resolved questions
- The system design's Security section claimed `Push` assigns Git's output to
the result without sanitizing it. `runGitCommand` in fact sanitizes every
invocation whose subcommand is `push`, dry-run included, so
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18 was already satisfied on both new
paths and no new redaction call was needed. The design's Security section has
been corrected to describe the existing behavior, and
`TestGitOperatorPushRedactsCredentialsOnExplicitTargetPath` plus
`TestPushPreflightRedactsCredentialsInRemoteOutput` guard it against
regression.
- The plan proposed `IsValidExpectedBranchName` because
`IsValidDefaultBranchName` strips an `origin/` prefix. Implementation
confirmed the two disagree on inputs such as `origin/-dash`, and the new
validator checks the value that is actually used in the comparison and the
refspec. `TestIsValidExpectedBranchName` pins the distinguishing case.
@@ -0,0 +1,141 @@
---
id: "01-resolution-primitives"
title: "Push-target and expected-branch primitives"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
acceptance_criteria:
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.3
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.4
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.5
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.6
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.7
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.15
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.16
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.17
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.2
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.11
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.7
system_design:
- ../../specs/workspaces/system-design/configured-push-target.md
---
# Task 01: Push-target and expected-branch primitives
## Summary
Add the vocabulary and pure helpers the rest of the package builds on: a
`PushOptions` request struct, the eleven error-code constants, the five result
fields, remote enumeration, push-target resolution, strict expected-branch
validation, and a symbolic-ref current-branch read. Push and preflight take the
new options and ignore them, so this work order changes no observable behavior.
## In scope
- `PushOptions{Force, SetUpstream bool; Remote, ExpectedBranch string}` in
`internal/agentctl/server/process`, threaded through `GitOperator.Push` and
`GitOperator.PushPreflight` as a pass-through with behavior unchanged.
- The eleven error-code constants from the system design's table.
- `PushedRemote`, `PushedBranch`, `ExpectedBranch`, `CurrentBranch`, and
`BaselinePublished` on `process.GitOperationResult`, all `omitempty`.
- `configuredRemotes`, which reads each remote's effective push URL set and
distinguishes a genuine read failure from an absent remote.
- `resolvePushTarget`, returning a remote name or one of the four resolution
refusals, and never placing a caller-supplied URL on a Git command line.
- `currentBranch`, reading `git symbolic-ref HEAD` so a detached `HEAD` is an
empty branch rather than the literal `HEAD`.
- `securityutil.IsValidExpectedBranchName`, the strict allowlist without
`IsValidBaseBranchRef`'s `origin/` prefix strip.
## Out of scope
- Any change to how `Push` or `PushPreflight` behave. The options are accepted
and ignored here; Tasks 02 and 03 own the behavior.
- The runtime client and backend handler result structs, which Task 04 owns.
- Widening `runGitCommand`'s argument allowlist. `symbolic-ref HEAD` and
`config --get-all` are already admitted; if an implementation needs a flag
that is not, change the approach rather than the allowlist.
## Acceptance
- `resolvePushTarget` returns the resolved remote name for the name form and
the single-entry URL form, `push_remote_fanout` for a multi-push-URL remote
carrying the value, `push_remote_url_unmatched` when nothing carries it,
`push_remote_not_found` for an unknown name, and
`push_remote_config_unreadable` when the remote configuration cannot be read.
- `currentBranch` returns the branch for an attached `HEAD` and an empty string
with no error for a detached one, distinguished from a real read failure.
- `go build ./...` and the existing process and securityutil suites pass with
`Push` and `PushPreflight` behavior unchanged.
## Verification
```bash
cd apps/backend && go test -tags fts5 ./internal/agentctl/server/process/... ./internal/common/securityutil/...
cd apps/backend && make lint
```
## Files likely touched
- `apps/backend/internal/agentctl/server/process/git_push_target.go` (new)
- `apps/backend/internal/agentctl/server/process/git_push_target_test.go` (new)
- `apps/backend/internal/agentctl/server/process/git.go`
- `apps/backend/internal/common/securityutil/git.go`
- `apps/backend/internal/common/securityutil/git_test.go`
- `apps/backend/internal/agentctl/server/api/git.go` (call-site signature only)
## Dependencies
None.
## Risks
- `IsValidDefaultBranchName` looks like the right validator but strips an
`origin/` prefix, so reusing it would let `origin/main` validate as `main`.
The new validator must not route through `IsValidBaseBranchRef`.
- Changing `Push`'s signature touches its existing call site in
`internal/agentctl/server/api/git.go`. Keep that edit mechanical; Task 04
owns the request-field work there.
- A remote name that fails `IsValidBranchName` cannot be safely interpolated
into `remote.<name>.url`. Skip such remotes during URL matching rather than
failing the whole enumeration.
## Parallelism
`sequential`
## Inputs
- Requirements REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001 and -002, and the
system design's [Push-target resolution](../../specs/workspaces/system-design/configured-push-target.md)
and [Data and contracts](../../specs/workspaces/system-design/configured-push-target.md) sections.
- `internal/agentctl/server/process/git.go` `runGitCommand` argument allowlist
and `getCurrentBranch`.
- `internal/common/securityutil/git.go` existing validators.
- `setupEmptyRemoteTaskRepo` in `git_empty_remote_test.go` as the fixture
pattern for real-repository tests.
## Results
Implemented. `PushOptions`, the eleven error-code constants, the five result
fields, `configuredRemotes`, `resolvePushTarget`, and `currentBranch` live in
`apps/backend/internal/agentctl/server/process/git_push_target.go`;
`securityutil.IsValidExpectedBranchName` lives alongside the existing
validators. `Push` and `PushPreflight` take `PushOptions` as a pass-through, so
this work order changed no observable behavior.
`IsValidBaseBranchRef`'s `origin/` strip was confirmed to matter: it and the new
validator disagree on inputs such as `origin/-dash`, so the new validator checks
the value that is actually compared against `HEAD` and used in the refspec.
`currentBranch` distinguishes a detached `HEAD` from an unreadable checkout by
falling back to `rev-parse --verify HEAD` only when `symbolic-ref` fails, so a
detached checkout reports an empty branch and a broken one still errors.
Verified with `go test -tags fts5 ./internal/agentctl/server/process/...
./internal/common/securityutil/...` and `make -C apps/backend lint`.
@@ -0,0 +1,155 @@
---
id: "02-push-path"
title: "Explicit push target and expected-branch verification on the push path"
status: completed
wave: 2
depends_on: ["01-resolution-primitives"]
plan: "plan.md"
requirements:
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
acceptance_criteria:
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.2
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.8
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.9
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.10
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.11
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.12
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.13
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.14
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.3
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.4
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.5
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.6
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.7
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.8
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.9
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.10
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.12
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.1
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.2
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.3
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.4
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.5
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.6
system_design:
- ../../specs/workspaces/system-design/configured-push-target.md
---
# Task 02: Explicit push target and expected-branch verification on the push path
## Summary
Wire `GitOperator.Push` onto Task 01's primitives: evaluate the fixed refusal
list before contacting any remote, skip empty-remote first publication for a
resolved remote other than `origin`, and verify the expected branch twice, the
second time as the last Git read before the push command. A request that names
neither input performs the same push it performs today.
## In scope
- A `resolvePushPlan` helper that evaluates the refusal list in the design's
fixed order and yields the resolved remote, the refspec, and the
set-upstream decision.
- Refusing `push_remote_contribution_conflict` and
`push_remote_upstream_unsupported` before any remote read.
- Restricting empty-remote first publication to the `origin` path.
- Moving `getUpstreamRef` ahead of the second verification so the branch read is
the last Git command before the push.
- The second verification, reporting `push_branch_mismatch_after_baseline` with
`baseline_published` set when first publication published a baseline in this
request, and `push_branch_mismatch` otherwise.
- Refspec selection: `HEAD:refs/heads/<expected>` with a target and an expected
branch, `HEAD:refs/heads/<current>` with a target alone, unchanged otherwise.
- Populating `pushed_remote` and `pushed_branch` on a successful push only when
the request carried an explicit target.
- Extending the push completion log with the resolved remote, destination
branch, and whether an expected branch was supplied and matched.
## Out of scope
- `PushPreflight`, which Task 03 owns even though it reuses `resolvePushPlan`.
- Transport plumbing, which Task 04 owns.
- Contribution routing behavior, its force refusal, and the empty-remote
publication mechanics themselves, all of which stay as they are.
- Adding a second output sanitization. `runGitCommand` already redacts every
`push` invocation.
## Acceptance
- A push naming a configured remote other than `origin` publishes
`HEAD:refs/heads/<branch>` to it, sets no upstream, and publishes no baseline.
- An expected branch that does not match the current branch, including a
detached `HEAD`, refuses the push with the expected and current branches
reported and no remote contacted.
- A request naming neither input produces the same Git commands and the same
result shape as before this task, with `git_empty_remote_test.go` unmodified
and green.
## Verification
```bash
cd apps/backend && go test -tags fts5 ./internal/agentctl/server/process/...
cd apps/backend && make lint
```
## Files likely touched
- `apps/backend/internal/agentctl/server/process/git.go`
- `apps/backend/internal/agentctl/server/process/git_push_target.go`
- `apps/backend/internal/agentctl/server/process/git_push_expected_branch_test.go` (new)
## Dependencies
Task 01.
## Risks
- `Push` is near the backend function-length limit already. Keep
`resolvePushPlan` and the second verification in `git_push_target.go` rather
than growing `Push` in place.
- The second verification can follow a completed baseline publication, so the
refusal is deliberately not side-effect-free. Do not add a rollback; report
`baseline_published` instead.
- Reordering the upstream read is observable to any test that asserts Git
command sequence on the `origin` path. Existing empty-remote tests must pass
unmodified; if one fails, the reorder is wrong rather than the test.
## Parallelism
`parallel-safe`
## Inputs
- The system design's [Control flow](../../specs/workspaces/system-design/configured-push-target.md)
and [Refusal ordering](../../specs/workspaces/system-design/configured-push-target.md) sections.
- `Push` at `internal/agentctl/server/process/git.go`, and
`prepareEmptyRemotePublication` in `git_empty_remote.go`.
- `git_empty_remote_test.go` as the regression net for the unchanged path.
## Results
Implemented. `Push` now calls `validateContributionState`, then
`resolvePushPlan`, which evaluates the whole refusal list before any remote is
contacted. Empty-remote first publication is gated on `plan.baselineEligible`,
so it runs only for `origin` with no contribution routing. `resolveSetUpstream`
moves the upstream read ahead of the second verification and skips it entirely
on the explicit-target path.
The ordering guarantee is proved rather than asserted: a PATH shim records every
git invocation, and `TestGitOperatorPushVerifiesBranchAsLastReadBeforePush`
checks that `symbolic-ref HEAD` immediately precedes `push` on both the
explicit-target and default paths. A second shim moves `HEAD` during first
publication so
`TestGitOperatorPushReportsMismatchAfterBaselinePublication` exercises the
post-baseline refusal, confirming `push_branch_mismatch_after_baseline`,
`baseline_published`, and that the task branch stays unpublished while the
baseline remains.
The existing `git_empty_remote_test.go` cases pass unmodified, which is the
regression evidence that the path naming no target is unchanged.
Verified with `go test -tags fts5 ./internal/agentctl/server/process/...` and
`make -C apps/backend lint`.
@@ -0,0 +1,121 @@
---
id: "03-preflight"
title: "Push preflight validates the resolved destination"
status: completed
wave: 3
depends_on: ["02-push-path"]
plan: "plan.md"
requirements:
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003
acceptance_criteria:
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.1
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.2
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.3
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.4
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.5
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.6
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.7
system_design:
- ../../specs/workspaces/system-design/configured-push-target.md
---
# Task 03: Push preflight validates the resolved destination
## Summary
Replace push preflight's blanket success on the ordinary path with a real
dry-run against the destination the equivalent push would use. Preflight reuses
Task 02's resolution, guard, and refusal ordering, verifies the expected branch
once, and reports the remote and branch it validated whenever contribution
routing did not apply.
## In scope
- Replacing the `g.remoteContribution == nil` early `result.Success = true`
return with a resolved-remote dry-run, defaulting to `origin`.
- Reporting `push_no_remote_configured` when there is no explicit target, no
contribution routing, and no `origin` remote.
- Applying the same refusal ordering and codes as the push path, with a single
expected-branch verification and no reachable
`push_branch_mismatch_after_baseline`.
- Reporting `pushed_remote` and `pushed_branch` on every successful preflight to
which contribution routing did not apply, with or without an explicit target.
- Reporting a remote's write refusal as a failure carrying the remote's output
rather than an unhandled error.
## Out of scope
- The contribution-destination and remote-contribution preflight branches,
which keep their bodies, their result shape, and their output handling.
- Preflighting the empty-remote baseline refspec. Preflight exercises the
destination-branch refspec only.
- Any change to the startup remote-contribution preflight caller in
`internal/agent/runtime/lifecycle/manager_startup.go`.
## Acceptance
- Preflight with no explicit target and no contribution routing dry-runs against
`origin` for the current branch and reports the remote and branch it checked.
- Preflight against a remote that refuses the write reports failure with the
remote's output, and creates, moves, updates, or deletes no ref anywhere.
- A checkout with no `origin` and no contribution routing reports
`push_no_remote_configured` instead of success.
## Verification
```bash
cd apps/backend && go test -tags fts5 ./internal/agentctl/server/process/... ./internal/agent/runtime/lifecycle/...
cd apps/backend && make lint
```
## Files likely touched
- `apps/backend/internal/agentctl/server/process/git.go`
- `apps/backend/internal/agentctl/server/process/git_push_target.go`
- `apps/backend/internal/agentctl/server/process/git_push_preflight_test.go` (new)
## Dependencies
Task 02, whose `resolvePushPlan` this reuses.
## Risks
- This turns a previously inert call into one that contacts a remote. The
startup caller is unaffected because it runs only when a contribution binding
exists, but the behavior change is real for any other caller on that path.
- A dry-run is still a `push` subcommand, so it must not be allowed to mutate.
Assert absence of ref movement directly in the test rather than trusting
`--dry-run`.
## Parallelism
`sequential`
## Inputs
- Requirement REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003 and the system design's
[Preflight](../../specs/workspaces/system-design/configured-push-target.md) section.
- `PushPreflight` at `internal/agentctl/server/process/git.go`.
- The startup caller at `internal/agent/runtime/lifecycle/manager_startup.go`.
## Results
Implemented. `PushPreflight` reuses `validateContributionState` and
`resolvePushPlan`, passing `requireConfiguredRemote: true` so a checkout with no
`origin` and no contribution routing reports `push_no_remote_configured` instead
of success. The blanket `result.Success = true` return is gone; every
non-routed preflight now dry-runs the destination-branch refspec and reports the
remote and branch it validated. A preflight under contribution routing keeps its
existing body and result shape.
Preflight verifies the expected branch once, through `resolvePushPlan`, and
never calls `verifyExpectedBranch`, so `push_branch_mismatch_after_baseline` is
unreachable from preflight. `TestPushPreflightAppliesSameRefusalOrdering`
asserts that directly.
`TestPushPreflightMutatesNothing` checks `HEAD`, the full local ref list,
upstream configuration, and both remotes across a preflight rather than trusting
`--dry-run`.
Verified with `go test -tags fts5 ./internal/agentctl/server/process/...
./internal/agent/runtime/lifecycle/...` and `make -C apps/backend lint`.
@@ -0,0 +1,123 @@
---
id: "04-transport"
title: "Carry both inputs across the push transport"
status: completed
wave: 2
depends_on: ["01-resolution-primitives"]
plan: "plan.md"
requirements:
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
acceptance_criteria:
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.1
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.1
- AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.8
system_design:
- ../../specs/workspaces/system-design/configured-push-target.md
---
# Task 04: Carry both inputs across the push transport
## Summary
Carry the explicit push target and the expected branch from the backend
workspace Git action handler, through the runtime client, to the agentctl HTTP
surface, and add the five result fields to the client-side result struct. Every
layer passes both values through without interpreting them; all destination
logic stays in the operator.
## In scope
- `remote` and `expected_branch` on `GitPushRequest` and
`GitPushPreflightRequest` in `internal/agentctl/server/api/git.go`, bound and
forwarded into `PushOptions`.
- The five result fields on `client.GitOperationResult` in
`internal/agent/runtime/agentctl/git.go`, matching the server struct's JSON
keys.
- Both inputs on `Client.GitPush` and `Client.GitPushPreflight`, carried in the
request payloads.
- Both fields on the `worktree.push` payload in
`internal/agent/handlers/git_handlers.go`, forwarded to the client.
- Keeping the startup preflight caller in
`internal/agent/runtime/lifecycle/manager_startup.go` passing neither.
## Out of scope
- Any validation, resolution, or refusal decision at any transport layer. The
HTTP surface performs no destination logic of its own.
- Any web, desktop, or mobile control. The existing push control keeps sending
neither input.
- Operator behavior, which Tasks 02 and 03 own.
## Acceptance
- A push request carrying `remote` and `expected_branch` reaches
`GitOperator.Push` with both values intact, and a request carrying neither
produces a payload and behavior identical to today's.
- Both values are scoped to the repository the request already selects via
`repo` and affect no other repository in a multi-repository task.
- The client result struct deserializes all five new fields.
## Verification
```bash
cd apps/backend && go test -tags fts5 ./internal/agentctl/server/api/... ./internal/agent/runtime/agentctl/... ./internal/agent/handlers/... ./internal/agent/runtime/lifecycle/...
cd apps/backend && make lint
```
## Files likely touched
- `apps/backend/internal/agentctl/server/api/git.go`
- `apps/backend/internal/agentctl/server/api/git_handlers_test.go`
- `apps/backend/internal/agent/runtime/agentctl/git.go`
- `apps/backend/internal/agent/runtime/agentctl/git_test.go`
- `apps/backend/internal/agent/handlers/git_handlers.go`
- `apps/backend/internal/agent/handlers/git_handlers_test.go`
## Dependencies
Task 01, for `PushOptions` and the server-side result fields.
## Risks
- `Client.GitPush` already takes four positional arguments. Adding two more
makes the call site unreadable; carry a small options struct instead, and
update the single existing caller in `internal/agent/handlers/git_handlers.go`.
- The client and server result structs are duplicated by design and drift
silently. The JSON keys of all five fields must match exactly.
## Parallelism
`parallel-safe`
## Inputs
- The system design's [Components and responsibilities](../../specs/workspaces/system-design/configured-push-target.md)
and [Data and contracts](../../specs/workspaces/system-design/configured-push-target.md) sections.
- The existing `repo` field convention across the git request structs.
- `GitContributionRequest` as the pattern for an optional string field carried
end to end.
## Results
Implemented. `GitPushRequest` and `GitPushPreflightRequest` carry `remote` and
`expected_branch` through the agentctl HTTP surface; the runtime client gained a
`PushOptions` struct plus the five result fields; and the backend `worktree.push`
payload forwards both. The startup remote-contribution preflight passes an empty
`PushOptions`, so it is unchanged.
Two call sites shadowed the client package identifier with a local variable named
`client`, which the package's misleading `Package agentctl` doc comment hides. The
handler's local was renamed to `agentClient` to match its siblings, and the
lifecycle call site imports the package under an explicit `agentctlclient` alias.
The new API-surface tests were placed in
`internal/agentctl/server/api/git_push_target_test.go` and
`internal/agent/handlers/git_push_options_test.go` rather than appended to the
existing `git_handlers_test.go` files, which were already close enough to
revive's 800-line file-length limit that appending crossed it.
Verified with `go test -tags fts5 ./internal/agentctl/server/api/...
./internal/agent/runtime/agentctl/ ./internal/agent/handlers/...
./internal/agent/runtime/lifecycle/...` and `make -C apps/backend lint`.
+7 -1
View File
@@ -302,7 +302,7 @@ These are the registered Kandev WebSocket actions. Every payload requires `sessi
| Action | Additional payload |
|--------|--------------------|
| `worktree.pull` | `rebase` boolean |
| `worktree.push` | `force` and `set_upstream` booleans |
| `worktree.push` | `force` and `set_upstream` booleans; optional `remote` (configured remote name or exact configured push URL) and `expected_branch` (current-branch precondition) |
| `worktree.rebase` | required `base_branch` |
| `worktree.merge` | required `base_branch` |
| `worktree.abort` | `operation`: exactly `merge` or `rebase` |
@@ -315,6 +315,12 @@ These are the registered Kandev WebSocket actions. Every payload requires `sessi
| `worktree.rename_branch` | required `new_name` |
| `worktree.reset` | required `commit_sha`; `mode` is `soft`, `mixed`, or `hard` |
The optional push target fields apply to the WebSocket action and the backend
API. An explicit target uses the expected branch as the destination and does
not set upstream tracking. A branch mismatch returns the expected and current
branch values. Existing contribution routing takes precedence over an explicit
target.
Example request and normal operation result:
```json
@@ -0,0 +1,344 @@
---
status: draft
system: workspaces
created: 2026-09-08
owners:
- kandev
---
# Configured Push Target Requirements
## Overview
The workspace push API can today publish only to `origin` or to a remote a
contribution binding selects. A caller cannot name a third destination, and
cannot state which branch it believes it is publishing.
This capability adds two independent, optional inputs to the workspace push and
push-preflight contracts: an explicit push target, and an expected branch. The
expected branch exists so a caller that decided to publish `feature/x` cannot
publish different content under that name when the agent moved `HEAD` between
the decision and the push.
The workspace system owns this contract because it governs workspace Git state
and its remotes, not the runtime that executes the push.
## Terminology
- **Push target:** The destination a push publishes to, expressed either as a
configured Git remote name or as a remote URL.
- **Explicit push target:** A push target the caller names in the request,
rather than one Kandev derives from `origin` or a contribution binding.
- **Configured remote:** A remote already present in the checkout's Git
configuration.
- **Effective push URL set:** A configured remote's push URLs when it has at
least one, otherwise the single-element list holding its fetch URL. A remote
with neither has an empty set.
- **Contribution routing:** The existing remote-contribution and
contribution-destination behavior that selects a remote and refspec for the
caller.
- **Expected branch:** The branch name a caller states it intends to publish.
- **Current branch:** The branch the workspace checkout's `HEAD` points at.
A detached `HEAD` has no current branch.
## Requirements
### REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001: Explicit Push Target Selection
**Intent:** Let a caller publish a task branch to a remote it names, without
changing what a caller that names nothing observes today.
**User story:** As a Kandev service that publishes task work, I want to name the
remote a push goes to, so that a work branch can be preserved somewhere other
than `origin`.
#### Acceptance criteria
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.1:** The workspace push and
push-preflight contracts shall accept an optional explicit push target.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.2:** When a request omits the
explicit push target and omits the expected branch, Kandev shall perform the
same push it performs today, including `origin` selection, contribution
routing, upstream selection, force behavior, and first publication.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.3:** When the explicit push target
satisfies the permissive ref-syntax allowlist Kandev already applies to branch
arguments, Kandev shall interpret it as a configured remote name; otherwise
Kandev shall interpret it as a remote URL.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.4:** When the explicit push target
is interpreted as a name and no configured remote carries that name, Kandev
shall refuse the request with error code `push_remote_not_found` and shall not
contact any remote.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.5:** When the explicit push target
is interpreted as a URL, Kandev shall resolve it to a configured remote whose
effective push URL set holds exactly one entry equal to the supplied value.
Each configured value shall be trimmed likewise, and the comparison shall
otherwise be exact: no case folding, no `.git` suffix added or
removed, no redirect resolved, no normalization. An empty set never matches.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.6:** When a URL push target matches
more than one configured remote, Kandev shall select the matching remote whose
name sorts first by byte order and shall publish to it.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.7:** When a URL push target matches
no configured remote, Kandev shall refuse the request with error code
`push_remote_url_unmatched` and shall not contact any remote.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.8:** Kandev shall never create,
rename, retarget, or delete a remote while serving a push or push-preflight
request. A push target that is not already configured shall be refused rather
than materialized.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.9:** When a request carries an
explicit push target and the workspace has contribution routing configured,
Kandev shall refuse with error code `push_remote_contribution_conflict` and
shall not push.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.10:** When a request carries an
explicit push target together with the set-upstream flag, Kandev shall refuse
the request with error code `push_remote_upstream_unsupported` and shall not
push.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.11:** A push that carries an
explicit push target shall not set, change, or remove the current branch's
upstream tracking configuration.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.12:** When a request carries an
explicit push target that resolves to a remote other than `origin`, Kandev
shall not perform empty-remote first publication. When it resolves to
`origin`, first publication shall behave exactly as it does for a request that
names no push target.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.13:** A push that carries an
explicit push target shall remain a non-force push unless the caller requests
force, and force shall stay refused whenever contribution routing applies.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.14:** No result field Kandev
populates, no error message Kandev composes, and no log record Kandev emits
shall contain a remote URL; each shall identify a push target by its resolved
remote name only. Git's passed-through output is excluded and keeps naming the
destination it contacted, as it does today.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.15:** Remote-name matching shall be
exact and case-sensitive. Kandev shall not accept a prefix, a suffix, or a
case-insensitive variant of a configured remote name.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.16:** When Kandev cannot read the
checkout's remote configuration while resolving an explicit push target, it
shall refuse the request with error code `push_remote_config_unreadable` and
shall not push. An unreadable configuration shall never be treated as an absent
push target, as an unmatched push target, or as a successful resolution.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.17:** When no configured remote
matches a URL push target under
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.5 but at least one remote carries that
value among several push URLs, Kandev shall refuse with error code
`push_remote_fanout` and shall not push. A matching
single-entry remote takes precedence and resolves normally; only when none
exists does the fan-out refusal apply, and only when neither exists does
`push_remote_url_unmatched` apply. Naming a multi-push-URL remote stays
allowed and shall publish through it unchanged, reaching every configured push
URL without reduction or reordering.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18:** On the explicit-push-target
push path, and on the preflight paths this capability introduces or changes,
Kandev shall apply to Git's passed-through output the same credential redaction
it already applies to empty-remote first-publication output. The push path
naming no explicit push target, and the existing contribution preflight paths,
shall keep the output handling they have today.
### REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002: Expected-Branch Verification
**Intent:** Let a caller state the branch it decided to publish, so an agent that
switches branches after that decision cannot cause different content to be
published under that name.
**User story:** As a Kandev service that publishes task work, I want the push to
be refused when the workspace is no longer on the branch I decided to publish,
so that I never publish one branch's commits as another branch.
#### Acceptance criteria
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.1:** The workspace push and
push-preflight contracts shall accept an optional expected branch, independent
of whether the request carries an explicit push target.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.2:** When the expected branch is
supplied and is not a valid branch name, Kandev shall refuse the request with
error code `push_branch_invalid` and shall not contact any remote. Valid means
the strictest branch allowlist Kandev already applies to a persisted branch
name: beyond the permissive rules on characters, length, `..` and `.lock`, an
expected branch ending in `/`, containing `//`, or equal to a reserved Git
symbolic pseudo-ref (`HEAD`, `ORIG_HEAD`, `FETCH_HEAD`, `MERGE_HEAD`) shall
also be rejected.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.3:** When the expected branch is
supplied and does not equal the current branch at the first verification point,
Kandev shall refuse with error code `push_branch_mismatch`, shall report the
expected and current branches in the result fields the system design declares,
and shall not contact any remote. A detached `HEAD` shall be reported as an
empty current branch, not as the literal `HEAD`.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.4:** When the expected branch is
supplied and `HEAD` is detached, Kandev shall treat the request as a mismatch
under AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.3.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.5:** On the push operation Kandev
shall verify the expected branch at two points inside the workspace Git
operation lock: once before it contacts any remote, so that a mismatch refuses
the request with no side effect; and again as the last Git read before the push
command, with no other Kandev-issued Git command between that verification and
the push. A mismatch at either point shall refuse the request.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.6:** When the second verification
fails, Kandev shall refuse the push, shall keep the task branch unpublished,
and shall report the expected and current branches as in
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.3. When empty-remote first publication
published the baseline earlier in the same request, the refusal shall carry
error code `push_branch_mismatch_after_baseline` and shall set the result's
baseline-published field; otherwise it shall carry `push_branch_mismatch` and
shall omit that field. A second-point refusal is not required to be
side-effect-free: empty-remote preparation may already have read `origin`'s
advertised refs, and may already have retired the local first-publication
marker without publishing anything.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.7:** When a request carries an
explicit push target and an expected branch, Kandev shall publish the current
`HEAD` to the branch named by the expected branch on the resolved remote.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.8:** When a request carries an
explicit push target and no expected branch, Kandev shall publish the current
`HEAD` to the branch named by the current branch on the resolved remote, and
shall refuse a detached `HEAD` with error code `push_branch_detached` without
contacting any remote.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.9:** When a request carries an
expected branch and no explicit push target, the expected branch shall act only
as a precondition. Kandev shall not change the remote, the refspec, the
upstream behavior, or the first-publication behavior that the request would
otherwise have.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.10:** When the destination branch
does not exist on the resolved remote, the push shall create it. Creating it
on a remote other than `origin` shall not publish a baseline commit.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.11:** Kandev shall determine whether
`HEAD` is detached by reading `HEAD` as a symbolic ref and observing whether it
resolves under `refs/heads/`, and shall not infer the detached state by
comparing an abbreviated ref name against the literal string `HEAD`.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.12:** Every Git read Kandev needs to
build the push command shall complete before the second expected-branch
verification required by AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.5, including
the upstream-tracking read on the path naming no explicit push target. Between that verification and the push, Kandev
shall issue no Git command other than the push itself.
### REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003: Push Preflight Validation
**Intent:** Make push preflight answer whether the destination-branch push that
would run is writable, instead of reporting success for a target it never
checked.
**User story:** As a Kandev service preparing to publish, I want preflight to
check the destination my push would actually use, so that a failure surfaces
before the push runs.
#### Acceptance criteria
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.1:** Push preflight shall apply the
same push-target resolution, expected-branch validation and comparison, refusal
codes, and refusal ordering that the push contract applies, and shall verify the
expected branch once, before its dry-run. Preflight publishes no baseline, so it
has no second verification and shall never report
`push_branch_mismatch_after_baseline`.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.2:** When a request carries an
explicit push target, preflight shall verify write access to the resolved
remote for the refspec the equivalent push would use.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.3:** When a request carries no
explicit push target and no contribution routing applies, preflight shall
verify write access to `origin` for the refspec the equivalent push would use,
instead of reporting success without checking a remote.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.4:** When no explicit push target is
supplied, no contribution routing applies, and the checkout has no `origin`
remote, preflight shall report failure with error code
`push_no_remote_configured`.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.5:** Push preflight shall not create,
move, update, or delete any ref on any remote, and shall not change local refs,
the working tree, or upstream tracking configuration.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.6:** Preflight against a remote that
refuses the write shall report failure with the remote's output rather than an
unhandled error, credential-redacted under
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-001.18.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.7:** A successful preflight to which
contribution routing did not apply shall report the resolved remote name and
destination branch it validated, in the same result fields a successful push
uses, whether or not the request carried an explicit push target. A successful
preflight under contribution routing shall report neither field and shall keep
the result shape it has today.
### REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004: Determinism, Concurrency, and Result Reporting
**Intent:** Make the outcome of a push request predictable when inputs conflict,
when two callers arrive together, and when a caller retries.
#### Acceptance criteria
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.1:** When a request would be refused
for more than one reason, Kandev shall report the first applicable refusal in
this fixed order: malformed request body; existing contribution binding
validation failure; `push_remote_contribution_conflict`;
`push_remote_upstream_unsupported`; `push_branch_invalid`; explicit push-target
resolution refusals (`push_remote_config_unreadable`, `push_remote_not_found`,
`push_remote_url_unmatched`, `push_remote_fanout`);
`push_no_remote_configured`; `push_branch_detached`; `push_branch_mismatch`
raised at the first verification point. Kandev shall evaluate every refusal in
this ordered list before it contacts any remote, including before empty-remote
first publication.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.2:** Every refusal listed in
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.1 shall leave the workspace checkout,
its refs, its upstream configuration, and every remote unchanged. The
second-verification refusals governed by
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.6 are the only refusals in this
capability that are not listed there, and the only ones permitted to leave a
published baseline or a retired first-publication marker behind.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.3:** A push or push-preflight
request shall hold the existing single workspace Git operation lock for the
whole request. When a second request for the same checkout arrives while one
holds the lock, Kandev shall report the existing operation-in-progress result
rather than queue, merge, or serialize it.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.4:** Repeating a successful push
request with identical inputs while the workspace `HEAD` and the destination
branch are unchanged shall report success and shall not change the destination
branch.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.5:** When the destination branch on
the resolved remote has commits the local `HEAD` does not contain, a non-force
push shall be refused by the remote and Kandev shall report that failure
without retrying and without escalating to a force push.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.6:** A successful push that carried
an explicit push target shall report the resolved remote name and the
destination branch name. A successful push that carried no explicit push target
shall omit both, so an existing consumer observes an unchanged result shape.
This omission rule governs the push operation only; preflight reports them in
every case under AC-WORKSPACES-CONFIGURED-PUSH-TARGET-003.7.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.7:** Kandev shall trim leading and
trailing whitespace from the explicit push target and from the expected branch
before any other check, including before the validation
AC-WORKSPACES-CONFIGURED-PUSH-TARGET-002.2 requires. Either input supplied
empty, or empty after that trim, shall be treated as absent and shall produce
the behavior of a request that omits it.
- **AC-WORKSPACES-CONFIGURED-PUSH-TARGET-004.8:** The explicit push target and
expected branch shall be scoped to the repository the request already selects
in a multi-repository task, and shall not affect any other repository in the
same workspace.
## Out of scope
- **Exact-OID lease.** No `--force-with-lease=<ref>:<oid>` form. A force push
keeps emitting the bare lease it emits today, and force stays refused for
contribution routing.
- **Eliminating the residual race window.** An agent shell can move `HEAD`
outside the operation lock, so the expected-branch check narrows the window but
cannot close it. Closing it requires the exact-OID lease above.
- **Preflighting the empty-remote baseline.** A push on the `origin` path may
first publish the baseline, a different refspec. Preflight validates only the
destination-branch refspec, so it can still report success where that baseline
publication would fail.
- **Auto-push policy.** When and under what condition a branch publishes
automatically is a separate capability; this contract only makes such a caller
expressible.
- **Creating or configuring remotes.** Adding a backup remote belongs to
workspace materialization, not the push contract.
- **Changing contribution routing.** Remote-contribution and
contribution-destination selection, their force-push refusal, and their
preflight behavior are unchanged.
- **A user-facing control.** No web, desktop, or mobile surface gains either
control; the existing push control keeps sending neither input.
- **Pushing tags, multiple refspecs, or deleting a remote branch.**
- **Credential selection.** Unchanged: the task runtime Git credential route.
## Related requirements
- [Empty Remote Repository Requirements](empty-remote-repositories.md)
- [Branch Policies](branch-policies.md)
## System design
- [Configured Push Target System Design](../system-design/configured-push-target.md)
@@ -0,0 +1,368 @@
---
status: draft
system: workspaces
requirements:
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003
- REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004
---
# Configured Push Target System Design
## Purpose and boundaries
The workspace system owns workspace Git state and the contract that publishes a
task branch to a remote. This design extends the existing workspace push and
push-preflight contracts with an explicit push target and an expected branch.
Adjacent contracts this design uses but does not own:
- Contribution routing (remote contribution, contribution destination) and its
force-push refusal. This design refuses rather than overriding it.
- Empty-remote first publication, which stays attached to `origin`.
- The task runtime Git credential route.
- The agent runtime transport that carries a push request from the orchestrator
into the workspace.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-001` | [Data and contracts](#data-and-contracts), [Push-target resolution](#push-target-resolution) |
| `REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-002` | [Control flow](#control-flow) |
| `REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-003` | [Preflight](#preflight) |
| `REQ-WORKSPACES-CONFIGURED-PUSH-TARGET-004` | [Refusal ordering](#refusal-ordering), [Failure and recovery](#failure-and-recovery) |
## Components and responsibilities
- **Workspace Git operator (agentctl, per checkout).** Owns the operation lock,
push-target resolution, the expected-branch guard, and Git command
construction. All new decisions live here so that no caller can reach the
destination selection by a different route.
- **Workspace Git HTTP surface (agentctl).** Binds the two new optional request
fields for both the push and push-preflight endpoints and passes them through
unchanged. It performs no destination logic of its own.
- **Agent runtime Git client (backend).** Carries the two new optional fields on
the push and push-preflight calls. Existing callers that pass neither keep
their current signatures' behavior.
- **Workspace Git action handler (backend).** Accepts the two new optional
fields on the existing push action and forwards them. The web client keeps
sending neither.
- **Git argument validator (agentctl).** The existing defense-in-depth allowlist
that inspects every argument before a Git process starts. This design keeps a
remote URL off the command line entirely so the allowlist does not have to be
widened.
## Data and contracts
Two optional inputs are added to the push and push-preflight request bodies, and
to the equivalent backend action payload and client calls. Both default to
absent, and absent reproduces today's behavior exactly.
- `remote`: the explicit push target, as a configured remote name or a remote
URL.
- `expected_branch`: the branch the caller believes it is publishing.
Both inputs are trimmed of leading and trailing whitespace before any other
check, and a value that is empty after that trim is absent. For the expected
branch this runs before the strict branch allowlist, so a whitespace-only value
is an omitted input rather than a `push_branch_invalid` refusal. The trim is
normalization, not a refusal: request-body parsing necessarily precedes it, and
it precedes every check in the refusal ordering below that reads either value.
The result gains five optional output fields. Each is omitted when its rule
below does not apply, so a consumer that reads none of them sees the shape it
sees today.
- `pushed_remote`: the resolved remote name. Populated on a successful push that
carried an explicit push target, and on a successful preflight to which
contribution routing did not apply.
- `pushed_branch`: the destination branch name. Same rule as `pushed_remote`.
- `expected_branch`: the expected branch the request supplied. Populated on a
`push_branch_mismatch` or `push_branch_mismatch_after_baseline` refusal.
- `current_branch`: the branch the checkout was actually on. Populated on the
same two refusals, and reported as empty when `HEAD` is detached rather than as
the literal `HEAD`.
- `baseline_published`: true when empty-remote first publication published the
baseline during this request. Populated only on
`push_branch_mismatch_after_baseline`, which is precisely the second-verification
refusal that followed such a publication. A successful push never sets it, even when first
publication ran, because doing so would change the result shape on the path
that names no push target.
The asymmetry between push and preflight on the first two fields is deliberate.
The push path omits them without an explicit target so that today's only
frontend consumer observes an unchanged result; a preflight on that same path
has no consumer that reads them at all, so it reports what it validated, which
is the whole point of asking it. A preflight under contribution routing reports
neither, because `## Out of scope` promises that path's behavior is unchanged
and these fields are `omitempty`: populating them would add keys to a response
shape this capability undertook not to touch.
Stable error codes, carried on the existing result error-code field:
| Code | Meaning |
| --- | --- |
| `push_remote_not_found` | Name form; no configured remote carries that name. |
| `push_remote_url_unmatched` | URL form; no configured remote has a single-entry effective push URL set equal to the supplied value. |
| `push_remote_fanout` | URL form; a remote carries that URL but also others, so publishing through it would reach destinations the caller did not name. |
| `push_remote_config_unreadable` | The checkout's remote configuration could not be read while resolving an explicit push target. |
| `push_remote_contribution_conflict` | Explicit push target supplied while contribution routing is configured. |
| `push_remote_upstream_unsupported` | Explicit push target supplied together with set-upstream. |
| `push_branch_invalid` | Expected branch fails the ref allowlist. |
| `push_branch_mismatch` | Expected branch is not the current branch, including detached `HEAD`. Side-effect-free at the first verification; at the second it may follow empty-remote preparation that read the remote and retired the local marker without publishing. |
| `push_branch_mismatch_after_baseline` | Same mismatch at the second verification, after first publication published the baseline in this request. |
| `push_branch_detached` | Explicit push target with no expected branch, and `HEAD` is detached. |
| `push_no_remote_configured` | Preflight with no explicit target, no contribution routing, and no `origin`. |
### Push-target resolution
Resolution is a pure function of the request value and the checkout's remote
configuration, and never mutates configuration.
1. Apply the shared trim above. An empty result means absent.
2. If the value satisfies the existing branch-name allowlist, treat it as a
remote name and require that the checkout's configured remote list contains
it.
3. Otherwise treat it as a URL. Read each configured remote's effective push URL
set: its push URLs when it has one or more, and otherwise the single entry
holding its fetch URL. Git allows a remote to carry several push URLs and
publishes to every one of them, so a remote matches only when its set holds
exactly one entry and that entry equals the supplied value under exact string
comparison, both sides trimmed. A remote that carries the supplied URL
alongside others is refused with `push_remote_fanout` rather than matched,
because resolving to it would publish to destinations the caller never named;
a caller that wants that fan-out names the remote instead, which is the
difference between asking for a destination and asking for a remote. A remote
with an empty set never matches. Several single-entry remotes can still match,
because several can legitimately carry the same URL; select the matching name
that sorts first by byte order, which is well defined because all such matches
address the same destination.
4. The name/URL discriminator is deliberately the permissive ref-syntax
allowlist rather than the strict branch allowlist an expected branch must
pass. A permissive reading cannot reach a destination on its own, because a
value read as a name must still match an already-configured remote before
anything is published; the expected branch has no such backstop, which is why
it is held to the stricter rule. The strict rule is also what keeps `HEAD`
out: the abbreviated-name read returns that literal for a detached checkout,
so an expected branch of `HEAD` would otherwise compare equal to it and pass a
guard whose whole purpose is to fail there.
5. Resolution yields a remote **name**. Only that name reaches a Git command
line. A caller-supplied URL is never passed to Git, which is what keeps the
existing argument allowlist unchanged and keeps credentials out of process
arguments.
## Control flow
A push request runs entirely inside the existing per-checkout operation lock.
1. Acquire the lock. A busy checkout returns the existing
operation-in-progress signal.
2. Evaluate every refusal in the fixed order below, including the first
expected-branch verification. This whole step runs before any remote is
contacted and before any local ref changes, so a refused request is a no-op.
3. When the resolved remote is `origin` and no contribution routing applies, run
empty-remote first publication as it runs today. Skip it for any other
resolved remote.
4. Complete every remaining Git read needed to build the push command. On the
path that names no explicit push target this includes the upstream-tracking
read that decides set-upstream behavior; the explicit-target path needs no
such read, because that flag combination is already refused at step 2. Then
re-read the current branch, reading `HEAD` as a symbolic ref so that a
detached checkout is distinguishable from a branch literally named `HEAD`.
That branch read is the last Git read before the push.
5. When an expected branch was supplied, compare it again to the current branch
and refuse on mismatch. A detached `HEAD` is a mismatch because it has no
current branch. The refusal carries `push_branch_mismatch_after_baseline`
when step 3 published a baseline in this request, and `push_branch_mismatch`
otherwise.
6. Issue the push. No Kandev-issued Git command runs between the branch read in
step 4 and the push in step 6.
Verifying twice is what lets both guarantees hold at once. The step 2
verification keeps every refusal side-effect-free even though first publication
sits between the refusal set and the push. The step 5 verification gives the
ordering guarantee the caller actually needs: nothing Kandev issues can move
`HEAD` between the check and the push. The residual window is bounded by the
push itself, because an agent shell can still move `HEAD` outside the lock;
closing that needs an exact source-OID refspec or an equivalent lease, which
the requirement places out of scope. A destination lease alone cannot freeze
local `HEAD`. In the rare case where step 5 refuses after step 3 already published a
baseline, the result reports the completed baseline publication and leaves the
task branch unpublished, which is the existing partial first-publication
contract rather than a new failure mode. A step 5 refusal after a step 3 that
published nothing is a plain `push_branch_mismatch`. It is still not a strict
no-op: when a first-publication marker is present but the baseline is already on
the remote, step 3 reads `origin`'s advertised refs and retires that local
marker. Both are idempotent and neither publishes the task branch, which is why
this case needs no distinct code — but it is why the no-op guarantee is stated
for the step 2 refusal list only.
Refspec selection:
- No explicit push target: unchanged on every existing path. An expected branch
acts only as a precondition and does not rewrite the refspec.
- Explicit push target with an expected branch: `HEAD:refs/heads/<expected>`.
- Explicit push target without an expected branch: `HEAD:refs/heads/<current>`,
with a detached `HEAD` refused first.
The explicit-target path never appends the set-upstream flag and never reads the
upstream ref, because the flag combination is refused before the push.
### Refusal ordering
Fixed, so that a request with several problems always reports the same code:
1. Malformed request body.
2. Existing contribution binding validation failure.
3. `push_remote_contribution_conflict`.
4. `push_remote_upstream_unsupported`.
5. `push_branch_invalid`.
6. `push_remote_config_unreadable` / `push_remote_not_found` /
`push_remote_url_unmatched` / `push_remote_fanout`. These four are mutually
exclusive by construction, so their relative order is never observable.
7. `push_no_remote_configured`. Preflight only: a push with no explicit target
and no `origin` fails at the push itself rather than at resolution. Ordered
ahead of the branch guards so a checkout with nowhere to publish reports that,
rather than reporting a stale branch the caller cannot act on yet.
8. `push_branch_detached`.
9. `push_branch_mismatch` raised at the first verification.
The second-verification refusal is deliberately absent from this list. It is
produced at step 5, which runs after first publication, and it is the only
refusal in this capability that can follow a completed baseline publication or a
retired first-publication marker. It carries
`push_branch_mismatch_after_baseline` when a baseline was published in this
request and `push_branch_mismatch` otherwise. Everything in the list above is
evaluated at step 2 and is therefore a no-op. Once the second-verification
refusal is set aside, the list is exhaustive for this capability: every other
refusal it introduces appears in it.
Cheap request-shape checks precede configuration reads, and configuration reads
precede the checkout read that resolves `HEAD`, so a rejected request performs
the least work. The whole list is evaluated at control-flow step 2, before
empty-remote first publication, which is why a refusal drawn from it never leaves
a partially-published remote behind.
### Preflight
Preflight reuses the same resolution, the same guard, and the same refusal
ordering, then issues a dry-run push for the refspec the equivalent push would
use. Two behaviors change relative to today:
- The path that previously reported success without contacting a remote now
dry-runs against `origin` for the current branch.
- Every successful preflight to which contribution routing did not apply, with
or without an explicit push target, reports the remote name and branch it
validated. The push path's rule of omitting those fields without an explicit
target exists to protect an existing consumer; a preflight on that path has
none, so withholding what it checked would only make the answer less useful. A
preflight under contribution routing reports neither and keeps the result shape
it has today.
- A checkout with no `origin` and no contribution routing now reports
`push_no_remote_configured` instead of success.
Preflight dry-runs the destination-branch refspec only. On the `origin` path an
actual push may first publish the empty-remote baseline, a different refspec that
preflight does not exercise, so preflight can still report success where that
baseline publication would fail. The requirement's `## Out of scope` names this
limit; REQ-003 removes preflight's blanket success for the destination branch and
does not extend it to the baseline.
Preflight verifies the expected branch once rather than twice, and the
requirement scopes the two-point rule to the push operation for that reason. The
second verification exists on the push path only because first publication sits
between the refusal set and the push; preflight runs no first publication and
mutates nothing, so there is no window for the second read to close and
`push_branch_mismatch_after_baseline` is unreachable from preflight. A dry-run is
a push command, so this is a deliberate exception stated in both documents rather
than an oversight.
The preflight paths this capability introduces or changes apply the same
credential redaction to Git's output that empty-remote first publication already
applies. The existing contribution preflight paths are left exactly as they are,
which is what `## Out of scope` promises.
These changes are reachable only through the preflight endpoint and the startup
remote-contribution preflight, and the startup caller runs only when a
contribution binding exists, so none of them alters an existing live path.
## Failure and recovery
- Every refusal in the ordered list is a no-op: no remote contacted, no local
ref, upstream setting, or working tree changed. A caller can retry after
correcting its inputs. The single exception is
`push_branch_mismatch_after_baseline`, which by construction follows a
completed baseline publication on `origin`; its result sets
`baseline_published`, so a caller can see that a retry does not begin from the
state the first attempt began from.
- A remote that rejects a non-force push is reported as a failure with the
remote's output. Kandev does not retry it and does not escalate to a force
push.
- Repeating an identical successful push while `HEAD` and the destination branch
are unchanged succeeds and changes nothing, which is what makes the operation
safe for a retrying caller.
- Concurrency is handled by the existing single per-checkout lock. Requests are
rejected rather than queued, so a caller never waits on a lock it cannot see.
- Empty-remote failure codes keep their current meaning and keep applying only
to the `origin` path.
## Persistence
None. Both inputs are per-request and are not stored. No schema, migration, or
retention change. Restart behavior is unchanged because nothing new survives a
request.
## Security
- The remote URL form is resolved against already-configured remotes and is
never placed on a Git command line, so the existing command-injection
allowlist stays as narrow as it is today.
- The remote name form is validated against the existing ref allowlist and must
name an already-configured remote, so a request cannot introduce a new
destination or a flag-shaped argument.
- Kandev-composed output never carries a remote URL: the result fields above,
the error messages Kandev writes, and its log records identify a push target by
its resolved name only.
- Git's own output is a separate thing, and the honest description of today is
not that it is already URL-free. The workspace Git operator redacts the output
of every command whose subcommand is `push`, the preflight dry-run included,
through the same credential redaction empty-remote first publication uses. So
the credential redaction this capability requires is already in force on both
paths it adds, by construction rather than by a new call, and no path's output
handling changes. That redaction removes embedded credentials and known token
values, not URLs, so Git's `To <url>` line ships today and continues to. This
design does not change that, which is what keeps the path naming no explicit
push target byte-identical.
- Credentials continue to come from the task runtime Git credential route. Read
access alone does not authorize publication.
## Observability
The existing push completion log gains the resolved remote name, the destination
branch, and whether an expected branch was supplied and matched. Each refusal is
logged once at its error code. No new metric is introduced: the failure modes are
per-request and are already visible to the caller in the result.
## Prior art
- **Kandev knowledge vault (`wiki-query`).** Searched for: nothing; the leg did
not run. The skill is not installed on this runner (`~/.claude/skills` has no
`wiki-*` entry), there is no `~/.obsidian-wiki` directory or config symlink,
and `OBSIDIAN_VAULT_PATH` is unset, so neither a QMD collection nor a `grep`
fallback resolved. Treat this as missing input, not an empty result.
- **`saas-kb` MCP (`search_fsm_docs`, `category: "ai_sdlc"`).** Searched for:
nothing; the leg did not run. The server is not configured for this session,
in which `kandev` is the only MCP server.
- **In-repository prior reasoning.** Read directly:
[Empty Remote Repositories](empty-remote-repositories.md), which owns first
publication on the `origin` path, and
[ADR 2026-08-12 task-bound fork destinations](../../../decisions/2026-08-12-task-bound-fork-destinations.md),
which owns contribution-destination routing. This capability departs from
neither: it refuses rather than overrides when contribution routing applies,
and leaves first publication attached to `origin`.
## Related decisions
- [Task-bound fork destinations](../../../decisions/2026-08-12-task-bound-fork-destinations.md)