From 2d9db745b0362d8b6461e01f6ecc30eddd0a36b9 Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Sat, 10 Oct 2026 10:19:06 +0200 Subject: [PATCH] stage/unstage all actions added --- tests/test_commands.py | 41 +++++++++++++++++++++++++++++++++++++++++ tests/test_git.py | 10 ++++++++++ vdiff/git.py | 13 +++++++++++++ vdiff/render.py | 10 ++++++---- vdiff/state.py | 12 ++++++------ 5 files changed, 76 insertions(+), 10 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index caf9f02..0e5bcce 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,3 +1,5 @@ +import os + import pytest import sublime # the stub, via conftest @@ -599,6 +601,45 @@ def test_stage_all_and_unstage_all_need_something_to_do(monkeypatch): assert messages[-1] == "stage all works on uncommitted changes only" +def watched(monkeypatch, tmp_path, all_mode): + """A working-tree view of a.py and b.py on disk with its fingerprint taken; returns (st, refreshes)""" + for name in ("a.py", "b.py", "index"): + (tmp_path / name).write_text(name) + monkeypatch.setattr(commands.git, "index_path", lambda repo: str(tmp_path / "index")) + refreshes = [] + monkeypatch.setattr(commands, "refresh_listing", lambda view, st: refreshes.append(st)) + st = state.ViewState(str(tmp_path)) + st.files = [change("a.py"), change("b.py")] + st.change, st.all_mode = st.files[0], all_mode + st.fingerprint = commands.render.fingerprint(st) + return st, refreshes + + +def touch(path): + """Push the file's mtime forward, as an edit would""" + stat = path.stat() + os.utime(path, (stat.st_atime, stat.st_mtime + 5)) + + +def test_refresh_if_changed_watches_every_file_of_the_all_files_view(monkeypatch, tmp_path): + st, refreshes = watched(monkeypatch, tmp_path, all_mode=True) + commands.refresh_if_changed(sublime.View(), st) + assert refreshes == [] + touch(tmp_path / "b.py") # not the file under the caret + commands.refresh_if_changed(sublime.View(), st) + assert refreshes == [st] + + +def test_refresh_if_changed_watches_the_shown_file_and_the_index(monkeypatch, tmp_path): + st, refreshes = watched(monkeypatch, tmp_path, all_mode=False) + touch(tmp_path / "b.py") # another file: the single-file view does not show it + commands.refresh_if_changed(sublime.View(), st) + assert refreshes == [] + touch(tmp_path / "index") + commands.refresh_if_changed(sublime.View(), st) + assert refreshes == [st] + + def test_staging_the_only_file_leaves_an_empty_listing(monkeypatch): messages = [] monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text)) diff --git a/tests/test_git.py b/tests/test_git.py index 8ce8aea..a5ffd82 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -97,6 +97,16 @@ def test_stage_all_unstage_all(repo): assert changes["sub/n.py"].status == "?" +def test_index_path_finds_a_worktrees_index(repo, tmp_path_factory): + assert git.index_path(repo) == os.path.join(repo, ".git", "index") + # a worktree's .git is a file pointing into the main repository + tree = str(tmp_path_factory.mktemp("tree") / "wt") + sh(repo, "worktree", "add", "-q", tree) + index = git.index_path(tree) + assert os.path.isfile(os.path.join(tree, ".git")) and os.path.isfile(index) + assert os.path.realpath(index).startswith(os.path.realpath(os.path.join(repo, ".git", "worktrees"))) + + def test_commits(repo): write(repo, "a.py", "def f():\n return 10\n\n\ndef g():\n return 2\n") write(repo, "c.py", "c\n") diff --git a/vdiff/git.py b/vdiff/git.py index fe50659..bd52d27 100644 --- a/vdiff/git.py +++ b/vdiff/git.py @@ -1,4 +1,5 @@ """Git access via subprocess; results are plain tuples, no sublime imports (testable)""" +import functools import os import re import subprocess @@ -115,6 +116,18 @@ def has_head(repo: str) -> bool: return False +@functools.lru_cache(maxsize=None) +def index_path(repo: str) -> Optional[str]: + """Absolute path of the repository's index file, None outside a repository + + A worktree or submodule keeps it outside /.git, which there is a file, not a directory + """ + try: + return os.path.join(repo, run(repo, ["rev-parse", "--git-path", "index"]).strip()) + except (GitError, OSError): + return None + + def base_ref(repo: str) -> str: """HEAD, or the empty tree when there is no commit yet""" return "HEAD" if has_head(repo) else EMPTY_TREE diff --git a/vdiff/render.py b/vdiff/render.py index 7944f49..46abaf3 100644 --- a/vdiff/render.py +++ b/vdiff/render.py @@ -8,7 +8,7 @@ from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple, Union import sublime import sublime_plugin -from . import palette, parse +from . import git, palette, parse from .git import FileChange, STATUS_NAMES from .parse import Block, Line from .state import VIEW_FLAG, ViewState @@ -215,11 +215,13 @@ def caret_index(view: sublime.View) -> int: return view.rowcol(view.sel()[0].begin())[0] - _HEADER_ROWS -def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], Optional[float]]]: - """Cheap "did anything change" key for working-tree mode: mtimes of the file and of the git index""" +def fingerprint(st: ViewState) -> Optional[Tuple[Optional[float], ...]]: + """Cheap "did anything change" key for working-tree mode: mtimes of the shown files and of the git index""" if st.in_commit or st.change is None: return None - return (_mtime(os.path.join(st.repo, st.change.path)), _mtime(os.path.join(st.repo, ".git", "index"))) + files = st.files if st.all_mode else [st.change] + index = git.index_path(st.repo) + return tuple(_mtime(os.path.join(st.repo, c.path)) for c in files) + (_mtime(index) if index else None,) def _mtime(path: str) -> Optional[float]: diff --git a/vdiff/state.py b/vdiff/state.py index 43b2b89..ad5f2f8 100644 --- a/vdiff/state.py +++ b/vdiff/state.py @@ -48,12 +48,9 @@ class ViewState: token: int = 0 # bumped per show request; a stale async result is dropped rendered_token: int = -1 # token of the request that produced the current render highlight: int = 0 # bumped per quick-panel highlight, to debounce previews - # (file mtime, index mtime) at render time, working-tree mode; either mtime is None when unreadable - fingerprint: Optional[Tuple[Optional[float], Optional[float]]] = None - # all-files view: diffs cached so collapse/expand is instant + fingerprint: Optional[Tuple[Optional[float], ...]] = None # all-files view: diffs cached so collapse/expand is instant all_mode: bool = False all_diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]] = field(default_factory=dict) - # path -> {(old_no, new_no): [(start, end, colour)]}: syntax colours per row, offsets within the line all_colours: Dict[str, Dict[Tuple[Optional[int], Optional[int]], List[Tuple[int, int, str]]]] = field(default_factory=dict) collapsed: set = field(default_factory=set) all_rows: List[Tuple[Optional["FileChange"], Optional["Line"], bool]] = field(default_factory=list) @@ -94,8 +91,11 @@ class ViewState: def set_all_diffs(self, diffs: Dict[str, Tuple[List["Line"], List["Block"], bool]]) -> None: """Replace the all-files diffs; a file's cached colours survive only if its diff is unchanged""" - self.all_colours = {path: colours for path, colours in self.all_colours.items() - if path in diffs and path in self.all_diffs and diffs[path][0] == self.all_diffs[path][0]} + self.all_colours = { + path: colours + for path, colours in self.all_colours.items() + if path in diffs and path in self.all_diffs and diffs[path][0] == self.all_diffs[path][0] + } self.all_diffs = diffs def persist(self, view: "sublime.View") -> None: