Subversionで日本語ファイル名(unicode)のNFC/NFD問題をやり過ごすスクリプト

# 動機

古くからOSX/macOSのクライアントも含めて運用しているSubversionのレポジトリで、`svn status`したときに日本語の濁点や半濁点を含むファイル名に関して正しくステータスが表示されないので、本当に必要な情報が大量のゴミメッセージに埋もれてしまうので、なんとかしたい。
ゴミメッセージのパターンとしては、

```bash:症例1
% svn status
...
? somewhere/...パとかバとかを含むファイル名.ext
...
! somewhere/...パとかバとかを含むファイル名.ext
...

```
のように、(可読文字としては)同名のファイルで、

```bash:svnのヘルプより
'?' item is not under version control
'!' item is missing (removed by non-svn command) or incomplete
```
の両方のステータスフラグをついた2行が表示される症例と、

```bash:症例2
% svn status
...
! somewhere/...パとかバとかを含むファイル名.ext
...
% ls 'somewhere/...パとかバとかを含むファイル名.ext'
somewhere/...パとかバとかを含むファイル名.ext
(エラーなし)
```

のように、'!'(missing)のステータスフラグ付きで表示されるが、実際にはちゃんとファイルが存在している場合のパターンが症例があった。

OSX/macOSやsubversionの色々なバージョンによるコミットの履歴があって問題の根本解決は難しそうなので、とりあえず、`svn status`の出力でゴミメッセージを捨てて、それ以外のメッセージを抽出するスクリプトを書くことにした。`python3`で実装してみた。

# ファイル置き場

https://github.com/nanigashi-uji/skim_svn_status.git
https://gitlab.com/nanigashi_uji/skim_svn_status.git

# 使い方

```tcsh:実行例
% ./bin/skim_svn_status3 -h

usage: skim_svn_status3.py [-h] [-v] [-i IGNORE_PATTERN] [-m] [-g] [-p] [svnarguments ...]

Skim svn status output to dealing with filename issue on macOS.

positional arguments:
  svnarguments          Arguments for svn command

options:
  -h, --help            show this help message and exit
  -v, --verbose         Show verbose output
  -i IGNORE_PATTERN, --ignore-pattern IGNORE_PATTERN
                        Add file name pattern to ignore (regular expression)
  -m, --ignore-msoffice-tmp
                        Ignore temporary file by MS-Office ~$*.(docx|xlsx|pptx)
  -g, --ignore-dot-git-dir
                        Ignore .git directory
  -p, --ignore-python-site-packages-dir
                        ../lib/python/site-package/?.?...
```

# 機能

標準モジュールしか使用していないのであまり意味ないですが、[以前つくった枠組み](https://qiita.com/Nanigashi_Uji/items/66c295485fd46f445458 "可搬的なpythonツールのためのスケルトン作成ユーティリティ")を利用しました。

コードはあとにつけますが、`subprocess.Popen()`で、`svn status`を呼んで、前述の`'?'`フラグと`'!'`フラグの両方が表示された場合と`'!'`フラグが表示されるが実際にはファイルが存在する場合には表示させないようにさせてます。`-v`オプションを指定した場合には、行の先頭に`Skip:`, `Fake-!:`, `Ignore:`をつけて表示することで確認できるようにしました。(これを利用して`grep`で弾くこともできます。)

`svn status`で表示される各行の情報を、ファイル名を`unicode.normalize()`で`NFD`に統一したものをキーとした`dict`に入れることで, `NFC/NFD`の両方があるかもしれないエントリーを検知してます。実際にファイルが存在するかどうかは、ファイル名を`NFC`,`NFD`に変換した両方で確認するようにしています。

追加の機能として、MS Officeの一次ファイル(`~$.*.(docx|pptx|xlsx)`)をフィルタリングするとか、`-i`オプション引数で与えられた正規表現に該当するファイル名もフィルタリングする、といったこともできるようにしました。

# コード

全部標準モジュールですが、呼んでるコードだけ見てどのモジュールかがわかるようにするために、あえて`import .... as ....`は使ってません。
`python`スクリプトとしてはお行儀の悪い書き方でなんでしょうが....。


```python:実際のコード
#!/bin/env python3
# -*- coding: utf-8 -*-
#
# skim_svn_status3.py : 
#   - Skim svn status output to dealing with filename issue on macOS. 
#      by Uji Nanigashi (53845049+nanigashi-uji@users.noreply.github.com)
#

import os
import sys
import re
import subprocess
import unicodedata
import argparse

def unicode_norm_filechk(path):
    for frm in ['NFC','NFKC','NFD','NFKD']: 
        if os.path.exists(unicodedata.normalize(frm, path)):
            return True
    return False

def main():
    """
    Skim svn status output to dealing with filename issue on macOS. 
    """
    argpsr = argparse.ArgumentParser(description='Skim svn status output to dealing with filename issue on macOS.')
    argpsr.add_argument('svnarguments', nargs='*', type=str, default=[],  help='Arguments for svn command')
    argpsr.add_argument('-v', '--verbose', action='store_true', help='Show verbose output')
    argpsr.add_argument('-i', '--ignore-pattern', action='append', help='Add file name pattern to ignore (regular expression)')
    argpsr.add_argument('-m', '--ignore-msoffice-tmp', action='store_true', help='Ignore temporary file by MS-Office ~$*.(docx|xlsx|pptx)')
    argpsr.add_argument('-g', '--ignore-dot-git-dir',  action='store_true', help='Ignore .git directory')
    argpsr.add_argument('-p', '--ignore-python-site-packages-dir',  action='store_true', help='../lib/python/site-package/?.?...')
    args = argpsr.parse_args()

    proc = subprocess.Popen(["svn", "status"]+args.svnarguments,
                            stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

    data = {}
    ignore_patterns=[]
    pttrn = re.compile(r'^(?P<vflag>[ ADMRCXI\?\!~])(?P<pflag>[ MC])(?P<dlflag>[ L])(?P<aflag>[ \+])(?P<Pflag>[ S])(?P<flflag>[ KOTB])(?P<uflag>[ *])?(?P<padding>\s*)(?P<fname>\S.*)\s*$')

    if args.ignore_msoffice_tmp:
        ignore_patterns.append(re.compile(r'~\$.*(xlsx?|docx?|pptx?)\s*$'))
    if args.ignore_dot_git_dir:
        ignore_patterns.append(re.compile(r'[\w/].git(/.*)?\s*$'))
    if args.ignore_python_site_packages_dir:
        ignore_patterns.append(re.compile(r'/lib/python/site-packages/\d.*$'))

    if args.ignore_pattern is not None:
        for ipattern in args.ignore_pattern:
            ignore_patterns.append(re.compile(ipattern))

    while True:
        line = proc.stdout.readline()
        if len(line)>0:
            m = pttrn.search(line.rstrip(b'\n').decode())
            if m:
                fn = unicodedata.normalize('NFD', m.group('fname'))
                if data.get(fn) is None:
                    data.update({fn: {'vflag': [ str(m.group('vflag')) ],
                                      'line' : [ str(line.rstrip(b'\n').decode()) ] }})
                else:
                    data.get(fn).get('vflag').append(str(m.group('vflag')))
                    data.get(fn).get('line').append(str(line.rstrip(b'\n').decode()))
            else:
                sys.stderr.write("Unknown statement:"+line.decode())

        if not line and proc.poll() is not None:
            break


    for fn in sorted(data.keys()):
        if '?' in data.get(fn).get('vflag') and '!' in data.get(fn).get('vflag'):
            if args.verbose:
                print('Skip:  ', fn, data.get(fn).get('vflag'))
            continue
        elif '!' in data.get(fn).get('vflag') and unicode_norm_filechk(fn):
            if args.verbose:
                print('Fake-!:', fn, data.get(fn).get('vflag'))
            continue
        else:
            flg_ignore=False
            for pttrn2 in ignore_patterns:
                if pttrn2.search(fn):
                    flg_ignore=True
                    if args.verbose:
                        print('Ignore:', fn, data.get(fn).get('vflag'))
                    break
            if flg_ignore:
                continue

        for l in data.get(fn).get('line'):
            print(str(l))

if __name__ == '__main__':
    main()

```