Skip to content
Merged
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
16 changes: 14 additions & 2 deletions runway/extension/merger/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,21 @@ Fetching by SHA guarantees the merger applies exactly the commit a URI names —
| 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 |
| `SQUASH_REBASE` | Applies each change like `REBASE`, then collapses the commits it produced into a single commit (squash unit = the change, not the step). | one revision per change, or none for a change already present |
| `MERGE` | Creates a `--no-ff` merge commit per change, keeping the change's original commits reachable through second-parent history. A commit already contained in the tip is skipped. | the merge-commit revision(s) |
| `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.
`PROMOTE` is defined by the wire contract but not yet applied here — a step naming it is rejected as an invalid request.

## Importing an unrelated history

A repository migration arrives as an ordinary change in the target repo whose branch carries the source repo's entire history. Living in the target repo is what makes its commits fetchable; it says nothing about ancestry, and the two graphs still share no common ancestor, so git refuses the merge by default.

`MERGE` is the only strategy that can serve this, because it is the only one that leaves the imported commits reachable under their original hashes — the picking strategies would rewrite every one of them, and have no range to compute in the first place, so they reject such a change outright.

The refusal is lifted by a per-instance option rather than always: it is a real safeguard, and without it a merge of the wrong object fails loudly instead of quietly producing a nonsense result. A queue that exists to perform imports turns it on. A refusal that surfaces without the option is reported as an invalid request, not as a conflict — nothing collided.

Redelivery is safe: once imported, the source head is contained in the target, so the change is skipped rather than merged twice.

## Committing, dry-run, atomicity, contention

Expand All @@ -56,7 +68,7 @@ The distinction between the last two matters operationally: a commit that is mis

## 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.
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 strategies (`REBASE`, `SQUASH_REBASE`, `MERGE`) require.

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.

Expand Down
233 changes: 214 additions & 19 deletions runway/extension/merger/git/git_merger.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@
// Strategy → git operation:
//
// - REBASE: cherry-pick each URI's head commit onto the target tip.
// - SQUASH_REBASE: cherry-pick the step's changes, then collapse them into a
// single commit (squash unit = the step).
// - MERGE: create a --no-ff merge commit per URI, preserving the
// original commit hashes in second-parent history.
// - DEFAULT: resolved to the instance's configured DefaultStrategy
// before any step runs.
//
// REBASE is the only strategy implemented so far. A step naming any other
// strategy is rejected as merger.ErrInvalidRequest until its apply path lands.
// PROMOTE is not implemented yet; a step naming it is rejected as
// merger.ErrInvalidRequest until its apply path lands.
//
// Atomicity: for a committing merge nothing reaches the remote until the final
// push. A step that fails to apply aborts the in-progress git operation and
Expand Down Expand Up @@ -77,8 +81,8 @@ const defaultMaxPushAttempts = 10

// Default committer identity used when Params leaves it unset. The scrubbed
// environment (GIT_CONFIG_NOSYSTEM, GIT_CONFIG_GLOBAL=/dev/null) leaves no
// ambient identity, so the commit-creating REBASE strategy needs one supplied
// explicitly.
// ambient identity, so commit-creating strategies (REBASE/SQUASH_REBASE/MERGE)
// need one supplied explicitly.
const (
defaultCommitterName = "SubmitQueue Runway"
defaultCommitterEmail = "runway@submitqueue.invalid"
Expand Down Expand Up @@ -112,7 +116,7 @@ type Params struct {
// Target is the destination branch ref on the remote (e.g. "main").
Target string
// DefaultStrategy resolves a step whose strategy is DEFAULT. Must be a
// concrete strategy (currently only REBASE).
// concrete strategy (REBASE, SQUASH_REBASE, or MERGE).
DefaultStrategy mergestrategypb.Strategy
// Runtime is the pinned Git runtime used for every invocation.
Runtime GitRuntime
Expand All @@ -129,6 +133,13 @@ type Params struct {
// CheckStaleness enables verifying, before applying, that each change's
// canonical ref still points at the commit its URI names.
CheckStaleness bool
// AllowUnrelatedHistories lets a MERGE step integrate a change that shares
// no ancestry with the target — importing one repository's history into
// another. Off by default: the refusal it lifts is a real safeguard, since
// without it a merge of the wrong object fails loudly instead of quietly
// producing a nonsense result. Enable it on a queue that exists to perform
// such an import.
AllowUnrelatedHistories bool
// CommitterName / CommitterEmail identify the author/committer of
// service-created commits. Defaults are used when empty.
CommitterName string
Expand All @@ -150,10 +161,13 @@ type gitMerger struct {
maxPushAttempts int
fetchRefspecs []string
checkStaleness bool
committerName string
committerEmail string
logger *zap.SugaredLogger
metricsScope tally.Scope

// allowUnrelatedHistories permits a MERGE across disjoint history graphs.
allowUnrelatedHistories bool
committerName string
committerEmail string
logger *zap.SugaredLogger
metricsScope tally.Scope

// mu serializes concurrent operations — the underlying checkout cannot be
// safely shared between operations.
Expand Down Expand Up @@ -182,7 +196,7 @@ func NewMerger(params Params) (merger.Merger, error) {
return nil, err
}
if !isConcreteStrategy(params.DefaultStrategy) {
return nil, fmt.Errorf("default strategy must be concrete (currently only REBASE), got %v", params.DefaultStrategy)
return nil, fmt.Errorf("default strategy must be concrete (REBASE, SQUASH_REBASE, or MERGE), got %v", params.DefaultStrategy)
}
maxAttempts := params.MaxPushAttempts
if maxAttempts <= 0 {
Expand All @@ -205,10 +219,12 @@ func NewMerger(params Params) (merger.Merger, error) {
maxPushAttempts: maxAttempts,
fetchRefspecs: params.FetchRefspecs,
checkStaleness: params.CheckStaleness,
committerName: committerName,
committerEmail: committerEmail,
logger: params.Logger.Named("git_merger"),
metricsScope: params.MetricsScope.SubScope("git_merger"),

allowUnrelatedHistories: params.AllowUnrelatedHistories,
committerName: committerName,
committerEmail: committerEmail,
logger: params.Logger.Named("git_merger"),
metricsScope: params.MetricsScope.SubScope("git_merger"),
}, nil
}

Expand Down Expand Up @@ -307,7 +323,8 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
}

// applyTransforming runs the reset/apply/push cycle for the transforming
// strategies, retrying on remote contention when committing. For a dry run it applies the steps locally then discards them.
// strategies (REBASE, SQUASH_REBASE, MERGE), retrying on remote contention when
// committing. For a dry run it applies the steps locally then discards them.
func (m *gitMerger) applyTransforming(ctx context.Context, req *runwaymq.MergeRequest, steps []resolvedStep, commit bool) (*runwaymq.MergeResult, error) {
// Fetch and vet every change the request names before the first attempt, so
// an unusable request fails without having touched the checkout. This sits
Expand Down Expand Up @@ -416,6 +433,10 @@ func (m *gitMerger) applySteps(ctx context.Context, steps []resolvedStep) ([]*ru
switch rs.strategy {
case mergestrategypb.Strategy_REBASE:
outputs, err = m.applyRebase(ctx, rs)
case mergestrategypb.Strategy_SQUASH_REBASE:
outputs, err = m.applySquashRebase(ctx, rs)
case mergestrategypb.Strategy_MERGE:
outputs, err = m.applyMerge(ctx, rs)
default:
// resolveAndValidate rejects anything else; defensive.
return nil, fmt.Errorf("%w: unsupported strategy %v", merger.ErrInvalidRequest, rs.strategy)
Expand All @@ -438,6 +459,140 @@ func (m *gitMerger) applyRebase(ctx context.Context, rs resolvedStep) ([]*runway
return toOutputs(picked), nil
}

// applySquashRebase collapses each change in the step into a single commit.
//
// The squash unit is the change, not the step: a change is one pull request,
// and squashing it is what "squash the PR" means. A step whose change carries
// several URIs is a stack of pull requests, and collapsing those into one
// commit would erase the per-PR boundary the stack exists to express. So each
// URI is picked as its own range and squashed on its own, in order, yielding
// one commit — and one output — per change that had anything to contribute.
func (m *gitMerger) applySquashRebase(ctx context.Context, rs resolvedStep) ([]*runwaymq.StepOutput, error) {
var outputs []*runwaymq.StepOutput
for _, ref := range rs.refs {
sha, squashed, err := m.squashChange(ctx, rs.step, ref)
if err != nil {
return nil, err
}
if squashed {
outputs = append(outputs, &runwaymq.StepOutput{Id: sha})
}
}
return outputs, nil
}

// squashChange replays one change and collapses whatever it produced into a
// single commit, reporting squashed=false when it produced nothing worth
// keeping.
//
// Two cases produce nothing. A change already present on the target creates no
// commits at all. A change that does create commits whose net tree matches the
// base — its effect was already there, spread differently — would squash to an
// empty commit, so the intermediates are dropped instead. Both keep redelivery
// idempotent.
func (m *gitMerger) squashChange(ctx context.Context, step *runwaymq.MergeStep, ref changeRef) (string, bool, error) {
preSHA, err := m.headSHA(ctx)
if err != nil {
return "", false, err
}

created, err := m.pickRange(ctx, ref)
if err != nil {
return "", false, err
}
if len(created) == 0 {
return "", false, nil
}

preTree, err := m.commitTreeSHA(ctx, preSHA)
if err != nil {
return "", false, err
}
postTree, err := m.commitTreeSHA(ctx, "HEAD")
if err != nil {
return "", false, err
}
if preTree == postTree {
if _, err := m.run(ctx, nil, "reset", "--hard", preSHA); err != nil {
return "", false, fmt.Errorf("git reset --hard %s after empty squash: %w", preSHA, err)
}
return "", false, nil
}

if _, err := m.run(ctx, nil, "reset", "--soft", preSHA); err != nil {
return "", false, fmt.Errorf("git reset --soft %s: %w", preSHA, err)
}
if _, err := m.run(ctx, nil, "commit", "-m", squashMessage(step, ref)); err != nil {
return "", false, fmt.Errorf("git commit (squash): %w", err)
}
sha, err := m.headSHA(ctx)
if err != nil {
return "", false, err
}
return sha, true, nil
}

// applyMerge creates a --no-ff merge commit for every change in the step,
// keeping the change's original commits reachable through second-parent
// history — the property that distinguishes MERGE from the picking strategies,
// which rewrite those commits. A change already contained in HEAD produces no
// output, which is what makes redelivery idempotent.
func (m *gitMerger) applyMerge(ctx context.Context, rs resolvedStep) ([]*runwaymq.StepOutput, error) {
var outputs []*runwaymq.StepOutput
for _, ref := range rs.refs {
contained, err := m.isAncestor(ctx, ref.SHA, "HEAD")
if err != nil {
return nil, err
}
if contained {
continue
}

args := []string{"merge", "--no-ff", "--no-edit"}
if m.allowUnrelatedHistories {
args = append(args, "--allow-unrelated-histories")
}
out, err := m.runCombined(ctx, nil, append(args, ref.SHA)...)
if err != nil {
// Read the index before aborting clears it; a non-zero exit alone
// does not establish that anything collided.
conflicted := m.hasUnmergedPaths(ctx)
_, _ = m.run(ctx, nil, "merge", "--abort")
return nil, m.classifyMergeFailure(ref, out, conflicted)
}
mergeSHA, err := m.headSHA(ctx)
if err != nil {
return nil, err
}
outputs = append(outputs, &runwaymq.StepOutput{Id: mergeSHA})
}
return outputs, nil
}

// classifyMergeFailure decides what a failed `git merge` actually means, given
// whether the index was left holding conflicted entries.
//
// Not every refusal is a conflict, and reporting one as such tells the client
// its change collides with the target when nothing of the sort happened. Two
// non-conflicts land here: an import of an unrelated history, refused outright
// and fixed by configuration rather than by rebasing, and any other way git
// can exit non-zero — a missing object, an unreadable repository, a killed
// process — which is infrastructure and should be retried, not made terminal.
func (m *gitMerger) classifyMergeFailure(ref changeRef, out []byte, conflicted bool) error {
detail := strings.TrimSpace(string(out))
if strings.Contains(detail, "refusing to merge unrelated histories") {
coremetrics.NamedCounter(m.metricsScope, "merge", "unrelated_histories", 1)
return fmt.Errorf("%w: %s shares no history with the merge target; integrating an imported history requires the merger to allow unrelated histories",
merger.ErrInvalidRequest, ref.Label)
}
if !conflicted {
coremetrics.NamedCounter(m.metricsScope, "merge", "merge_errors", 1)
return fmt.Errorf("git merge %s: %s", ref.SHA, detail)
}
coremetrics.NamedCounter(m.metricsScope, "merge", "merge_conflicts", 1)
return fmt.Errorf("%w: git merge %s: %s", merger.ErrConflict, ref.SHA, detail)
}

// pickStepChanges applies every change in the step, in order, returning the
// SHAs of the commits created on the target (empty for a change whose content
// was already present).
Expand Down Expand Up @@ -618,6 +773,33 @@ func (m *gitMerger) refetchTipSHA(ctx context.Context) (string, error) {
return strings.TrimSpace(string(out)), nil
}

// isAncestor reports whether ancestor is an ancestor of (or equal to)
// descendant. `git merge-base --is-ancestor` exits 0 for true, 1 for false;
// any other exit is a real error.
func (m *gitMerger) isAncestor(ctx context.Context, ancestor, descendant string) (bool, error) {
cmd := m.command(ctx, "merge-base", "--is-ancestor", ancestor, descendant)
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, fmt.Errorf("git merge-base --is-ancestor %s %s: %w: %s", ancestor, descendant, err, strings.TrimSpace(stderr.String()))
}

// commitTreeSHA returns the tree SHA recorded in the commit object at ref.
func (m *gitMerger) commitTreeSHA(ctx context.Context, ref string) (string, error) {
out, err := m.run(ctx, nil, "rev-parse", ref+"^{tree}")
if err != nil {
return "", fmt.Errorf("git rev-parse %s^{tree}: %w", ref, err)
}
return strings.TrimSpace(string(out)), nil
}

// push pushes the current HEAD to refs/heads/<target> on the remote.
func (m *gitMerger) push(ctx context.Context) error {
refspec := "HEAD:refs/heads/" + m.target
Expand Down Expand Up @@ -750,12 +932,13 @@ func passthroughEnv(extra []string) []string {
}

// isConcreteStrategy reports whether s names a concrete integration strategy
// (i.e. not DEFAULT and not an unknown value). Only REBASE is implemented so
// far; the remaining strategies are rejected as invalid requests until their
// apply paths land.
// (i.e. not DEFAULT and not an unknown value). PROMOTE is not implemented yet
// and is rejected as an invalid request until its apply path lands.
func isConcreteStrategy(s mergestrategypb.Strategy) bool {
switch s {
case mergestrategypb.Strategy_REBASE:
case mergestrategypb.Strategy_REBASE,
mergestrategypb.Strategy_SQUASH_REBASE,
mergestrategypb.Strategy_MERGE:
return true
default:
return false
Expand All @@ -770,6 +953,18 @@ func isRedundantCherryPick(out []byte) bool {
strings.Contains(s, "nothing to commit")
}

// squashMessage synthesizes a commit message for one squashed change. The wire
// carries no upstream commit message, so the change is named by its provider
// label — which is why this goes through the resolver rather than one
// provider's fields, so a non-GitHub change is named too instead of being
// silently omitted.
func squashMessage(step *runwaymq.MergeStep, ref changeRef) string {
if step.GetStepId() != "" {
return fmt.Sprintf("squash: %s (%s)", step.GetStepId(), ref.Label)
}
return fmt.Sprintf("squash: %s", ref.Label)
}

// toOutputs wraps commit SHAs as StepOutputs in order.
func toOutputs(shas []string) []*runwaymq.StepOutput {
if len(shas) == 0 {
Expand Down
Loading
Loading