Skip to content

Commit 6cb6ab6

Browse files
authored
feat: add --branch-from to create the remote branch if it doesn't exist (#18)
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.
1 parent 10284fa commit 6cb6ab6

File tree

9 files changed

+87
-9
lines changed

9 files changed

+87
-9
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ token in one of the following environment variables:
2727
- GITHUB_TOKEN
2828
- GH_TOKEN
2929

30+
Note that, by default, both of these commands expect the remote branch to already exist. If your
31+
workflow primarily works on *new* branches, you should additionally add the `--branch-from` flag and
32+
supply a commit hash to use as a branch point. With this flag, `commit-headless` will create the
33+
branch on GitHub from that commit hash if it doesn't already exist.
34+
35+
Example: `commit-headless <command> [flags...] --branch-from=$(git rev-parse main HEAD) ...`
36+
3037
In normal usage, `commit-headless` will print *only* the reference to the last commit created on the
3138
remote, allowing this to easily be captured in a script.
3239

action-template/action.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ function main() {
4040
"--branch", process.env.INPUT_BRANCH
4141
];
4242

43+
const branchFrom = process.env["INPUT_BRANCH-FROM"] || "";
44+
if (branchFrom !== "") {
45+
args.push("--branch-from", branchFrom);
46+
}
47+
4348
if (command === "push") {
4449
args.push(...process.env.INPUT_COMMITS.split(/\s+/));
4550
} else {

action-template/action.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ inputs:
1919
branch:
2020
description: 'Target branch name'
2121
required: true
22+
branch-from:
23+
description: 'If necessary, create the remote branch using this commit hash as the branch point.'
2224
command:
2325
description: 'Command to run. One of "commit" or "push"'
2426
required: true

cmd_commit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,5 @@ func (c *CommitCmd) Run() error {
8686

8787
owner, repository := c.Target.Owner(), c.Target.Repository()
8888

89-
return pushChanges(context.Background(), owner, repository, c.Branch, c.DryRun, change)
89+
return pushChanges(context.Background(), owner, repository, c.Branch, c.BranchFrom, c.DryRun, change)
9090
}

cmd_push.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,5 @@ func (c *PushCmd) Run() error {
6969

7070
owner, repository := c.Target.Owner(), c.Target.Repository()
7171

72-
return pushChanges(context.Background(), owner, repository, c.Branch, c.DryRun, changes...)
72+
return pushChanges(context.Background(), owner, repository, c.Branch, c.BranchFrom, c.DryRun, changes...)
7373
}

github.go

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ func (c *Client) branchURL() string {
4444
return fmt.Sprintf("%s/repos/%s/%s/branches/%s", c.baseURL, c.owner, c.repo, c.branch)
4545
}
4646

47+
func (c *Client) refsURL() string {
48+
return fmt.Sprintf("%s/repos/%s/%s/git/refs", c.baseURL, c.owner, c.repo)
49+
}
50+
4751
func (c *Client) browseCommitsURL() string {
4852
return fmt.Sprintf("https://github.com/%s/%s/commits/%s", c.owner, c.repo, c.branch)
4953
}
@@ -57,7 +61,8 @@ func (c *Client) graphqlURL() string {
5761
}
5862

5963
// GetHeadCommitHash returns the current head commit hash for the configured repository and branch
60-
func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) {
64+
// If the branch does not exist (404 return), we'll attempt to create it from commit branchFrom
65+
func (c *Client) GetHeadCommitHash(ctx context.Context, branchFrom string) (string, error) {
6166
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.branchURL(), nil)
6267
if err != nil {
6368
return "", fmt.Errorf("prepare http request: %w", err)
@@ -69,6 +74,14 @@ func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) {
6974
}
7075
defer resp.Body.Close()
7176

77+
if resp.StatusCode == http.StatusNotFound {
78+
if branchFrom != "" {
79+
return c.createBranch(ctx, branchFrom)
80+
}
81+
82+
return "", fmt.Errorf("branch %q does not exist on the remote", c.branch)
83+
}
84+
7285
if resp.StatusCode != http.StatusOK {
7386
return "", fmt.Errorf("get commit hash: http %d", resp.StatusCode)
7487
}
@@ -86,6 +99,52 @@ func (c *Client) GetHeadCommitHash(ctx context.Context) (string, error) {
8699
return payload.Commit.Sha, nil
87100
}
88101

102+
// createBranch attempts to create c.branch using branchFrom as the branch point
103+
func (c *Client) createBranch(ctx context.Context, branchFrom string) (string, error) {
104+
log("Creating branch from commit %s\n", branchFrom)
105+
106+
var input bytes.Buffer
107+
108+
err := json.NewEncoder(&input).Encode(map[string]string{
109+
"ref": fmt.Sprintf("refs/heads/%s", c.branch),
110+
"sha": branchFrom,
111+
})
112+
if err != nil {
113+
return "", err
114+
}
115+
116+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.refsURL(), &input)
117+
if err != nil {
118+
return "", fmt.Errorf("prepare http request: %w", err)
119+
}
120+
121+
resp, err := c.httpC.Do(req)
122+
if err != nil {
123+
return "", fmt.Errorf("create branch request: %w", err)
124+
}
125+
defer resp.Body.Close()
126+
127+
if resp.StatusCode == http.StatusUnprocessableEntity {
128+
return "", fmt.Errorf("create branch: http 422 (does the branch point exist?)")
129+
}
130+
131+
if resp.StatusCode != http.StatusCreated {
132+
return "", fmt.Errorf("create branch: http %d", resp.StatusCode)
133+
}
134+
135+
payload := struct {
136+
Commit struct {
137+
Sha string
138+
} `json:"object"`
139+
}{}
140+
141+
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
142+
return "", fmt.Errorf("decode create branch response: %w", err)
143+
}
144+
145+
return payload.Commit.Sha, nil
146+
}
147+
89148
// PushChanges takes a list of changes and a commit hash and produces commits using the GitHub GraphQL API.
90149
// The commit hash is expected to be the current head of the remote branch, see [GetHeadCommitHash]
91150
// for more.

main.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ func (f targetFlag) Repository() string {
4242

4343
// flags that are shared among commands that interact with the remote
4444
type remoteFlags struct {
45-
Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."`
46-
Branch string `required:"" help:"Name of the target branch on the remote."`
47-
DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."`
45+
Target targetFlag `name:"target" short:"T" required:"" help:"Target repository in owner/repo format."`
46+
Branch string `required:"" help:"Name of the target branch on the remote."`
47+
BranchFrom string `help:"If necessary, create the remote branch using this commit sha."`
48+
DryRun bool `name:"dry-run" help:"Perform everything except the final remote writes to GitHub."`
4849
}
4950

5051
type CLI struct {

pushchanges.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010

1111
// Takes a list of changes to push to the remote identified by target.
1212
// Prints the last commit pushed to standard output.
13-
func pushChanges(ctx context.Context, owner, repository, branch string, dryrun bool, changes ...Change) error {
13+
func pushChanges(ctx context.Context, owner, repository, branch, branchFrom string, dryrun bool, changes ...Change) error {
1414
hashes := []string{}
1515
for i := 0; i < len(changes) && i < 10; i++ {
1616
hashes = append(hashes, changes[i].hash)
@@ -25,6 +25,10 @@ func pushChanges(ctx context.Context, owner, repository, branch string, dryrun b
2525
log("Branch: %s\n", branch)
2626
log("Commits: %s\n", strings.Join(hashes, ", "))
2727

28+
if branchFrom != "" && (!hashRegex.MatchString(branchFrom) || len(branchFrom) != 40) {
29+
return fmt.Errorf("cannot branch from %q, must be a full 40 hex digit commit hash", branchFrom)
30+
}
31+
2832
token := getToken(os.Getenv)
2933
if token == "" {
3034
return errors.New("no GitHub token supplied")
@@ -33,7 +37,7 @@ func pushChanges(ctx context.Context, owner, repository, branch string, dryrun b
3337
client := NewClient(ctx, token, owner, repository, branch)
3438
client.dryrun = dryrun
3539

36-
headRef, err := client.GetHeadCommitHash(context.Background())
40+
headRef, err := client.GetHeadCommitHash(context.Background(), branchFrom)
3741
if err != nil {
3842
return err
3943
}

version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
package main
22

3-
const VERSION = "0.4.0"
3+
const VERSION = "0.5.0"

0 commit comments

Comments
 (0)