mirror of
https://github.com/abhinav/git-spice.git
synced 2026-08-31 07:47:47 +02:00
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
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+39
-39
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+1360
-496
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Vendored
+39
-7
@@ -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.
|
||||
|
||||
Vendored
+36
-27
@@ -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.
|
||||
|
||||
Vendored
+36
-20
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user