Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions runway/extension/merger/git/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
srcs = [
"changeref.go",
"git_merger.go",
"objects.go",
],
importpath = "github.com/uber/submitqueue/runway/extension/merger/git",
visibility = ["//visibility:public"],
deps = [
"//api/base/mergestrategy/protopb:go_default_library",
"//api/runway/messagequeue:go_default_library",
"//api/runway/messagequeue/protopb:go_default_library",
"//platform/base/change/git:go_default_library",
"//platform/base/change/github:go_default_library",
"//platform/metrics:go_default_library",
"//runway/extension/merger:go_default_library",
"@com_github_uber_go_tally//:go_default_library",
"@org_uber_go_zap//:go_default_library",
],
)

go_test(
name = "go_default_test",
srcs = ["git_merger_test.go"],
data = [
"@git",
"@git//:git_receive_pack",
"@git//:git_upload_archive",
"@git//:git_upload_pack",
"@git//:templates",
"@git//:templates/description",
],
embed = [":go_default_library"],
env = {
"SUBMITQUEUE_TEST_GIT": "$(location @git//:git)",
"SUBMITQUEUE_TEST_GIT_TEMPLATE_DESCRIPTION": "$(location @git//:templates/description)",
},
deps = [
"//api/base/change/protopb:go_default_library",
"//api/base/mergestrategy/protopb:go_default_library",
"//api/runway/messagequeue:go_default_library",
"//api/runway/messagequeue/protopb:go_default_library",
"//runway/extension/merger:go_default_library",
"@com_github_stretchr_testify//assert:go_default_library",
"@com_github_stretchr_testify//require:go_default_library",
"@com_github_uber_go_tally//:go_default_library",
"@org_uber_go_zap//zaptest:go_default_library",
],
)
63 changes: 63 additions & 0 deletions runway/extension/merger/git/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# git merger

A `merger.Merger` backed by the `git` CLI operating on a local checkout. It applies a merge request's ordered steps onto a target branch, honoring each step's strategy, and — for a committing merge — pushes the result. It is constructed by the wiring layer (see [`service/runway`](../../../../service/runway)) with the checkout path, remote, target branch, pinned git runtime, committer identity, and the default strategy; the request itself carries only a queue name and the changes, so the merger reads change URIs straight from the payload (no store, no resolver).

## Model

A request is an ordered list of steps; each step names a change (a set of provider URIs, each ending in a full head commit SHA) and a strategy. Steps are applied in order on top of the target tip — earlier steps are the in-flight base, the last step is the candidate. Each step yields one `StepResult`; the revisions a step produces on the target are its outputs, in application order.

A URI pins a change to one head commit, but a change is routinely several commits. The full set is recovered locally rather than from the wire: the commits to replay are the range from the change's merge base with the target up to its head. Applying the head commit alone would apply only that commit's diff against its own parent — conflicting against context its predecessors would have established, or silently dropping them when they touch different files.

## Change providers

Every URI is reduced to three things: the commit to apply, the ref the provider publishes that commit under, and a short label for synthesized commit messages. Adding a provider is one case in that mapping, not a change to any apply path.

| Scheme | Commit | Canonical ref | Label |
|---|---|---|---|
| `github://` | head commit SHA | `refs/pull/{n}/head` | `org/repo#n` |
| `git://` | the URI's commit SHA | the URI's own ref | `repo@ref` |

An unrecognized scheme is a terminal invalid request.

## Object availability

The default fetch refspec is `+refs/heads/*`, which does not cover a provider's change refs — a pull request head never also pushed as a branch, the normal case for a fork, is simply absent locally. Every referenced commit is therefore fetched and verified before any step is applied, so a request naming an unreachable commit fails without having touched the checkout.

Commits are requested by SHA, which relies on the server serving a want for an object that is reachable but not advertised (`uploadpack.allowReachableSHA1InWant`, which github.com enables); the provider's canonical ref is the fallback. Neither fetch is shallow — the apply paths need ancestry, not just the commit. A remote that supports neither can supply explicit refspecs via configuration.

A commit that cannot be fetched from a reachable remote is a terminal invalid request, not a conflict. If the remote itself is unreachable the error stays retryable.

The same line is drawn inside an apply: a non-zero exit from git is not by itself a conflict. The index is consulted for conflicted entries before the operation is aborted, so a missing object, an unreadable repository or a killed process stays a retryable infrastructure error instead of permanently failing a change that never actually collided.

## Staleness

Fetching by SHA guarantees the merger applies exactly the commit a URI names — not that the commit is still the change's head. A force-push leaves the superseded commit fetchable for some time on most hosts, so a successful fetch says nothing about freshness. When enabled, each change's canonical ref is read (one ref advertisement, no object transfer) and a mismatch is terminal. A ref that no longer exists yields no verdict.

## Strategies

| Strategy | What it does | Outputs |
|---|---|---|
| `REBASE` | Cherry-picks every commit each change introduces onto the tip, in order. A commit already present on the target is skipped (no output), as is one that was empty to begin with. | one revision per newly-created commit |
| `DEFAULT` | Resolved to the instance's configured default strategy before any step runs. | per the resolved strategy |

`REBASE` is the only strategy implemented so far. `SQUASH_REBASE`, `MERGE`, and `PROMOTE` are defined by the wire contract but not yet applied here — a step naming one is rejected as an invalid request.

## Committing, dry-run, atomicity, contention

`Merge` commits and reports outputs; `CheckMergeability` runs the identical apply but never pushes, then resets the checkout to discard the local commits and reports empty outputs. A multi-step check commits its intermediate steps locally so it sees the same conflict surface a real merge would.

For a committing merge nothing reaches the remote until the final push. A step that fails to apply aborts its in-progress git operation and returns without pushing. If the push fails because the remote tip moved between reset and push, the whole reset/apply/push cycle is retried up to a bounded number of attempts; detection re-fetches the tip and compares it to the SHA the cycle was based on.

## Failure classification

A merge conflict surfaces as `merger.ErrConflict`. An unusable request surfaces as `merger.ErrInvalidRequest`: an unsupported strategy or URI scheme, a malformed URI, a commit a reachable remote cannot supply, a change whose head has moved on, or a change sharing no history with the target under a picking strategy. Both are terminal — the controller publishes a `FAILED` result rather than retrying. Everything else (network/auth/push faults, and an unreachable remote) is returned as a plain error for the consumer to retry.

The distinction between the last two matters operationally: a commit that is missing while the remote answers is a property of the request, whereas a remote that will not answer is a property of the moment.

## Runtime and identity

Every git invocation uses the pinned runtime (explicit executable, exec-path, and template dir) and a scrubbed environment: no ambient configuration, no system or global git config, no interactive prompts. Because that leaves no ambient identity, the committer name and email are injected per-invocation, which the commit-creating `REBASE` strategy requires.

Scrubbing denies git ambient *configuration* — anything that could change what a merge produces. It deliberately does not deny it the means to reach the remote, so the agent socket, `PATH`, ssh-command, TLS and proxy variables are inherited when set. Without them an SSH remote cannot authenticate and git cannot even exec `ssh`; none of them can influence merge semantics. A deployment needing more can name additional variables on the runtime.

See [Object availability](#object-availability) for how referenced commits are obtained.
96 changes: 96 additions & 0 deletions runway/extension/merger/git/changeref.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) 2025 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package git

import (
"fmt"
"strings"

entitygit "github.com/uber/submitqueue/platform/base/change/git"
entitygithub "github.com/uber/submitqueue/platform/base/change/github"
"github.com/uber/submitqueue/runway/extension/merger"
)

// changeRef is everything the merger needs from a change URI, reduced to a
// form that does not depend on which provider minted it. Adding a provider
// means adding one case to resolveChange, not touching the apply paths.
type changeRef struct {
// SHA is the full commit hash the URI pins the change to. This is the
// commit that gets fetched and applied.
SHA string
// Ref is the fully-qualified ref the provider publishes this change's head
// under (e.g. "refs/pull/12/head"). Used as a fetch fallback and as the
// staleness comparison point. Empty when the scheme has no such ref, in
// which case both are skipped.
Ref string
// Label is a short human-readable identifier for the change, used in
// commit messages the merger synthesizes.
Label string
}

// resolveChange maps a change URI onto the merger's provider-neutral view of
// it. An unrecognized scheme is terminal: no retry produces a parser for it.
func resolveChange(uri string) (changeRef, error) {
scheme, _, ok := strings.Cut(uri, "://")
if !ok {
return changeRef{}, fmt.Errorf("%w: change URI %q has no scheme", merger.ErrInvalidRequest, uri)
}

switch scheme {
case "github":
cid, err := entitygithub.ParseChangeID(uri)
if err != nil {
return changeRef{}, fmt.Errorf("%w: invalid change URI %q: %v", merger.ErrInvalidRequest, uri, err)
}
return changeRef{
SHA: cid.HeadCommitSHA,
// GitHub publishes every PR's head under refs/pull/<n>/head in the
// base repository, including PRs opened from a fork.
Ref: fmt.Sprintf("refs/pull/%d/head", cid.PRNumber),
Label: fmt.Sprintf("%s#%d", cid.OwnerRepo(), cid.PRNumber),
}, nil

case "git":
cid, err := entitygit.ParseChangeID(uri)
if err != nil {
return changeRef{}, fmt.Errorf("%w: invalid change URI %q: %v", merger.ErrInvalidRequest, uri, err)
}
// A git:// URI already names its own fully-qualified ref, so the
// staleness check reads exactly the ref the caller pinned.
return changeRef{
SHA: cid.CommitSHA,
Ref: cid.Ref,
Label: fmt.Sprintf("%s@%s", cid.Repo, cid.Ref),
}, nil

default:
return changeRef{}, fmt.Errorf("%w: unsupported change URI scheme %q in %q", merger.ErrInvalidRequest, scheme, uri)
}
}

// resolveStepChanges resolves every URI of every step, in application order.
func resolveStepChanges(steps []resolvedStep) ([]changeRef, error) {
var refs []changeRef
for _, rs := range steps {
for _, uri := range rs.step.GetChange().GetUris() {
ref, err := resolveChange(uri)
if err != nil {
return nil, err
}
refs = append(refs, ref)
}
}
return refs, nil
}
Loading
Loading