概要
以前書いたGitHub Actionsでsigned commitにする方法に関連して、GitHub Actionsでcommit -all
やcherry-pick
を実現する必要がありました。
このあたりの処理はGitHub REST APIなど低レベルなgitの操作では少し工夫が必要だったのでその方法の紹介になります。
また今回もgo-githubライブラリを使用したサンプルコードも記載してあります。
commit -all
の実現方法
背景・前提情報
GitHub Actionsでactions/checkoutされたファイルに変更が入り、後続の処理として変更が入ったファイルをすべてコミットしたい場合などのケース。
CLIコマンドであればgit commit --all
ですべてコミットできますが、GitHub REST APIを使用する場合はいい感じに差分のファイルを検出してツリーの作成が必要になってきます。
この際にファイル変更がすべて内部の処理で完結していればリストなどで保持しておけばよいので簡単ですが、外部の処理後に検出が必要でgit add
されてステージングに追加されているものもあれば、されてない場合も考慮必要な場合は少しだけ工夫が必要です。
処理の流れ
- 差分取得は
git diff --name-status HEAD
のコマンドで一覧取得できるので、このコマンドの結果を元に処理していく-
HEAD
を指定して差分を出すことで最後のコミットと作業ディレクトリの差分を出すことになり、ステージングに追加されているものと、されていないもの両方の差分を取得することができます
-
- これを元にコミットのためのツリーを作成することで
git commit --all
と同じような処理を実現できる- この際にステータスが削除(
D
)になっているものは、削除するようにツリーを作成する必要があるので少し注意が必要です
- この際にステータスが削除(
サンプルコード
package main
import (
"log"
"os/exec"
"github.com/google/go-github/v71/github"
)
func main() {
// ...省略
// 1. git diff --name-status HEADで差分ファイル一覧を取得する
out, err := exec.Command("git", "diff", "--name-status", "HEAD").Output()
if err != nil {
log.Fatalf("Failed to get git diff: %v", err)
}
// 2. ツリーを作成する
var treeEntries []*github.TreeEntry
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
status, filePath := parts[0], parts[1]
switch status {
case "A", "M": // 追加/変更ファイル
contentBytes, err := os.ReadFile(filePath)
if err != nil {
log.Fatalf("Failed to read file %s: %v", filePath, err)
}
treeEntries = append(treeEntries, &github.TreeEntry{
Path: github.Ptr(filePath), Type: github.Ptr("blob"), Content: github.Ptr(string(contentBytes)), Mode: github.Ptr("100644"),
})
case "D": // 削除ファイル
treeEntries = append(treeEntries, &github.TreeEntry{
SHA: nil, Path: github.Ptr(filePath), Type: github.Ptr("blob"), Mode: github.Ptr("100644"),
})
}
}
tree, _, err := client.Git.CreateTree(ctx, owner, repo, *parentCommit.SHA, treeEntries)
if err != nil {
log.Fatalf("Failed to create tree: %v", err)
}
// コミットなどは省略...
}
cherry-pick
の実現方法
背景・前提情報
GitHub REST APIではcherry-pickのコマンドは提供されていません。
そのため少し複雑な操作が必要になるのですが、少し調べたところgithub-cherry-pickリポジトリを見つけこちらの仕組みをそのまま使用させていただいて実現できました。
処理の流れ
下記のようなコミットの状態の際に、「Commit D」と「Commit E」をcherry-pickしてmain
ブランチに持ってくる場合の流れで説明します。
- 処理用の一時ブランチを作成する
-
main
ブランチのツリーを使用しつつ、親はcherry-pickしたいコミットの親にしたコミットを作成する。また一時ブランチの参照先を作成したコミットに変更する - cherry-pickしたいコミットを一時ブランチにマージする
- マージされたコミットのツリーを使用しつつ、親は
main
ブランチのコミットを作成する。また一時ブランチの参照先を作成したコミットに変更する - cherry-pickしたいコミット数分だけ2~4の処理を繰り返す
-
main
ブランチの参照先を最後にcherry-pickしてコミットに変更する - 一時ブランチを削除する
サンプルコード
package main
import (
"log"
"github.com/google/go-github/v71/github"
)
func main() {
// ...mainブランチのコミット取得部分など省略
cherryPickCommitHashes := []string{
"0792434b539135668db73c9162a4d7852be9006c",
"4fcd4f602c7561ce0255553ad73398a52b2d3c4c",
}
// 1. 処理用の一時ブランチを作成する
tempRef := &github.Reference{
Ref: github.Ptr("refs/heads/temp"),
Object: &github.GitObject{SHA: parentCommit.SHA},
}
tempRef, _, err = client.Git.CreateRef(ctx, owner, repo, tempRef)
if err != nil {
log.Fatalf("Failed to create temporary branch: %v", err)
}
// 5. cherri-pickしたいコミット数分だけ繰り返す
for _, cherryPickCommitHash := range cherryPickCommitHashes {
// 2-1. cherry-pick対象のコミット情報を取得
cherryPickCommit, _, err := client.Repositories.GetCommit(ctx, owner, repo, cherryPickCommitHash, nil)
if err != nil {
log.Fatalf("Failed to get cherry-pick commit: %v", err)
}
// 2-2. ツリーはcherry-pick先の親のコミット、親はcherry-pick対象の親のコミットを新規作成
newCherryPickCommit := &github.Commit{
Message: parentCommit.Message,
Tree: parentCommit.Tree,
Parents: cherryPickCommit.Parents,
}
tempCommit, _, err := client.Git.CreateCommit(ctx, owner, repo, newCherryPickCommit, nil)
if err != nil {
log.Fatalf("Failed to create cherry-pick commit: %v", err)
}
// 2-3. 一時ブランチの参照先を更新する
tempRef.Object.SHA = tempCommit.SHA
_, _, err = client.Git.UpdateRef(ctx, owner, repo, tempRef, true)
if err != nil {
log.Fatalf("Failed to update temporary branch reference: %v", err)
}
// 3. cherry-pickしたいコミットを一時ブランチにマージする
mergeRequest := &github.RepositoryMergeRequest{
Base: github.Ptr("temp"),
Head: github.Ptr(cherryPickCommitHash),
}
mergeCommit, _, err := client.Repositories.Merge(ctx, owner, repo, mergeRequest)
if err != nil {
log.Fatalf("Failed to merge cherry-pick commit: %v", err)
}
// 4-1. マージされたコミットのツリーを使用しつつ、親はcherry-pick先の親のコミットを新規作成
commit := &github.Commit{
Message: cherryPickCommit.Commit.Message,
Tree: mergeCommit.Commit.Tree,
Parents: []*github.Commit{parentCommit},
// NOTE: verified commitにしたい場合はauthorとcommitterを指定しない
// Author: cherryPickCommit.Commit.Author,
// Committer: cherryPickCommit.Commit.Committer,
}
newCommit, _, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil)
if err != nil {
log.Fatalf("Failed to create commit: %v", err)
}
parentCommit = newCommit
// 4-2. 一時ブランチの参照先を更新する
tempRef.Object.SHA = newCommit.SHA
_, _, err = client.Git.UpdateRef(ctx, owner, repo, tempRef, true)
if err != nil {
log.Fatalf("Failed to update temporary branch reference: %v", err)
}
}
// 6. mainブランチの参照先を更新する
mainRef.Object.SHA = parentCommit.SHA
_, _, err = client.Git.UpdateRef(ctx, owner, repo, mainRef, false)
if err != nil {
log.Fatalf("Failed to update new branch reference: %v", err)
}
// 7. 一時ブランチを削除する
_, err = client.Git.DeleteRef(ctx, owner, repo, "refs/heads/temp")
if err != nil {
log.Fatalf("Failed to delete temporary branch: %v", err)
}
}