thasmto's blog

フロントエンドエンジニアやってます。プログラマーとして学んだことや考えていることを投稿するブログです。

コードリーディング reg-suit/reg-keygen-git-hash-plugin

業務でビジュアルリグレッションテストの導入を進めており、画像の比較と差分出力のツールとしてNode.jsで動くreg-suitを使用することになりました。

reg-suitではGitのコミットを辿り、どのコミットを比較対象にするか選択することができる「reg-keygen-git-hash-plugin」というプラグインがあり、これを使用することでブランチをマージする前などに作業ブランチとその親ブランチの変更点をスクリーンショットで比較をすることができます。

このプラグインでどのようにNode.jsでGitのブランチやコミットの情報を取得し、利用しているのか気になったのでプラグインのコードを見てみました。

reg-keygen-git-hash-plugin

比較するスクリーンショットを識別するキーとしてGitのコミットハッシュを識別するために使われるプラグイン。 どのように比較するかは公式のREADME内で提示されているグラフを見るとわかりやすいです。

f:id:thasmto:20210711211428p:plain
引用:https://github.com/reg-viz/reg-suit/blob/master/packages/reg-keygen-git-hash-plugin/README.md
分岐元のコミットと現在のブランチのコミットを比較対象としています。

コードを見ていく

srcの中のファイルは以下の3つ。

index.ts

まずはindex.tsから見ていきます。

// index.ts
import { KeyGeneratorPlugin, PluginCreateOptions, KeyGeneratorPluginFactory } from "reg-suit-interface";
import { fsUtil } from "reg-suit-util";

import { CommitExplorer } from "./commit-explorer";

class GitHashKeyGenPlugin implements KeyGeneratorPlugin {
  // ...省略

  init(config: PluginCreateOptions): void {
    this._conf = config;
  }

  // 比較元のコミットハッシュを取得
  getExpectedKey(): Promise<string> {
    // ...省略
  }
  
 // 比較先のコミットハッシュを取得
  getActualKey(): Promise<string> {
    // ...省略
  }
  // ...省略
}

const pluginFactory: KeyGeneratorPluginFactory = () => {
  return {
    keyGenerator: new GitHashKeyGenPlugin(),
  };
};

export = pluginFactory;

index.tsにはGitHashKeyGenPluginクラスが定義されています。
getExpectedKeygetActuralKeyが重要そうで、メソッドの名前から比較元と比較先のキーとしてGitのコミットハッシュを取得をするメソッドだと予測できます。
getExpectedKeyの中は以下のようになっています。

class GitHashKeyGenPlugin implements KeyGeneratorPlugin {
  // ...
  getExpectedKey(): Promise<string> {
    if (!this._checkAndMessage()) {
      return Promise.reject<string>(null);
    }
    try {
      const result = this._explorer.getBaseCommitHash();
      if (result) {
        return Promise.resolve(result);
      } else {
        return Promise.reject<string>(null);
      }
    } catch (e) {
      this._conf.logger.error(this._conf.logger.colors.red(e.message));
      return Promise.reject<string>(null);
    }
  }
 ...
}

_checkAndMessage()はgitの情報を取得できるか判断するために.gitディレクトリが作業ディレクトリ内に存在するかをチェックするメソッド。 Git操作可能であれば別ファイルから読み込んだCommitExplorerクラスのインスタンスからgetBaseCommitHashメソッドを呼び出して、比較元のコミットハッシュを取得しています。

getActualKeyメソッドの処理も同じようにCommitExplorerのインスタンスからgetCurrentCommitHashメソッドを呼び出して比較先のコミットハッシュを取得しています。

commit-explorer.ts

次にCommitExplorerの詳細を見ていきます。 GitCmdClientクラスで取得したコミット情報を利用して比較対象のコミットを探すための処理がまとめられています。 見ていくコード量が多かったので、コード内に直接メモを書いています。 また、複雑で理解しきれていないくてメモできていない部分や間違ったことを書いている可能性がありますのでご了承ください。

import { GitCmdClient } from "./git-cmd-client";

export type CommitNode = string[];

export class CommitExplorer {
  private _gitCmdClient = new GitCmdClient();
  private _commitNodes!: CommitNode[];
  private _branchName!: string;
  private _branchNameCache: { [hash: string]: string[] } = {};

  /*
   * e.g. return `[["a38df15", "8e1ac3a"], ["8e1ac3a", "7ba8507"]]`.
   *      The first element of node means commit hash, rest elements means parent commit hashes.
   */
  // カレントブランチ内にあるコミットハッシュと親コミットハッシュのペアを格納した配列を取得する
  getCommitNodes(): CommitNode[] {
    return this._gitCmdClient
      .logGraph()
      .split("\n")
      .map((hashes: string) =>
        hashes
          .replace(/\*|\/|\||\\|_|-+\.|/g, "")
          .split(" ")
          .filter(hash => !!hash),
      )
      .filter((hashes: CommitNode) => hashes.length);
  }

  /*
   * e.g. return `master`.
   */
  // 作業ブランチのブランチ名を取得する
  getCurrentBranchName(): string {
    const currentName = this._gitCmdClient.currentName().replace("\n", "");
    if (
      currentName.startsWith("(HEAD detached") ||
      currentName.startsWith("(no branch") ||
      currentName.startsWith("(detached from") ||
      (currentName.startsWith("[") && currentName.indexOf("detached") !== -1)
    ) {
      throw new Error("Can't detect branch name because HEAD is on detached commit node.");
    }
    return currentName;
  }

  /*
   * e.g. return `ede92258d154f1ba1e88dc109a83b9ba143d561e`.
   */
  // 作業ブランチの現在のコミットのハッシュを取得する
  getCurrentCommitHash(): string {
    const currentName = this._branchName;
    if (!currentName || !currentName.length) {
      throw new Error("Fail to detect the current branch.");
    }
    return this._gitCmdClient.revParse(currentName).replace("\n", "");
  }

  /*
   * Return branch name including target hash.
   * e.g. `["master", "feat-x"]`.
   */
  // 引数に渡したコミットハッシュのコミットを含むブランチの名前を配列で取得する
  getBranchNames(hash: string): string[] {
    if (this._branchNameCache[hash]) return this._branchNameCache[hash];
    const names = this._gitCmdClient
      .containedBranches(hash)
      .split("\n")
      .filter(h => !!h)
      .map(branch => branch.replace("*", "").trim());
    this._branchNameCache[hash] = names;
    return names;
  }
  
  // 全てのブランチ名を配列で取得する
  getAllBranchNames(): string[] {
    return this._gitCmdClient
      .branches()
      .split("\n")
      .map(b => b.replace(/^\*/, "").trim().split(" ")[0])
      .filter(b => !!b || b === this._branchName)
      .filter((x, i, self) => self.indexOf(x) === i);
  }
  
  // 作業ブランチと引数に渡したハッシュのブランチとの分岐元のハッシュを取得する
  getIntersection(hash: string): string | undefined {
    try {
      return this._gitCmdClient.mergeBase(hash, this._branchName).slice(0, 8);
    } catch (e) {}
  }


  getBranchHash(): string | undefined {
    const branches = this.getAllBranchNames();
    return branches
      .map(b => {
        
        const hash = this.getIntersection(b);
        // 作業ブランチと引数に渡したハッシュのブランチとの分岐元のハッシュを取得する

        const time = hash ? new Date(this._gitCmdClient.logTime(hash).trim()).getTime() : Number.MAX_SAFE_INTEGER;
        // hashが作られた時間を取得する

        return { hash, time };
      })
      .filter(a => !!a.hash)
      .sort((a, b) => a.time - b.time) // ハッシュが古い順にソート
      .map(b => b.hash)[0]; // 一番最初に作られた分岐元のブランチを返す
  }

  getCandidateHashes(): string[] {
    const mergedBranches = this.getBranchNames(this._commitNodes[0][0]).filter(
      b => !b.endsWith("/" + this._branchName) && b !== this._branchName,
    );
    // this.getBranchNames(this._commitNodes[0][0])は作業ブランチの
    // 最新コミットを含んでいるブランチ名一覧(作業ブランチと、作業ブランチからさらに分岐しているブランチ、
    // 作業ブランチをすでにマージしているブランチの一覧)
    
    // branch:master
    // *
    // |\
    // | * currentコミット(branch:a)
    // |/ \
    // *   * other(branch:b)
    //
    // この場合、this._commitNodes[0][0]はcurrentコミットが入るので、
    // getBrancheNamesで取得できるブランチ名はmaster, a, b の3つになる
    // this._branchNameであるaブランチと**/aとなるブランチが除かれ、
    // mergedBranchesには['master', 'b']が返る

    return this._commitNodes
      .map(c => c[0])
      .filter(c => {
        const branches = this.getBranchNames(c);
      
        
        const hasCurrent = !!branches.find(b => this._branchName === b);
        // this._commitNodesはcurrentブランチ(this._branchName)上のログに含まれるコミットの一覧なのでhasCurrentは必ずtrueになる?
        
        const others = branches.filter(b => {
          return !(b.endsWith(this._branchName) || (mergedBranches.length && mergedBranches.some(c => b === c)));
        });
        // currentブランチ以外にもコミットを含んでいるブランチ
        
        return hasCurrent && !!others.length;
        // currentブランチに存在して、他のブランチにも存在しているコミットを返す
      });
  }
 
  isReachable(a: string, b: string) {
    const between = this._gitCmdClient.logBetween(a, b).trim();
    // aからは到達できないがbからは到達できるコミットを返す。
    // つまりaのあとbまでの間に追加されたコミットを返す。

    return !between;
    // aのあとにbまでの間に追加されたコミットがなければtrueを返す
  }

  findBaseCommitHash(candidateHashes: string[], branchHash: string): string | undefined {
    const traverseLog = (candidateHash: string): boolean | undefined => {
      if (candidateHash === branchHash) return true;
      return this.isReachable(candidateHash, branchHash);
      // currentブランチに存在して他のブランチにも存在しているコミットと、分岐元のコミットのハッシュの間にコミットがなければtrueを返す
    };
    const target = candidateHashes.find(hash => !!traverseLog(hash));
    return target;
  }

  getBaseCommitHash(): string | null {
    this._branchName = this.getCurrentBranchName();
    this._commitNodes = this.getCommitNodes();
    
    const candidateHashes = this.getCandidateHashes();
    // currentブランチに存在して、他のブランチにも存在しているコミットのハッシュ
    const branchHash = this.getBranchHash();
    if (!branchHash) return null;
    const baseHash = this.findBaseCommitHash(candidateHashes, branchHash);
    if (!baseHash) return null;
    const result = this._gitCmdClient.revParse(baseHash).replace("\n", "");
    return result ? result : null;
  }
}

git-cmd-client.ts

最後にGitCmdClientクラスのコードを見ていきます。 Gitのコマンドを直接呼び出す処理がまとめられています。

コード概要

import { execSync } from "child_process";
import shellEscape from "shell-escape";

export class GitCmdClient {
  private _revParseHash: { [key: string]: string } = {};
  
  // 現在選択しているブランチ名を出力
  currentName() {
    return execSync('git branch | grep "^\\*" | cut -b 3-', { encoding: "utf8" });
  }
  
  // 現在のブランチの最新のコミットを出力
  revParse(currentName: string) {
    if (!this._revParseHash[currentName]) {
      this._revParseHash[currentName] = execSync(`git rev-parse "${currentName}"`, { encoding: "utf8" });
    }
    return this._revParseHash[currentName];
  }

  // ブランチの一覧を出力
  branches() {
    return execSync("git branch -a", { encoding: "utf8" });
  }

  // 引数に渡したハッシュのコミットを含んだブランチの一覧を出力
  containedBranches(hash: string): string {
    return execSync(shellEscape(["git", "branch", "-a", "--contains", hash]), { encoding: "utf8" });
  }
  
  // 引数に渡したハッシュのコミットのコミットされた日時を出力
  logTime(hash: string) {
    return execSync(shellEscape(["git", "log", "--pretty=%ci", "-n", "1", hash]), { encoding: "utf8" });
  }
  
  // 引数に渡した期間のコミットを出力
  logBetween(a: string, b: string) {
    return execSync(shellEscape(["git", "log", "--oneline", `${a}..${b}`]), { encoding: "utf8" });
  }
  
  // コミットハッシュと親のコミットハッシュのペアのリストを出力
  logGraph() {
    return execSync('git log -n 300 --graph --pretty=format:"%h %p"', { encoding: "utf8" });
  }
  
  // 引数に渡した二つのブランチの分岐元となるコミットを出力
  mergeBase(a: string, b: string) {
    return execSync(shellEscape(["git", "merge-base", "-a", a, b]), { encoding: "utf8" });
  }
}

使用しているモジュール 

Node.js child_process

https://nodejs.org/api/child_process.html#child_process_child_process

The child_process module provides the ability to spawn subprocesses in a manner that is similar, but not identical, to popen(3). This capability is primarily provided by the child_process.spawn() function:

サブプロセスを生成する機能を提供するモジュール。

child_process.execSync()

The child_process.execSync() method is generally identical to child_process.exec() with the exception that the method will not return until the child process has fully closed. When a timeout has been encountered and killSignal is sent, the method won't return until the process has completely exited. If the child process intercepts and handles the SIGTERM signal and doesn't exit, the parent process will wait until the child process has exited.

execメソッドを非同期ではなく同期処理で呼び出すメソッド。 Node.jsのメソッドは基本的に非同期処理されるので、同期処理をしたい場合はメソッド名の後ろにSyncとついたメソッドが用意されていることが多い。

child_process.exec()

https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback

Spawns a shell then executes the command within that shell, buffering any generated output. The command string passed to the exec function is processed directly by the shell and special characters (vary based on shell) need to be dealt with accordingly

シェルを生成し、そのシェル内でコマンドを実行して、生成された出力をバッファリングする。

shell-escape

https://github.com/xxorax/node-shell-escape
コマンドを構成する文字列を配列で渡すことで、コマンド実行に適した文字列にエスケープする

// 実行例
var shellescape = require('shell-escape');

var args = ['curl', '-v', '-H', 'Location;', '-H', 'User-Agent: dave#10', 'http://www.daveeddy.com/?name=dave&age=24'];

var escaped = shellescape(args);
console.log(escaped);
//出力結果
curl -v -H 'Location;' -H 'User-Agent: dave#10' 'http://www.daveeddy.com/?name=dave&age=24'

まとめ

コードを見てなんとなく全体像を掴めました。 Nodejsでのコマンドの実行にchild_processモジュールとshell-escapeもモジュールが使われていることがわかったので、今後コマンドラインツールを作るときに活用できそうです。 また、Gitコマンドを直接呼び出すクラスとコミット情報を加工するクラスを分けるといったクラス設計の考え方も具体的な実装から学ぶことができました。