GitHub REST APIなど低レベルなgit操作でcommit -allやcherry-pickを実現する方法

# 概要

https://qiita.com/yasu89/items/487c020f2db8c1c376aa

以前書いたGitHub Actionsでsigned commitにする方法に関連して、GitHub Actionsで`commit -all`や`cherry-pick`を実現する必要がありました。
このあたりの処理はGitHub REST APIなど低レベルなgitの操作では少し工夫が必要だったのでその方法の紹介になります。

また今回も[go-github](https://github.com/google/go-github)ライブラリを使用したサンプルコードも記載してあります。

# `commit -all`の実現方法

## 背景・前提情報

GitHub Actionsで[actions/checkout](https://github.com/actions/checkout)されたファイルに変更が入り、後続の処理として変更が入ったファイルをすべてコミットしたい場合などのケース。

CLIコマンドであれば`git commit --all`ですべてコミットできますが、GitHub REST APIを使用する場合はいい感じに差分のファイルを検出してツリーの作成が必要になってきます。
この際にファイル変更がすべて内部の処理で完結していればリストなどで保持しておけばよいので簡単ですが、外部の処理後に検出が必要で`git add`されてステージングに追加されているものもあれば、されてない場合も考慮必要な場合は少しだけ工夫が必要です。

## 処理の流れ

1. 差分取得は`git diff --name-status HEAD`のコマンドで一覧取得できるので、このコマンドの結果を元に処理していく
    - `HEAD`を指定して差分を出すことで最後のコミットと作業ディレクトリの差分を出すことになり、ステージングに追加されているものと、されていないもの両方の差分を取得することができます
1. これを元にコミットのためのツリーを作成することで`git commit --all`と同じような処理を実現できる
    - この際にステータスが削除(`D`)になっているものは、削除するようにツリーを作成する必要があるので少し注意が必要です

## サンプルコード

```go
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](https://github.com/tibdex/github-cherry-pick?tab=readme-ov-file#how-it-works)リポジトリを見つけこちらの仕組みをそのまま使用させていただいて実現できました。

## 処理の流れ

下記のようなコミットの状態の際に、「Commit D」と「Commit E」をcherry-pickして`main`ブランチに持ってくる場合の流れで説明します。

![step0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295261/04851e94-05fd-46eb-96a2-7bb2be255e39.png)

1. 処理用の一時ブランチを作成する
    - `main`ブランチと同じ参照先で一時ブランチを作成する
    - ![step1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295261/dee5bc77-1bf8-49c6-b305-dfee7e484c31.png)
1. `main`ブランチのツリーを使用しつつ、親はcherry-pickしたいコミットの親にしたコミットを作成する。また一時ブランチの参照先を作成したコミットに変更する
    - `main`ブランチのツリーを使用するのでファイルの中身は`main`ブランチと同じ状態になっている
    - ![step2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295261/6dcd8d82-63b7-4b7a-a4bf-e5d58abd02ed.png)
1. cherry-pickしたいコミットを一時ブランチにマージする
    - マージすることで「Commit D」の変更差分を`main`ブランチの状態のものに反映させることができる
    - 親を「Commit C」にしているため、履歴上は「Commit C」が存在するため「Commit C」の変更差分は反映されない
    - ![step3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295261/f0737e00-9a8b-4953-a8ec-b8c1b18be560.png)
1. マージされたコミットのツリーを使用しつつ、親は`main`ブランチのコミットを作成する。また一時ブランチの参照先を作成したコミットに変更する
    - cherry-pickしたいコミットだけが反映された`main`ブランチに続くコミットが作成できる
    - またこの際に`author`と`committer`を指定するとVerified Commitにならなくなってしまうため注意してください(⚠️ただし指定しないと元のコミットからコミットした人などの情報が変わってしまいます)
    - ![step4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295261/d65a0315-2f1c-4ce4-b97a-f8ba04296a63.png)
1. cherry-pickしたいコミット数分だけ2~4の処理を繰り返す
1. `main`ブランチの参照先を最後にcherry-pickしてコミットに変更する
1. 一時ブランチを削除する

## サンプルコード

```go
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)
	}
}
```