Files
headscale/tools/bump/git.go
T
Kristoffer Dalby 380d1b0d0a tools/bump: carry hand-pushed commits instead of pausing
Open PR with commits by others: rebase onto base, build on top, push
with lease. Rebase conflict pauses and comments once.
2026-10-15 10:50:32 +02:00

173 lines
4.8 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"strings"
)
// changedFiles lists paths that differ from HEAD, tracked or not.
func changedFiles(ctx context.Context, r *repo) ([]string, error) {
out, err := r.run(ctx, "git", "status", "--porcelain", "--untracked-files=all")
if err != nil {
return nil, err
}
var files []string
for line := range strings.Lines(out) {
line = strings.TrimRight(line, "\n")
if len(line) < 4 {
continue
}
path := strings.TrimSpace(line[3:])
// Renames read as "old -> new"; the destination is what matters.
if _, dst, ok := strings.Cut(path, " -> "); ok {
path = dst
}
files = append(files, strings.Trim(path, `"`))
}
return files, nil
}
func headSHA(ctx context.Context, r *repo) (string, error) {
out, err := r.run(ctx, "git", "rev-parse", "HEAD")
return strings.TrimSpace(out), err
}
// treeSHA identifies the content of the working commit, independent of message
// or parentage. It is what the pull request marker records so a rerun can tell
// "nothing new" from "same change, previously rejected".
func treeSHA(ctx context.Context, r *repo) (string, error) {
out, err := r.run(ctx, "git", "rev-parse", "HEAD^{tree}")
return strings.TrimSpace(out), err
}
// resetTo rewinds the worktree to sha, leaving nothing behind. Every dropped
// area goes through here, which is why a drop cannot leave a partial edit in
// the pull request.
func resetTo(ctx context.Context, r *repo, sha string) error {
if _, err := r.run(ctx, "git", "reset", "--hard", sha); err != nil { //nolint:noinlineerr
return err
}
_, err := r.run(ctx, "git", "clean", "-fd")
return err
}
func commitAll(ctx context.Context, r *repo, message string) error {
if _, err := r.run(ctx, "git", "add", "--all"); err != nil { //nolint:noinlineerr
return err
}
// The bot's own commits are machine-generated and already gated; the
// hooks re-run the same checks far more slowly.
_, err := r.run(ctx, "git", "commit", "--no-verify", "--message", message)
return err
}
var errRebaseConflict = errors.New("hand-pushed commits do not rebase cleanly")
// startBranch rebuilds the working branch from the base every run, so it never
// accumulates history and the bot keeps no state on disk between runs. The
// exception is an open pull request holding commits someone pushed by hand:
// rebuilding would erase them, so the branch is rebased onto the base and the
// run builds on top. It returns the subjects of the commits it carried.
func startBranch(ctx context.Context, r *repo, remote, base, branch string, carry bool) ([]string, error) {
if _, err := r.run(ctx, "git", "fetch", remote, base); err != nil { //nolint:noinlineerr
return nil, err
}
var hand []string
if carry {
var err error
hand, err = handCommits(ctx, r, remote, base, branch)
if err != nil {
return nil, err
}
}
start := remote + "/" + base
if len(hand) > 0 {
start = remote + "/" + branch
}
if _, err := r.run(ctx, "git", "checkout", "-B", branch, start); err != nil { //nolint:noinlineerr
return nil, err
}
if len(hand) == 0 {
return nil, nil
}
if _, err := r.run(ctx, "git", "rebase", remote+"/"+base); err != nil { //nolint:noinlineerr
_, _ = r.run(ctx, "git", "rebase", "--abort")
return nil, fmt.Errorf("%w onto %s: %w", errRebaseConflict, base, err)
}
return hand, nil
}
// handCommits lists, oldest first, the subjects of commits on the remote branch
// authored by anyone but the configured committer.
func handCommits(ctx context.Context, r *repo, remote, base, branch string) ([]string, error) {
if _, err := r.run(ctx, "git", "fetch", remote, branch); err != nil { //nolint:noinlineerr
return nil, err
}
self, err := r.run(ctx, "git", "config", "user.email")
if err != nil {
return nil, err
}
out, err := r.run(ctx, "git", "log", "--reverse", "--format=%ae%x00%s",
remote+"/"+base+".."+remote+"/"+branch)
if err != nil {
return nil, err
}
return notAuthoredBy(out, strings.TrimSpace(self)), nil
}
// notAuthoredBy filters `git log --format=%ae%x00%s` output to the subjects of
// commits whose author is not email.
func notAuthoredBy(log, email string) []string {
var subjects []string
for line := range strings.Lines(log) {
author, subject, _ := strings.Cut(strings.TrimRight(line, "\n"), "\x00")
if author != email {
subjects = append(subjects, subject)
}
}
return subjects
}
// forcePush replaces branch on remote. A non-empty lease is the tip the run
// started from; the push is refused if anyone moved the branch since.
func forcePush(ctx context.Context, r *repo, remote, branch, lease string) error {
force := "--force"
if lease != "" {
force = "--force-with-lease=refs/heads/" + branch + ":" + lease
}
_, err := r.run(ctx, "git", "push", force, remote, "HEAD:refs/heads/"+branch)
if err != nil {
return fmt.Errorf("pushing %s: %w", branch, err)
}
return nil
}