Gitで実行可能バイナリファイルを排除する（すべての実行可能ファイルの名前を.gitignoreファイルに追加するスクリプトを書いた話）


## 動機
競プロで書き散らかしたコードを雑にGitで管理したい。

コード（とサンプルケースのデータ）は残したいけれど、コンパイルして出来たバイナリの実行可能ファイルはどちらかというとリポジトリから排除したい…

どうしてGitには実行可能ファイルを排除する仕組みがないんだろう？ （そう何度も思ってはGitはそういうものだと諦めてきた）

せめて実行可能ファイルに`.EXE`などの拡張子が付いていれば`.gitignore`で一括指定することもできるのだけれど、わざわざそのために実行可能ファイルに拡張子を追加したくはない。

## というわけで、

ディレクトリツリー（デフォルトでカレントディレクトリ以下）を走査し、すべてのディレクトリ実行可能ファイルの名前を`.gitignore`（なければ作成）に追加するスクリプトを書いた話です。

各ディレクトリに`.gitignore`を書き出すモード（デフォルト）と、走査するディレクトリツリーの一番上（カレントディレクトリとか）の`.gitignore`に書き出す`--put-together`オプションがあります。

すでに`.gitignore`に含まれている場合には追加されません。（`.gitignore`の内容を一旦正規表現に変換することで実現しています。`.`→`\.`、`*`→`.*`程度の変換はしています。）

## 動作環境
実行可能ファイルがMach-OやELFバイナリな環境を想定しています。
macOS上のPython2.7/3.6で動作確認しています。
`six`と`click`がなければpip installしてください。

## 実行方法
```
$ python add_executable_files_to_gitignore.py .
```
などと（普通に）実行してください。
引数の`.`は走査対象となるディレクトリで、無指定だとカレントディレクトリを走査します。指定可能なオプションは以下のとおりです。

| オプション | 概要 |
|:-----------|:------------|
| `--put-together` | 個々のサブディレクトリにではなく、ディレクトリツリーの根本1箇所の.gitignoreにすべてのファイルを追記する |
| `--use-path` | `--put-together` オプションと共に使い、.gitignoreに記載するファイル名をそこまでのpathを含めた形にする|
| `--dry-run` | 実際には実行せず、ただ何が起こるか見てみたい時に使う |

```add_executable_files_to_gitignore.py
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
#
# add executable files to .gitignore in each directory (create if not exists)
#
from __future__ import print_function
import os
import re
import click
import six


def get_signature(path):
    try:
        with open(path, 'rb') as f:
            data = f.read(4)
    except Exception:
        return False

    if six.PY2:
        data = [ord(x) for x in data]
    return ''.join(['%02x' % x for x in data])


def is_ELF(signature):
    return signature == '7f454c64'


def is_MachO(signature):
    return bool(re.match(r'(cffaedfe|cefaedfe|feedface|feedfacf|cafebabe)', signature))


def parse_gitignore(gitignore_path):
    with open(gitignore_path, 'r') as f:
        lines = f.readlines()

    tmp = []
    for line in lines:
        line = line.rstrip()
        line = re.sub(r'#.*$', '', line)
        line = re.sub(r'[ \t]*', '', line)
        if line == '':
            continue
        if '.' in line:
            line = line.replace('.', '\\.')
        if '*' in line:
            line = line.replace('*', '.*')
        tmp.append(line)

    exp = '^(' + '|'.join(tmp) + ')$'

    return re.compile(exp)


def append_dry_run(path, files, redirect='>'):
    if files:
        print('%s %s' % (redirect, path))
        for filename in files:
            print('  + %s' % filename)


def append_actual(path, files, mode='a', verbose=False):
    with open(path, mode) as f:
        for filename in files:
            if verbose:
                print('  +', filename)
            f.write(filename)
            f.write('\n')


def add_files_to_gitignore(dir, files, gitignore='.gitignore', dry_run=False):
    gitignore_path = os.path.join(dir, gitignore)
    if os.path.exists(gitignore_path):
        print(gitignore_path, 'exists')

        rx = parse_gitignore(gitignore_path)
        if dry_run:
            print(rx.pattern)

        to_be_appended = []
        for filename in files:
            if rx.match(filename):
                print('  -', filename, '(already ignored)')
            else:
                print('  +', filename)
                to_be_appended.append(filename)
    else:
        print(gitignore_path, 'does not exist')
        to_be_appended = files

    to_be_appended = sorted(list(to_be_appended))

    if dry_run:
        append_dry_run(gitignore_path, to_be_appended)
    else:
        append_actual(gitignore_path, to_be_appended, verbose=True)


def add_executable_files_to_gitignore(dir='.', put_together=False, use_path=False, dry_run=False):
    executable_files = set()

    for curDir, dirs, files in os.walk(dir):
        print('[%s]' % curDir)

        for file in files:
            path = os.path.join(curDir, file)

            if not os.access(path, os.X_OK):
                continue

            signature = get_signature(path)
            if is_ELF(signature) or is_MachO(signature):
                if put_together and use_path:
                    executable_files.add(path)
                else:
                    executable_files.add(file)

        if not put_together and executable_files:
            add_files_to_gitignore(curDir, executable_files, dry_run=dry_run)
            executable_files = set()

    if put_together:
        add_files_to_gitignore(dir, executable_files, dry_run=dry_run)


@click.command()
@click.argument('dir', type=click.Path(), default='.')
@click.option('--put-together', is_flag=True, default=False)
@click.option('--use-path', is_flag=True, default=False)
@click.option('--dry-run', is_flag=True, default=False)
def main(dir, put_together, use_path, dry_run):
    add_executable_files_to_gitignore(dir, put_together=put_together, use_path=use_path, dry_run=dry_run)


if __name__ == '__main__':
    main()
```

## あとがき
それ○○でできるよ？的な有識者コメント歓迎です。