0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

概要

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

また今回もgo-githubライブラリを使用したサンプルコードも記載してあります。

commit -allの実現方法

背景・前提情報

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

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

処理の流れ

  1. 差分取得はgit diff --name-status HEADのコマンドで一覧取得できるので、このコマンドの結果を元に処理していく
    • HEADを指定して差分を出すことで最後のコミットと作業ディレクトリの差分を出すことになり、ステージングに追加されているものと、されていないもの両方の差分を取得することができます
  2. これを元にコミットのためのツリーを作成することで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ブランチに持ってくる場合の流れで説明します。

step0.png

  1. 処理用の一時ブランチを作成する
    • mainブランチと同じ参照先で一時ブランチを作成する
    • step1.png
  2. mainブランチのツリーを使用しつつ、親はcherry-pickしたいコミットの親にしたコミットを作成する。また一時ブランチの参照先を作成したコミットに変更する
    • mainブランチのツリーを使用するのでファイルの中身はmainブランチと同じ状態になっている
    • step2.png
  3. cherry-pickしたいコミットを一時ブランチにマージする
    • マージすることで「Commit D」の変更差分をmainブランチの状態のものに反映させることができる
    • 親を「Commit C」にしているため、履歴上は「Commit C」が存在するため「Commit C」の変更差分は反映されない
    • step3.png
  4. マージされたコミットのツリーを使用しつつ、親はmainブランチのコミットを作成する。また一時ブランチの参照先を作成したコミットに変更する
    • cherry-pickしたいコミットだけが反映されたmainブランチに続くコミットが作成できる
    • またこの際にauthorcommitterを指定するとVerified Commitにならなくなってしまうため注意してください(⚠️ただし指定しないと元のコミットからコミットした人などの情報が変わってしまいます)
    • step4.png
  5. cherry-pickしたいコミット数分だけ2~4の処理を繰り返す
  6. mainブランチの参照先を最後にcherry-pickしてコミットに変更する
  7. 一時ブランチを削除する

サンプルコード

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)
	}
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?