merge: Add configurable readiness and queue controls (#1333)
CI / Lint (push) Has been cancelled
CI / Generate test matrix (push) Has been cancelled
CI / Test / ${{ matrix.name }} (push) Has been cancelled
CI / OK (push) Has been cancelled
GitHub Pages / deploy (push) Has been cancelled
GitHub Pages / build (push) Has been cancelled
autofix.ci / autofix (push) Has been cancelled

Add `spice.merge.readyCommand` so merge commands can delegate
the ready-to-merge decision to a user command
instead of relying only on forge readiness.
The command runs before the merge request step,
receives the same common and forge-specific environment
as `spice.merge.command`,
and reports ready, waiting, blocked, or command failure
by exit status.

Add `--fail-fast` to merge commands
so users can stop a multi-branch merge queue
on the first branch failure.
Remove `--no-branch-check` from downstack and stack merge
so stale-base validation always protects those merge flows.

Refresh merge command help and guide documentation
around branch selection, merge readiness, command environment,
and failure behavior so the released merge feature
describes the final command surface.
This commit is contained in:
Abhinav Gupta
2026-07-03 12:00:25 -07:00
committed by GitHub
parent 60277c3209
commit ea0104ec08
36 changed files with 2830 additions and 1106 deletions
@@ -0,0 +1,5 @@
kind: Added
body: >-
merge: Add 'spice.merge.readyCommand' configuration option
to customize what is considered ready-to-merge for a CR.
time: 2026-06-30T16:36:40.452559-07:00
@@ -0,0 +1,5 @@
kind: Added
body: >-
merge: Add --fail-fast flag to all merge commands to stop scheduling
remaining merge queue work after the first branch failure.
time: 2026-06-30T20:02:20.005463-07:00
@@ -0,0 +1,3 @@
kind: Removed
body: 'downstack merge, stack merge: Remove --no-branch-check flag.'
time: 2026-06-30T19:54:54.493552-07:00
+23 -11
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"slices"
"go.abhg.dev/gs/internal/cli"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/merge"
"go.abhg.dev/gs/internal/spice/state"
@@ -19,22 +20,33 @@ type branchMergeCmd struct {
}
func (*branchMergeCmd) Help() string {
return text.Dedent(`
return text.Dedent(fmt.Sprintf(`
Merges the CR for the current branch into trunk.
Use --branch to merge a different branch.
Use --branch multiple times to merge multiple branches.
Only the selected branches are merged.
To merge a branch and its downstack,
use 'git-spice downstack merge'.
To merge a whole stack,
use 'git-spice stack merge'.
All selected branches must be stacked on trunk
or on a branch that is also selected.
Before checking merge readiness,
the command waits briefly for the forge to observe the pushed head.
Then it waits for the forge to report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait.
`)
For example, for the following stack:
┌── B
┌─┴ A
trunk
This command can merge A alone,
or A and B together.
%[1]s branch merge --branch A
%[1]s branch merge --branch A --branch B
It cannot merge B alone, because A is not selected:
%[1]s branch merge --branch B // error
To merge multiple branches in a stack
prefer '%[1]s downstack merge' or '%[1]s stack merge'.
`, cli.Name())) + _mergeHelpCommon
}
func (cmd *branchMergeCmd) AfterApply(
+107 -64
View File
@@ -296,42 +296,55 @@ gs stack (s) merge (m) [flags]
Merge a stack
Merges the CRs for the current branch's stack into trunk.
Merges CRs for the current branch's full stack into trunk.
Use --branch to merge a different branch's stack.
Use --branch multiple times to merge multiple stacks.
The stack includes the selected branch,
Use --branch multiple times to merge independent stacks.
A stack includes the selected branch,
its downstack branches down to trunk,
and every upstack branch.
Overlapping stacks are merged once.
Already-merged branches are skipped automatically.
Branches must have an open Change Request to be merged.
For example, for the following stack:
Before merging, the stack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.
┌── E
┌─┴ D
│ ┌── C
├─┴ B
┌─┴ A
trunk
Before checking merge readiness,
the command waits briefly for the forge to observe the pushed head.
Then it waits for the forge to report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
before failing if merge readiness is not reached.
The following commands have the following effects:
gs stack merge --branch A # merge A, B, C, D, E
gs stack merge --branch B # merge A, B, C
gs stack merge --branch D # merge A, D, E
Branches merge bottom-up starting with those stacked on trunk.
After a branch merges, its upstack branches are restacked and resubmitted.
When those are ready to merge, they are merged in turn, and the process repeats.
A branch is considered ready to merge when the forge reports it as mergeable,
based on the forge and the repository configuration.
Override this with the 'spice.merge.readyCommand' configuration option.
Branches are merged using the forge's merge API.
Override this with the 'spice.merge.command' configuration option.
If a branch becomes blocked and will not become ready without intervention,
or it takes too long to become ready, or otherwise fails to merge,
it is skipped and any branches stacked on it are also skipped.
Use --fail-fast to stop scheduling remaining merge queue work
after the first branch failure.
By default, a branch failure skips that branch's upstack descendants,
but independent sibling branches continue.
Use --fail-fast to stop the queue after the first branch failure.
**Flags**
* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--merge-timeout=2m` ([:material-wrench:{ .middle title="spice.merge.mergeTimeout" }](/cli/config.md#spicemergemergetimeout)): Max time to wait for merge completion after requesting merge.
* `--no-branch-check`: Skip stale base validation before merging.
* `--fail-fast`: Stop the merge queue after the first branch failure.
* `--fail-fast`: Stop scheduling remaining merge queue work after the first branch failure.
* `--branch=NAME,...`: Branches whose stacks to merge. May be repeated.
**Configuration**: [spice.merge.command](/cli/config.md#spicemergecommand), [spice.merge.mergeTimeout](/cli/config.md#spicemergemergetimeout), [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)
**Configuration**: [spice.merge.command](/cli/config.md#spicemergecommand), [spice.merge.mergeTimeout](/cli/config.md#spicemergemergetimeout), [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyCommand](/cli/config.md#spicemergereadycommand), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)
### git-spice stack restack {#gs-stack-restack}
@@ -621,57 +634,57 @@ gs downstack (ds) merge (m) [flags]
Merge a branch and those below it
Merges the current branch and all branches below it
into trunk via the forge API, bottom-up.
Use --branch to start at a different branch.
Use --branch multiple times to merge multiple downstacks.
Merges CRs for the current branch and all branches below it into trunk.
Use --branch to merge the downstack of a different branch.
Use --branch multiple times to merge downstacks of multiple branches.
Selected branches and their downstack branches down to trunk are merged.
Each selected branch expands to that branch
and its downstack branches down to trunk.
Overlapping downstacks are merged once.
For example, for the following stack:
This command acts as a local merge queue:
it merges one Change Request,
waits for that merge to finish,
restacks and updates the next Change Request,
waits for merge readiness on the updated Change Request,
and then repeats the process.
┌── D
│ ┌── C
├─┴ B
┌─┴ A
trunk
For a stack like this:
The following commands have the following effects:
main <- feature1 <- feature2 <- feature3
gs downstack merge --branch D # merge A, D
gs downstack merge --branch B # merge A, B
gs downstack merge --branch C # merge A, B, C
gs downstack merge \ # merge A, B, C, D
--branch C --branch D
Running from feature3 merges in this order:
Use 'gs stack merge' to merge a branch
and its upstack branches in one operation.
feature1, feature2, feature3
Branches merge bottom-up starting with those stacked on trunk.
After a branch merges, its upstack branches are restacked and resubmitted.
When those are ready to merge, they are merged in turn, and the process repeats.
Already-merged branches are skipped automatically.
Branches must have an open Change Request to be merged.
A branch is considered ready to merge when the forge reports it as mergeable,
based on the forge and the repository configuration.
Override this with the 'spice.merge.readyCommand' configuration option.
Before merging, the downstack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.
Branches are merged using the forge's merge API.
Override this with the 'spice.merge.command' configuration option.
Before checking merge readiness,
the command waits briefly for the forge to observe the pushed head.
Then it waits for the forge to report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
(default: 30m, 0 means fail immediately if not ready).
If a branch becomes blocked and will not become ready without intervention,
or it takes too long to become ready, or otherwise fails to merge,
it is skipped and any branches stacked on it are also skipped.
Use --fail-fast to stop scheduling remaining merge queue work
after the first branch failure.
Between merges, the command waits for each merge
to complete, restacks and updates the next PR,
waits for merge readiness on the updated PR,
and syncs merged branch cleanup.
**Flags**
* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--merge-timeout=2m` ([:material-wrench:{ .middle title="spice.merge.mergeTimeout" }](/cli/config.md#spicemergemergetimeout)): Max time to wait for merge completion after requesting merge.
* `--no-branch-check`: Skip stale base validation before merging.
* `--fail-fast`: Stop scheduling remaining merge queue work after the first branch failure.
* `--branch=NAME,...`: Branches to start merging from. May be repeated.
**Configuration**: [spice.merge.command](/cli/config.md#spicemergecommand), [spice.merge.mergeTimeout](/cli/config.md#spicemergemergetimeout), [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)
**Configuration**: [spice.merge.command](/cli/config.md#spicemergecommand), [spice.merge.mergeTimeout](/cli/config.md#spicemergemergetimeout), [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyCommand](/cli/config.md#spicemergereadycommand), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)
### git-spice downstack edit {#gs-downstack-edit}
@@ -1138,26 +1151,56 @@ Merge a branch into trunk
Merges the CR for the current branch into trunk.
Use --branch to merge a different branch.
Use --branch multiple times to merge multiple branches.
Only the selected branches are merged.
To merge a branch and its downstack,
use 'git-spice downstack merge'.
To merge a whole stack,
use 'git-spice stack merge'.
All selected branches must be stacked on trunk
or on a branch that is also selected.
For example, for the following stack:
┌── B
┌─┴ A
trunk
This command can merge A alone,
or A and B together.
gs branch merge --branch A
gs branch merge --branch A --branch B
It cannot merge B alone, because A is not selected:
gs branch merge --branch B // error
To merge multiple branches in a stack
prefer 'gs downstack merge' or 'gs stack merge'.
Branches merge bottom-up starting with those stacked on trunk.
After a branch merges, its upstack branches are restacked and resubmitted.
When those are ready to merge, they are merged in turn, and the process repeats.
A branch is considered ready to merge when the forge reports it as mergeable,
based on the forge and the repository configuration.
Override this with the 'spice.merge.readyCommand' configuration option.
Branches are merged using the forge's merge API.
Override this with the 'spice.merge.command' configuration option.
If a branch becomes blocked and will not become ready without intervention,
or it takes too long to become ready, or otherwise fails to merge,
it is skipped and any branches stacked on it are also skipped.
Use --fail-fast to stop scheduling remaining merge queue work
after the first branch failure.
Before checking merge readiness,
the command waits briefly for the forge to observe the pushed head.
Then it waits for the forge to report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait.
**Flags**
* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'.
* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once.
* `--merge-timeout=2m` ([:material-wrench:{ .middle title="spice.merge.mergeTimeout" }](/cli/config.md#spicemergemergetimeout)): Max time to wait for merge completion after requesting merge.
* `--fail-fast`: Stop scheduling remaining merge queue work after the first branch failure.
* `--branch=NAME,...`: Branches to merge. May be repeated.
**Configuration**: [spice.merge.command](/cli/config.md#spicemergecommand), [spice.merge.mergeTimeout](/cli/config.md#spicemergemergetimeout), [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)
**Configuration**: [spice.merge.command](/cli/config.md#spicemergecommand), [spice.merge.mergeTimeout](/cli/config.md#spicemergemergetimeout), [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyCommand](/cli/config.md#spicemergereadycommand), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout)
### git-spice branch submit {#gs-branch-submit}
+43 -28
View File
@@ -475,16 +475,47 @@ whether the branch is in sync with its pushed counterpart.
<!-- gs:version v0.30.0 -->
Maximum time merge commands wait for merge readiness before each merge.
Merge readiness requires the forge to report the pushed head
and report that the CR is ready to merge.
Maximum time that the merge commands wait
for a CR to be ready to merge after enqueued.
Whether a CR is ready is determined by the forge and repository configuration,
or by $$spice.merge.readyCommand$$ if configured.
The value must be a duration string such as
`30m`, `1h`, `90s`, etc.
Set to `0` to fail immediately if merge readiness is not already reached.
Set to `0` to fail immediately for any CRs
that aren't already ready to merge.
Defaults to `30m`.
### spice.merge.readyCommand
<!-- gs:version unreleased -->
Command to run to check whether a CR is ready to merge.
If unset, this is determined by the forge and repository settings.
If set, the command replaces the forge readiness check.
The command does not request the merge.
Use $$spice.merge.command$$ to customize the later merge-request step.
Both settings may be configured together.
The command must exit with one of the following statuses:
- `0`: the CR is ready to merge
- `1`: the CR is not ready yet, and git-spice should poll again later
- `2`: the CR is permanently blocked by the command's policy;
use this for conditions that cannot be resolved by waiting,
such as a missing approval or failing CI checks
- any other exit status: the operation failed
See [Command environment](/guide/merge.md#command-environment)
for the variables passed to the command.
git-spice may run the command concurrently for different CRs.
### spice.merge.mergeTimeout
<!-- gs:version v0.30.0 -->
@@ -520,37 +551,21 @@ git-spice warns and lets the forge use its default merge method.
<!-- gs:version v0.30.0 -->
Command to run to request a forge merge
when a CR is deemed mergeable by a merge command.
Command to run to request a forge merge when a CR is deemed ready-to-merge.
If unset, git-spice requests the merge through the forge API.
git-spice still waits for the forge to report merge readiness before running
the command,
and still waits for the forge to report that the CR merged after the command
exits successfully.
git-spice may run the command concurrently for different CRs.
git-spice waits for the CR to be reported as ready-to-merge
before attempting a merge.
Ready-to-merge is defined by the forge and repository settings,
or by $$spice.merge.readyCommand$$ if configured.
Exit status `0` means the command requested the merge.
Any non-zero exit status means the merge request failed for that CR.
The command receives these common environment variables:
See [Command environment](/guide/merge.md#command-environment)
for the variables passed to the command.
- `GIT_SPICE_FORGE_ID`
- `GIT_SPICE_BRANCH`
- `GIT_SPICE_BASE_BRANCH`
- `GIT_SPICE_TRUNK_BRANCH`
- `GIT_SPICE_CHANGE_URL`
- `GIT_SPICE_HEAD_SHA`
Forges may also provide provider-specific variables.
**Provider-specific variables:**
- `GIT_SPICE_GITHUB_PR_NUMBER`
- `GIT_SPICE_GITLAB_MR_IID`
- `GIT_SPICE_BITBUCKET_PR_ID`
- `GIT_SPICE_FORGEJO_PR_NUMBER`
- `GIT_SPICE_GITEA_PR_NUMBER`
git-spice may run the command concurrently for different CRs.
### spice.rebaseContinue.edit
+118 -10
View File
@@ -105,16 +105,102 @@ text "feat3" color red
## When is a CR ready to merge?
Before git-spice requests a merge,
it waits for the forge to report that the CR is ready to merge.
Before git-spice requests a merge, it waits for the CR to be ready.
The forge decides what "ready to merge" means for the repository.
Depending on the forge and repository settings,
this can include required CI checks, review approvals, or other requirements.
By default, the definition of "ready to merge" for a CR
depends on the forge and repository settings.
This can include CI checks, required approvals, or other requirements.
If a CR is not ready to merge within 30 minutes,
git-spice treats the branch as failed, and skips its upstack branches.
The wait time can be changed with the $$spice.merge.mergeTimeout$$ configuration option.
git-spice will poll the forge until the CR is reported as mergeable,
waiting up to 30 minutes for the CR to become ready.
The wait time can be changed
with the $$spice.merge.readyTimeout$$ configuration option.
If a CR is not ready to merge after the configured timeout,
that CR is assumed to be blocked and it, and its upstack branches,
will not be merged.
### Custom merge readiness
If a repository's configured definition of "ready to merge" is not sufficient,
git-spice offers a $$spice.merge.readyCommand$$ configuration option
to customize it.
If set, git-spice will run the configured command to poll for a CR's readiness.
The configuration value can be set to a shell command or a script/executable.
Its exit status determines whether the CR is ready to merge:
- exit status `0` means the CR is ready
- exit status `1` means git-spice should try again later
- exit status `2` means the CR is blocked and waiting won't help;
the CR and its upstack branches will not be merged
<details>
<summary>Example: Wait for GitHub mergeability and review</summary>
For example, this command waits until GitHub reports a PR mergeable,
with at least one approval and no blocking reviews.
```bash title="Configuration"
git config \
spice.merge.readyCommand \
"$HOME/bin/merge-ready.sh"
```
```bash title="$HOME/bin/merge-ready.sh"
#!/usr/bin/env bash
set -euo pipefail
readonly PR="${GIT_SPICE_GITHUB_PR_NUMBER}"
MERGEABLE="$(
gh pr view "$PR" \
--json mergeable \
--jq .mergeable
)"
case "$MERGEABLE" in
MERGEABLE)
;;
UNKNOWN)
# GitHub may return UNKNOWN while it computes whether the PR can merge.
# Treat that as temporary and let git-spice poll again.
exit 1
;;
CONFLICTING)
exit 2
;;
esac
REVIEW="$(
gh pr view "$PR" \
--json reviewDecision \
--jq .reviewDecision
)"
case "$REVIEW" in
APPROVED)
exit 0
;;
"" | REVIEW_REQUIRED)
# GitHub may return an empty reviewDecision
# when no review requirement applies or no review decision is available.
# For this policy, both empty and REVIEW_REQUIRED mean keep waiting.
exit 1
;;
CHANGES_REQUESTED)
exit 2
;;
esac
exit 1
```
</details>
See [Command environment](#command-environment)
for the variables passed to $$spice.merge.readyCommand$$.
## Merging multiple stacks
@@ -259,5 +345,27 @@ If the command exits with a non-zero exit code,
the merge is considered to have failed
and the upstack branches are not merged.
See $$spice.merge.command$$ for the full set of environment variables
available to the command.
See [Command environment](#command-environment)
for the variables passed to $$spice.merge.command$$.
## Command environment
$$spice.merge.readyCommand$$ and $$spice.merge.command$$
receive the following environment variables when executed:
- `GIT_SPICE_FORGE_ID`
- `GIT_SPICE_BRANCH`
- `GIT_SPICE_BASE_BRANCH`
- `GIT_SPICE_TRUNK_BRANCH`
- `GIT_SPICE_CHANGE_URL`
- `GIT_SPICE_HEAD_SHA`
Depending on the forge, the following additional variables may be set:
| Environment variable | Forge |
|---|---|
| `GIT_SPICE_GITHUB_PR_NUMBER` | <!-- gs:badge:github --> |
| `GIT_SPICE_GITLAB_MR_IID` | <!-- gs:badge:gitlab --> |
| `GIT_SPICE_BITBUCKET_PR_ID` | <!-- gs:badge:bitbucket --> |
| `GIT_SPICE_FORGEJO_PR_NUMBER` | <!-- gs:badge:forgejo --> |
| `GIT_SPICE_GITEA_PR_NUMBER` | <!-- gs:badge:gitea --> |
+23 -40
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"slices"
"go.abhg.dev/gs/internal/cli"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/merge"
"go.abhg.dev/gs/internal/spice/state"
@@ -13,55 +14,37 @@ import (
)
type downstackMergeCmd struct {
merge.DownstackMergeOptions
merge.Options
Branches []string `name:"branch" placeholder:"NAME" help:"Branches to start merging from. May be repeated." predictor:"trackedBranches"`
}
func (*downstackMergeCmd) Help() string {
return text.Dedent(`
Merges the current branch and all branches below it
into trunk via the forge API, bottom-up.
Use --branch to start at a different branch.
Use --branch multiple times to merge multiple downstacks.
return text.Dedent(fmt.Sprintf(`
Merges CRs for the current branch and all branches below it into trunk.
Use --branch to merge the downstack of a different branch.
Use --branch multiple times to merge downstacks of multiple branches.
Selected branches and their downstack branches down to trunk are merged.
Each selected branch expands to that branch
and its downstack branches down to trunk.
Overlapping downstacks are merged once.
For example, for the following stack:
This command acts as a local merge queue:
it merges one Change Request,
waits for that merge to finish,
restacks and updates the next Change Request,
waits for merge readiness on the updated Change Request,
and then repeats the process.
┌── D
│ ┌── C
├─┴ B
┌─┴ A
trunk
For a stack like this:
The following commands have the following effects:
main <- feature1 <- feature2 <- feature3
%[1]s downstack merge --branch D # merge A, D
%[1]s downstack merge --branch B # merge A, B
%[1]s downstack merge --branch C # merge A, B, C
%[1]s downstack merge \ # merge A, B, C, D
--branch C --branch D
Running from feature3 merges in this order:
feature1, feature2, feature3
Already-merged branches are skipped automatically.
Branches must have an open Change Request to be merged.
Before merging, the downstack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.
Before checking merge readiness,
the command waits briefly for the forge to observe the pushed head.
Then it waits for the forge to report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
(default: 30m, 0 means fail immediately if not ready).
Between merges, the command waits for each merge
to complete, restacks and updates the next PR,
waits for merge readiness on the updated PR,
and syncs merged branch cleanup.
`)
Use '%[1]s stack merge' to merge a branch
and its upstack branches in one operation.
`, cli.Name())) + _mergeHelpCommon
}
// MergeHandler merges change requests via a forge.
@@ -97,6 +80,6 @@ func (cmd *downstackMergeCmd) Run(
return mergeHandler.MergeDownstack(ctx, &merge.DownstackMergeRequest{
Branches: cmd.Branches,
Options: &cmd.DownstackMergeOptions,
Options: &cmd.Options,
})
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"go.abhg.dev/gs/internal/forge"
)
// MergeCommandEnvironment returns Bitbucket-specific variables for merge hooks.
func (r *Repository) MergeCommandEnvironment(
// CommandEnvironment returns Bitbucket-specific variables for command hooks.
func (r *Repository) CommandEnvironment(
_ context.Context,
id forge.ChangeID,
) (map[string]string, error) {
+3 -3
View File
@@ -290,13 +290,13 @@ type Repository interface {
// MergeChange merges an open change into its base branch.
MergeChange(ctx context.Context, id ChangeID, opts MergeChangeOptions) error
// MergeCommandEnvironment returns forge-specific environment variables
// for a command that requests merge of the given change.
// CommandEnvironment returns forge-specific environment variables
// for commands that operate on the given change.
//
// Keys must use the GIT_SPICE_ prefix.
// Callers own common git-spice variables
// and may ignore forge values that collide with those keys.
MergeCommandEnvironment(ctx context.Context, id ChangeID) (map[string]string, error)
CommandEnvironment(ctx context.Context, id ChangeID) (map[string]string, error)
// ChangeMergeability reports whether the forge currently considers
// the change mergeable.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"go.abhg.dev/gs/internal/forge"
)
// MergeCommandEnvironment returns Forgejo-specific variables for merge hooks.
func (r *Repository) MergeCommandEnvironment(
// CommandEnvironment returns Forgejo-specific variables for command hooks.
func (r *Repository) CommandEnvironment(
_ context.Context,
id forge.ChangeID,
) (map[string]string, error) {
+39 -39
View File
@@ -826,6 +826,45 @@ func (c *MockRepositoryChangeStatusesCall) DoAndReturn(f func(context.Context, [
return c
}
// CommandEnvironment mocks base method.
func (m *MockRepository) CommandEnvironment(ctx context.Context, id forge.ChangeID) (map[string]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CommandEnvironment", ctx, id)
ret0, _ := ret[0].(map[string]string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CommandEnvironment indicates an expected call of CommandEnvironment.
func (mr *MockRepositoryMockRecorder) CommandEnvironment(ctx, id any) *MockRepositoryCommandEnvironmentCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommandEnvironment", reflect.TypeOf((*MockRepository)(nil).CommandEnvironment), ctx, id)
return &MockRepositoryCommandEnvironmentCall{Call: call}
}
// MockRepositoryCommandEnvironmentCall wrap *gomock.Call
type MockRepositoryCommandEnvironmentCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRepositoryCommandEnvironmentCall) Return(arg0 map[string]string, arg1 error) *MockRepositoryCommandEnvironmentCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRepositoryCommandEnvironmentCall) Do(f func(context.Context, forge.ChangeID) (map[string]string, error)) *MockRepositoryCommandEnvironmentCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRepositoryCommandEnvironmentCall) DoAndReturn(f func(context.Context, forge.ChangeID) (map[string]string, error)) *MockRepositoryCommandEnvironmentCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// CommentCountsByChange mocks base method.
func (m *MockRepository) CommentCountsByChange(ctx context.Context, ids []forge.ChangeID) ([]*forge.CommentCounts, error) {
m.ctrl.T.Helper()
@@ -1172,45 +1211,6 @@ func (c *MockRepositoryMergeChangeCall) DoAndReturn(f func(context.Context, forg
return c
}
// MergeCommandEnvironment mocks base method.
func (m *MockRepository) MergeCommandEnvironment(ctx context.Context, id forge.ChangeID) (map[string]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MergeCommandEnvironment", ctx, id)
ret0, _ := ret[0].(map[string]string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// MergeCommandEnvironment indicates an expected call of MergeCommandEnvironment.
func (mr *MockRepositoryMockRecorder) MergeCommandEnvironment(ctx, id any) *MockRepositoryMergeCommandEnvironmentCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MergeCommandEnvironment", reflect.TypeOf((*MockRepository)(nil).MergeCommandEnvironment), ctx, id)
return &MockRepositoryMergeCommandEnvironmentCall{Call: call}
}
// MockRepositoryMergeCommandEnvironmentCall wrap *gomock.Call
type MockRepositoryMergeCommandEnvironmentCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockRepositoryMergeCommandEnvironmentCall) Return(arg0 map[string]string, arg1 error) *MockRepositoryMergeCommandEnvironmentCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockRepositoryMergeCommandEnvironmentCall) Do(f func(context.Context, forge.ChangeID) (map[string]string, error)) *MockRepositoryMergeCommandEnvironmentCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockRepositoryMergeCommandEnvironmentCall) DoAndReturn(f func(context.Context, forge.ChangeID) (map[string]string, error)) *MockRepositoryMergeCommandEnvironmentCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// NewChangeMetadata mocks base method.
func (m *MockRepository) NewChangeMetadata(ctx context.Context, id forge.ChangeID) (forge.ChangeMetadata, error) {
m.ctrl.T.Helper()
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"go.abhg.dev/gs/internal/forge"
)
// MergeCommandEnvironment returns Gitea-specific variables for merge hooks.
func (r *Repository) MergeCommandEnvironment(
// CommandEnvironment returns Gitea-specific variables for command hooks.
func (r *Repository) CommandEnvironment(
_ context.Context,
id forge.ChangeID,
) (map[string]string, error) {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"go.abhg.dev/gs/internal/forge"
)
// MergeCommandEnvironment returns GitHub-specific variables for merge hooks.
func (r *Repository) MergeCommandEnvironment(
// CommandEnvironment returns GitHub-specific variables for command hooks.
func (r *Repository) CommandEnvironment(
_ context.Context,
id forge.ChangeID,
) (map[string]string, error) {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"go.abhg.dev/gs/internal/forge"
)
// MergeCommandEnvironment returns GitLab-specific variables for merge hooks.
func (r *Repository) MergeCommandEnvironment(
// CommandEnvironment returns GitLab-specific variables for command hooks.
func (r *Repository) CommandEnvironment(
_ context.Context,
id forge.ChangeID,
) (map[string]string, error) {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"go.abhg.dev/gs/internal/forge"
)
// MergeCommandEnvironment returns ShamHub-specific variables for merge hooks.
func (r *forgeRepository) MergeCommandEnvironment(
// CommandEnvironment returns ShamHub-specific variables for command hooks.
func (r *forgeRepository) CommandEnvironment(
_ context.Context,
id forge.ChangeID,
) (map[string]string, error) {
+174
View File
@@ -0,0 +1,174 @@
package merge
import (
"context"
"fmt"
"slices"
"go.abhg.dev/gs/internal/forge"
"go.abhg.dev/gs/internal/scriptrun"
"go.abhg.dev/gs/internal/silog"
)
// mergeRequester requests the forge-side merge after readiness checks pass.
//
// A successful request does not mean the change has merged.
// The merge executor must still wait for the forge to report the merged state.
type mergeRequester interface {
RequestMerge(context.Context, *mergeItem) error
}
// forgeMergeRequester requests merges through the forge API.
type forgeMergeRequester struct {
Repository forge.Repository // required
Method forge.MergeMethod
}
func (r *forgeMergeRequester) RequestMerge(ctx context.Context, item *mergeItem) error {
return r.Repository.MergeChange(ctx, item.changeID, forge.MergeChangeOptions{
Method: r.Method,
HeadHash: item.headHash,
})
}
// commandMergeRequester requests merges through a user-configured command.
//
// The command is only the merge request step.
// The caller remains responsible for waiting until the forge reports the
// change as merged.
type commandMergeRequester struct {
Runner *commandRunner // required
Script string // required
}
func (r *commandMergeRequester) RequestMerge(ctx context.Context, item *mergeItem) error {
result, err := r.Runner.Run(ctx, r.Script, item)
if err != nil {
return err
}
if result.ExitCode != 0 {
return fmt.Errorf("command exited with status %d", result.ExitCode)
}
return nil
}
// readinessChecker reports whether a merge queue item can enter
// the merge request phase.
type readinessChecker interface {
CheckMergeItemReady(context.Context, *mergeItem) (forge.ChangeMergeability, error)
}
type forgeReadinessChecker struct {
Repository forge.Repository // required
}
func (r *forgeReadinessChecker) CheckMergeItemReady(ctx context.Context, item *mergeItem) (forge.ChangeMergeability, error) {
return r.Repository.ChangeMergeability(ctx, item.changeID)
}
type commandReadinessChecker struct {
Runner *commandRunner // required
Script string // required
}
func (r *commandReadinessChecker) CheckMergeItemReady(ctx context.Context, item *mergeItem) (forge.ChangeMergeability, error) {
result, err := r.Runner.Run(ctx, r.Script, item)
if err != nil {
return forge.ChangeMergeability{},
fmt.Errorf("run readiness command: %w", err)
}
switch result.ExitCode {
case 0:
return forge.ChangeMergeability{
State: forge.ChangeMergeabilityReady,
Reason: forge.ChangeMergeabilityReasonUnknown,
}, nil
case 1:
return forge.ChangeMergeability{
State: forge.ChangeMergeabilityWaiting,
Reason: forge.ChangeMergeabilityReasonUnknown,
}, nil
case 2:
return forge.ChangeMergeability{
State: forge.ChangeMergeabilityBlocked,
Reason: forge.ChangeMergeabilityReasonUnknown,
}, nil
default:
return forge.ChangeMergeability{}, fmt.Errorf(
"readiness command exited with status %d",
result.ExitCode,
)
}
}
// commandRunner runs a configured merge workflow command
// with shared git-spice and forge-specific environment variables.
type commandRunner struct {
// Log receives command stdout and stderr.
Log *silog.Logger // required
// Repository supplies provider-specific environment variables.
Repository forge.Repository // required
// ForgeID identifies the active forge in GIT_SPICE_FORGE_ID.
ForgeID string // required
// Trunk is the repository trunk branch name.
Trunk string // required
// Runner executes Script with the environment built for the merge item.
Runner ScriptRunner // required
}
func (r *commandRunner) Run(ctx context.Context, script string, item *mergeItem) (*scriptrun.RunResult, error) {
env, err := r.environment(ctx, item)
if err != nil {
return nil, fmt.Errorf("build environment: %w", err)
}
output, flushOutput := silog.Writer(
r.Log.WithPrefix("merge"),
silog.LevelInfo,
)
defer flushOutput()
return r.Runner.Run(ctx, &scriptrun.RunRequest{
Script: script,
Env: env,
Stdout: output,
Stderr: output,
})
}
func (r *commandRunner) environment(
ctx context.Context,
item *mergeItem,
) ([]string, error) {
common := map[string]string{
"GIT_SPICE_FORGE_ID": r.ForgeID,
"GIT_SPICE_BRANCH": item.branch,
"GIT_SPICE_BASE_BRANCH": item.base,
"GIT_SPICE_TRUNK_BRANCH": r.Trunk,
"GIT_SPICE_CHANGE_URL": item.mergeURL,
"GIT_SPICE_HEAD_SHA": item.headHash.String(),
}
forgeEnv, err := r.Repository.CommandEnvironment(ctx, item.changeID)
if err != nil {
return nil, fmt.Errorf("forge environment: %w", err)
}
for key, value := range forgeEnv {
if _, blocked := common[key]; blocked {
continue
}
common[key] = value
}
env := make([]string, 0, len(common))
for key, value := range common {
env = append(env, key+"="+value)
}
slices.Sort(env)
return env, nil
}
+113 -189
View File
@@ -16,6 +16,7 @@ import (
"go.abhg.dev/gs/internal/forge"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/must"
"go.abhg.dev/gs/internal/scriptrun"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
@@ -63,6 +64,12 @@ type GitRepository interface {
) (ahead, behind int, err error)
}
// ScriptRunner is the command execution boundary
// used to run user-provided scripts.
type ScriptRunner interface {
Run(context.Context, *scriptrun.RunRequest) (*scriptrun.RunResult, error)
}
// Options controls behavior shared by forge-backed merge commands.
type Options struct {
// Method selects the forge merge strategy.
@@ -73,22 +80,22 @@ type Options struct {
// Empty means use the forge merge API.
Command string `hidden:"" config:"merge.command" help:"Command to request merge instead of using the forge merge API."`
// MergeReadinessTimeout is the maximum time to wait for the forge
// to report that a change is ready to merge.
// ReadyCommand checks merge readiness through a user-defined command.
// Empty means use forge readiness.
ReadyCommand string `hidden:"" config:"merge.readyCommand" help:"Command to check merge readiness instead of using the forge."`
// ReadyTimeout is the maximum time to wait
// for the configured readiness provider.
// Zero means check once and fail if merge readiness is not reached.
MergeReadinessTimeout time.Duration `name:"ready-timeout" config:"merge.readyTimeout" default:"30m" help:"Max time to wait for merge readiness before each merge. 0 means check once."`
ReadyTimeout time.Duration `name:"ready-timeout" config:"merge.readyTimeout" default:"30m" help:"Max time to wait for merge readiness before each merge. 0 means check once."`
// MergeTimeout is the maximum time to wait for the forge
// to report that a change is merged after requesting merge.
MergeTimeout time.Duration `name:"merge-timeout" config:"merge.mergeTimeout" default:"2m" help:"Max time to wait for merge completion after requesting merge."`
}
// DownstackMergeOptions controls downstack merge behavior.
type DownstackMergeOptions struct {
Options
// NoBranchCheck skips stale base validation before merging.
NoBranchCheck bool `help:"Skip stale base validation before merging."`
// FailFast stops scheduling remaining merge queue work
// after the first branch failure.
FailFast bool `help:"Stop scheduling remaining merge queue work after the first branch failure."`
}
// DownstackMergeRequest asks Handler to merge each requested branch
@@ -96,7 +103,7 @@ type DownstackMergeOptions struct {
type DownstackMergeRequest struct {
Branches []string // required
Options *DownstackMergeOptions // optional
Options *Options // optional
// BranchGraph reuses branch graph data already loaded by the caller.
BranchGraph *spice.BranchGraph // optional
@@ -109,24 +116,13 @@ type BranchMergeRequest struct {
Options *Options // optional
}
// StackMergeOptions controls stack merge behavior.
type StackMergeOptions struct {
Options
// NoBranchCheck skips stale base validation before merging.
NoBranchCheck bool `help:"Skip stale base validation before merging."`
// FailFast stops the merge queue after the first branch failure.
FailFast bool `help:"Stop the merge queue after the first branch failure."`
}
// StackMergeRequest asks Handler to merge each requested branch,
// its downstack branches down to trunk,
// and its upstack branches.
type StackMergeRequest struct {
Branches []string // required
Options *StackMergeOptions // optional
Options *Options // optional
}
// Handler merges change requests via the forge API.
@@ -141,6 +137,8 @@ type Handler struct {
Submit SubmitHandler // required
Sync SyncHandler // required
ScriptRunner ScriptRunner // required
// Cleanup dependencies:
Repository GitRepository // required
Remote string // required
@@ -151,7 +149,7 @@ type Handler struct {
func (h *Handler) MergeDownstack(
ctx context.Context, req *DownstackMergeRequest,
) error {
opts := cmp.Or(req.Options, &DownstackMergeOptions{})
opts := cmp.Or(req.Options, &Options{})
plan, err := h.buildPlan(ctx, req)
if err != nil {
return err
@@ -170,11 +168,13 @@ func (h *Handler) MergeDownstack(
}
return h.executePlan(ctx, plan.items, mergeExecutionOptions{
Method: opts.Method,
Command: opts.Command,
MergeReadinessTimeout: opts.MergeReadinessTimeout,
MergeTimeout: opts.MergeTimeout,
SyncBeforeStart: plan.syncBeforeStart,
Method: opts.Method,
Command: opts.Command,
ReadyCommand: opts.ReadyCommand,
ReadyTimeout: opts.ReadyTimeout,
MergeTimeout: opts.MergeTimeout,
FailFast: opts.FailFast,
SyncBeforeStart: plan.syncBeforeStart,
})
}
@@ -234,11 +234,13 @@ func (h *Handler) MergeBranch(
}
return h.executePlan(ctx, plan.items, mergeExecutionOptions{
Method: opts.Method,
Command: opts.Command,
MergeReadinessTimeout: opts.MergeReadinessTimeout,
MergeTimeout: opts.MergeTimeout,
SyncBeforeStart: plan.syncBeforeStart,
Method: opts.Method,
Command: opts.Command,
ReadyCommand: opts.ReadyCommand,
ReadyTimeout: opts.ReadyTimeout,
MergeTimeout: opts.MergeTimeout,
FailFast: opts.FailFast,
SyncBeforeStart: plan.syncBeforeStart,
})
}
@@ -248,7 +250,7 @@ func (h *Handler) MergeBranch(
func (h *Handler) MergeStack(
ctx context.Context, req *StackMergeRequest,
) error {
opts := cmp.Or(req.Options, &StackMergeOptions{})
opts := cmp.Or(req.Options, &Options{})
graph, err := h.Service.BranchGraph(ctx, nil)
if err != nil {
return fmt.Errorf("build branch graph: %w", err)
@@ -277,9 +279,8 @@ func (h *Handler) MergeStack(
}
plan, err := h.buildPlanFromBranches(ctx, mergePlanRequest{
Graph: graph,
Branches: branches,
NoBranchCheck: opts.NoBranchCheck,
Graph: graph,
Branches: branches,
})
if err != nil {
return err
@@ -298,12 +299,13 @@ func (h *Handler) MergeStack(
}
return h.executePlan(ctx, plan.items, mergeExecutionOptions{
Method: opts.Method,
Command: opts.Command,
MergeReadinessTimeout: opts.MergeReadinessTimeout,
MergeTimeout: opts.MergeTimeout,
FailFast: opts.FailFast,
SyncBeforeStart: plan.syncBeforeStart,
Method: opts.Method,
Command: opts.Command,
ReadyCommand: opts.ReadyCommand,
ReadyTimeout: opts.ReadyTimeout,
MergeTimeout: opts.MergeTimeout,
FailFast: opts.FailFast,
SyncBeforeStart: plan.syncBeforeStart,
})
}
@@ -356,7 +358,6 @@ type mergePlan struct {
func (h *Handler) buildPlan(
ctx context.Context, req *DownstackMergeRequest,
) (mergePlan, error) {
opts := cmp.Or(req.Options, &DownstackMergeOptions{})
graph := req.BranchGraph
if graph == nil {
var err error
@@ -380,9 +381,8 @@ func (h *Handler) buildPlan(
}
return h.buildPlanFromBranches(ctx, mergePlanRequest{
Graph: graph,
Branches: branches,
NoBranchCheck: opts.NoBranchCheck,
Graph: graph,
Branches: branches,
})
}
@@ -397,8 +397,6 @@ type mergePlanRequest struct {
Graph *spice.BranchGraph // required
Branches []string // required
NoBranchCheck bool
}
func (h *Handler) buildPlanFromBranches(
@@ -462,12 +460,10 @@ func (h *Handler) buildPlanFromBranches(
return mergePlan{}, fmt.Errorf("validate branch sync: %w", err)
}
if !req.NoBranchCheck {
if err := h.validateFreshBases(
ctx, req.Graph, branches,
); err != nil {
return mergePlan{}, fmt.Errorf("validate stale bases: %w", err)
}
if err := h.validateFreshBases(
ctx, req.Graph, branches,
); err != nil {
return mergePlan{}, fmt.Errorf("validate stale bases: %w", err)
}
return mergePlan{
@@ -610,12 +606,13 @@ func (h *Handler) confirm(plan []*mergeItem, title string) error {
}
type mergeExecutionOptions struct {
Method forge.MergeMethod
Command string
MergeReadinessTimeout time.Duration
MergeTimeout time.Duration
FailFast bool
SyncBeforeStart bool
Method forge.MergeMethod
Command string
ReadyCommand string
ReadyTimeout time.Duration
MergeTimeout time.Duration
FailFast bool
SyncBeforeStart bool
}
func (opts mergeExecutionOptions) mergeTimeout() time.Duration {
@@ -655,17 +652,40 @@ func (h *Handler) executePlan(
progress = newLogMergeProgress(h.Log)
}
requester := mergeRequester(&directMergeRequester{
repo: h.RemoteRepository,
method: opts.Method,
var _commandRunner *commandRunner
getCommandRunner := func() *commandRunner {
if _commandRunner != nil {
return _commandRunner
}
_commandRunner = &commandRunner{
Log: h.Log,
Repository: h.RemoteRepository,
ForgeID: h.RemoteRepository.Forge().ID(),
Trunk: h.Store.Trunk(),
Runner: h.ScriptRunner,
}
return _commandRunner
}
mergeRequester := mergeRequester(&forgeMergeRequester{
Repository: h.RemoteRepository,
Method: opts.Method,
})
if opts.Command != "" {
requester = &commandMergeRequester{
log: h.Log,
repo: h.RemoteRepository,
forgeID: h.RemoteRepository.Forge().ID(),
trunk: h.Store.Trunk(),
command: opts.Command,
mergeRequester = &commandMergeRequester{
Runner: getCommandRunner(),
Script: opts.Command,
}
}
readinessChecker := readinessChecker(&forgeReadinessChecker{
Repository: h.RemoteRepository,
})
if opts.ReadyCommand != "" {
readinessChecker = &commandReadinessChecker{
Runner: getCommandRunner(),
Script: opts.ReadyCommand,
}
}
@@ -678,14 +698,15 @@ func (h *Handler) executePlan(
Submit: h.Submit,
Sync: h.Sync,
Progress: progress,
Requester: requester,
Progress: progress,
MergeRequester: mergeRequester,
ReadinessChecker: readinessChecker,
Trunk: h.Store.Trunk(),
MergeReadinessTimeout: opts.MergeReadinessTimeout,
MergeTimeout: opts.mergeTimeout(),
Method: opts.Method,
FailFast: opts.FailFast,
Trunk: h.Store.Trunk(),
ReadyTimeout: opts.ReadyTimeout,
MergeTimeout: opts.mergeTimeout(),
Method: opts.Method,
FailFast: opts.FailFast,
}).Execute(ctx, plan)
if err != nil {
return err
@@ -695,105 +716,6 @@ func (h *Handler) executePlan(
return nil
}
// mergeRequester requests the forge-side merge after readiness checks pass.
//
// A successful request does not mean the change has merged.
// The merge executor must still wait for the forge to report the merged state.
type mergeRequester interface {
RequestMerge(context.Context, *mergeItem) error
}
// directMergeRequester requests merges through the forge API.
type directMergeRequester struct {
repo forge.Repository // required
method forge.MergeMethod
}
func (r *directMergeRequester) RequestMerge(
ctx context.Context,
item *mergeItem,
) error {
return r.repo.MergeChange(ctx, item.changeID, forge.MergeChangeOptions{
Method: r.method,
HeadHash: item.headHash,
})
}
// commandMergeRequester requests merges through a user-configured command.
//
// The command is only the merge request step.
// The caller remains responsible for waiting until the forge reports the
// change as merged.
type commandMergeRequester struct {
log *silog.Logger // required
repo forge.Repository // required
forgeID string // required
trunk string // required
command string // required
}
func (r *commandMergeRequester) RequestMerge(
ctx context.Context,
item *mergeItem,
) error {
env, err := r.mergeCommandEnvironment(ctx, item)
if err != nil {
return fmt.Errorf("build environment: %w", err)
}
output, flushOutput := silog.Writer(
r.log.WithPrefix("merge"),
silog.LevelInfo,
)
defer flushOutput()
result, err := new(scriptrun.Runner).Run(ctx, &scriptrun.RunRequest{
Script: r.command,
Env: env,
Stdout: output,
Stderr: output,
})
if err != nil {
return err
}
if result.ExitCode != 0 {
return fmt.Errorf("command exited with status %d", result.ExitCode)
}
return nil
}
func (r *commandMergeRequester) mergeCommandEnvironment(
ctx context.Context,
item *mergeItem,
) ([]string, error) {
common := map[string]string{
"GIT_SPICE_FORGE_ID": r.forgeID,
"GIT_SPICE_BRANCH": item.branch,
"GIT_SPICE_BASE_BRANCH": item.base,
"GIT_SPICE_TRUNK_BRANCH": r.trunk,
"GIT_SPICE_CHANGE_URL": item.mergeURL,
"GIT_SPICE_HEAD_SHA": item.headHash.String(),
}
forgeEnv, err := r.repo.MergeCommandEnvironment(ctx, item.changeID)
if err != nil {
return nil, fmt.Errorf("forge environment: %w", err)
}
for key, value := range forgeEnv {
if _, blocked := common[key]; blocked {
continue
}
common[key] = value
}
env := make([]string, 0, len(common))
for key, value := range common {
env = append(env, key+"="+value)
}
slices.Sort(env)
return env, nil
}
func (h *Handler) validateFreshBases(
ctx context.Context,
graph *spice.BranchGraph,
@@ -818,8 +740,7 @@ func (h *Handler) validateFreshBases(
}
return fmt.Errorf(
"%d branches with stale bases were found; "+
"run 'gs repo sync' first, "+
"or use --no-branch-check to merge anyway",
"run 'gs repo sync' first",
len(staleBases),
)
}
@@ -956,7 +877,7 @@ func (e *mergePlanExecutor) awaitMergeability(
)
return e.awaitMergeabilityWithDelay(
ctx, item, e.MergeReadinessTimeout, _baseDelay, _maxDelay,
ctx, item, e.ReadyTimeout, _baseDelay, _maxDelay,
)
}
@@ -965,12 +886,21 @@ func (e *mergePlanExecutor) awaitMergeabilityWithDelay(
item *mergeItem,
timeout, baseDelay, maxDelay time.Duration,
) error {
must.NotBeNilf(e.ReadinessChecker, "merge: ReadinessChecker is required")
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
delay := baseDelay
for attempt := 0; ; attempt++ {
mergeability, err := e.RemoteRepository.ChangeMergeability(
ctx, item.changeID,
)
mergeability, err := e.ReadinessChecker.CheckMergeItemReady(ctx, item)
if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("not ready after %v", timeout)
}
return fmt.Errorf("check merge readiness: %w", err)
}
switch mergeability.State {
@@ -993,12 +923,6 @@ func (e *mergePlanExecutor) awaitMergeabilityWithDelay(
default:
return fmt.Errorf("unknown state: %v", mergeability.State)
}
if attempt == 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
e.Progress.Event(mergeProgressEvent{
Kind: mergeProgressWaitingForMergeability,
Item: item,
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -24,14 +24,15 @@ type mergePlanExecutor struct {
Submit SubmitHandler // required
Sync SyncHandler // required
Progress mergeProgress // required
Requester mergeRequester // required
Progress mergeProgress // required
MergeRequester mergeRequester // required
ReadinessChecker readinessChecker // required
Trunk string // required
MergeReadinessTimeout time.Duration // required
MergeTimeout time.Duration // required
Method forge.MergeMethod // required
FailFast bool
Trunk string // required
ReadyTimeout time.Duration // required
MergeTimeout time.Duration // required
Method forge.MergeMethod // required
FailFast bool
}
// Execute runs the merge queue over the supplied plan items.
@@ -170,7 +171,7 @@ func (e *mergePlanExecutor) mergeItem(
Item: item,
URL: item.mergeURL,
})
if err := e.Requester.RequestMerge(ctx, item); err != nil {
if err := e.MergeRequester.RequestMerge(ctx, item); err != nil {
e.Progress.Event(mergeProgressEvent{
Kind: mergeProgressMergeFailed,
Item: item,
+240 -67
View File
@@ -46,8 +46,12 @@ func TestMergeScheduler_parentMergeUnlocksIndependentChildren(t *testing.T) {
mockGit.EXPECT().
PeelToCommit(gomock.Any(), "feat3").
Return(git.Hash("head3"), nil)
expectPushedHead(mockForge, pr2, "head2")
expectPushedHead(mockForge, pr3, "head3")
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr2}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head2")}}, nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr3}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head3")}}, nil)
expectMergeWithRecord(mockForge, pr2, operations)
expectMergeWithRecord(mockForge, pr3, operations)
@@ -88,7 +92,15 @@ func TestMergeScheduler_siblingMergeRequestsRunWhileSyncBlocked(t *testing.T) {
pr1 := fakeChangeID("pr-1")
pr2 := fakeChangeID("pr-2")
pr3 := fakeChangeID("pr-3")
expectMergeItem(mockForge, pr1)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr1).
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
mockForge.EXPECT().
MergeChange(gomock.Any(), pr1, gomock.Any()).
Return(nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr1}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
mockService := NewMockService(ctrl)
mockService.EXPECT().
@@ -105,8 +117,12 @@ func TestMergeScheduler_siblingMergeRequestsRunWhileSyncBlocked(t *testing.T) {
mockGit.EXPECT().
PeelToCommit(gomock.Any(), "feat3").
Return(git.Hash("head3"), nil)
expectPushedHead(mockForge, pr2, "head2")
expectPushedHead(mockForge, pr3, "head3")
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr2}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head2")}}, nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr3}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head3")}}, nil)
siblingMerge := make(chan struct{}, 2)
expectMergeWithSignal(mockForge, pr2, siblingMerge)
@@ -180,9 +196,15 @@ func TestMergeScheduler_syncBarrierRunsBeforePreparingAboves(t *testing.T) {
mockGit.EXPECT().
PeelToCommit(gomock.Any(), "feat4").
Return(git.Hash("head4"), nil)
expectPushedHead(mockForge, pr2, "head2")
expectPushedHead(mockForge, pr3, "head3")
expectPushedHead(mockForge, pr4, "head4")
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr2}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head2")}}, nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr3}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head3")}}, nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr4}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head4")}}, nil)
expectMergeWithRecord(mockForge, pr2, operations)
expectMergeWithRecord(mockForge, pr3, operations)
expectMergeWithRecord(mockForge, pr4, operations)
@@ -219,7 +241,15 @@ func TestMergeScheduler_siblingContinuesAfterSubtreeFails(t *testing.T) {
pr2 := fakeChangeID("pr-2")
pr3 := fakeChangeID("pr-3")
pr4 := fakeChangeID("pr-4")
expectMergeItem(mockForge, pr1)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr1).
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
mockForge.EXPECT().
MergeChange(gomock.Any(), pr1, gomock.Any()).
Return(nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr1}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
mockService := NewMockService(ctrl)
mockService.EXPECT().
@@ -236,23 +266,52 @@ func TestMergeScheduler_siblingContinuesAfterSubtreeFails(t *testing.T) {
mockGit.EXPECT().
PeelToCommit(gomock.Any(), "feat3").
Return(git.Hash("head3"), nil)
expectPushedHead(mockForge, pr2, "head2")
expectPushedHead(mockForge, pr3, "head3")
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr2}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head2")}}, nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr3}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head3")}}, nil)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr2).
Return(mergeability(forge.ChangeMergeabilityBlocked), nil)
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityBlocked, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
expectMergeItem(mockForge, pr3)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr3).
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
mockForge.EXPECT().
MergeChange(gomock.Any(), pr3, gomock.Any()).
Return(nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr3}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
progress := &recordingMergeProgress{}
err := newTestMergePlanExecutor(
newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
service: mockService,
gitRepo: mockGit,
}),
progress,
).Execute(t.Context(), testMergePlanWithBases(
h := newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
service: mockService,
gitRepo: mockGit,
})
err := (&mergePlanExecutor{
RemoteRepository: h.RemoteRepository,
Repository: h.Repository,
Service: h.Service,
Restack: h.Restack,
Submit: h.Submit,
Sync: h.Sync,
Progress: progress,
MergeRequester: &forgeMergeRequester{
Repository: h.RemoteRepository,
Method: forge.MergeMethodDefault,
},
ReadinessChecker: &forgeReadinessChecker{
Repository: h.RemoteRepository,
},
Trunk: "main",
ReadyTimeout: 30 * time.Minute,
MergeTimeout: 2 * time.Minute,
Method: forge.MergeMethodDefault,
}).Execute(t.Context(), testMergePlanWithBases(
testPlanEntry("feat1", "main", pr1),
testPlanEntry("feat2", "feat1", pr2),
testPlanEntry("feat4", "feat2", pr4),
@@ -280,17 +339,44 @@ func TestMergeScheduler_missingParentIsQueueRoot(t *testing.T) {
mockGit.EXPECT().
PeelToCommit(gomock.Any(), "feat2").
Return(git.Hash("head2"), nil)
expectPushedHead(mockForge, pr2, "head2")
expectMergeItem(mockForge, pr2)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr2}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head2")}}, nil)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr2).
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
mockForge.EXPECT().
MergeChange(gomock.Any(), pr2, gomock.Any()).
Return(nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr2}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
err := newTestMergePlanExecutor(
newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
service: mockService,
gitRepo: mockGit,
}),
&recordingMergeProgress{},
).Execute(t.Context(), testMergePlanWithBases(
h := newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
service: mockService,
gitRepo: mockGit,
})
err := (&mergePlanExecutor{
RemoteRepository: h.RemoteRepository,
Repository: h.Repository,
Service: h.Service,
Restack: h.Restack,
Submit: h.Submit,
Sync: h.Sync,
Progress: &recordingMergeProgress{},
MergeRequester: &forgeMergeRequester{
Repository: h.RemoteRepository,
Method: forge.MergeMethodDefault,
},
ReadinessChecker: &forgeReadinessChecker{
Repository: h.RemoteRepository,
},
Trunk: "main",
ReadyTimeout: 30 * time.Minute,
MergeTimeout: 2 * time.Minute,
Method: forge.MergeMethodDefault,
}).Execute(t.Context(), testMergePlanWithBases(
testPlanEntry("feat2", "already-merged-parent", pr2),
))
require.NoError(t, err)
@@ -301,10 +387,12 @@ func TestMergeScheduler_rootWaitsForChangeHeadBeforeReadiness(t *testing.T) {
mockForge := forgetest.NewMockRepository(ctrl)
pr1 := fakeChangeID("pr-1")
status := expectPushedHead(mockForge, pr1, "head1")
status := mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr1}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head1")}}, nil)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr1).
Return(mergeability(forge.ChangeMergeabilityReady), nil).
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil).
After(status.Call)
mockForge.EXPECT().
MergeChange(gomock.Any(), pr1, forge.MergeChangeOptions{
@@ -312,14 +400,33 @@ func TestMergeScheduler_rootWaitsForChangeHeadBeforeReadiness(t *testing.T) {
HeadHash: git.Hash("head1"),
}).
Return(nil)
expectMerged(mockForge, pr1)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr1}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
err := newTestMergePlanExecutor(
newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
}),
&recordingMergeProgress{},
).Execute(t.Context(), testMergePlanWithBases(&mergeItem{
h := newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
})
err := (&mergePlanExecutor{
RemoteRepository: h.RemoteRepository,
Repository: h.Repository,
Service: h.Service,
Restack: h.Restack,
Submit: h.Submit,
Sync: h.Sync,
Progress: &recordingMergeProgress{},
MergeRequester: &forgeMergeRequester{
Repository: h.RemoteRepository,
Method: forge.MergeMethodDefault,
},
ReadinessChecker: &forgeReadinessChecker{
Repository: h.RemoteRepository,
},
Trunk: "main",
ReadyTimeout: 30 * time.Minute,
MergeTimeout: 2 * time.Minute,
Method: forge.MergeMethodDefault,
}).Execute(t.Context(), testMergePlanWithBases(&mergeItem{
branch: "feat1",
base: "main",
changeID: pr1,
@@ -338,7 +445,15 @@ func TestMergeScheduler_restackFailureSkipsSubtree(t *testing.T) {
pr2 := fakeChangeID("pr-2")
pr3 := fakeChangeID("pr-3")
pr4 := fakeChangeID("pr-4")
expectMergeItem(mockForge, pr1)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr1).
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
mockForge.EXPECT().
MergeChange(gomock.Any(), pr1, gomock.Any()).
Return(nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr1}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
mockService := NewMockService(ctrl)
mockService.EXPECT().
@@ -352,18 +467,45 @@ func TestMergeScheduler_restackFailureSkipsSubtree(t *testing.T) {
mockGit.EXPECT().
PeelToCommit(gomock.Any(), "feat3").
Return(git.Hash("head3"), nil)
expectPushedHead(mockForge, pr3, "head3")
expectMergeItem(mockForge, pr3)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr3}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head3")}}, nil)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr3).
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
mockForge.EXPECT().
MergeChange(gomock.Any(), pr3, gomock.Any()).
Return(nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr3}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
progress := &recordingMergeProgress{}
err := newTestMergePlanExecutor(
newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
service: mockService,
gitRepo: mockGit,
}),
progress,
).Execute(t.Context(), testMergePlanWithBases(
h := newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
service: mockService,
gitRepo: mockGit,
})
err := (&mergePlanExecutor{
RemoteRepository: h.RemoteRepository,
Repository: h.Repository,
Service: h.Service,
Restack: h.Restack,
Submit: h.Submit,
Sync: h.Sync,
Progress: progress,
MergeRequester: &forgeMergeRequester{
Repository: h.RemoteRepository,
Method: forge.MergeMethodDefault,
},
ReadinessChecker: &forgeReadinessChecker{
Repository: h.RemoteRepository,
},
Trunk: "main",
ReadyTimeout: 30 * time.Minute,
MergeTimeout: 2 * time.Minute,
Method: forge.MergeMethodDefault,
}).Execute(t.Context(), testMergePlanWithBases(
testPlanEntry("feat1", "main", pr1),
testPlanEntry("feat2", "feat1", pr2),
testPlanEntry("feat4", "feat2", pr4),
@@ -385,7 +527,15 @@ func TestMergeScheduler_failFastSkipsPendingUpstack(t *testing.T) {
pr1 := fakeChangeID("pr-1")
pr2 := fakeChangeID("pr-2")
pr3 := fakeChangeID("pr-3")
expectMergeItem(mockForge, pr1)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr1).
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
mockForge.EXPECT().
MergeChange(gomock.Any(), pr1, gomock.Any()).
Return(nil)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr1}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
mockService := NewMockService(ctrl)
mockService.EXPECT().
@@ -396,21 +546,40 @@ func TestMergeScheduler_failFastSkipsPendingUpstack(t *testing.T) {
mockGit.EXPECT().
PeelToCommit(gomock.Any(), "feat2").
Return(git.Hash("head2"), nil)
expectPushedHead(mockForge, pr2, "head2")
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{pr2}).
Return([]forge.ChangeStatus{{State: forge.ChangeOpen, HeadHash: git.Hash("head2")}}, nil)
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), pr2).
Return(mergeability(forge.ChangeMergeabilityBlocked), nil)
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityBlocked, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
progress := &recordingMergeProgress{}
executor := newTestMergePlanExecutor(
newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
service: mockService,
gitRepo: mockGit,
}),
progress,
)
executor.FailFast = true
h := newTestHandler(t, ctrl, testHandlerOpts{
forgeRepo: mockForge,
service: mockService,
gitRepo: mockGit,
})
executor := &mergePlanExecutor{
RemoteRepository: h.RemoteRepository,
Repository: h.Repository,
Service: h.Service,
Restack: h.Restack,
Submit: h.Submit,
Sync: h.Sync,
Progress: progress,
MergeRequester: &forgeMergeRequester{
Repository: h.RemoteRepository,
Method: forge.MergeMethodDefault,
},
ReadinessChecker: &forgeReadinessChecker{
Repository: h.RemoteRepository,
},
Trunk: "main",
ReadyTimeout: 30 * time.Minute,
MergeTimeout: 2 * time.Minute,
Method: forge.MergeMethodDefault,
FailFast: true,
}
err := executor.Execute(t.Context(), testMergePlanWithBases(
testPlanEntry("feat1", "main", pr1),
@@ -552,7 +721,7 @@ func expectMergeWithRecord(
) {
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), id).
Return(mergeability(forge.ChangeMergeabilityReady), nil)
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
mockForge.EXPECT().
MergeChange(gomock.Any(), id, gomock.Any()).
@@ -565,7 +734,9 @@ func expectMergeWithRecord(
return nil
})
expectMerged(mockForge, id)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{id}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
}
func expectMergeWithSignal(
@@ -575,7 +746,7 @@ func expectMergeWithSignal(
) {
mockForge.EXPECT().
ChangeMergeability(gomock.Any(), id).
Return(mergeability(forge.ChangeMergeabilityReady), nil)
Return(forge.ChangeMergeability{State: forge.ChangeMergeabilityReady, Reason: forge.ChangeMergeabilityReasonUnknown}, nil)
mockForge.EXPECT().
MergeChange(gomock.Any(), id, gomock.Any()).
@@ -588,5 +759,7 @@ func expectMergeWithSignal(
return nil
})
expectMerged(mockForge, id)
mockForge.EXPECT().
ChangeStatuses(gomock.Any(), []forge.ChangeID{id}).
Return([]forge.ChangeStatus{{State: forge.ChangeMerged}}, nil)
}
+17 -12
View File
@@ -15,7 +15,7 @@
//
// If a script is the path to a regular file, it is executed directly.
//
// Otherwise, the script is passed to 'sh -c'. Runner.Args are forwarded
// Otherwise, the script is passed to 'sh -c'. RunRequest.Args are forwarded
// as positional parameters ($1, $2, ...).
//
// # Output handling
@@ -40,6 +40,7 @@ import (
"io"
"os"
"strings"
"time"
"go.abhg.dev/gs/internal/must"
"go.abhg.dev/gs/internal/silog"
@@ -54,11 +55,6 @@ type Runner struct {
// Log receives debug messages and is forwarded to xec.
// Defaults to a no-op logger if nil.
Log *silog.Logger
// Args is forwarded to the script as positional parameters.
//
// For example, Args[0] becomes $1 in shell scripts.
Args []string
}
// RunRequest is a single script invocation.
@@ -70,6 +66,9 @@ type RunRequest struct {
// "sh -c".
Script string // required
// Args is forwarded to the script as positional parameters.
Args []string
// Dir is the working directory for the script.
// Empty means the current working directory.
Dir string
@@ -122,7 +121,7 @@ func (r *Runner) Run(ctx context.Context, req *RunRequest) (*RunResult, error) {
log = silog.Nop()
}
cmd, cleanup, err := r.buildCmd(ctx, log, req.Script)
cmd, cleanup, err := r.buildCmd(ctx, log, req.Script, req.Args)
if err != nil {
return nil, fmt.Errorf("build command: %w", err)
}
@@ -137,6 +136,7 @@ func (r *Runner) Run(ctx context.Context, req *RunRequest) (*RunResult, error) {
if req.Stdin != nil {
cmd.WithStdin(req.Stdin)
}
cmd.WithWaitDelay(100 * time.Millisecond)
var stdout, stderr bytes.Buffer
stdoutWriter := io.Writer(&stdout)
@@ -150,6 +150,9 @@ func (r *Runner) Run(ctx context.Context, req *RunRequest) (*RunResult, error) {
cmd.WithStdout(stdoutWriter).WithStderr(stderrWriter)
runErr := cmd.Run()
if runErr != nil && ctx.Err() != nil {
return nil, ctx.Err()
}
exitErr := new(xec.ExitError)
switch {
case runErr == nil:
@@ -175,16 +178,17 @@ func (r *Runner) buildCmd(
ctx context.Context,
log *silog.Logger,
script string,
scriptArgs []string,
) (*xec.Cmd, func(), error) {
if strings.HasPrefix(script, "#!") {
return r.buildShebangCmd(ctx, log, script)
return r.buildShebangCmd(ctx, log, script, scriptArgs)
}
if info, err := os.Stat(script); err == nil && info.Mode().IsRegular() {
return xec.Command(ctx, log, script, r.Args...), func() {}, nil
return xec.Command(ctx, log, script, scriptArgs...), func() {}, nil
}
args := make([]string, 0, 3+len(r.Args))
args := make([]string, 0, 3+len(scriptArgs))
args = append(args, "-c", script, "gs-scriptrun")
args = append(args, r.Args...)
args = append(args, scriptArgs...)
return xec.Command(ctx, log, "sh", args...), func() {}, nil
}
@@ -197,6 +201,7 @@ func (r *Runner) buildShebangCmd(
ctx context.Context,
log *silog.Logger,
script string,
scriptArgs []string,
) (cmd *xec.Cmd, cleanup func(), err error) {
f, err := os.CreateTemp("", "gs-scriptrun-*.sh")
if err != nil {
@@ -222,5 +227,5 @@ func (r *Runner) buildShebangCmd(
return nil, nil, fmt.Errorf("chmod script: %w", err)
}
return xec.Command(ctx, log, f.Name(), r.Args...), cleanup, nil
return xec.Command(ctx, log, f.Name(), scriptArgs...), cleanup, nil
}
+18 -6
View File
@@ -44,12 +44,12 @@ func TestRunner_Run_envVars(t *testing.T) {
func TestRunner_Run_positionalArgs(t *testing.T) {
r := &Runner{
Log: silog.Nop(),
Args: []string{"alpha", "beta"},
Log: silog.Nop(),
}
res, err := r.Run(t.Context(), &RunRequest{
Script: `echo "$1-$2"`,
Args: []string{"alpha", "beta"},
})
require.NoError(t, err)
assert.Equal(t, "alpha-beta\n", string(res.Stdout))
@@ -157,12 +157,12 @@ func TestRunner_Run_shebangReceivesArgs(t *testing.T) {
t.Skip("shebang scripts are POSIX-only")
}
r := &Runner{
Log: silog.Nop(),
Args: []string{"first", "second"},
Log: silog.Nop(),
}
res, err := r.Run(t.Context(), &RunRequest{
Script: "#!/bin/sh\necho \"$1=$2\"\n",
Args: []string{"first", "second"},
})
require.NoError(t, err)
assert.Equal(t, "first=second\n", string(res.Stdout))
@@ -180,11 +180,11 @@ func TestRunner_Run_scriptFilePath(t *testing.T) {
))
r := &Runner{
Log: silog.Nop(),
Args: []string{"one", "two"},
Log: silog.Nop(),
}
res, err := r.Run(t.Context(), &RunRequest{
Script: path,
Args: []string{"one", "two"},
})
require.NoError(t, err)
assert.Equal(t, "one:two\n", string(res.Stdout))
@@ -226,6 +226,18 @@ func TestRunner_Run_contextCancellation(t *testing.T) {
"unexpected error: %v", err)
}
func TestRunner_Run_contextCancelsRunningScript(t *testing.T) {
r := &Runner{Log: silog.Nop()}
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
defer cancel()
_, err := r.Run(ctx, &RunRequest{
Script: `sleep 1`,
})
require.ErrorIs(t, err, context.DeadlineExceeded)
}
func TestRunner_Run_nilLogger(t *testing.T) {
r := &Runner{} // no logger
+8
View File
@@ -40,6 +40,7 @@ import (
"os"
"os/exec"
"strings"
"time"
"go.abhg.dev/gs/internal/silog"
)
@@ -155,6 +156,13 @@ func (c *Cmd) WithDir(dir string) *Cmd {
return c
}
// WithWaitDelay bounds the time Wait spends on command shutdown
// after the command context is canceled or the process exits.
func (c *Cmd) WithWaitDelay(d time.Duration) *Cmd {
c.cmd.WaitDelay = d
return c
}
// WithStdout redirects the command's stdout to the given writer.
func (c *Cmd) WithStdout(w io.Writer) *Cmd {
c.cmd.Stdout = w
+8
View File
@@ -38,6 +38,7 @@ import (
"go.abhg.dev/gs/internal/handler/submit"
"go.abhg.dev/gs/internal/handler/sync"
"go.abhg.dev/gs/internal/handler/track"
"go.abhg.dev/gs/internal/scriptrun"
"go.abhg.dev/gs/internal/secret"
"go.abhg.dev/gs/internal/sigstack"
"go.abhg.dev/gs/internal/silog"
@@ -651,6 +652,7 @@ func (cmd *mainCmd) AfterApply(
restackHandler RestackHandler,
submitHandler SubmitHandler,
syncHandler SyncHandler,
scriptRunner *scriptrun.Runner,
) (MergeHandler, error) {
return &merge.Handler{
Log: log,
@@ -664,6 +666,7 @@ func (cmd *mainCmd) AfterApply(
Sync: syncHandler,
Repository: repo,
Remote: remote.Upstream,
ScriptRunner: scriptRunner,
}, nil
}),
kctx.BindSingletonProvider(func(
@@ -706,6 +709,11 @@ func (cmd *mainCmd) AfterApply(
) (state.Remote, error) {
return ensureRemote(ctx, repo, store, log, view)
}),
kctx.BindSingletonProvider(func(log *silog.Logger) *scriptrun.Runner {
return &scriptrun.Runner{
Log: log,
}
}),
)
}
+21
View File
@@ -0,0 +1,21 @@
package main
const _mergeHelpCommon = `
Branches merge bottom-up starting with those stacked on trunk.
After a branch merges, its upstack branches are restacked and resubmitted.
When those are ready to merge, they are merged in turn, and the process repeats.
A branch is considered ready to merge when the forge reports it as mergeable,
based on the forge and the repository configuration.
Override this with the 'spice.merge.readyCommand' configuration option.
Branches are merged using the forge's merge API.
Override this with the 'spice.merge.command' configuration option.
If a branch becomes blocked and will not become ready without intervention,
or it takes too long to become ready, or otherwise fails to merge,
it is skipped and any branches stacked on it are also skipped.
Use --fail-fast to stop scheduling remaining merge queue work
after the first branch failure.
`
+19 -22
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"slices"
"go.abhg.dev/gs/internal/cli"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/merge"
"go.abhg.dev/gs/internal/spice/state"
@@ -13,39 +14,35 @@ import (
)
type stackMergeCmd struct {
merge.StackMergeOptions
merge.Options
Branches []string `name:"branch" placeholder:"NAME" help:"Branches whose stacks to merge. May be repeated." predictor:"trackedBranches"`
}
func (*stackMergeCmd) Help() string {
return text.Dedent(`
Merges the CRs for the current branch's stack into trunk.
return text.Dedent(fmt.Sprintf(`
Merges CRs for the current branch's full stack into trunk.
Use --branch to merge a different branch's stack.
Use --branch multiple times to merge multiple stacks.
The stack includes the selected branch,
Use --branch multiple times to merge independent stacks.
A stack includes the selected branch,
its downstack branches down to trunk,
and every upstack branch.
Overlapping stacks are merged once.
Already-merged branches are skipped automatically.
Branches must have an open Change Request to be merged.
For example, for the following stack:
Before merging, the stack is checked for branches
whose base PR was already merged on the forge.
Use --no-branch-check to skip this validation.
┌── E
┌─┴ D
│ ┌── C
├─┴ B
┌─┴ A
trunk
Before checking merge readiness,
the command waits briefly for the forge to observe the pushed head.
Then it waits for the forge to report that the CR is ready to merge.
Use --ready-timeout to configure the maximum wait
before failing if merge readiness is not reached.
The following commands have the following effects:
By default, a branch failure skips that branch's upstack descendants,
but independent sibling branches continue.
Use --fail-fast to stop the queue after the first branch failure.
`)
%[1]s stack merge --branch A # merge A, B, C, D, E
%[1]s stack merge --branch B # merge A, B, C
%[1]s stack merge --branch D # merge A, D, E
`, cli.Name())) + _mergeHelpCommon
}
func (cmd *stackMergeCmd) AfterApply(
@@ -74,6 +71,6 @@ func (cmd *stackMergeCmd) Run(
return mergeHandler.MergeStack(ctx, &merge.StackMergeRequest{
Branches: cmd.Branches,
Options: &cmd.StackMergeOptions,
Options: &cmd.Options,
})
}
+39 -7
View File
@@ -4,14 +4,42 @@ Merge a branch into trunk
Merges the CR for the current branch into trunk. Use --branch to merge a
different branch. Use --branch multiple times to merge multiple branches.
Only the selected branches are merged. All selected branches must be stacked on
trunk or on a branch that is also selected.
Only the selected branches are merged. To merge a branch and its downstack,
use 'git-spice downstack merge'. To merge a whole stack, use 'git-spice stack
For example, for the following stack:
┌── B
┌─┴ A
trunk
This command can merge A alone, or A and B together.
gs branch merge --branch A
gs branch merge --branch A --branch B
It cannot merge B alone, because A is not selected:
gs branch merge --branch B // error
To merge multiple branches in a stack prefer 'gs downstack merge' or 'gs stack
merge'.
Before checking merge readiness, the command waits briefly for the forge to
observe the pushed head. Then it waits for the forge to report that the CR is
ready to merge. Use --ready-timeout to configure the maximum wait.
Branches merge bottom-up starting with those stacked on trunk. After a branch
merges, its upstack branches are restacked and resubmitted. When those are ready
to merge, they are merged in turn, and the process repeats.
A branch is considered ready to merge when the forge reports it as mergeable,
based on the forge and the repository configuration. Override this with the
'spice.merge.readyCommand' configuration option.
Branches are merged using the forge's merge API. Override this with the
'spice.merge.command' configuration option.
If a branch becomes blocked and will not become ready without intervention,
or it takes too long to become ready, or otherwise fails to merge, it is
skipped and any branches stacked on it are also skipped. Use --fail-fast to stop
scheduling remaining merge queue work after the first branch failure.
Flags:
--method=METHOD Preferred merge method. One of 'merge', 'squash',
@@ -20,6 +48,8 @@ Flags:
0 means check once. (🔧 spice.merge.readyTimeout)
--merge-timeout=2m Max time to wait for merge completion after requesting
merge. (🔧 spice.merge.mergeTimeout)
--fail-fast Stop scheduling remaining merge queue work after the
first branch failure.
--branch=NAME,... Branches to merge. May be repeated.
Global Flags:
@@ -30,5 +60,7 @@ Global Flags:
--[no-]prompt Whether to prompt for missing information
Configuration (🔧):
spice.merge.command Command to request merge instead of using the forge
merge API.
spice.merge.command Command to request merge instead of using the
forge merge API.
spice.merge.readyCommand Command to check merge readiness instead of using
the forge.
+36 -27
View File
@@ -2,39 +2,45 @@ Usage: gs downstack (ds) merge (m) [flags]
Merge a branch and those below it
Merges the current branch and all branches below it into trunk via the forge
API, bottom-up. Use --branch to start at a different branch. Use --branch
multiple times to merge multiple downstacks.
Merges CRs for the current branch and all branches below it into trunk.
Use --branch to merge the downstack of a different branch. Use --branch multiple
times to merge downstacks of multiple branches. Selected branches and their
downstack branches down to trunk are merged.
Each selected branch expands to that branch and its downstack branches down to
trunk. Overlapping downstacks are merged once.
For example, for the following stack:
This command acts as a local merge queue: it merges one Change Request, waits
for that merge to finish, restacks and updates the next Change Request, waits
for merge readiness on the updated Change Request, and then repeats the process.
┌── D
│ ┌── C
├─┴ B
┌─┴ A
trunk
For a stack like this:
The following commands have the following effects:
main <- feature1 <- feature2 <- feature3
gs downstack merge --branch D # merge A, D
gs downstack merge --branch B # merge A, B
gs downstack merge --branch C # merge A, B, C
gs downstack merge \ # merge A, B, C, D
--branch C --branch D
Running from feature3 merges in this order:
Use 'gs stack merge' to merge a branch and its upstack branches in one
operation.
feature1, feature2, feature3
Branches merge bottom-up starting with those stacked on trunk. After a branch
merges, its upstack branches are restacked and resubmitted. When those are ready
to merge, they are merged in turn, and the process repeats.
Already-merged branches are skipped automatically. Branches must have an open
Change Request to be merged.
A branch is considered ready to merge when the forge reports it as mergeable,
based on the forge and the repository configuration. Override this with the
'spice.merge.readyCommand' configuration option.
Before merging, the downstack is checked for branches whose base PR was already
merged on the forge. Use --no-branch-check to skip this validation.
Branches are merged using the forge's merge API. Override this with the
'spice.merge.command' configuration option.
Before checking merge readiness, the command waits briefly for the forge to
observe the pushed head. Then it waits for the forge to report that the CR is
ready to merge. Use --ready-timeout to configure the maximum wait (default: 30m,
0 means fail immediately if not ready).
Between merges, the command waits for each merge to complete, restacks and
updates the next PR, waits for merge readiness on the updated PR, and syncs
merged branch cleanup.
If a branch becomes blocked and will not become ready without intervention,
or it takes too long to become ready, or otherwise fails to merge, it is
skipped and any branches stacked on it are also skipped. Use --fail-fast to stop
scheduling remaining merge queue work after the first branch failure.
Flags:
--method=METHOD Preferred merge method. One of 'merge', 'squash',
@@ -43,7 +49,8 @@ Flags:
0 means check once. (🔧 spice.merge.readyTimeout)
--merge-timeout=2m Max time to wait for merge completion after requesting
merge. (🔧 spice.merge.mergeTimeout)
--no-branch-check Skip stale base validation before merging.
--fail-fast Stop scheduling remaining merge queue work after the
first branch failure.
--branch=NAME,... Branches to start merging from. May be repeated.
Global Flags:
@@ -54,5 +61,7 @@ Global Flags:
--[no-]prompt Whether to prompt for missing information
Configuration (🔧):
spice.merge.command Command to request merge instead of using the forge
merge API.
spice.merge.command Command to request merge instead of using the
forge merge API.
spice.merge.readyCommand Command to check merge readiness instead of using
the forge.
+36 -20
View File
@@ -2,27 +2,41 @@ Usage: gs stack (s) merge (m) [flags]
Merge a stack
Merges the CRs for the current branch's stack into trunk. Use --branch to merge
a different branch's stack. Use --branch multiple times to merge multiple
stacks.
Merges CRs for the current branch's full stack into trunk. Use --branch to merge
a different branch's stack. Use --branch multiple times to merge independent
stacks. A stack includes the selected branch, its downstack branches down to
trunk, and every upstack branch.
The stack includes the selected branch, its downstack branches down to trunk,
and every upstack branch. Overlapping stacks are merged once.
For example, for the following stack:
Already-merged branches are skipped automatically. Branches must have an open
Change Request to be merged.
┌── E
┌─┴ D
│ ┌── C
├─┴ B
┌─┴ A
trunk
Before merging, the stack is checked for branches whose base PR was already
merged on the forge. Use --no-branch-check to skip this validation.
The following commands have the following effects:
Before checking merge readiness, the command waits briefly for the forge to
observe the pushed head. Then it waits for the forge to report that the CR is
ready to merge. Use --ready-timeout to configure the maximum wait before failing
if merge readiness is not reached.
gs stack merge --branch A # merge A, B, C, D, E
gs stack merge --branch B # merge A, B, C
gs stack merge --branch D # merge A, D, E
By default, a branch failure skips that branch's upstack descendants, but
independent sibling branches continue. Use --fail-fast to stop the queue after
the first branch failure.
Branches merge bottom-up starting with those stacked on trunk. After a branch
merges, its upstack branches are restacked and resubmitted. When those are ready
to merge, they are merged in turn, and the process repeats.
A branch is considered ready to merge when the forge reports it as mergeable,
based on the forge and the repository configuration. Override this with the
'spice.merge.readyCommand' configuration option.
Branches are merged using the forge's merge API. Override this with the
'spice.merge.command' configuration option.
If a branch becomes blocked and will not become ready without intervention,
or it takes too long to become ready, or otherwise fails to merge, it is
skipped and any branches stacked on it are also skipped. Use --fail-fast to stop
scheduling remaining merge queue work after the first branch failure.
Flags:
--method=METHOD Preferred merge method. One of 'merge', 'squash',
@@ -31,8 +45,8 @@ Flags:
0 means check once. (🔧 spice.merge.readyTimeout)
--merge-timeout=2m Max time to wait for merge completion after requesting
merge. (🔧 spice.merge.mergeTimeout)
--no-branch-check Skip stale base validation before merging.
--fail-fast Stop the merge queue after the first branch failure.
--fail-fast Stop scheduling remaining merge queue work after the
first branch failure.
--branch=NAME,... Branches whose stacks to merge. May be repeated.
Global Flags:
@@ -43,5 +57,7 @@ Global Flags:
--[no-]prompt Whether to prompt for missing information
Configuration (🔧):
spice.merge.command Command to request merge instead of using the forge
merge API.
spice.merge.command Command to request merge instead of using the
forge merge API.
spice.merge.readyCommand Command to check merge readiness instead of using
the forge.
@@ -0,0 +1,115 @@
# 'branch merge' can use readiness and final merge commands together.
as 'Test <test@example.com>'
at '2026-06-30T12:02:00Z'
# setup
cd repo
git init
git config spice.experiment.merge true
git commit --allow-empty -m 'Initial commit'
# set up a fake GitHub remote
shamhub init
shamhub new origin alice/example.git
shamhub register alice
git push origin main
env SHAMHUB_USERNAME=alice
gs auth login
# create a branch directly on trunk
git add feature1.txt
gs bc feature1 -m 'Add feature 1'
# submit the branch
gs branch submit --fill
stderr 'Created #1'
# configure separate readiness and merge commands
chmod 755 $WORK/ready-command.sh
chmod 755 $WORK/merge-command.sh
git config spice.merge.readyCommand $WORK/ready-command.sh
git config spice.merge.command $WORK/merge-command.sh
# merge the current branch through both configured commands
env ROBOT_INPUT=$WORK/robot-merge.golden ROBOT_OUTPUT=$WORK/robot-merge.actual
gs branch merge
cmp $WORK/robot-merge.actual $WORK/robot-merge.golden
stderr 'INF merge: readiness command ready'
stderr 'INF merge: merge command requested'
stderr 'feature1: #1 was merged'
stderr 'All 1 change.s. merged.'
# verify the merge command requested the forge merge
shamhub dump changes
cmpenvJSON stdout $WORK/golden/changes.json
-- repo/feature1.txt --
This is feature 1
-- ready-command.sh --
#!/bin/sh
test "$GIT_SPICE_FORGE_ID" = shamhub
test "$GIT_SPICE_BRANCH" = feature1
test "$GIT_SPICE_BASE_BRANCH" = main
test "$GIT_SPICE_TRUNK_BRANCH" = main
test -n "$GIT_SPICE_CHANGE_URL"
test -n "$GIT_SPICE_HEAD_SHA"
test "$GIT_SPICE_SHAMHUB_CHANGE_NUMBER" = 1
test -n "$GIT_SPICE_SHAMHUB_API_URL"
test -n "$GIT_SPICE_SHAMHUB_TOKEN"
echo readiness command ready
exit 0
-- merge-command.sh --
#!/bin/sh
test "$GIT_SPICE_FORGE_ID" = shamhub
test "$GIT_SPICE_BRANCH" = feature1
test "$GIT_SPICE_BASE_BRANCH" = main
test "$GIT_SPICE_TRUNK_BRANCH" = main
test -n "$GIT_SPICE_CHANGE_URL"
test -n "$GIT_SPICE_HEAD_SHA"
test "$GIT_SPICE_SHAMHUB_CHANGE_NUMBER" = 1
test -n "$GIT_SPICE_SHAMHUB_API_URL"
test -n "$GIT_SPICE_SHAMHUB_TOKEN"
echo merge command requested
curl -fsS -X POST \
-H 'Content-Type: application/json' \
-H "Authentication-Token: $GIT_SPICE_SHAMHUB_TOKEN" \
-d '{}' \
"$GIT_SPICE_SHAMHUB_API_URL/alice/example/change/$GIT_SPICE_SHAMHUB_CHANGE_NUMBER/merge"
-- robot-merge.golden --
===
> Merge 1 change(s) bottom-up?: [Y/n]
> feature1 (#1)
true
-- golden/changes.json --
[
{
"number": 1,
"html_url": "$SHAMHUB_URL/alice/example/change/1",
"state": "closed",
"merged": true,
"title": "Add feature 1",
"body": "",
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "547f6f39cf85eede1ec262c0c361f9497dcc8e23"
},
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature1",
"sha": "80705ee7f4750daacdcc0fe1807f4853ac40f81d"
}
}
]
@@ -0,0 +1,96 @@
# 'branch merge' polls spice.merge.readyCommand until it reports ready.
as 'Test <test@example.com>'
at '2026-06-30T12:00:00Z'
# setup
cd repo
git init
git config spice.experiment.merge true
git commit --allow-empty -m 'Initial commit'
# set up a fake GitHub remote
shamhub init
shamhub new origin alice/example.git
shamhub register alice
git push origin main
env SHAMHUB_USERNAME=alice
gs auth login
# create a branch directly on trunk
git add feature1.txt
gs bc feature1 -m 'Add feature 1'
# submit the branch
gs branch submit --fill
stderr 'Created #1'
# configure a readiness command that waits once and then reports ready
chmod 755 $WORK/ready-command.sh
git config spice.merge.readyCommand $WORK/ready-command.sh
git config spice.merge.readyTimeout 11s
# merge the current branch after the readiness command allows the merge
env ROBOT_INPUT=$WORK/robot-merge.golden ROBOT_OUTPUT=$WORK/robot-merge.actual
gs branch merge
cmp $WORK/robot-merge.actual $WORK/robot-merge.golden
stderr 'INF merge: readiness waiting'
stderr 'INF merge: readiness ready'
stderr 'feature1: #1 was merged'
stderr 'All 1 change.s. merged.'
# verify the change is now merged
shamhub dump changes
cmpenvJSON stdout $WORK/golden/changes.json
-- repo/feature1.txt --
This is feature 1
-- ready-command.sh --
#!/bin/sh
test "$GIT_SPICE_BRANCH" = feature1
test "$GIT_SPICE_BASE_BRANCH" = main
test "$GIT_SPICE_TRUNK_BRANCH" = main
test "$GIT_SPICE_SHAMHUB_CHANGE_NUMBER" = 1
if ! test -f "$WORK/readiness-seen"; then
echo readiness waiting
echo seen > "$WORK/readiness-seen"
exit 1
fi
echo readiness ready
exit 0
-- robot-merge.golden --
===
> Merge 1 change(s) bottom-up?: [Y/n]
> feature1 (#1)
true
-- golden/changes.json --
[
{
"number": 1,
"html_url": "$SHAMHUB_URL/alice/example/change/1",
"state": "closed",
"merged": true,
"title": "Add feature 1",
"body": "",
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "39357f190900be0a2dac405bd77b939441d1c688"
},
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature1",
"sha": "ad58974ffb72f15e51c8b097f1b08d6f9c77d0d8"
}
}
]
+9 -15
View File
@@ -1,4 +1,4 @@
# 'downstack merge' skips branches that are already merged.
# 'downstack merge' reports already-merged branches before stale-base failure.
as 'Test <test@example.com>'
at '2024-04-05T16:40:32Z'
@@ -32,23 +32,17 @@ stderr 'Created #2'
# merge feature1 externally
shamhub merge alice/example 1
# now merge from feature2 — feature1 should be skipped.
# Use --no-branch-check because feature1's merged state
# would otherwise trigger stale base validation.
# feature2's PR base is retargeted from feature1 to main.
env ROBOT_INPUT=$WORK/robot-merge.golden ROBOT_OUTPUT=$WORK/robot-merge.actual
gs downstack merge --no-branch-check
cmp $WORK/robot-merge.actual $WORK/robot-merge.golden
# now merge from feature2 — feature1 should be skipped before stale-base
# validation blocks feature2.
! gs downstack merge
stderr 'already merged, skipping'
stderr 'feature2: retargeting #2 onto main'
stderr 'feature2: merging #2:'
stderr 'Branch has stale base'
stderr 'branch=feature2'
stderr 'base=feature1'
stderr '1 branches with stale bases were found'
stderr 'gs repo sync'
-- repo/feature1.txt --
This is feature 1
-- repo/feature2.txt --
This is feature 2
-- robot-merge.golden --
===
> Merge 1 change(s) bottom-up?: [Y/n]
> feature2 (#2)
true
+10 -15
View File
@@ -1,4 +1,4 @@
# 'downstack merge' skips multiple already-merged branches in a stack.
# 'downstack merge' reports multiple already-merged branches before stale-base failure.
as 'Test <test@example.com>'
at '2024-04-05T16:40:32Z'
@@ -36,17 +36,17 @@ stderr 'Created #3'
shamhub merge alice/example 1
shamhub merge alice/example 2
# downstack merge should skip both and merge only feature3.
# Use --no-branch-check because the external merges
# would trigger stale base validation.
# feature3's PR base is retargeted from feature2 to main.
env ROBOT_INPUT=$WORK/robot-merge.golden ROBOT_OUTPUT=$WORK/robot-merge.actual
gs downstack merge --no-branch-check
cmp $WORK/robot-merge.actual $WORK/robot-merge.golden
# downstack merge should skip both before stale-base validation blocks feature3.
! gs downstack merge
stderr 'feature1.*already merged, skipping'
stderr 'feature2.*already merged, skipping'
stderr 'feature3: retargeting #3 onto main'
stderr 'feature3: merging #3:'
stderr 'Branch has stale base'
stderr 'branch=feature2'
stderr 'base=feature1'
stderr 'branch=feature3'
stderr 'base=feature2'
stderr '2 branches with stale bases were found'
stderr 'gs repo sync'
-- repo/feature1.txt --
This is feature 1
@@ -54,8 +54,3 @@ This is feature 1
This is feature 2
-- repo/feature3.txt --
This is feature 3
-- robot-merge.golden --
===
> Merge 1 change(s) bottom-up?: [Y/n]
> feature3 (#3)
true
-15
View File
@@ -39,23 +39,8 @@ stderr 'branch=feature2'
stderr 'base=feature1'
stderr '1 branches with stale bases were found'
stderr 'gs repo sync'
stderr 'no-branch-check'
# --no-branch-check bypasses the stale base check.
# feature2's PR base is retargeted from feature1 to main.
env ROBOT_INPUT=$WORK/robot-merge.golden ROBOT_OUTPUT=$WORK/robot-merge.actual
gs downstack merge --no-branch-check
cmp $WORK/robot-merge.actual $WORK/robot-merge.golden
stderr 'already merged, skipping'
stderr 'feature2: retargeting #2 onto main'
stderr 'feature2: merging #2:'
-- repo/feature1.txt --
This is feature 1
-- repo/feature2.txt --
This is feature 2
-- robot-merge.golden --
===
> Merge 1 change(s) bottom-up?: [Y/n]
> feature2 (#2)
true
@@ -0,0 +1,121 @@
# 'stack merge' skips upstack branches after readiness command blocks a base.
as 'Test <test@example.com>'
at '2026-06-30T12:01:00Z'
# setup
cd repo
git init
git config spice.experiment.merge true
git commit --allow-empty -m 'Initial commit'
# set up a fake GitHub remote
shamhub init
shamhub new origin alice/example.git
shamhub register alice
git push origin main
env SHAMHUB_USERNAME=alice
gs auth login
# create a stack: main -> feature1 -> feature2
git add feature1.txt
gs bc feature1 -m 'Add feature 1'
git add feature2.txt
gs bc feature2 -m 'Add feature 2'
# submit the stack
gs stack submit --fill
stderr 'Created #1'
stderr 'Created #2'
# configure a readiness command that permanently blocks the base branch
chmod 755 $WORK/ready-command.sh
git config spice.merge.readyCommand $WORK/ready-command.sh
# stack merge should skip the dependent upstack branch
env ROBOT_INPUT=$WORK/robot-merge.golden ROBOT_OUTPUT=$WORK/robot-merge.actual
! gs stack merge
cmp $WORK/robot-merge.actual $WORK/robot-merge.golden
stderr 'INF merge: readiness blocked'
stderr 'wait for merge readiness: blocked: unknown'
stderr 'feature2: skipped'
# verify neither change was merged
shamhub dump changes
cmpenvJSON stdout $WORK/golden/changes.json
-- repo/feature1.txt --
This is feature 1
-- repo/feature2.txt --
This is feature 2
-- ready-command.sh --
#!/bin/sh
test -n "$GIT_SPICE_CHANGE_URL"
test -n "$GIT_SPICE_HEAD_SHA"
test -n "$GIT_SPICE_SHAMHUB_API_URL"
test -n "$GIT_SPICE_SHAMHUB_TOKEN"
if test "$GIT_SPICE_BRANCH" = feature1; then
echo readiness blocked
exit 2
fi
echo readiness ready
exit 0
-- robot-merge.golden --
===
> Merge 2 change(s)?: [Y/n]
> feature1 (#1)
> feature2 (#2)
true
-- golden/changes.json --
[
{
"number": 1,
"html_url": "$SHAMHUB_URL/alice/example/change/1",
"state": "open",
"title": "Add feature 1",
"body": "",
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "main",
"sha": "9bbaa8eb7a0b942b16fdebe0752dda54446e3f8c"
},
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature1",
"sha": "5e87719b9168fa7d7908e971c65ef3d85b30d864"
}
},
{
"number": 2,
"html_url": "$SHAMHUB_URL/alice/example/change/2",
"state": "open",
"title": "Add feature 2",
"body": "",
"base": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature1",
"sha": "5e87719b9168fa7d7908e971c65ef3d85b30d864"
},
"head": {
"repository": {
"owner": "alice",
"name": "example"
},
"ref": "feature2",
"sha": "b1158be1739dba20f1516b1a10fbdb5fda6a1706"
}
}
]