From 19f3cf25c35aa9ad9bb602468be0632a754556ae Mon Sep 17 00:00:00 2001 From: Alex Vidal Date: Tue, 15 Jul 2025 15:43:55 -0500 Subject: [PATCH] feat: add --branch-from to create the remote branch if it doesn't exist Several cases have already popped up of users creating commits on brand new branches. I was hoping to avoid bringing this in, but the implementation is pretty simple. Both `commit` and `push` gain a new `--branch-from` flag to specify a commit hash that `--branch` should branch from, if the branch doesn't already exist on the remote. --- README.md | 7 +++++ action-template/action.js | 5 ++++ action-template/action.yml | 2 ++ cmd_commit.go | 2 +- cmd_push.go | 2 +- github.go | 61 +++++++++++++++++++++++++++++++++++++- main.go | 7 +++-- pushchanges.go | 8 +++-- version.go | 2 +- 9 files changed, 87 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 47ddbee..62256b4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ token in one of the following environment variables: - GITHUB_TOKEN - GH_TOKEN +Note that, by default, both of these commands expect the remote branch to already exist. If your +workflow primarily works on *new* branches, you should additionally add the `--branch-from` flag and +supply a commit hash to use as a branch point. With this flag, `commit-headless` will create the +branch on GitHub from that commit hash if it doesn't already exist. + +Example: `commit-headless [flags...] --branch-from=$(git rev-parse main HEAD) ...` + In normal usage, `commit-headless` will print *only* the reference to the last commit created on the remote, allowing this to easily be captured in a script. diff --git a/action-template/action.js b/action-template/action.js index 5fcbafe..50ecfa0 100644 --- a/action-template/action.js +++ b/action-template/action.js @@ -40,6 +40,11 @@ function main() { "--branch", process.env.INPUT_BRANCH ]; + const branchFrom = process.env["INPUT_BRANCH-FROM"] || ""; + if (branchFrom !== "") { + args.push("--branch-from", branchFrom); + } + if (command === "push") { args.push(...process.env.INPUT_COMMITS.split(/\s+/)); } else { diff --git a/action-template/action.yml b/action-template/action.yml index e44645e..0d8433f 100644 --- a/action-template/action.yml +++ b/action-template/action.yml @@ -19,6 +19,8 @@ inputs: branch: description: 'Target branch name' required: true + branch-from: + description: 'If necessary, create the remote branch using this commit hash as the branch point.' command: description: 'Command to run. One of "commit" or "push"' required: true diff --git a/cmd_commit.go b/cmd_commit.go index 444c078..22fe19b 100644 --- a/cmd_commit.go +++ b/cmd_commit.go @@ -86,5 +86,5 @@ func (c *CommitCmd) Run() error { owner, repository := c.Target.Owner(), c.Target.Repository() - return pushChanges(context.Background(), owner, repository, c.Branch, c.DryRun, change) + return pushChanges(context.Background(), owner, repository, c.Branch, c.BranchFrom, c.DryRun, change) } diff --git a/cmd_push.go b/cmd_push.go index 841f8f6..4209229 100644 --- a/cmd_push.go +++ b/cmd_push.go @@ -69,5 +69,5 @@ func (c *PushCmd) Run() error { owner, repository := c.Target.Owner(), c.Target.Repository() - return pushChanges(context.Background(), owner, repository, c.Branch, c.DryRun, changes...) + return pushChanges(context.Background(), owner, repository, c.Branch, c.BranchFrom, c.DryRun, changes...) } diff --git a/github.go b/github.go index b2ebc9f..84008d4 100644 --- a/github.go +++ b/github.go @@ -44,6 +44,10 @@ func (c *Client) branchURL() string { return fmt.Sprintf("%s/repos/%s/%s/branches/%s", c.baseURL, c.owner, c.repo, c.branch) } +func (c *Client) refsURL() string { + return fmt.Sprintf("%s/repos/%s/%s/git/refs", c.baseURL, c.owner, c.repo) +} + func (c *Client) browseCommitsURL() string { return fmt.Sprintf("https://github.com/%s/%s/commits/%s", c.owner, c.repo, c.branch) } @@ -57,7 +61,8 @@ func (c *Client) graphqlURL() string { } // GetHeadCommitHash returns the current head commit hash for the configured repository and branch -func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) { +// If the branch does not exist (404 return), we'll attempt to create it from commit branchFrom +func (c *Client) GetHeadCommitHash(ctx context.Context, branchFrom string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.branchURL(), nil) if err != nil { return "", fmt.Errorf("prepare http request: %w", err) @@ -69,6 +74,14 @@ func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) { } defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + if branchFrom != "" { + return c.createBranch(ctx, branchFrom) + } + + return "", fmt.Errorf("branch %q does not exist on the remote", c.branch) + } + if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("get commit hash: http %d", resp.StatusCode) } @@ -86,6 +99,52 @@ func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) { return payload.Commit.Sha, nil } +// createBranch attempts to create c.branch using branchFrom as the branch point +func (c *Client) createBranch(ctx context.Context, branchFrom string) (string, error) { + log("Creating branch from commit %s\n", branchFrom) + + var input bytes.Buffer + + err := json.NewEncoder(&input).Encode(map[string]string{ + "ref": fmt.Sprintf("refs/heads/%s", c.branch), + "sha": branchFrom, + }) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.refsURL(), &input) + if err != nil { + return "", fmt.Errorf("prepare http request: %w", err) + } + + resp, err := c.httpC.Do(req) + if err != nil { + return "", fmt.Errorf("create branch request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnprocessableEntity { + return "", fmt.Errorf("create branch: http 422 (does the branch point exist?)") + } + + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("create branch: http %d", resp.StatusCode) + } + + payload := struct { + Commit struct { + Sha string + } `json:"object"` + }{} + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("decode create branch response: %w", err) + } + + return payload.Commit.Sha, nil +} + // PushChanges takes a list of changes and a commit hash and produces commits using the GitHub GraphQL API. // The commit hash is expected to be the current head of the remote branch, see [GetHeadCommitHash] // for more. diff --git a/main.go b/main.go index 8603a0e..f8f1df7 100644 --- a/main.go +++ b/main.go @@ -42,9 +42,10 @@ func (f targetFlag) Repository() string { // flags that are shared among commands that interact with the remote type remoteFlags struct { - Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."` - Branch string `required:"" help:"Name of the target branch on the remote."` - DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."` + Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."` + Branch string `required:"" help:"Name of the target branch on the remote."` + BranchFrom string `help:"If necessary, create the remote branch using this commit sha."` + DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."` } type CLI struct { diff --git a/pushchanges.go b/pushchanges.go index 028d673..7b4c819 100644 --- a/pushchanges.go +++ b/pushchanges.go @@ -10,7 +10,7 @@ import ( // Takes a list of changes to push to the remote identified by target. // Prints the last commit pushed to standard output. -func pushChanges(ctx context.Context, owner, repository, branch string, dryrun bool, changes ...Change) error { +func pushChanges(ctx context.Context, owner, repository, branch, branchFrom string, dryrun bool, changes ...Change) error { hashes := []string{} for i := 0; i < len(changes) && i < 10; i++ { hashes = append(hashes, changes[i].hash) @@ -25,6 +25,10 @@ func pushChanges(ctx context.Context, owner, repository, branch string, dryrun b log("Branch: %s\n", branch) log("Commits: %s\n", strings.Join(hashes, ", ")) + if branchFrom != "" && (!hashRegex.MatchString(branchFrom) || len(branchFrom) != 40) { + return fmt.Errorf("cannot branch from %q, must be a full 40 hex digit commit hash", branchFrom) + } + token := getToken(os.Getenv) if token == "" { return errors.New("no GitHub token supplied") @@ -33,7 +37,7 @@ func pushChanges(ctx context.Context, owner, repository, branch string, dryrun b client := NewClient(ctx, token, owner, repository, branch) client.dryrun = dryrun - headRef, err := client.GetHeadCommitHash(context.Background()) + headRef, err := client.GetHeadCommitHash(context.Background(), branchFrom) if err != nil { return err } diff --git a/version.go b/version.go index 3d18682..ea10636 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const VERSION = "0.4.0" +const VERSION = "0.5.0"