Skip to main content

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())

以下広告