Go言語でGitの構成管理を手助けするCLIを作ってみました。

Table of Contents

経緯

作成の動機はGo言語スキルをアップしたかったからです。

Go言語を業務で

最近、業務でGo言語を導入するチャンスがあったので導入を進めています。
Go言語が持つ以下の特徴がプロジェクトにマッチしそうなためです。

  • 仕様が少なく泥臭い (良くも悪くも)
  • 教育コストが低い
  • パフォーマンスが良い
  • シングルバイナリで環境にほとんど依存しない
  • Googleが開発しており実績も十分

習得の近道

本やWebで真面目に勉強するのが王道です.. が私にとってそれはあまり効率良くありません。
まず動くモノ、使えるモノを作る方が身になると考えています。

一通り作った後に勉強することにより吸収の度合いは数倍以上になると思っています。
ああ..勿論その後にリファクタリングしてくださいね。

作ったモノ

gowlというツールを作りました。

どういうツール?

GitHubやBitbucketと軽く連携しつつ、ローカルのリポジトリ構成を管理するツールです。
具体的には以下の様な機能があります。

  • リポジトリの取得
  • リポジトリの編集
  • リポジトリのWebサイト表示
  • 取得したリポジトリ一覧の表示

対話型のシェルを使い、リポジトリの管理場所を意識せずに上記を実現させます。

LinuxだけではなくWindowsでも動きます。さすがGo言語!!
今の業務環境はほぼWindowsであるため欠かせないポイントです ☺️

お試しいただける場合はInstallの項をご覧下さい。

影響を受けたツール

以下ツールの影響を受けています。
車輪の再発明かもしれませんが目的がスキルアップなので問題ありません。

開発環境

ここからは開発側の話に入ります。

IDE

まずIDEですが、VSCodeを使っています。

VSCodeを選んだ理由は以下の記事をご覧下さい。

構成管理

構成管理にはdepという依存関係管理ツールを使っています。
PythonでいうPipenvのようなものです。

Go言語1.11から導入されたmodulesを使わない理由は、まだ不安定だからです。
安定してきたら乗り換えると思います。公式ですからね。

depを使えばdep ensureと実行するだけでプロジェクト特有の環境を構築できます。
依存関係の追加はdep ensure --add ...です。

レシピとメモ

レシピのような形で学んだ事を簡潔にまとめてみました。
丁寧な説明ではなくメモに近いです。

GitHub APIを利用する

go-githubというライブラリを使いました。

depコマンド
$ dep ensure --add github.com/google/go-github/github
go-githubを追加できない...

指定がgo-github/githubではなくgo-githubになっていないかを確認してください。

  • OK: dep ensure --add github.com/google/go-github/github
  • NG: dep ensure --add github.com/google/go-github

OAUth2認証を利用する

GitHub APIを使用する際にOAuth2認証をするため、以下のライブラリを使用しています。

depコマンド
$ dep ensure --add golang.org/x/oauth2

tomlファイルから設定を読み込む

tokenをはじめとした各種設定をtomlで読み込むため、以下のライブラリを使用しています。

depコマンド
$ dep ensure --add github.com/BurntSushi/toml

ファイルから読み込むためにtoml.DecodeFileを使いました。

config.go
package main

import (
    "path/filepath"

    "github.com/BurntSushi/toml"
    homedir "github.com/mitchellh/go-homedir"
    "github.com/pkg/errors"
)

// Service is information of Github, Bitbucket, and so on.
type Service struct {
    Token    *string
    UserName *string
    Password *string
    BaseURL  *string
    Prefix   *string
}

// Config configuration
type Config struct {
    Editors         map[string]string
    Browser         string
    Root            string
    GitHub          Service
    BitbucketServer Service
}

// CreateConfig creates configurations from .gowlconfig(toml)
func CreateConfig() (Config, error) {
    home, err := homedir.Dir()
    if err != nil {
        return Config{}, errors.Wrap(err, "Home directory is not found.")
    }

    configPath := filepath.Join(home, ".gowlconfig")

    var conf Config
    if _, err := toml.DecodeFile(configPath, &conf); err != nil {
        return Config{}, err
    }

    return conf, nil
}

API結果のjsonを構造体に変換する

GitHub以外にもBitbucket Serverに対応させる必要がありました。
しかし、GitHubのように著名なライブラリが無かったためClientを自作しました。
その際、できるだけ楽にjsonを構造体として扱う方法を調べてみました。

なぜBitbucket Serverに対応させる必要があったのか?
職場でBitbucket Serverを使用しており、仕事でも使用したかったからです。

jsonから構造体定義を作成する

jsonと睨めっこして構造体定義をするほど暇ではありません。
楽をする方法をいくつか紹介します。

JSON-to-Go

一番簡単な方法で、JSON-to-GOというサイトを使います。
サイトを開いてjsonを左側に貼り付けてみて下さい。

右に定義が出現しましたね! 素晴らしい!

Paste JSON as Code

VS Codeを使っているならオススメです。
先日の記事で紹介しましたのでそちらをご覧下さい。

上記にある通り、quicktypeを直接利用してもOKですね。

jsonを構造体に変換する

変換は簡単です。
httpクライアントから取得した結果(res)のBodyを取り出し、デコード関数に構造体インスタンスを渡すだけです。

import (
    "encoding/json"
    "net/http"
)

// ....

res, err := client.Get(url)
if err != nil {
    panic(err)
}
defer res.Body.Close()

var r BitbucketRepositoryResponse
json.NewDecoder(res.Body).Decode(&r)

BitbucketRepositoryResponseは先ほどjsonから作成した構造体です。
その定義は例えば以下のようになります。

// BitbucketRepositoryResponse is struct of a API response
type BitbucketRepositoryResponse struct {
    Size       int64                 `json:"size"`
    Limit      int64                 `json:"limit"`
    IsLastPage bool                  `json:"isLastPage"`
    Values     []BitbucketRepository `json:"values"`
    Start      int64                 `json:"start"`
}

タグにjsonのプロパティを指定すると、構造体のフィールドに紐付けることができます。

Basic認証を利用する

Bitbucket Serverとの認証にはBasic認証を使う必要があります。

http.Getを直接呼び出さず、作成したリクエストに対してBasic認証情報をセットしてやるだけです。

req, err := http.NewRequest("GET", url, nil)
if err != nil {
    panic(err)
}

req.SetBasicAuth(username, password)

client := &http.Client{}
res, err := client.Do(req)

コマンドライン引数を渡す

CLIツールなので当然引数が必要です。
以前紹介したflagモジュールではなくdocoptを使います。

docoptを使うとgowlの引数取り扱い部分を以下のように分離できます。

args.go
package main

import (
    "github.com/docopt/docopt-go"
    "github.com/pkg/errors"
)

const version = "0.2.0-alpha"
const usage = `Gowl.

Usage:
  gowl get [-f | --force] [-r | --recursive] [-s | --shallow] [-B | --bitbucket-server]
  gowl edit [-e=<editor> | --editor=<editor>]
  gowl web
  gowl list
  gowl -h | --help
  gowl --version

Options:
  -e --editor=<editor>        Use editor [default: default]
  -f --force                  Force remove and reclone if exists
  -r --recursive              Clone recursively
  -s --shallow                Use shallow clone
  -B --bitbucket-server       Use Bitbucket Server
  -h --help                   Show this screen.
  --version                   Show version.
  `

// Args created by CLI args
type Args struct {
    CmdGet  bool `docopt:"get"`
    CmdEdit bool `docopt:"edit"`
    CmdWeb  bool `docopt:"web"`
    CmdList bool `docopt:"list"`

    Editor          string `docopt:"--editor"`
    Force           bool   `docopt:"--force"`
    Recursive       bool   `docopt:"--recursive"`
    Shallow         bool   `docopt:"--shallow"`
    BitbucketServer bool   `docopt:"--bitbucket-server"`
}

// CreateArgs creates Args
func CreateArgs(usage string, argv []string, version string) (Args, error) {
    parser := &docopt.Parser{
        HelpHandler:  docopt.PrintHelpOnly,
        OptionsFirst: false,
    }

    opts, err := parser.ParseArgs(usage, argv, version)
    if err != nil {
        return Args{}, errors.Wrap(err, "Fail to parse arguments.")
    }

    var args Args
    opts.Bind(&args)

    return args, nil
}

Usageのように指定して実行すると、その内容がArgsに取り込まれます。
これを別のファイル(main.goなど)から以下のように呼び出すわけです。

args, err := CreateArgs(usage, os.Args[1:], version)
if err != nil {
    log.Fatal(errors.Wrap(err, "Fail to create arguments."))
}
depコマンド
$ dep ensure --add github.com/docopt/docopt-go@master
なぜflagではなくdocoptを使うのか?

複雑な組み合わせを容易にバリデーションできるからです。

コマンドが複雑になればなるほど、if文による制御では考慮漏れが生じます。
しかし、docoptはUsageに一致しないパターンをエラーと判定できるため処理をシンプルに保つことができます。

&docopt.Parserが解決しない

以下のように依存関係の追加コマンドから@masterが抜けている可能性があります。

$ dep ensure --add github.com/docopt/docopt-go

上記でインストールされるのは執筆時点でv0.6.2です。
しかし、GitHubのmasterは更に先を行っているため&docopt.ParserなどのIFが存在しません。

masterブランチを指定して追加してみましょう。

対話型の実現

初めは非対話式にしていましたが、キーワード検索が予期した結果になるとは限りません。
また、検索結果を表示した後に改めて指定するのも面倒です。

survey.v1というライブラリを使って対話型を実現します。

他の対話型CLIを実現するライブラリも検討しましたが以下の理由で断念しました。

  • Windowsだと表示がおかしくなる
  • 上手く動かない
  • 多機能すぎて実装のコスパが悪い
depコマンド
$ dep ensure --add gopkg.in/AlecAivazis/survey.v1

外部コマンドを実行する

CLIでは実際にgitなどのコマンドを実行します。
exec.Commandを使用しますが、以下の様な関数を定義して使っています。

func execCommand(workdir *string, name string, arg ...string) error {
    cmd := exec.Command(name, arg...)
    if workdir != nil {
        cmd.Dir = *workdir
    }
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    return cmd.Run()
}

以下は呼び出しの一例です。

if err := execCommand(nil, "git", "commit", "-m", "hogehoge"); err != nil {
    return errors.Wrap(err, "Fail to clone "+url)
}
execCommandの引数にスペースが含まれると正しく動作しない...

トークンの切れ目を表す場合は別の引数を指定して下さい。
引数arg ...stringは1つ1つの要素がコマンドと見なされます。

ソース 解釈されるコマンド
execCommand(ni., “git”, “clone hoge”) git "clone hoge"
execCommand(ni., “git”, “clone”, “hoge”) git clone hoge
cdコマンドを実行してもgowl終了後にカレントディレクトリが移動していない...

Go言語で呼び出し元ターミナルのカレントディレクトリを変更することはできません。
なぜならgowlはターミナルから呼び出されたプロセスであり、ターミナルは親プロセスにあたるからです。

コマンドの出力結果や入力待ちが表示されない場合は...

コマンドの標準入出力にOSの入出力が割り当てられていることを確認してください。
たとえば以下のような記述があるかどうかです。

cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

スピナーを表示する

最後に..GitHubと通信中は少し待ち時間が発生するので退屈しない演出を入れてみました。
スピナーを表示するライブラリを使用します。

depコマンド
$ dep ensure --add github.com/briandowns/spinner

43種類も選べるのでテンション上がりますが、Windowsでもちゃんと表示されるモノを選ぶ必要があります。
gowlでは35番を使用しています。

s := spinner.New(spinner.CharSets[35], 100*time.Millisecond)
s.Color("fgHiGreen")
s.Start()
repos, err := handler.SearchRepositories(word)
s.Stop()

ハマッたところ

その他ハマッたところを2つほどご紹介します。

WindowsとLinuxのセパレータが混ざる

filepath.Joinを使用しましょう。
path.Joinを使用していたため発生した問題でした。

Interfaceの実装を認識してくれない

pointer receiverを使用している場合はInterfaceに値ではなく参照を返す必要があります。
ちょっと古いですが以下の記事が分かりやすいです。

gowlでの実装例を一部抜き出してみました。

pointer receiverとInterfaceを使う例
type IHandler interface {
    SearchRepositories(word string) ([]Repository, error)
    GetPrefix() string
}

type BitbucketServerHandler struct {
    client *BitbucketClient
    prefix string
}

// pointer receiverを使用している
func (h *BitbucketServerHandler) GetPrefix() string {
    return h.prefix
}

// pointer receiverを使用している
func (h *BitbucketServerHandler) SearchRepositories(word string) ([]Repository, error) {
    res, err := h.client.searchRepositories(word)
    if err != nil {
        return nil, errors.Wrap(err, "Fail to search repositories.")
    }

    var repos []Repository
    for _, bsrepo := range res.Values {
        var r Repository
        repos = append(repos, *r.fromBitbucketServer(&bsrepo))
    }

    return repos, nil
}


func NewBitbucketServerHandler(config Config) IHandler {
    // BitbucketServerHandlerはpointer reciverを使用しているので参照を返す
    return &BitbucketServerHandler{
        client: createBitbucketClient(*config.BitbucketServer.UserName, *config.BitbucketServer.Password, *config.BitbucketServer.BaseURL),
        prefix: *config.BitbucketServer.Prefix,
    }
}
上記の呼び出し元
var handler IHandler
if args.BitbucketServer {
    handler = NewBitbucketServerHandler(config)
} else {
    handler = NewGithubHandler(config)
}

総括

Go言語でGitの構成管理を手助けするCLIを作り、学んだ事をまとめてみました。

呼び出し元シェルのワーキングディレクトリを変更出来ないのは残念ですが、改修しやすい設計にすることができたので今後も機能追加していこうと考えています 😄