Skip to content

Commit 7ecb461

Browse files
williamdesclaudehappy-otter
committed
feat: add fast-forward_or_merge merge method
Attempt a fast-forward first; if the base branch is not an ancestor of the head, fall back to a regular merge. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent 2b37a7b commit 7ecb461

5 files changed

Lines changed: 122 additions & 5 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Marketplace: https://github.com/marketplace/actions/pull-request-merge
1212
| ------------------------- | :------: | --------- | ----------------------------------------------------------------------------------------------------- |
1313
| `github-token` | yes || GitHub token used to call the REST API. Usually `${{ secrets.GITHUB_TOKEN }}`. |
1414
| `number` | yes || Pull-request number to merge. |
15-
| `merge-method` | no | `merge` | One of `merge`, `squash`, `rebase`, `fast-forward`. |
15+
| `merge-method` | no | `merge` | One of `merge`, `squash`, `rebase`, `fast-forward`, `fast-forward_or_merge`. |
1616
| `allowed-usernames-regex` | no | `^.*$` | Regex the triggering actor (`github.actor`) must match. Skips the merge otherwise. |
1717
| `filter-label` | no | *(empty)* | Regex matched against PR labels. When set, the merge is skipped unless a label matches, and the first matching label is removed after a successful merge. |
1818
| `merge-title` | no | *(empty)* | Commit title used by the merge/squash/rebase API. Ignored for `fast-forward`. |
@@ -25,6 +25,9 @@ Marketplace: https://github.com/marketplace/actions/pull-request-merge
2525
move the base branch to the PR's head SHA. This is a *true* fast-forward: the
2626
base ref must already be an ancestor of the head, otherwise GitHub refuses
2727
the update. No merge commit is created.
28+
- `fast-forward_or_merge` — attempt a fast-forward first; if the base branch is
29+
not an ancestor of the head (i.e. the fast-forward fails), fall back to a
30+
regular `merge`.
2831

2932
## Required permissions
3033

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ inputs:
99
description: "Pull Request number"
1010
required: true
1111
merge-method:
12-
description: "The merge method: merge,squash,rebase,fast-forward"
12+
description: "The merge method: merge,squash,rebase,fast-forward,fast-forward_or_merge"
1313
required: false
1414
default: "merge"
1515
allowed-usernames-regex:

src/action.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,44 @@ pub async fn run<C: GithubClient + ?Sized, L: Logger + ?Sized>(
9191
.context("fast-forward update_ref failed")?;
9292
Outcome::FastForwarded
9393
}
94+
MergeMethod::FastForwardOrMerge => {
95+
log.info(&format!(
96+
"Attempting fast-forward: heads/{}@{}",
97+
pr.base.ref_, pr.head.sha
98+
));
99+
match client
100+
.update_ref(
101+
&ctx.owner,
102+
&ctx.repo,
103+
&format!("heads/{}", pr.base.ref_),
104+
&pr.head.sha,
105+
false,
106+
)
107+
.await
108+
{
109+
Ok(()) => {
110+
log.info("Fast-forward succeeded.");
111+
Outcome::FastForwarded
112+
}
113+
Err(e) => {
114+
log.warning(&format!(
115+
"Fast-forward failed ({}), falling back to merge.",
116+
e
117+
));
118+
let req = MergeRequest::from_inputs(
119+
MergeMethod::Merge,
120+
&pr.head.sha,
121+
&inputs.merge_title,
122+
&inputs.merge_message,
123+
);
124+
client
125+
.merge_pull(&ctx.owner, &ctx.repo, inputs.number, &req)
126+
.await
127+
.context("merge request failed (after fast-forward fallback)")?;
128+
Outcome::Merged
129+
}
130+
}
131+
}
94132
method => {
95133
let req = MergeRequest::from_inputs(
96134
method,
@@ -367,6 +405,76 @@ mod tests {
367405
assert!(client.merge_calls.lock().unwrap().is_empty());
368406
}
369407

408+
#[tokio::test]
409+
async fn fast_forward_or_merge_succeeds_with_ff() {
410+
let client = FakeClient::with_pr(open_pr(&[]));
411+
let mut log = CaptureLogger::new();
412+
let out = run(
413+
&client,
414+
&inputs(MergeMethod::FastForwardOrMerge, "^.*$", ""),
415+
&ctx(),
416+
&mut log,
417+
)
418+
.await
419+
.unwrap();
420+
assert_eq!(out, Outcome::FastForwarded);
421+
422+
let calls = client.update_ref_calls.lock().unwrap();
423+
assert_eq!(calls.len(), 1);
424+
assert!(client.merge_calls.lock().unwrap().is_empty());
425+
assert!(log.contains("Fast-forward succeeded"));
426+
}
427+
428+
#[tokio::test]
429+
async fn fast_forward_or_merge_falls_back_to_merge() {
430+
struct FailFfClient(FakeClient);
431+
#[async_trait]
432+
impl GithubClient for FailFfClient {
433+
async fn get_pull(&self, o: &str, r: &str, n: u64) -> Result<PullRequest> {
434+
self.0.get_pull(o, r, n).await
435+
}
436+
async fn update_ref(
437+
&self,
438+
_o: &str,
439+
_r: &str,
440+
_ref: &str,
441+
_sha: &str,
442+
_f: bool,
443+
) -> Result<()> {
444+
Err(anyhow::anyhow!("not a fast-forward"))
445+
}
446+
async fn merge_pull(
447+
&self,
448+
o: &str,
449+
r: &str,
450+
n: u64,
451+
req: &MergeRequest,
452+
) -> Result<()> {
453+
self.0.merge_pull(o, r, n, req).await
454+
}
455+
async fn remove_label(&self, o: &str, r: &str, n: u64, l: &str) -> Result<()> {
456+
self.0.remove_label(o, r, n, l).await
457+
}
458+
}
459+
460+
let client = FailFfClient(FakeClient::with_pr(open_pr(&[])));
461+
let mut log = CaptureLogger::new();
462+
let out = run(
463+
&client,
464+
&inputs(MergeMethod::FastForwardOrMerge, "^.*$", ""),
465+
&ctx(),
466+
&mut log,
467+
)
468+
.await
469+
.unwrap();
470+
assert_eq!(out, Outcome::Merged);
471+
472+
let merges = client.0.merge_calls.lock().unwrap();
473+
assert_eq!(merges.len(), 1);
474+
assert_eq!(merges[0].1.merge_method, "merge");
475+
assert!(log.contains("falling back to merge"));
476+
}
477+
370478
#[tokio::test]
371479
async fn label_removal_failure_is_only_a_warning() {
372480
let client = FakeClient::with_pr(open_pr(&["merge-it"]));

src/github_client.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ impl MergeRequest {
4848
MergeMethod::Merge => "merge",
4949
MergeMethod::Squash => "squash",
5050
MergeMethod::Rebase => "rebase",
51-
// FastForward is handled out-of-band; defensively fall back to merge.
52-
MergeMethod::FastForward => "merge",
51+
// FastForward / FastForwardOrMerge are handled out-of-band; defensively fall back to merge.
52+
MergeMethod::FastForward | MergeMethod::FastForwardOrMerge => "merge",
5353
},
5454
sha: head_sha.to_string(),
5555
commit_title: Some(title.to_string()).filter(|s| !s.trim().is_empty()),

src/inputs.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ pub enum MergeMethod {
7373
Squash,
7474
Rebase,
7575
FastForward,
76+
FastForwardOrMerge,
7677
}
7778

7879
impl MergeMethod {
@@ -82,8 +83,9 @@ impl MergeMethod {
8283
"squash" => Ok(Self::Squash),
8384
"rebase" => Ok(Self::Rebase),
8485
"fast-forward" => Ok(Self::FastForward),
86+
"fast-forward_or_merge" => Ok(Self::FastForwardOrMerge),
8587
other => Err(anyhow!(
86-
"invalid merge-method '{}': expected one of merge, squash, rebase, fast-forward",
88+
"invalid merge-method '{}': expected one of merge, squash, rebase, fast-forward, fast-forward_or_merge",
8789
other
8890
)),
8991
}
@@ -228,6 +230,10 @@ mod tests {
228230
MergeMethod::parse("fast-forward").unwrap(),
229231
MergeMethod::FastForward
230232
);
233+
assert_eq!(
234+
MergeMethod::parse("fast-forward_or_merge").unwrap(),
235+
MergeMethod::FastForwardOrMerge
236+
);
231237
assert!(MergeMethod::parse("nope").is_err());
232238
}
233239
}

0 commit comments

Comments
 (0)