git diff の変更を vimdiff で、タブで見たい
やりたいこと
- git difftool コマンドでは vimdiff で差分が見れるが、複数ファイル差分があるときに、順番にしか見られない
- タブ (vim -p) で開いて、差分の確認を行ったり来たりしたい
コード
git-vimdiff-all.py
py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
from typing import List, Tuple
def run_git(args: List[str], cwd: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=cwd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
def get_repo_root() -> str:
p = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if p.returncode != 0:
msg = p.stderr.strip()
if msg == "":
msg = "Not a git repository."
raise RuntimeError(msg)
return p.stdout.strip()
def list_changed_files(repo_root: str, cached: bool) -> List[str]:
args = ["diff", "--name-only"]
if cached:
args.append("--cached")
p = run_git(args, cwd=repo_root)
if p.returncode != 0:
msg = p.stderr.strip()
if msg == "":
msg = "git diff failed"
raise RuntimeError(msg)
files = [line.strip() for line in p.stdout.splitlines() if line.strip()]
return [f.replace("\\", "/") for f in files]
def list_changed_files_between(repo_root: str, right_rev: str, left_rev: str, paths: List[str]) -> List[str]:
args = ["diff", "--name-only", right_rev, left_rev]
if len(paths) > 0:
args.append("--")
args.extend(paths)
p = run_git(args, cwd=repo_root)
if p.returncode != 0:
msg = p.stderr.strip()
if msg == "":
msg = "git diff (rev..rev) failed"
raise RuntimeError(msg)
files = [line.strip() for line in p.stdout.splitlines() if line.strip()]
return [f.replace("\\", "/") for f in files]
def read_text_file(path: str) -> Tuple[str, bool]:
if not os.path.exists(path):
return ("", False)
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return (f.read(), True)
except Exception:
# binary -> empty
return ("", True)
def get_rev_content(repo_root: str, rev: str, relpath: str) -> Tuple[str, bool]:
p = run_git(["show", f"{rev}:{relpath}"], cwd=repo_root)
if p.returncode != 0:
return ("", False)
return (p.stdout, True)
def get_index_content(repo_root: str, relpath: str) -> Tuple[str, bool]:
p = run_git(["show", f":{relpath}"], cwd=repo_root)
if p.returncode != 0:
return ("", False)
return (p.stdout, True)
def get_merge_base(repo_root: str, a: str, b: str) -> str:
p = run_git(["merge-base", a, b], cwd=repo_root)
if p.returncode != 0:
msg = p.stderr.strip()
if msg == "":
msg = "git merge-base failed"
raise RuntimeError(msg)
return p.stdout.strip()
def parse_range(repo_root: str, range_spec: str) -> Tuple[str, str]:
if "..." in range_spec:
parts = range_spec.split("...")
if len(parts) != 2:
raise RuntimeError(f"Invalid range: {range_spec}")
a = parts[0].strip()
b = parts[1].strip()
if a == "" or b == "":
raise RuntimeError(f"Invalid range: {range_spec}")
base = get_merge_base(repo_root, a, b)
return (base, b)
if ".." in range_spec:
parts = range_spec.split("..")
if len(parts) != 2:
raise RuntimeError(f"Invalid range: {range_spec}")
a = parts[0].strip()
b = parts[1].strip()
if a == "" or b == "":
raise RuntimeError(f"Invalid range: {range_spec}")
return (a, b)
raise RuntimeError(f"Invalid range (expected '..' or '...'): {range_spec}")
def write_blob_to(path: pathlib.Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", errors="replace")
def vim_escape_for_cmd(s: str) -> str:
return s.replace("\\", "\\\\").replace(" ", "\\ ")
def build_vim_commands(pairs: List[Tuple[str, str, str]]) -> List[str]:
cmds: List[str] = []
cmds.append("set hidden")
cmds.append("set showtabline=2")
cmds.append("set laststatus=2")
cmds.append("set diffopt+=vertical")
left0, right0, label0 = pairs[0]
cmds.append(f"file {vim_escape_for_cmd(label0 + ' [target]')}")
cmds.append(f"vert diffsplit {vim_escape_for_cmd(right0)}")
cmds.append(f"file {vim_escape_for_cmd(label0 + ' [base]')}")
cmds.append("wincmd h | diffthis")
cmds.append("wincmd l | diffthis")
cmds.append("wincmd h")
for left, right, label in pairs[1:]:
cmds.append(f"tabnew {vim_escape_for_cmd(left)}")
cmds.append(f"file {vim_escape_for_cmd(label + ' [target]')}")
cmds.append(f"vert diffsplit {vim_escape_for_cmd(right)}")
cmds.append(f"file {vim_escape_for_cmd(label + ' [base]')}")
cmds.append("wincmd h | diffthis | wincmd l | diffthis | wincmd h")
cmds.append("tabfirst")
return cmds
def main() -> int:
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument("--cached", action="store_true", help="diff staged (index) vs base")
parser.add_argument("--base", default="HEAD", help="base revision for working-tree/index mode (default: HEAD)")
parser.add_argument("--left", help="compare rev vs rev: left (changed / newer) side")
parser.add_argument("--right", help="compare rev vs rev: right (base / older) side")
parser.add_argument("--range", dest="range_spec", help="compare rev vs rev using range (A..B or A...B)")
parser.add_argument("paths", nargs="*", help="optional pathspecs (like: -- path/to/dir) for rev-vs-rev mode")
args = parser.parse_args()
try:
repo_root = get_repo_root()
except RuntimeError as e:
print(str(e), file=sys.stderr)
return 2
is_rev_mode = (args.left is not None) or (args.right is not None) or (args.range_spec is not None)
right_rev = ""
left_rev = ""
try:
if is_rev_mode:
if args.cached:
print("Error: --cached is not supported with rev-vs-rev mode.", file=sys.stderr)
return 2
if args.range_spec is not None:
right_rev, left_rev = parse_range(repo_root, args.range_spec)
else:
left_rev = args.left if args.left is not None else "HEAD"
right_rev = args.right if args.right is not None else "HEAD"
files = list_changed_files_between(repo_root, right_rev, left_rev, args.paths)
else:
if len(args.paths) > 0:
print("Note: pathspec filtering is currently supported only in rev-vs-rev mode.", file=sys.stderr)
files = list_changed_files(repo_root, cached=args.cached)
except RuntimeError as e:
print(str(e), file=sys.stderr)
return 2
if len(files) == 0:
print("No diffs.")
return 0
tmpdir = tempfile.mkdtemp(prefix="git-vimdiff-all-")
try:
pairs: List[Tuple[str, str, str]] = []
for rel in files:
if is_rev_mode:
target_content, target_exists = get_rev_content(repo_root, left_rev, rel)
base_content, base_exists = get_rev_content(repo_root, right_rev, rel)
else:
base_content, base_exists = get_rev_content(repo_root, args.base, rel)
if args.cached:
target_content, target_exists = get_index_content(repo_root, rel)
else:
wt_path = os.path.join(repo_root, rel)
target_content, target_exists = read_text_file(wt_path)
target_path = os.path.join(tmpdir, "target", rel)
base_path = os.path.join(tmpdir, "base", rel)
if target_exists:
write_blob_to(pathlib.Path(target_path), target_content)
else:
write_blob_to(pathlib.Path(target_path), "")
if base_exists:
write_blob_to(pathlib.Path(base_path), base_content)
else:
write_blob_to(pathlib.Path(base_path), "")
pairs.append((target_path, base_path, rel))
vim_script_path = os.path.join(tmpdir, "open_all_diffs.vim")
vim_cmds = build_vim_commands(pairs)
with open(vim_script_path, "w", encoding="utf-8") as f:
for c in vim_cmds:
f.write(c + "\n")
left0 = pairs[0][0] # target
argv: List[str] = ["vim", "-n", left0, "-S", vim_script_path]
subprocess.run(argv, cwd=repo_root, check=False)
return 0
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())
以下広告