JavaScript
Git
Node.js

Nodeでgitのラッパーを書く(1)---branch, ls-file, config, log, status

概要

Windows/Linux/OS X、どんな環境でも動くBash Likeなスクリプトをを書ける「shelljs」はどうだろうか?のサンプルでいきなりgitを走らせてごめんなさいをしているのがあったのであると便利かなと思って書いてみた。
gitについては初心者であるがラッパーを書くことによってmanを読んだり勉強になった。

使い方サンプル

gitSample.js
const git=require('git');
// return []
console.log(git.branch.array());
// return string
console.log(git.branch.get());
// return []
console.log(git.file.array());
// return {}
console.log(git.file.stat());
// return {}
console.log(git.config.json());
// return {}
console.log(git.log.json());
// return []
console.log(git.log.array());
// return []
console.log(git.status.ignored());
// return []
console.log(git.status.deleted());
// return []
console.log(git.status.modified());
// return []
console.log(git.status.untracked());

実装方法

shelljsは内部で一切execを使っていないが、gitインストール済みならコマンドの結果をゴリゴリjsonなりarrayなりにするのがいいのですべてgit XXX (-opt)の結果を文字列として受け取って書き換えています。オプションは複雑性を増すだけなので今は渡せないようにしています。
名前も完全一致を目指すよりは結果がわかりやすい名付けにしました。

エントリーポイン卜

git.js
const branch=require('./git/branch');
const file=require('./git/file');
const config=require('./git/config');
const log=require('./git/log');
const status=require('./git/status');

const git={
    branch: branch,
    file: file,
    config: config,
    log: log,
    status: status,
}

module.exports=git;

ディレクトリを含むファイル名とモジュール名が一致するよにしています。実装はありません。

モジュールファイル

git/status.js
const deleted=require('./status/deleted');
const modified=require('./status/modified');
const ignored=require('./status/ignored');
const untracked=require('./status/untracked');

const status={
    deleted: deleted,
    modified: modified,
    ignored: ignored,
    untracked: untracked,
}

module.exports=status;

こちらも関数は書いていません。

実装ファイル

git/status/modified.js
const exec=require('child_process').execSync;

const modified=()=>{
    const out=exec('git status -s').toString();
    return arr=out.split('\n').map(a=>{ return a.split(' ').filter(a=>{ return a.length!==0 }) })
        .filter(a=>{ return a[0]==='M' }).map(a=>{ return a[1] });

}

module.exports=modified;
git/status/ignored.js
const exec=require('child_process').execSync;

const ignored=()=>{
    const out=exec('git status -s --ignored').toString();
    return arr=out.split('\n').map(a=>{ return a.split(' ').filter(a=>{ return a.length!==0 }) })
        .filter(a=>{ return a[0]==='!!' }).map(a=>{ return a[1] });

}

module.exports=ignored;

初めて実装が出てきます。status-sオプションで余計なものを省いています。後は文字列をlineで区切った配列にしてタグとファイル名に分けます。後はタグ名でfilterをかけています。modifiedはM、deletedはD、untrackedは??です。ignoredは--ignoredオプションをつけると!!で出てきます。これは.gitignoredに書いたもののマッチングのみで実際に無視されているファイルはもっとあると思われます。

git/file/array.js
const exec=require('child_process').execSync;

const array=()=>{ return exec('git ls-files').toString().split('\n').filter(a=>{ return a.length!==0 }); }

module.exports=array;
git/file/stat.js
const fs=require('fs');
const array=require('./array');

const stat=()=>{
    const arr=array();
    const json={};
    for( const a of arr ){
        const stat=fs.statSync(a);
        json[a]=stat;
    }
    return json;
}

module.exports=stat;

gitで管理されるファイルはgit ls-fileで取ってこれます。nodeはfs.stats(Sync)でファイルの情報も取得できるのでパスのみのarrayとファイル情報をもったstatを作りました。ファイルパスはjsonのキー値にしました。

git/config/json.js
onst exec=require('child_process').execSync;

const setValue=(json, arr, val)=>{
    if( arr.length===1 ) json[arr.shift()]=val;
    else{
        const key=arr.shift()
        if( json[key]==null ) json[key]={};
        setValue(json[key], arr, val);
    }
}

const json=()=>{
    const out=exec('git config -l').toString();
    const arr=out.split('\n').filter(a=>{ return a.length!==0; }).map(a=>{ return a.split('='); });

    const json={};
    for( const [ key, val ] of arr ){
        const keyChain=key.split('.');
        setValue(json, keyChain, val);
    }
    console.log(json);
}

module.exports=json;

git configコマンドに関するユーティリティです。-lで全情報をリストにしています。結果はJavaScritpライクなMOD.info=valの形式です。一応多重ネストでも解析できるように再帰関数でネストを掘るようにしてあります。

git/branch/array.js
const exec=require('child_process').execSync;

const array=()=>{
    const out=exec('git branch').toString();
    return out.split('\n').map(a=>{ return a.replace('\*', '').trim(); }).filter(a=>{ return a.length!==0; });
}

module.exports=array;
git/branch/get.js
const exec=require('child_process').execSync;

const get=()=>{
    const out=exec('git branch').toString();
    return out.split('\n').filter(a=>{ return a.indexOf('\*')===0; }).map(a=>{ return a.replace('\*', '').trim(); })[0];
}

module.exports=get;

branchはそのまま表示させてガリガリいらない情報を削るだけです。getは*の付いた現在のブランチだけを選んでいます。

git/log/json.js
const exec=require('child_process').execSync;

const json=()=>{
    const out=exec("git log --date=format:'%Y-%m-%dT%H:%M:%S'").toString().split('\n');
    const json={};
    let obj;
    for( const l of out ){
        if( l.indexOf('commit')===0 ){
            const key=l.split(' ').filter(a=>{ return a.length!==0 })[1];
            json[key]={};
            obj=json[key];
            continue;
        }
        console.log(l);
        if( l.indexOf(' ')===0 ) obj['message']=l.trim();

        const arr=l.split(' ').filter(a=>{ return a.length!==0 });
        if( arr.length===0 ) continue;
        console.log(arr);
        if( arr[0].indexOf(':')===arr[0].length-1 ){
            const key=arr[0].replace(':', '');
            if( key==='Date' ) obj[key]=new Date(arr[1]);
            else  obj[key]=arr[1];
        }
    }
    return json;
}

module.exports=json;

ログは複数行にまたがって情報が出てくるのでcommit ${KEY}が出てくるまで同じオブジェクトを見るようにしました。メッセージは最初に空白行が入るのでそれで判定しています。時間情報は--dete=formatオプションで標準に近い形にしてDateオブジェクトで解析させています。JavaScriptのDateオブジェクトはいろいろ使いづらいですがそれなりに優秀です。
ログ系は複数人のcommitがある環境とかで試していないので多分不備があります。

まとめ

現在の実装は以上です。大体副作用のない比較的安全なメソッドを中心に実装をはじめました。この記事を書くきっかけのshelljsとは全く思想が違いexecSyncを使ってその結果をJavaScriptで扱いやすい形式に変える方針にしました。シェルスクリプトの問題はマルチプラットホームでない以外にも型がなくすべて文字列で扱うというインターフェイスの貧弱さもあると思います。JavaScriptは一見型が無いようですが、弱い動的付けなので型は存在します。そしてその弱い動的型付けが配列や連想配列(オブジェクト)の入れ子を可能にしJsonという強力なファイル形式を生み出しました。
型のないシェルとJavaScriptでは変数の表現力が段違いです。そこを上手く使えるようになるとユーティリティの利便性を格段に上げられます。shelljsも多分、シェルスクリプト以上のポテンシャルは秘めていると思います。

to do

今回は副作用の少ない検索系コマンドに絞ったが次回以降でcommitpushなどもやっていきたい。
せっかくgit使ってるんだからgithubにpushしてみたい。
...実はこのモジュールはgit管理ではありません。nodeが自動的にパスを通してくれた.node_modulesに便利系モジュールをガツガツ書いていたら特定のコマンドだけ切りだすのが難しくなって放置状態です。きちんとこのモジュールだけ切り出してgit管理にしたいです。