detached 5aba7f2

fix(ci): run push workflows when a push brings no new commit

A new branch at an existing commit, or a branch rewound to an ancestor, gave
an empty range and an error that wrapped nil, so nothing ran. The head commit
is read alone, with no changed file, as GitHub does.
commit
5aba7f2d3064eea6e191baff2b30481aa8908126
author
brice · · 2026-10-12 19:07 UTC
parent
cfd70eb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +43 -2
M internal/app/ci_flow_test.go +24 -0
@@ -556,3 +556,27 @@ func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
556556 require.NoError(t, err)
557557 c.sentinel(t, home, "sentinel")
558558 }
559+
560+// A new branch at a commit that already exists, and a branch rewound to an
561+// ancestor, bring no new commit: GitHub still runs the push workflows (those
562+// without a paths filter, as no file changed).
563+func TestCIPushWithoutNewCommitsStillRuns(t *testing.T) {
564+ c := newCIHarness(t, nil)
565+ c.registerRunner("alpha", "ubuntu-latest")
566+ c.push("main", map[string]string{".github/workflows/any.yml": workflowWith("branches: ['**']")}, "")
567+ c.waitFor("main runs", func() bool { return len(c.runs()) == 1 })
568+ first := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
569+
570+ // A new branch at the commit main already has.
571+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "HEAD:refs/heads/same")
572+ c.waitFor("the new branch runs", func() bool { return len(c.runs()) == 2 })
573+ assert.Equal(t, "refs/heads/same", c.runs()[0].GetRef())
574+ assert.Equal(t, first, c.runs()[0].GetSha())
575+
576+ // A branch moved forward, then rewound to its ancestor (a forced push).
577+ c.push("main", map[string]string{"a.txt": "a\n"}, "")
578+ c.waitFor("the push runs", func() bool { return len(c.runs()) == 3 })
579+ c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), first+":refs/heads/main")
580+ c.waitFor("the rewind runs", func() bool { return len(c.runs()) == 4 })
581+ assert.Equal(t, first, c.runs()[0].GetSha())
582+}
@@ -556,3 +556,27 @@ func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
556 require.NoError(t, err)556 require.NoError(t, err)
557 c.sentinel(t, home, "sentinel")557 c.sentinel(t, home, "sentinel")
558 }558 }
559+
560+// A new branch at a commit that already exists, and a branch rewound to an
561+// ancestor, bring no new commit: GitHub still runs the push workflows (those
562+// without a paths filter, as no file changed).
563+func TestCIPushWithoutNewCommitsStillRuns(t *testing.T) {
564+ c := newCIHarness(t, nil)
565+ c.registerRunner("alpha", "ubuntu-latest")
566+ c.push("main", map[string]string{".github/workflows/any.yml": workflowWith("branches: ['**']")}, "")
567+ c.waitFor("main runs", func() bool { return len(c.runs()) == 1 })
568+ first := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
569+
570+ // A new branch at the commit main already has.
571+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "HEAD:refs/heads/same")
572+ c.waitFor("the new branch runs", func() bool { return len(c.runs()) == 2 })
573+ assert.Equal(t, "refs/heads/same", c.runs()[0].GetRef())
574+ assert.Equal(t, first, c.runs()[0].GetSha())
575+
576+ // A branch moved forward, then rewound to its ancestor (a forced push).
577+ c.push("main", map[string]string{"a.txt": "a\n"}, "")
578+ c.waitFor("the push runs", func() bool { return len(c.runs()) == 3 })
579+ c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), first+":refs/heads/main")
580+ c.waitFor("the rewind runs", func() bool { return len(c.runs()) == 4 })
581+ assert.Equal(t, first, c.runs()[0].GetSha())
582+}
M internal/usecase/ci_trigger.go +19 -2
@@ -177,9 +177,12 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
177177 f.commit = res.CommitID
178178 if isTag {
179179 cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
180- if err != nil || len(cs) == 0 {
180+ if err != nil {
181181 return f, fmt.Errorf("read the commit of the tag: %w", err)
182182 }
183+ if len(cs) == 0 {
184+ return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
185+ }
183186 f.head = cs[0]
184187 return f, nil
185188 }
@@ -199,9 +202,23 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
199202 exclude = base
200203 }
201204 cs, more, err := c.cfg.Git.ListRange(ctx, repo, exclude, f.commit, ports.MaxCommitLimit)
202- if err != nil || len(cs) == 0 {
205+ if err != nil {
203206 return f, fmt.Errorf("read the commits of the push: %w", err)
204207 }
208+ if len(cs) == 0 {
209+ // A new branch at a commit the default branch has, or a branch rewound
210+ // to an ancestor: no new commit, no changed file. GitHub still runs
211+ // the push workflows that have no paths filter, for the head commit.
212+ head, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
213+ if err != nil {
214+ return f, fmt.Errorf("read the head commit of the push: %w", err)
215+ }
216+ if len(head) == 0 {
217+ return f, fmt.Errorf("read the head commit of the push: %w", domain.ErrNotFound)
218+ }
219+ f.head = head[0]
220+ return f, nil
221+ }
205222 f.head = cs[0]
206223 n := min(len(cs), maxPushCommits)
207224 f.commits = slices.Clone(cs[:n])
@@ -177,9 +177,12 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
177 f.commit = res.CommitID177 f.commit = res.CommitID
178 if isTag {178 if isTag {
179 cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)179 cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
180- if err != nil || len(cs) == 0 {180+ if err != nil {
181 return f, fmt.Errorf("read the commit of the tag: %w", err)181 return f, fmt.Errorf("read the commit of the tag: %w", err)
182 }182 }
183+ if len(cs) == 0 {
184+ return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
185+ }
183 f.head = cs[0]186 f.head = cs[0]
184 return f, nil187 return f, nil
185 }188 }
@@ -199,9 +202,23 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
199 exclude = base202 exclude = base
200 }203 }
201 cs, more, err := c.cfg.Git.ListRange(ctx, repo, exclude, f.commit, ports.MaxCommitLimit)204 cs, more, err := c.cfg.Git.ListRange(ctx, repo, exclude, f.commit, ports.MaxCommitLimit)
202- if err != nil || len(cs) == 0 {205+ if err != nil {
203 return f, fmt.Errorf("read the commits of the push: %w", err)206 return f, fmt.Errorf("read the commits of the push: %w", err)
204 }207 }
208+ if len(cs) == 0 {
209+ // A new branch at a commit the default branch has, or a branch rewound
210+ // to an ancestor: no new commit, no changed file. GitHub still runs
211+ // the push workflows that have no paths filter, for the head commit.
212+ head, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
213+ if err != nil {
214+ return f, fmt.Errorf("read the head commit of the push: %w", err)
215+ }
216+ if len(head) == 0 {
217+ return f, fmt.Errorf("read the head commit of the push: %w", domain.ErrNotFound)
218+ }
219+ f.head = head[0]
220+ return f, nil
221+ }
205 f.head = cs[0]222 f.head = cs[0]
206 n := min(len(cs), maxPushCommits)223 n := min(len(cs), maxPushCommits)
207 f.commits = slices.Clone(cs[:n])224 f.commits = slices.Clone(cs[:n])

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close