branches view initial version

This commit is contained in:
Vaclav Tvrdik
2026-10-13 13:54:51 +02:00
parent cf120991bc
commit c894d925a1
20 changed files with 3002 additions and 236 deletions

View File

@@ -23,3 +23,25 @@ def scheme_override_sandbox(tmp_path, monkeypatch):
palette.reset()
monkeypatch.setattr(state, "STATES", {})
monkeypatch.setattr(sublime, "packages_path", lambda: str(tmp_path))
@pytest.fixture
def rings(monkeypatch):
"""Record visual bells instead of drawing them"""
import sublime # noqa: F401 (the stub must be importable before vdiff.render)
from vdiff import render
bells = []
monkeypatch.setattr(render, "flash", lambda view: bells.append(view))
return bells
def put_caret(view, row):
"""Move the stub view's caret to the start of `row` (0 is the first row below the header)"""
import sublime
from vdiff import render
view.sel().clear()
view.sel().add(sublime.Region(render.index_point(view, row)))

View File

@@ -70,6 +70,16 @@ def status_message(text):
pass
# what ok_cancel_dialog answers; a test flips it to model Cancel
dialog_answer = True
dialogs = []
def ok_cancel_dialog(message, ok_title="", title=""):
dialogs.append((message, ok_title))
return dialog_answer
def packages_path():
return "/tmp/Packages"
@@ -146,6 +156,7 @@ class View:
self.regions = {}
self.syntax = None
self.read_only = True
self.changes = 0
self.name_ = ""
self.sel_ = []
self.status = {}
@@ -168,6 +179,10 @@ class View:
def replace(self, edit, region, text):
self.text = self.text[: region.begin()] + text + self.text[region.end() :]
self.changes += 1
def change_count(self):
return self.changes
def run_command(self, name, args=None):
if name == "vdiff_replace":
@@ -252,6 +267,19 @@ class View:
def em_width(self):
return 8.0
def style(self):
# the scheme's globals; no background here, so palette falls back as it did before this was stubbed
return {"line_highlight": "#3b4350"}
def line(self, point):
region = self.full_line(point)
end = region.b - 1 if self.text[region.b - 1:region.b] == "\n" else region.b
return Region(region.a, end)
def viewport_extent(self):
# 100 columns at the stub's em width
return (800.0, 600.0)
def extract_tokens_with_scopes(self, region):
"""One token per run of word characters and one per run of anything else, scoped by its shape

763
tests/test_branches.py Normal file
View File

@@ -0,0 +1,763 @@
import pytest
import sublime # the stub, via conftest
from conftest import put_caret # noqa: E402
from test_render import header, with_loaded_override # noqa: E402
from vdiff import branches, commands, git, listeners, palette, render, state
def ref(name, remote=None, upstream=None, ahead=0, behind=0, gone=False, current=False, sha="abc1234", upstream_remote=""):
"""A Ref; the upstream's remote is read off its name unless given (None for a local upstream)"""
branch = name.partition("/")[2] if remote else name
if upstream_remote == "":
upstream_remote = upstream.partition("/")[0] if upstream else None
return git.Ref(name, branch, remote, sha, upstream, ahead, behind, gone, current, upstream_remote)
def commit(subject, tags=(), files=8, insertions=260, deletions=58, sha="1" * 40):
return git.Commit(sha, sha[:7], "vtvrdik", "20.12.2025", subject, files, insertions, deletions, tuple(tags))
REFS = [
ref("feature"),
ref("main", upstream="origin/main", ahead=1, current=True),
ref("github/main", remote="github"),
ref("origin/main", remote="origin"),
]
COMMITS = [
commit("default file view mode and some colors now configurable in settings", tags=("v1.1.0",), sha="a" * 40),
commit("next commit message", files=None, insertions=None, deletions=None, sha="b" * 40),
]
def make_state(refs=REFS, commits=COMMITS, selected="main", remotes=("origin", "github")):
st = state.ViewState("/repo/vdiff")
st.mode = state.MODE_BRANCHES
st.branch_refs = list(refs)
st.branch_remotes = list(remotes)
st.branch_commits = list(commits)
st.branch_ref = selected
st.branch_head_sha = "abc1234"
return st
def rendered(st=None, live=False):
"""A drawn branches view; `live` models a scheme that has picked up the override, as after a restart"""
view = with_loaded_override(sublime.View()) if live else sublime.View()
render.init_view(view)
st = st or make_state()
branches.render_branches(view, st)
return view, st
def rows(view):
return view.text.split("\n")[1:]
class Calls:
"""Records the git helper calls an action makes; `answer` is what a worker returns"""
def __init__(self, monkeypatch, answer=None):
self.calls = []
self.answer = answer
for name in ("checkout", "checkout_detached", "pull", "fetch_into", "push", "fetch_all", "delete_branch"):
monkeypatch.setattr(branches.git, name, self._record(name))
self.loads = record_loads(monkeypatch)
def _record(self, name):
def call(*args, **kwargs):
self.calls.append((name, args[1:], kwargs))
if isinstance(self.answer, Exception):
raise self.answer
return self.answer
return call
def record_loads(monkeypatch):
"""Replace branches.load with a recorder of (branch, mode, message, keep_caret) per call"""
loads = []
def fake_load(view, st, message=None, keep_caret=False):
loads.append((st.branch_ref, st.mode, message, keep_caret))
monkeypatch.setattr(branches, "load", fake_load)
return loads
@pytest.fixture
def calls(monkeypatch):
return Calls(monkeypatch)
@pytest.fixture
def loads(monkeypatch):
return record_loads(monkeypatch)
@pytest.fixture
def dialogs(monkeypatch):
monkeypatch.setattr(sublime, "dialogs", [])
monkeypatch.setattr(sublime, "dialog_answer", True)
return sublime.dialogs
# --- drawing ----------------------------------------------------------------
def test_two_columns_are_laid_out_as_in_the_mockup():
view, st = rendered()
lines = rows(view)
# the left column is as wide as its longest cell plus the padding; the gap follows it
width = len(" feature") + branches.LEFT_PAD
assert all(line[width:width + 2] == ("├─" if line.endswith("─") else "│ ") for line in lines)
right = 100 - width - 2
# the working tree comes first, five rows like a commit; nothing changed here
assert lines[0] == f"{'BRANCHES':<{width}}│ No Changes"
assert lines[1] == f"{' feature':<{width}}│ "
# main is one commit ahead of its upstream: the count sits on the right edge of its cell
assert lines[2] == f"{'* main':<{width - 3}}↑1 │ "
assert lines[3] == f"{'REMOTES':<{width}}│ "
assert lines[4] == f"{'origin':<{width}}├─" + "─" * right
assert lines[5] == f"{' main':<{width}}│ default file view mode and some colors now configurable in settings"
assert lines[6].startswith(f"{'github':<{width}}│ Author: vtvrdik")
# the tag sits on the right edge of the author row
assert lines[6].endswith("v1.1.0") and len(lines[6]) == width + 2 + right
assert lines[7] == f"{' main':<{width}}│ Date: 20.12.2025"
# the left column ran out, so the cells are blank
assert lines[8] == f"{'':<{width}}│ Stats: 8 files changed -58 +260"
assert lines[9] == f"{'':<{width}}├─" + "─" * right
assert lines[10] == f"{'':<{width}}│ next commit message"
assert lines[11].rstrip() == f"{'':<{width}}│ Author: vtvrdik"
# no diffstat for a merge or an empty commit
assert lines[13] == f"{'':<{width}}│ Stats: (no diffstat)"
assert lines[14] == f"{'':<{width}}├─" + "─" * right
assert len(lines) == 15
assert view.syntax == "scope:text.plain"
assert view.name_ == "⟐ branches"
CHANGES = [
git.FileChange("a.py", "M", False, 1, 1, None, unstaged=True),
git.FileChange("b.txt", "M", True, 1, 0, None),
git.FileChange("c.txt", "M", True, 2, 0, None, unstaged=True), # partially staged: counted on both sides
git.FileChange("img.png", "M", True, None, None, None), # binary: no counts
]
def test_the_working_tree_entry_counts_the_staged_and_unstaged_files_and_their_diffstat():
st = make_state()
st.branch_changes = list(CHANGES)
view, st = rendered(st)
lines = rows(view)
width = len(" feature") + branches.LEFT_PAD
assert lines[0] == f"{'BRANCHES':<{width}}│ 3 staged files, 2 unstaged files"
assert lines[1] == f"{' feature':<{width}}│ Stats: 4 files changed -1 +4"
assert lines[2] == f"{'* main':<{width - 3}}↑1 │ " and lines[3] == f"{'REMOTES':<{width}}│ "
assert lines[4].endswith("─" * 10) and lines[5].endswith("│ default file view mode and some colors now configurable in settings")
# its counts are coloured like the commits', in row order
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_DEL][0]] == ["-1", "-58"]
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["+4", "v1.1.0", "+260"]
# the singular
st.branch_changes = [CHANGES[0]]
branches.render_branches(view, st)
assert rows(view)[0] == f"{'BRANCHES':<{width}}│ 0 staged files, 1 unstaged file"
st.branch_changes = [CHANGES[1]]
branches.render_branches(view, st)
assert rows(view)[0] == f"{'BRANCHES':<{width}}│ 1 staged file, 0 unstaged files"
def test_local_branches_show_their_standing_against_the_upstream():
refs = [ref("level", upstream="origin/level"), ref("ahead", upstream="origin/ahead", ahead=3, current=True),
ref("both", upstream="origin/both", ahead=12, behind=4), ref("lost", upstream="origin/lost", gone=True),
ref("untracked"), ref("origin/level", remote="origin")]
view, st = rendered(make_state(refs=refs, selected="ahead", remotes=("origin",)))
width = len(" both ↑12 ↓4") + branches.LEFT_PAD
cells = [line[:width] for line in rows(view)]
# in the listing's order; level and untracked branches carry nothing, nor do remote ones
assert cells[1:6] == [f"{' level':<{width}}", f"{'* ahead':<{width - 3}}↑3 ", f"{' both':<{width - 7}}↑12 ↓4 ",
f"{' lost':<{width - 5}}gone ", f"{' untracked':<{width}}"]
assert cells[7] == f"{'origin':<{width}}" and cells[8] == f"{' level':<{width}}"
# ahead is green, behind and gone red; the selected branch's standing is painted apart, on its background
def painted(key):
"""The coloured texts under a key"""
return [view.text[r.a:r.b] for r in view.regions[key][0]]
assert painted(render.KEY_BRANCH_STANDING_INS) == ["↑12"] and painted(render.KEY_BRANCH_STANDING_DEL) == ["↓4", "gone"]
assert painted(render.KEY_BRANCH_SEL_INS) == ["↑3"] and painted(render.KEY_BRANCH_SEL_DEL) == []
# moving the selection moves the standing between the keys
branches.ACTIONS["next_branch"](view, None, st)
assert st.branch_ref == "both"
assert painted(render.KEY_BRANCH_STANDING_INS) == ["↑3"] and painted(render.KEY_BRANCH_STANDING_DEL) == ["gone"]
assert painted(render.KEY_BRANCH_SEL_INS) == ["↑12"] and painted(render.KEY_BRANCH_SEL_DEL) == ["↓4"]
# a long name gives way to its counts, never the other way round
long = ref("feature/" + "x" * 60, upstream="origin/x", behind=2)
view, st = rendered(make_state(refs=[ref("main", current=True), long], selected="main"))
cell = rows(view)[2][:branches.LEFT_MAX]
assert cell.endswith("↓2 ") and "…" in cell and len(cell) == branches.LEFT_MAX
def test_rows_know_their_branch_and_commit():
view, st = rendered()
# item 0 is the working tree, the commits follow from 1
assert [(r.ref and r.ref.name, r.commit) for r in st.branch_rows] == [
(None, 0), ("feature", 0), ("main", 0), (None, 0), (None, 0),
("origin/main", 1), (None, 1), ("github/main", 1), (None, 1), (None, 1),
(None, 2), (None, 2), (None, 2), (None, 2), (None, 2),
]
def test_a_remote_without_fetched_branches_keeps_its_row():
view, st = rendered(make_state(refs=[ref("main", current=True)], remotes=("origin", "gitea")))
names = [line[:st.branch_width].rstrip() for line in rows(view)]
assert names[:5] == ["BRANCHES", "* main", "REMOTES", "origin", "gitea"]
def test_long_branch_names_are_clipped_at_the_cap():
long = "feature/" + "x" * 60
view, st = rendered(make_state(refs=[ref("main", current=True), ref(long)], selected="main"))
width = branches.LEFT_MAX
assert rows(view)[2].startswith(f"{' ' + long[:width - 4]}…" + " │ ") or rows(view)[2][width:width + 2] == "│ "
assert rows(view)[2][:width].rstrip().endswith("…")
def test_selection_headings_tags_and_counts_are_painted():
view, st = rendered()
width = len(" feature") + branches.LEFT_PAD
starts = render._row_starts_for_text(rows(view))
selected, scope, _, _ = view.regions[render.KEY_BRANCH_SEL]
assert selected == [sublime.Region(starts[2], starts[2] + width)]
assert scope == render._file_header_scope(view)
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_DEL][0]] == ["-58"]
# the tags are green like the insertions, in row order; main's ↑1 sits on the selected row
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["v1.1.0", "+260"]
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_SEL_INS][0]] == ["↑1"]
assert view.regions[render.KEY_BRANCH_STANDING_INS][0] == []
# filled regions, since only those take the scope's text colour; the scheme's tint stands in until the
# override's red/green-on-background rules are live, after which the text is red / green on the editor
# background, or on the heading background along the selected row so its tint runs through the standing
assert not view.regions[render.KEY_BRANCH_DEL][3] & sublime.DRAW_NO_FILL
assert view.regions[render.KEY_BRANCH_DEL][1] == palette.SCOPE_DEL
assert view.regions[render.KEY_BRANCH_INS][1] == palette.SCOPE_INS
assert view.regions[render.KEY_BRANCH_SEL_INS][1] == palette.SCOPE_INS
live, _ = rendered(live=True)
assert live.regions[render.KEY_BRANCH_DEL][1] == palette.SCOPE_TEXT_DEL
assert live.regions[render.KEY_BRANCH_INS][1] == palette.SCOPE_TEXT_INS
assert live.regions[render.KEY_BRANCH_STANDING_DEL][1] == palette.SCOPE_TEXT_DEL
assert live.regions[render.KEY_BRANCH_SEL_DEL][1] == palette.SCOPE_FILE_DEL_COUNT
assert live.regions[render.KEY_BRANCH_SEL_INS][1] == palette.SCOPE_FILE_INS_COUNT
def test_caret_starts_on_the_working_tree_entry_and_stays_on_its_item_across_a_refresh():
view, st = rendered()
assert view.sel()[0].a == render.index_point(view, 0) and st.branch_rows[0].commit == branches.WORKTREE
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == branches.ROWS_PER_COMMIT
branches.render_branches(view, st, keep_caret=True)
assert render.caret_index(view) == branches.ROWS_PER_COMMIT
# a plain redraw goes back to the top
branches.render_branches(view, st)
assert render.caret_index(view) == 0
def test_no_commits_and_no_branches_still_draw():
view, st = rendered(make_state(refs=[], commits=[], selected=None, remotes=()))
rule = "─" * (100 - len("BRANCHES │ "))
assert rows(view) == ["BRANCHES │ No Changes", "REMOTES │ ", " │ ", " │ ", " ├─" + rule,
" │ (no commits)"]
# the one item to step through is the working tree
assert view.sel()[0].a == render.index_point(view, 0)
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == 0
assert view.regions[render.KEY_BRANCH_SEL][0] == []
assert "no branch selected" in header(view)
def test_render_sets_the_mode_and_persists_the_branch():
view, st = rendered()
assert view.settings().get(state.MODE_SETTING) == "branches"
assert view.settings().get(state.VIEW_DATA) == {"repo": "/repo/vdiff", "sha": None, "mode": "branches", "ref": "main"}
assert view.status[render.STATUS_KEY] == render.key_hints("branches")
assert st.fingerprint == ((), (None,)) # no ref paths and no index outside a repository
# a diff render switches back
st.change = git.FileChange("x.py", "M", False, 1, 1, None)
render.render_message(view, st, "nothing")
assert view.settings().get(state.MODE_SETTING) == "diff"
assert "mode" not in view.settings().get(state.VIEW_DATA)
assert view.status[render.STATUS_KEY] == render.key_hints()
assert render.KEY_BRANCH_SEL not in view.regions
def test_an_unchanged_header_is_not_redrawn():
view, st = rendered()
first = view.phantoms[render.PHANTOM_KEY][0]
# the same header over the same text keeps the phantom in place
render._header(view, st, st.header[0])
assert view.phantoms[render.PHANTOM_KEY] == [first]
# new text may have dropped the phantom, so it is drawn again even for the same header
branches.render_branches(view, st, keep_caret=True)
assert view.phantoms[render.PHANTOM_KEY][0] is not first and len(view.phantoms[render.PHANTOM_KEY]) == 1
def test_header_shows_head_upstream_and_the_links_of_the_selected_kind():
view, st = rendered()
text = header(view)
assert "Branches · vdiff" in text and ">main<" in text and "on <span" in text
# the standing as the cell shows it: a zero count is left out
assert "→ origin/main" in text and '<span class="ins">↑1</span>' in text and "↓0" not in text
st.branch_refs[1] = ref("main", upstream="origin/main", behind=2, current=True)
branches.render_branches(view, st)
assert '<span class="del">↓2</span>' in header(view) and "↑0" not in header(view)
st.branch_refs[1] = ref("main", upstream="origin/main", current=True)
branches.render_branches(view, st)
assert "up to date" in header(view)
st.branch_refs[1] = REFS[1]
branches.render_branches(view, st)
for link in ("checkout", "pull", "push", "fetch", "delete_branch", "files", "commits", "branches", "help"):
assert f'href="{link}"' in text
assert 'href="checkout_detached"' not in text
st.branch_ref = "origin/main"
branches.render_branches(view, st)
text = header(view)
assert "remote branch of origin" in text and 'href="checkout_detached"' in text
assert 'href="delete_branch"' not in text and 'href="pull"' not in text
st.branch_ref = "feature"
st.branch_refs[1] = ref("main", upstream="origin/main", ahead=1) # nothing checked out: detached
branches.render_branches(view, st)
text = header(view)
assert "detached at" in text and "abc1234" in text and "→" not in text
st.branch_head_sha = ""
branches.render_branches(view, st)
assert "no commits" in header(view)
st.branch_refs[0] = ref("feature", upstream="origin/feature", gone=True)
branches.render_branches(view, st)
assert ">gone<" in header(view)
# --- navigation -------------------------------------------------------------
def test_j_and_k_step_commits_and_ring_at_the_ends(rings):
view, st = rendered()
# the working tree, then the two commits
branches.ACTIONS["prev_commit"](view, None, st)
assert len(rings) == 1 and render.caret_index(view) == 0
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == 5
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == 10
branches.ACTIONS["next_commit"](view, None, st)
assert len(rings) == 2 and render.caret_index(view) == 10
branches.ACTIONS["prev_commit"](view, None, st)
assert render.caret_index(view) == 5
# off the items (the caret moved by the arrows onto a left-column-only row): k goes to the last, j to the first
view, st = rendered(make_state(refs=REFS + [ref("x"), ref("y"), ref("z")], commits=COMMITS[:1]))
assert st.branch_rows[10].commit == -1
put_caret(view, 10)
branches.ACTIONS["prev_commit"](view, None, st)
assert render.caret_index(view) == 5
put_caret(view, 10)
branches.ACTIONS["next_commit"](view, None, st)
assert render.caret_index(view) == 0
def test_J_and_K_select_branches_skipping_headings_and_reload_the_log(monkeypatch, rings):
logged = []
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: logged.append(name) or [COMMITS[1]])
view, st = rendered()
assert st.branch_ref == "main"
branches.ACTIONS["prev_branch"](view, None, st)
assert st.branch_ref == "feature" and logged == ["feature"]
branches.ACTIONS["prev_branch"](view, None, st)
assert st.branch_ref == "feature" and len(rings) == 1
for expected in ("main", "github/main", "origin/main"):
branches.ACTIONS["next_branch"](view, None, st)
assert st.branch_ref == expected
branches.ACTIONS["next_branch"](view, None, st)
assert st.branch_ref == "origin/main" and len(rings) == 2
# the stub runs the worker at once, so the log was redrawn with the new selection
assert rows(view)[5].endswith("│ next commit message")
selected = view.regions[render.KEY_BRANCH_SEL][0]
assert view.text[selected[0].a:selected[0].b].strip() == "main"
# the caret went back to the working-tree entry
assert render.caret_index(view) == 0 and st.branch_rows[0].commit == branches.WORKTREE
def test_a_stale_log_is_dropped(monkeypatch):
pending = []
monkeypatch.setattr(branches, "run_async", lambda work, done: pending.append((work, done)))
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: [commit(name)])
view, st = rendered()
branches.ACTIONS["prev_branch"](view, None, st) # feature
branches.ACTIONS["next_branch"](view, None, st) # main
assert [name for name in (st.branch_ref,)] == ["main"] and len(pending) == 2
# the older answer lands first, but the token moved on
work, done = pending[0]
done(work())
assert "│ feature" not in view.text
work, done = pending[1]
done(work())
assert rows(view)[5].endswith("│ main")
def test_load_lists_everything_and_picks_the_branch(monkeypatch):
monkeypatch.setattr(branches.git, "head_sha", lambda repo: "abc1234")
monkeypatch.setattr(branches.git, "ref_paths", lambda repo: ())
monkeypatch.setattr(branches.git, "branches", lambda repo: list(REFS))
monkeypatch.setattr(branches.git, "remotes", lambda repo: ["origin", "github"])
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: [commit(f"log of {name}")])
monkeypatch.setattr(branches.git, "index_path", lambda repo: None)
monkeypatch.setattr(branches.git, "changed_files", lambda repo: [CHANGES[2]])
view = sublime.View()
render.init_view(view)
st = state.ViewState("/repo/vdiff")
branches.load(view, st)
# nothing wanted: the checked-out branch; the working tree above its log
assert st.branch_ref == "main" and rows(view)[5].endswith("│ log of main")
assert rows(view)[0].endswith("│ 1 staged file, 1 unstaged file") and rows(view)[1].endswith("│ Stats: 1 file changed -0 +2")
assert st.branch_changes == [CHANGES[2]]
assert st.branch_remotes == ["origin", "github"] and st.branch_head_sha == "abc1234"
assert st.token == st.rendered_token
# a wanted branch that vanished falls back the same way
st.branch_ref = "deleted"
branches.load(view, st)
assert st.branch_ref == "main"
st.branch_ref = "origin/main"
branches.load(view, st)
assert st.branch_ref == "origin/main" and rows(view)[5].endswith("│ log of origin/main")
def test_pick_prefers_the_wanted_then_the_current_then_the_first_local_then_the_first():
assert branches._pick(REFS, "origin/main").name == "origin/main"
assert branches._pick(REFS, "gone").name == "main"
detached = [ref("feature"), ref("zeta"), ref("origin/x", remote="origin")]
assert branches._pick(detached, None).name == "feature"
assert branches._pick([ref("origin/x", remote="origin")], None).name == "origin/x"
assert branches._pick([], None) is None
# --- actions ----------------------------------------------------------------
def test_checkout_routes_local_current_and_remote_branches(calls):
view, st = rendered()
branches.ACTIONS["checkout"](view, None, st)
assert calls.calls == [] and calls.loads == [] # already on main
st.branch_ref = "feature"
branches.ACTIONS["checkout"](view, None, st)
assert calls.calls[-1] == ("checkout", (REFS[0], ["feature", "main"]), {})
assert calls.loads[-1] == ("feature", "branches", "checked out feature", False)
st.branch_ref = "github/main"
branches.ACTIONS["checkout"](view, None, st)
assert calls.calls[-1][1][0].name == "github/main"
assert st.branch_ref == "main" # the local branch of that name is what got checked out
st.branch_ref = "origin/main"
branches.ACTIONS["checkout_detached"](view, None, st)
assert calls.calls[-1] == ("checkout_detached", ("origin/main",), {})
assert calls.loads[-1][2] == "checked out origin/main detached"
st.branch_ref = "feature"
branches.ACTIONS["checkout_detached"](view, None, st)
assert calls.calls[-1][0] == "checkout_detached" and len(calls.calls) == 3 # refused for a local branch
def test_pull_pulls_the_current_branch_and_fast_forwards_another(calls):
view, st = rendered(make_state(refs=REFS + [ref("tracked", upstream="origin/tracked", behind=2)]))
branches.ACTIONS["pull"](view, None, st)
assert calls.calls[-1] == ("pull", (), {}) and calls.loads[-1][2] == "pulled main"
st.branch_ref = "tracked"
branches.ACTIONS["pull"](view, None, st)
assert calls.calls[-1] == ("fetch_into", ("origin", "tracked", "tracked"), {})
assert calls.loads[-1][2] == "fast-forwarded tracked to origin/tracked"
# the upstream's own name on its remote, not the local one
st.branch_refs.append(ref("foo", upstream="origin/bar", behind=1))
st.branch_ref = "foo"
branches.ACTIONS["pull"](view, None, st)
assert calls.calls[-1] == ("fetch_into", ("origin", "bar", "foo"), {})
st.branch_refs.append(ref("local", upstream="main", upstream_remote=None, behind=1))
for name in ("feature", "origin/main", "local"):
st.branch_ref = name
branches.ACTIONS["pull"](view, None, st)
assert len(calls.calls) == 3 # no upstream, a remote row and a local upstream: all refused
def test_push_uses_the_upstream_or_sets_one_on_origin(calls):
view, st = rendered()
branches.ACTIONS["push"](view, None, st)
assert calls.calls[-1] == ("push", ("origin", "main"), {"remote_branch": "main"}) and calls.loads[-1][2] == "pushed main to origin"
# to the upstream itself when it is called differently there
st.branch_refs.append(ref("foo", upstream="gitea/bar", ahead=2))
st.branch_ref = "foo"
branches.ACTIONS["push"](view, None, st)
assert calls.calls[-1] == ("push", ("gitea", "foo"), {"remote_branch": "bar"})
st.branch_ref = "feature"
branches.ACTIONS["push"](view, None, st)
assert calls.calls[-1] == ("push", ("origin", "feature"), {"set_upstream": True})
# without origin the first remote takes it; without any, nothing happens; nor for a local upstream
st.branch_remotes = ["gitea"]
branches.ACTIONS["push"](view, None, st)
assert calls.calls[-1] == ("push", ("gitea", "feature"), {"set_upstream": True})
st.branch_remotes = []
branches.ACTIONS["push"](view, None, st)
st.branch_ref = "origin/main"
st.branch_remotes = ["origin"]
branches.ACTIONS["push"](view, None, st)
st.branch_refs.append(ref("local", upstream="main", upstream_remote=None, ahead=1))
st.branch_ref = "local"
branches.ACTIONS["push"](view, None, st)
assert len(calls.calls) == 4
def test_fetch_fetches_everything_and_keeps_the_cursor(calls):
view, st = rendered()
branches.ACTIONS["fetch"](view, None, st)
assert calls.calls == [("fetch_all", (), {})] and calls.loads == [("main", "branches", "fetched all remotes", True)]
def test_delete_asks_then_deletes_and_asks_again_for_an_unmerged_branch(calls, dialogs, monkeypatch):
view, st = rendered()
st.branch_ref = "feature"
branches.ACTIONS["delete_branch"](view, None, st)
assert dialogs == [("Delete the branch feature?", "Delete")]
assert calls.calls == [("delete_branch", ("feature",), {})] and calls.loads == [("feature", "branches", "deleted feature", False)]
# not fully merged: a second question, then the forced deletion
calls.answer = git.GitError("error: The branch 'feature' is not fully merged.\n", 1)
del dialogs[:]
branches.ACTIONS["delete_branch"](view, None, st)
assert [d[1] for d in dialogs] == ["Delete", "Delete anyway"]
assert calls.calls[-1] == ("delete_branch", ("feature",), {"force": True})
# Cancel on the first question does nothing; so does a remote or the checked-out branch
monkeypatch.setattr(sublime, "dialog_answer", False)
count = len(calls.calls)
branches.ACTIONS["delete_branch"](view, None, st)
st.branch_ref = "main"
branches.ACTIONS["delete_branch"](view, None, st)
st.branch_ref = "origin/main"
branches.ACTIONS["delete_branch"](view, None, st)
assert len(calls.calls) == count
def test_other_git_errors_from_delete_still_reach_the_status_bar(calls, dialogs, monkeypatch):
messages = []
monkeypatch.setattr(branches, "status", lambda m: messages.append(m))
monkeypatch.setattr("vdiff.utils.status", lambda m: messages.append(m))
calls.answer = git.GitError("fatal: something else\n", 1)
view, st = rendered()
st.branch_ref = "feature"
branches.ACTIONS["delete_branch"](view, None, st)
assert messages[-1] == "fatal: something else" and calls.loads == []
def test_o_reviews_the_commit_under_the_caret():
view, st = rendered()
window = sublime.Window()
# the working-tree entry opens the files view, which itself stays put when there is nothing to list
branches.ACTIONS["open_commit"](view, window, st)
assert window.commands == [("vdiff_files", None)]
branches.ACTIONS["next_commit"](view, window, st)
branches.ACTIONS["open_commit"](view, window, st)
branches.ACTIONS["next_commit"](view, window, st)
branches.ACTIONS["open_commit"](view, window, st)
assert window.commands[1:] == [("vdiff_commits", {"sha": "a" * 40}), ("vdiff_commits", {"sha": "b" * 40})]
# a row without an item (the left column outran the log) opens nothing
view, st = rendered(make_state(refs=REFS + [ref("x"), ref("y"), ref("z")], commits=COMMITS[:1]))
put_caret(view, 10)
branches.ACTIONS["open_commit"](view, window, st)
assert len(window.commands) == 3
def test_branch_actions_refuse_a_diff_view(calls):
view, st = rendered()
st.mode = state.MODE_DIFF
for name in branches.ACTIONS:
branches.ACTIONS[name](view, sublime.Window(), st)
assert calls.calls == [] and calls.loads == []
# --- wiring -----------------------------------------------------------------
def test_b_opens_the_branches_command_and_refresh_reloads_in_place(loads):
view, st = rendered()
window = sublime.Window()
commands.ACTIONS["branches"](view, window, st)
assert window.commands == [("vdiff_branches", None)]
commands.ACTIONS["refresh"](view, window, st)
assert loads == [("main", "branches", None, True)]
def test_restore_view_comes_back_in_branches_mode(loads, tmp_path):
view = sublime.View()
view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "mode": "branches", "ref": "feature"})
st = commands.restore_view(view)
assert loads == [("feature", "branches", None, False)] and view.settings().get(state.MODE_SETTING) == "branches"
# while the load (stubbed here) is in flight an activation does not start a second one
st.token += 1
commands.refresh_if_changed(view, st)
assert len(loads) == 1
def test_esc_from_the_files_panel_goes_back_to_the_branches(monkeypatch, loads):
monkeypatch.setattr(commands, "show_file", lambda view, st, c: None)
view, st = rendered()
kept = st.enter()
assert kept.mode == "branches"
window = sublime.Window()
files = [git.FileChange("a.py", "M", False, 1, 1, None)]
commands.show_files_panel(window, view, st, files, kept, placeholder="x")
_, on_select, _, on_highlight, _ = window.panel
on_highlight(0)
st.mode = state.MODE_DIFF # the preview drew a diff
on_select(-1)
assert [m for _, m, _, _ in loads] == ["branches"] and st.in_branches
def test_keymap_context_answers_the_mode():
listener = listeners.VdiffEventListener()
view, st = rendered()
assert view.settings().get(state.MODE_SETTING) == "branches"
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_EQUAL, "branches", False) is True
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_EQUAL, "diff", False) is False
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_NOT_EQUAL, "diff", False) is True
assert listener.on_query_context(view, "vdiff_mode", sublime.OP_NOT_EQUAL, "branches", False) is False
# other keys and other views are someone else's business
assert listener.on_query_context(view, "setting.vdiff_mode", sublime.OP_EQUAL, "branches", False) is None
assert listener.on_query_context(sublime.View(), "vdiff_mode", sublime.OP_EQUAL, "diff", False) is None
# a review view without the setting (an older session) is a diff view
plain = sublime.View()
plain.settings().set(state.VIEW_FLAG, True)
assert listener.on_query_context(plain, "vdiff_mode", sublime.OP_EQUAL, "diff", False) is True
def test_branches_view_reloads_when_the_refs_changed(monkeypatch):
mtimes = {"/repo/.git/HEAD": 1.0, "/repo/.git/refs/heads": 2.0, "/repo/vdiff/a.py": 5.0}
monkeypatch.setattr(branches.git, "ref_paths", lambda repo: ("/repo/.git/HEAD", "/repo/.git/refs/heads"))
monkeypatch.setattr(branches.git, "index_path", lambda repo: "/repo/.git/index")
monkeypatch.setattr(git, "mtime", lambda path: mtimes.get(path))
loads = record_loads(monkeypatch)
worktree_loads = []
monkeypatch.setattr(branches, "load_worktree", lambda view, st: worktree_loads.append(st.branch_ref))
st = make_state()
st.branch_changes = [CHANGES[0]]
view, st = rendered(st)
st.rendered_token = st.token
# the refs, then the changed files of the working-tree entry and the index they are staged in
assert st.fingerprint == ((1.0, 2.0), (5.0, None))
commands.refresh_if_changed(view, st)
assert loads == [] and worktree_loads == []
mtimes["/repo/.git/HEAD"] = 3.0 # a checkout elsewhere: everything is relisted
commands.refresh_if_changed(view, st)
assert loads == [("main", "branches", None, True)]
st.rendered_token = st.token
st.fingerprint = branches.fingerprint(st)
# the changed file saved, something staged: only the working tree is relisted
mtimes["/repo/vdiff/a.py"] = 6.0
commands.refresh_if_changed(view, st)
assert len(loads) == 1 and worktree_loads == ["main"]
st.fingerprint = branches.fingerprint(st)
mtimes["/repo/.git/index"] = 7.0
commands.refresh_if_changed(view, st)
assert len(loads) == 1 and worktree_loads == ["main", "main"]
# not while a load is in flight
st.token += 1
mtimes["/repo/.git/HEAD"] = 4.0
commands.refresh_if_changed(view, st)
assert len(loads) == 1 and len(worktree_loads) == 2
def test_load_worktree_relists_only_the_changes_and_keeps_the_caret(monkeypatch):
listed = []
monkeypatch.setattr(branches.git, "changed_files", lambda repo: listed.append(repo) or [CHANGES[1]])
monkeypatch.setattr(branches.git, "index_path", lambda repo: None)
view, st = rendered()
branches.ACTIONS["next_commit"](view, None, st)
branches.load_worktree(view, st)
assert listed == ["/repo/vdiff"] and st.branch_changes == [CHANGES[1]]
assert rows(view)[0].endswith("│ 1 staged file, 0 unstaged files") and rows(view)[5].endswith("│ default file view mode and some colors now configurable in settings")
assert render.caret_index(view) == 5 and st.token == st.rendered_token
# a later load wins over a slower relisting
pending = []
monkeypatch.setattr(branches, "run_async", lambda work, done: pending.append((work, done)))
branches.load_worktree(view, st)
st.token += 1
work, done = pending[0]
done([])
assert st.branch_changes == [CHANGES[1]]
def test_the_commit_cursor_tints_only_the_right_column_once_its_rule_is_live():
# before the restart: Sublime's line highlight, no cursor regions
view, st = rendered()
assert view.settings().get("highlight_line") is True
assert render.KEY_BRANCH_CURSOR not in view.regions
# after it: the four cells of the caret's commit, from the gap to the end of the row
view, st = rendered(live=True)
assert view.settings().get("highlight_line") is False
regions, scope, _, _ = view.regions[render.KEY_BRANCH_CURSOR]
assert scope == palette.SCOPE_CURSOR
width = len(" feature") + branches.LEFT_PAD + 2
lines = rows(view)
assert [view.text[r.a:r.b] for r in regions] == [line[width:] for line in lines[:4]]
# the commits' counts and tags keep the plain red / green
assert [view.text[r.a:r.b] for r in view.regions[render.KEY_BRANCH_INS][0]] == ["v1.1.0", "+260"]
assert view.regions[render.KEY_BRANCH_INS][1] == palette.SCOPE_TEXT_INS
assert view.regions[render.KEY_BRANCH_CURSOR_INS][0] == [] and view.regions[render.KEY_BRANCH_CURSOR_DEL][0] == []
branches.ACTIONS["next_commit"](view, None, st)
regions = view.regions[render.KEY_BRANCH_CURSOR][0]
assert regions[0].a == render.index_point(view, 5) + width
# the tint leaves out the counts and tags, which get the cursor's background with their own colour instead:
# overlapping regions would let the later one recolour the text
text = lambda key: [view.text[r.a:r.b] for r in view.regions[key][0]] # noqa: E731
assert text(render.KEY_BRANCH_CURSOR_INS) == ["v1.1.0", "+260"] and text(render.KEY_BRANCH_CURSOR_DEL) == ["-58"]
assert view.regions[render.KEY_BRANCH_CURSOR_INS][1] == palette.SCOPE_CURSOR_INS
assert text(render.KEY_BRANCH_INS) == [] and text(render.KEY_BRANCH_DEL) == []
pieces = sorted(sum((list(view.regions[key][0]) for key in (render.KEY_BRANCH_CURSOR, render.KEY_BRANCH_CURSOR_INS,
render.KEY_BRANCH_CURSOR_DEL)), []), key=lambda r: r.a)
assert all(a.b <= b.a for a, b in zip(pieces, pieces[1:]))
by_row = {}
for r in pieces:
by_row.setdefault(view.rowcol(r.a)[0] - 1, []).append(view.text[r.a:r.b])
assert {row: "".join(parts) for row, parts in by_row.items()} == {i: lines[i][width:] for i in range(5, 9)}
# back in a diff the line highlight returns
st.change = git.FileChange("x.py", "M", False, 1, 1, None)
render.render_message(view, st, "nothing")
assert view.settings().get("highlight_line") is True
def test_holding_J_fetches_the_log_once_the_key_rests(monkeypatch):
timers = []
monkeypatch.setattr(sublime, "set_timeout", lambda fn, delay=0: timers.append((fn, delay)))
logged = []
monkeypatch.setattr(branches.git, "ref_log", lambda repo, name, count: logged.append(name) or [])
view, st = rendered()
branches.ACTIONS["prev_branch"](view, None, st)
branches.ACTIONS["next_branch"](view, None, st)
branches.ACTIONS["next_branch"](view, None, st)
assert st.branch_ref == "github/main" and logged == []
assert [delay for _, delay in timers] == [branches.DEBOUNCE_MS] * 3
for fn, _ in timers:
fn()
# only the last selection's fetch ran; the cell moved at once each time
assert logged == ["github/main"]
selected = view.regions[render.KEY_BRANCH_SEL][0]
assert view.text[selected[0].a:selected[0].b].strip() == "main"
def test_entering_the_branches_view_vacates_the_diff_state(monkeypatch):
monkeypatch.setattr(branches, "run_async", lambda work, done: None)
view = sublime.View()
render.init_view(view)
st = state.ViewState("/repo/vdiff")
st.sha, st.change = "abc", git.FileChange("x.py", "M", False, 1, 1, None)
st.files = [st.change]
branches.load(view, st)
assert st.in_branches and st.sha is None and st.change is None and st.requested is None and st.lines == []
assert st.files == [st.change] or st.files == [git.FileChange("x.py", "M", False, 1, 1, None)]

View File

@@ -4,6 +4,7 @@ import pytest
import sublime # the stub, via conftest
from conftest import put_caret # noqa: E402
from vdiff import commands, git, state, utils
@@ -163,6 +164,58 @@ def test_on_cancel_goes_back_to_commit_list(monkeypatch):
assert back == [1] and st.sha is None
def test_esc_from_the_reopened_commit_list_returns_to_the_branches_view(monkeypatch):
loads = []
monkeypatch.setattr(commands.branches, "load", lambda view, st, keep_caret=False: loads.append(st.mode))
window = sublime.Window()
view = sublime.View()
st = state.ViewState("/repo")
st.mode = state.MODE_BRANCHES
commit = git.Commit("a" * 40, "a" * 7, "me", "today", "subject", 1, 1, 0)
# Esc on the list opened straight over the branches view: nothing to put back
st.drawn, st.rendered_token = st.describe(), st.token
commands.VdiffCommitsCommand(window).show_commits(view, st, [commit], 0)
window.panel[1](-1)
assert loads == []
# reopened after a commit's files were cancelled: the state says branches, the screen still shows the commit
st.drawn = (state.MODE_DIFF, "a" * 40, False, "x.py")
commands.VdiffCommitsCommand(window).show_commits(view, st, [commit], 0)
window.panel[1](-1)
assert loads == [state.MODE_BRANCHES]
# the same holds for a diff the list was reopened over: the kept working-tree file is shown again
shown = []
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(c.path))
st.mode, st.change = state.MODE_DIFF, change("kept.py")
commands.VdiffCommitsCommand(window).show_commits(view, st, [commit], 0)
window.panel[1](-1)
assert shown == ["kept.py"]
def test_files_on_a_clean_tree_leave_the_branches_view_in_place(monkeypatch):
view, st = sublime.View(), state.ViewState("/repo")
messages, notices = [], []
monkeypatch.setattr(commands, "open_review", lambda window: (view, st))
monkeypatch.setattr(commands, "status", messages.append)
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: notices.append(text))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
window = sublime.Window()
st.mode = state.MODE_BRANCHES
commands.VdiffFilesCommand(window).run()
assert messages == ["no uncommitted changes"] and notices == [] and st.in_branches and window.panel is None
# with changes the panel comes up as usual; in a diff view the notice is shown
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py")])
commands.VdiffFilesCommand(window).run()
assert window.panel is not None
# nor does a commit under review give way to it
st.mode, st.sha = state.MODE_DIFF, "a" * 40
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
commands.VdiffFilesCommand(window).run()
assert notices == [] and st.sha == "a" * 40
st.sha = None
commands.VdiffFilesCommand(window).run()
assert notices == ["No uncommitted changes in /repo"]
def test_step_change_uses_blocks(rings):
view, st = at_change([(2, 4), (7, 8)], 0)
commands.ACTIONS["next_change"](view, None, st)
@@ -476,14 +529,6 @@ def test_esc_rewinds_the_file_navigation_counts_from(loading):
# --- cycling past the ends --------------------------------------------------
@pytest.fixture
def rings(monkeypatch):
"""Record visual bells instead of drawing them"""
bells = []
monkeypatch.setattr(commands.render, "flash", lambda view: bells.append(view))
return bells
def at_file(files, path):
"""A loaded working-tree view sitting on `path`"""
view, window = sublime.View(), sublime.Window()
@@ -600,6 +645,9 @@ def test_next_unstaged_scans_forward_and_wraps():
assert commands._next_unstaged(files, 2) == 2 # the slot we count from counts itself
assert commands._next_unstaged(files, 3) == 0 # wraps past the end
assert commands._next_unstaged(flagged(True, True), 0) == -1
# a partially staged file still has hunks to stage
partial = git.FileChange("p.py", "M", True, 1, 1, None, unstaged=True)
assert commands._next_unstaged(flagged(True, True) + [partial], 0) == 2
def test_staging_moves_to_the_next_unstaged_file(monkeypatch):
@@ -653,6 +701,10 @@ def test_stage_all_and_unstage_all_need_something_to_do(monkeypatch):
assert press(monkeypatch, "stage_all", flagged(True, True), "f0.py", []) == []
assert press(monkeypatch, "unstage_all", flagged(False, False), "f0.py", []) == []
assert messages == ["nothing to stage", "nothing to unstage"]
# a partially staged file is something to stage
partial = git.FileChange("p.py", "M", True, 1, 1, None, unstaged=True)
assert press(monkeypatch, "stage_all", flagged(True) + [partial], "f0.py", flagged(True, True)) == ["f0.py"]
assert messages[-1] == "staged 1 file"
view, window, st = at_file(flagged(False), "f0.py")
st.enter("abc", git.CommitInfo("abc", "abc1234", "Ann", "2026-09-23 10:00", "Fix things", ""))
commands.ACTIONS["stage_all"](view, window, st)
@@ -670,6 +722,9 @@ def watched(monkeypatch, tmp_path, all_mode):
st.files = [change("a.py"), change("b.py")]
st.change, st.all_mode = st.files[0], all_mode
st.fingerprint = commands.render.fingerprint(st)
# git status lists what the view lists, unless a test says otherwise
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: list(st.files))
return st, refreshes
@@ -698,6 +753,27 @@ def test_refresh_if_changed_watches_the_shown_file_and_the_index(monkeypatch, tm
assert refreshes == [st]
@pytest.mark.parametrize("all_mode", [False, True])
def test_refresh_if_changed_sees_a_file_changed_outside_the_list(monkeypatch, tmp_path, all_mode):
st, refreshes = watched(monkeypatch, tmp_path, all_mode=all_mode)
# no listed file and not the index moved, but git status now lists c.py too
monkeypatch.setattr(commands.git, "changed_files", lambda repo: st.files + [change("c.py")])
commands.refresh_if_changed(sublime.View(), st)
assert refreshes == [st]
def test_a_stale_git_status_does_not_refresh(monkeypatch, tmp_path):
st, refreshes = watched(monkeypatch, tmp_path, all_mode=False)
pending = []
monkeypatch.setattr(commands, "run_async", lambda work, done: pending.append(done))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
commands.refresh_if_changed(sublime.View(), st)
# the user moved on to another file before git status answered
st.token += 1
pending[0]([change("c.py")])
assert refreshes == []
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))
@@ -744,9 +820,31 @@ def heading_row(st, path):
return next(i for i, (c, _, section) in enumerate(st.all_rows) if section and c.path == path)
def put_caret(view, row):
view.sel().clear()
view.sel().add(sublime.Region(commands.render.index_point(view, row)))
def test_the_header_follows_the_caret_from_file_to_file():
from vdiff import listeners
view, st = all_files_view()
st.files[1] = git.FileChange("b.py", "M", False, 0, 4, None)
commands.render.render_all(view, st)
st.rendered_token = st.token
commands.render.init_view(view)
state.put(view, st)
title = lambda: view.phantoms[commands.render.PHANTOM_KEY][0].content.split("</div>", 1)[1] # noqa: E731
listener = listeners.VdiffEventListener()
put_caret(view, heading_row(st, "b.py") + 1)
listener.on_selection_modified(view)
assert st.change.path == "b.py" and ">b.py<" in title() and '<span class="del">-4</span> <span class="ins">+0</span>' in title()
# within the same file nothing is redrawn; back in a.py the header is a.py's again
shown = view.phantoms[commands.render.PHANTOM_KEY]
listener.on_selection_modified(view)
assert view.phantoms[commands.render.PHANTOM_KEY] is shown
put_caret(view, heading_row(st, "a.py"))
listener.on_selection_modified(view)
assert ">a.py<" in title() and '<span class="del">-1</span> <span class="ins">+1</span>' in title()
# not while a render is pending: the rows describe the old text
st.token += 1
put_caret(view, heading_row(st, "b.py"))
listener.on_selection_modified(view)
assert st.change.path == "a.py"
def test_all_files_step_change_from_a_heading_lands_on_its_first_hunk():
@@ -808,3 +906,101 @@ def test_all_files_context_zero_shows_only_changed_lines(monkeypatch):
view, st = all_files_view()
shown = [line for _, line, _ in st.all_rows if line is not None]
assert shown and all(line.kind != " " for line in shown)
def test_refresh_in_the_all_files_view_relists_the_working_tree(monkeypatch):
view, st = all_files_view()
st.change = st.files[0]
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
# b.py was reverted and c.py is new since the view was drawn
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("a.py"), change("c.py")])
shown = []
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(focus))
commands.ACTIONS["refresh"](view, None, st)
assert shown == ["a.py"] and [c.path for c in st.files] == ["a.py", "c.py"]
@pytest.mark.parametrize("all_mode, expected", [(False, ("file", "b.py")), (True, ("all", "b.py"))])
def test_restore_falls_back_to_the_first_changed_file(monkeypatch, tmp_path, all_mode, expected):
view = sublime.View()
view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "path": "gone.py", "all_mode": all_mode})
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [change("b.py"), change("c.py")])
shown = []
monkeypatch.setattr(commands, "show_all", lambda view, st, focus=None: shown.append(("all", st.change.path)))
monkeypatch.setattr(commands, "show_file", lambda view, st, c: shown.append(("file", c.path)))
commands.restore_view(view)
assert shown == [expected]
def test_restore_with_no_changes_left_says_so(monkeypatch, tmp_path):
view = sublime.View()
view.settings().set(state.VIEW_DATA, {"repo": str(tmp_path), "sha": None, "path": "gone.py"})
monkeypatch.setattr(commands, "run_async", lambda work, done: done(work()))
monkeypatch.setattr(commands.git, "changed_files", lambda repo: [])
messages = []
monkeypatch.setattr(commands.render, "render_message", lambda view, st, text: messages.append(text))
commands.restore_view(view)
assert messages == [f"No uncommitted changes in {tmp_path}"]
def test_open_in_the_all_files_view_maps_a_deleted_row_like_the_single_file_view(tmp_path):
from vdiff import parse
view, st = all_files_view()
st.repo = str(tmp_path)
(tmp_path / "a.py").write_text("")
(tmp_path / "b.py").write_text("")
lines_b = [parse.Line(" ", i, i, f"b{i}") for i in range(1, 5)] + [parse.Line("-", 5, None, "old b5"),
parse.Line(" ", 6, 5, "b6")]
st.all_diffs["b.py"] = (lines_b, parse.change_blocks(lines_b), False)
commands.render.render_all(view, st)
opened = []
class Window:
def open_file(self, name, flags=0):
opened.append(name)
def open_at(match):
put_caret(view, next(i for i, (_, line, _) in enumerate(st.all_rows) if line is not None and match(line)))
commands.ACTIONS["open"](view, Window(), st)
# the deleted row opens at the line that took its place, as in the single-file view, not at line 1
open_at(lambda line: line.kind == "-")
# a surviving row keeps its own number
open_at(lambda line: line.new_no == 26)
assert opened == [f"{tmp_path}/b.py:5", f"{tmp_path}/a.py:26"]
def test_a_blank_row_in_the_all_files_view_is_no_file(monkeypatch):
view, st = all_files_view()
messages, ran = [], []
monkeypatch.setattr(commands, "status", messages.append)
monkeypatch.setattr(commands, "run_async", lambda work, done: ran.append(work))
# the caret was on a.py, then moved to the blank row above b.py: git actions and open act on nothing
put_caret(view, heading_row(st, "a.py") + 1)
commands.render.follow_caret(view, st)
put_caret(view, heading_row(st, "b.py") - 1)
commands.ACTIONS["discard"](view, None, st)
commands.ACTIONS["stage"](view, None, st)
commands.ACTIONS["open"](view, None, st)
assert ran == [] and messages == ["no file under the caret"] * 3
@pytest.mark.parametrize("action, path", [("next_file", "b.py"), ("prev_file", "a.py")])
def test_a_blank_row_steps_files_as_the_gap_above_the_file_below(action, path):
view, st = all_files_view()
st.change = st.files[0]
put_caret(view, heading_row(st, "b.py") - 1)
commands.ACTIONS[action](view, None, st)
assert st.change.path == path
def test_a_blank_row_steps_changes_as_the_gap_above_the_file_below():
view, st = all_files_view()
put_caret(view, heading_row(st, "b.py") - 1)
commands.ACTIONS["next_change"](view, None, st)
assert st.change.path == "b.py"
put_caret(view, heading_row(st, "b.py") - 1)
commands.ACTIONS["prev_change"](view, None, st)
# a.py's last hunk, not the first or last of the whole list from nowhere
assert st.change.path == "a.py" and commands.render.caret_index(view) == st.all_change_rows[1]

View File

@@ -1,4 +1,5 @@
import os
import re
import subprocess
import pytest
@@ -43,12 +44,25 @@ def test_changed_files_all_kinds(repo):
sh(repo, "add", "b.txt") # staged M
os.remove(os.path.join(repo, "gone.py")) # unstaged D
write(repo, "sub/new.py", "x = 1\ny = 2") # untracked, no trailing newline
write(repo, "c.txt", "c\n")
sh(repo, "add", "c.txt")
write(repo, "c.txt", "c\nd\n") # partially staged: staged A, unstaged M
changes = {c.path: c for c in git.changed_files(repo)}
assert set(changes) == {"a.py", "b.txt", "gone.py", "sub/new.py"}
assert changes["a.py"] == git.FileChange("a.py", "M", False, 1, 1, None)
assert set(changes) == {"a.py", "b.txt", "c.txt", "gone.py", "sub/new.py"}
assert changes["a.py"] == git.FileChange("a.py", "M", False, 1, 1, None, unstaged=True)
assert changes["b.txt"] == git.FileChange("b.txt", "M", True, 1, 0, None)
assert changes["c.txt"] == git.FileChange("c.txt", "M", True, 2, 0, None, unstaged=True)
assert changes["gone.py"].status == "D" and changes["gone.py"].deletions == 1
assert changes["sub/new.py"] == git.FileChange("sub/new.py", "?", False, 2, 0, None)
assert changes["sub/new.py"] == git.FileChange("sub/new.py", "?", False, 2, 0, None, unstaged=True)
def test_worktree_stats_counts_a_partially_staged_file_in_both_and_a_binary_in_neither_total():
changes = [git.FileChange("a.py", "M", False, 1, 1, None, unstaged=True),
git.FileChange("b.txt", "M", True, 1, 0, None),
git.FileChange("c.txt", "M", True, 2, 0, None, unstaged=True),
git.FileChange("img.png", "M", True, None, None, None)]
assert git.worktree_stats(changes) == git.WorkTree(staged=3, unstaged=2, files=4, insertions=4, deletions=1)
assert git.worktree_stats([]) == git.WorkTree(0, 0, 0, 0, 0)
def test_full_diff_working_tree(repo):
@@ -296,3 +310,250 @@ def test_head_message_without_a_commit_raises(tmp_path):
sh(fresh, "init", "-q", "-b", "main")
with pytest.raises(git.GitError):
git.head_message(fresh)
# --- branches ---------------------------------------------------------------
@pytest.fixture
def remote(repo, tmp_path_factory):
"""A bare `origin` holding main, with the repo's main tracking it"""
bare = str(tmp_path_factory.mktemp("origin"))
sh(bare, "init", "-q", "--bare", "-b", "main")
sh(repo, "remote", "add", "origin", bare)
sh(repo, "push", "-q", "-u", "origin", "main")
return bare
def clone(bare, tmp_path_factory):
"""A second working copy of `bare`, to push foreign commits from"""
other = str(tmp_path_factory.mktemp("other"))
sh(other, "clone", "-q", bare, ".")
sh(other, "config", "user.email", "o@example.com")
sh(other, "config", "user.name", "Other")
return other
def commit_file(repo, name, text, message):
write(repo, name, text)
sh(repo, "add", "-A")
sh(repo, "commit", "-q", "-m", message)
def by_name(repo):
return {r.name: r for r in git.branches(repo)}
def test_branches_lists_locals_remotes_and_tracking(repo, remote, tmp_path_factory):
sh(repo, "branch", "feature/x")
sh(repo, "push", "-q", "-u", "origin", "feature/x")
sh(repo, "branch", "local-only")
commit_file(repo, "ahead.txt", "a\n", "ahead")
other = clone(remote, tmp_path_factory)
commit_file(other, "behind.txt", "b\n", "behind")
sh(other, "push", "-q", "origin", "main")
sh(other, "push", "-q", "origin", "--delete", "feature/x")
sh(repo, "fetch", "-q", "--prune")
# the remote's symbolic HEAD is a row of for-each-ref too
sh(repo, "remote", "set-head", "origin", "main")
refs = by_name(repo)
assert set(refs) == {"main", "feature/x", "local-only", "origin/main"}
main = refs["main"]
assert main.current and main.remote is None and main.upstream == "origin/main"
assert (main.ahead, main.behind, main.gone) == (1, 1, False)
assert refs["feature/x"].gone and refs["feature/x"].upstream == "origin/feature/x"
assert refs["local-only"].upstream is None and not refs["local-only"].current
assert (main.upstream_remote, main.upstream_branch) == ("origin", "main")
assert refs["local-only"].upstream_remote is None and refs["local-only"].upstream_branch is None
origin_main = refs["origin/main"]
assert (origin_main.remote, origin_main.branch, origin_main.current) == ("origin", "main", False)
assert len(origin_main.sha) >= 7
# locals come before remotes, in git's own order
assert [r.name for r in git.branches(repo)] == ["feature/x", "local-only", "main", "origin/main"]
def test_remotes_lists_a_remote_without_refs(repo, remote):
sh(repo, "remote", "add", "github", "/nowhere")
assert git.remotes(repo) == ["github", "origin"]
def test_head_sha_on_a_branch_detached_and_without_commits(repo, tmp_path):
sha = git.head_sha(repo)
assert len(sha) >= 7 and by_name(repo)["main"].current
sh(repo, "checkout", "-q", "--detach")
assert git.head_sha(repo) == sha and not any(r.current for r in git.branches(repo))
empty = str(tmp_path / "empty")
os.makedirs(empty)
sh(empty, "init", "-q", "-b", "main")
assert git.head_sha(empty) == "" and git.branches(empty) == []
def test_ref_log_carries_tags_dates_and_stats(repo, remote):
commit_file(repo, "new.py", "one\ntwo\n", "added a file")
sh(repo, "tag", "v1.0.0")
sh(repo, "tag", "-a", "release", "-m", "annotated")
sh(repo, "commit", "-q", "--allow-empty", "-m", "empty")
empty, added, initial = git.ref_log(repo, "main", 10)
assert (empty.subject, empty.tags, empty.files) == ("empty", (), None)
assert added.tags == ("release", "v1.0.0") or added.tags == ("v1.0.0", "release")
assert (added.files, added.insertions, added.deletions) == (1, 2, 0)
assert re.fullmatch(r"\d\d\.\d\d\.\d{4}", added.date) and added.author == "Test"
assert initial.tags == () and initial.files == 3
# a remote ref logs too, and the count caps the list
assert [c.subject for c in git.ref_log(repo, "origin/main", 1)] == ["initial"]
# the recent-commits panel keeps its relative dates and carries the same tags
recent = git.recent_commits(repo, 2)
assert recent[0].tags == () and recent[1].tags == added.tags and recent[0].date.endswith("ago")
def test_checkout_tracks_a_remote_branch_once_and_reuses_the_local(repo, remote, tmp_path_factory):
other = clone(remote, tmp_path_factory)
sh(other, "checkout", "-q", "-b", "topic")
commit_file(other, "t.txt", "t\n", "topic work")
sh(other, "push", "-q", "-u", "origin", "topic")
sh(repo, "fetch", "-q")
refs = by_name(repo)
assert "topic" not in refs
git.checkout(repo, refs["origin/topic"], local_names=[])
refs = by_name(repo)
assert refs["topic"].current and refs["topic"].upstream == "origin/topic"
assert os.path.exists(os.path.join(repo, "t.txt"))
# back on main, the remote row now checks out the existing local branch rather than failing
git.checkout(repo, refs["main"])
assert by_name(repo)["main"].current
git.checkout(repo, refs["origin/topic"], local_names=["topic"])
assert by_name(repo)["topic"].current
def test_checkout_detached(repo, remote):
git.checkout_detached(repo, "origin/main")
refs = by_name(repo)
assert not any(r.current for r in refs.values()) and git.head_sha(repo) == refs["origin/main"].sha
def test_delete_branch_refuses_unmerged_then_forces(repo):
sh(repo, "checkout", "-q", "-b", "side")
commit_file(repo, "s.txt", "s\n", "side work")
sh(repo, "checkout", "-q", "main")
with pytest.raises(git.GitError) as caught:
git.delete_branch(repo, "side")
assert "not fully merged" in caught.value.stderr
assert "side" in by_name(repo)
git.delete_branch(repo, "side", force=True)
assert "side" not in by_name(repo)
# a merged branch goes without force
sh(repo, "branch", "merged")
git.delete_branch(repo, "merged")
assert "merged" not in by_name(repo)
def test_fetch_into_fast_forwards_and_refuses_non_ff(repo, remote, tmp_path_factory):
sh(repo, "checkout", "-q", "-b", "work")
other = clone(remote, tmp_path_factory)
commit_file(other, "n.txt", "n\n", "upstream work")
sh(other, "push", "-q", "origin", "main")
git.fetch_into(repo, "origin", "main", "main")
refs = by_name(repo)
assert refs["main"].sha == refs["origin/main"].sha and refs["main"].ahead == 0
# diverge main, then the fast-forward is refused
sh(repo, "checkout", "-q", "main")
commit_file(repo, "m.txt", "m\n", "local work")
sh(repo, "checkout", "-q", "work")
commit_file(other, "o.txt", "o\n", "more upstream work")
sh(other, "push", "-q", "origin", "main")
with pytest.raises(git.GitError):
git.fetch_into(repo, "origin", "main", "main")
# nor does git update the checked-out branch this way
with pytest.raises(git.GitError):
git.fetch_into(repo, "origin", "main", "work")
def test_pull_fast_forwards_the_current_branch(repo, remote, tmp_path_factory):
other = clone(remote, tmp_path_factory)
commit_file(other, "n.txt", "n\n", "upstream work")
sh(other, "push", "-q", "origin", "main")
git.pull(repo)
assert os.path.exists(os.path.join(repo, "n.txt"))
assert by_name(repo)["main"].behind == 0
def test_push_sets_the_upstream_once(repo, remote):
sh(repo, "checkout", "-q", "-b", "topic")
commit_file(repo, "t.txt", "t\n", "topic work")
assert by_name(repo)["topic"].upstream is None
git.push(repo, "origin", "topic", set_upstream=True)
topic = by_name(repo)["topic"]
assert topic.upstream == "origin/topic" and (topic.ahead, topic.behind) == (0, 0)
commit_file(repo, "t.txt", "tt\n", "more topic work")
assert by_name(repo)["topic"].ahead == 1
git.push(repo, "origin", "topic")
assert by_name(repo)["topic"].ahead == 0
def test_push_goes_to_a_differently_named_upstream(repo, remote):
sh(repo, "checkout", "-q", "-b", "foo")
commit_file(repo, "f.txt", "f\n", "foo work")
sh(repo, "push", "-q", "-u", "origin", "foo:bar")
foo = by_name(repo)["foo"]
assert (foo.upstream, foo.upstream_remote, foo.upstream_branch) == ("origin/bar", "origin", "bar")
commit_file(repo, "f.txt", "ff\n", "more foo work")
git.push(repo, "origin", "foo", remote_branch="bar")
refs = by_name(repo)
assert refs["foo"].ahead == 0 and "origin/foo" not in refs
# an upstream that is a local branch has no remote
sh(repo, "branch", "--set-upstream-to=main", "foo")
foo = by_name(repo)["foo"]
assert (foo.upstream, foo.upstream_remote, foo.upstream_branch) == ("main", None, None)
def test_fetch_all_prunes_a_deleted_remote_branch(repo, remote, tmp_path_factory):
other = clone(remote, tmp_path_factory)
sh(other, "checkout", "-q", "-b", "gone")
commit_file(other, "g.txt", "g\n", "to be deleted")
sh(other, "push", "-q", "origin", "gone")
git.fetch_all(repo)
assert "origin/gone" in by_name(repo)
sh(other, "push", "-q", "origin", "--delete", "gone")
git.fetch_all(repo)
assert "origin/gone" not in by_name(repo)
def test_run_passes_env_and_closes_stdin(repo):
assert git.run(repo, ["var", "GIT_EDITOR"], env={"GIT_EDITOR": "xyz-editor"}).strip() == "xyz-editor"
# the inherited environment stays: git still finds its config and the user's PATH
assert git.run(repo, ["config", "user.name"]).strip() == "Test"
def test_ref_paths_point_into_the_git_dir(repo, tmp_path_factory):
paths = git.ref_paths(repo)
assert [os.path.relpath(p, repo) for p in paths] == [".git/" + name for name in git.REF_PATH_NAMES]
assert os.path.exists(paths[0]) and os.path.isdir(paths[4])
assert git.ref_paths(str(tmp_path_factory.mktemp("not-a-repo"))) == ()
# the directories a slashed branch and a remote put their refs in: those are the ones their updates touch
sh(repo, "branch", "feature/x")
sh(repo, "update-ref", "refs/remotes/origin/main", "HEAD")
nested = [os.path.relpath(p, repo) for p in git.ref_paths(repo)[len(git.REF_PATH_NAMES):]]
assert nested == [".git/refs/heads/feature", ".git/refs/remotes/origin"]
def test_ref_paths_walk_again_only_when_a_ref_directory_moved(repo, monkeypatch):
sh(repo, "branch", "feature/x")
git.ref_paths(repo)
walks = []
real_walk = os.walk
monkeypatch.setattr(git.os, "walk", lambda root, *args: walks.append(root) or real_walk(root, *args))
git.ref_paths(repo)
assert walks == [] # nothing moved: the directories found last time stand
# a branch two levels down adds a directory inside a nested one, whose mtime moves
sh(repo, "branch", "feature/deep/y")
nested = [os.path.relpath(p, repo) for p in git.ref_paths(repo)[len(git.REF_PATH_NAMES):]]
assert walks and nested == [".git/refs/heads/feature", ".git/refs/heads/feature/deep"]
def test_run_keeps_gits_messages_in_english(repo):
# a localized git would translate the refusal delete_branch tells apart by its text
assert "LC_ALL=C" in git.run(repo, ["-c", "alias.showenv=!env", "showenv"]).split("\n")

View File

@@ -157,3 +157,72 @@ def test_the_status_letter_is_white_on_the_heading_unless_set(tmp_path, monkeypa
colour_option(monkeypatch, "file_header_status_foreground", "#ffff00")
palette.ensure(sublime.View())
assert by_scope(tmp_path)[palette.SCOPE_FILE_STATUS]["foreground"] == "#ffff00"
def test_red_and_green_text_rules_share_the_heading_colours_on_the_editor_background(tmp_path, monkeypatch):
view = sublime.View()
palette.ensure(view)
rules = by_scope(tmp_path)
plain = palette._nudge(palette._global_background(view))
assert rules[palette.SCOPE_TEXT_DEL] == {"scope": palette.SCOPE_TEXT_DEL, "background": plain,
"foreground": palette.FILE_DEL_FOREGROUND, "font_style": "bold"}
assert rules[palette.SCOPE_TEXT_INS]["foreground"] == palette.FILE_INS_FOREGROUND
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
palette.ensure(sublime.View())
assert by_scope(tmp_path)[palette.SCOPE_TEXT_INS]["foreground"] == "#00ff00"
# until the override is live the scheme's diff tints stand in
assert palette.text_scope(view, "del") == palette.SCOPE_DEL
assert palette.text_scope(view, "ins") == palette.SCOPE_INS
assert palette.count_scope(view, "del") == palette.SCOPE_DEL
view.override_loaded = True
assert palette.text_scope(view, "del") == palette.SCOPE_TEXT_DEL
assert palette.text_scope(view, "ins") == palette.SCOPE_TEXT_INS
assert palette.count_scope(view, "del") == palette.SCOPE_FILE_DEL_COUNT
assert palette.count_scope(view, "ins") == palette.SCOPE_FILE_INS_COUNT
def test_the_cursor_count_rules_share_the_cursor_background_and_the_heading_colours(tmp_path, monkeypatch):
colour_option(monkeypatch, "file_header_inserted_foreground", "#00ff00")
view = sublime.View()
palette.ensure(view)
rules = by_scope(tmp_path)
assert rules[palette.SCOPE_CURSOR_INS] == {"scope": palette.SCOPE_CURSOR_INS, "background": rules[palette.SCOPE_CURSOR]["background"],
"foreground": "#00ff00", "font_style": "bold"}
assert rules[palette.SCOPE_CURSOR_DEL]["foreground"] == palette.FILE_DEL_FOREGROUND
# the text scope stands in until the rule is live
assert palette.cursor_text_scope(view, "ins") == palette.SCOPE_INS
view.override_loaded = True
assert palette.cursor_text_scope(view, "ins") == palette.SCOPE_CURSOR_INS
def test_the_restart_notice_is_claimed_per_view_kind(tmp_path):
view = sublime.View()
palette.ensure(view)
# nothing live yet: each kind of view asks once
assert palette.claim_restart_notice(view, palette.DIFF_SCOPES)
assert not palette.claim_restart_notice(view, palette.DIFF_SCOPES)
assert palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False)
assert not palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False)
# an upgrade that adds only the branches rules: a diff view, whose rules are live, says nothing
palette.reset()
palette.ensure(view)
view.override_loaded = True
for scope in palette.BRANCH_SCOPES[3:]:
view.styles[scope] = {"foreground": sublime.DEFAULT_FG} # known to the scheme, but without a background: not live
assert not palette.claim_restart_notice(view, palette.DIFF_SCOPES)
assert palette.claim_restart_notice(view, palette.BRANCH_SCOPES, tokens=False)
def test_the_commit_cursor_rule_uses_the_line_highlight_colour(tmp_path, monkeypatch):
view = sublime.View()
palette.ensure(view)
rule = by_scope(tmp_path)[palette.SCOPE_CURSOR]
assert rule["background"] == "#3b4350" and rule["foreground"] == sublime.DEFAULT_FG
assert palette.cursor_scope(view) is None
view.override_loaded = True
assert palette.cursor_scope(view) == palette.SCOPE_CURSOR
# a scheme without a line highlight: the background nudged towards the foreground
assert palette._blend("#000000", "#ffffff", 0.12) == "#1f1f1f"
assert palette._blend("#303841", "#d8dee9", 0.12) == "#444c55"
monkeypatch.setattr(sublime.View, "style", lambda self: {})
assert palette._line_highlight(view) == palette._blend("#000000", sublime.DEFAULT_FG, palette.CURSOR_BLEND)

View File

@@ -159,7 +159,7 @@ def test_help_popup_lists_every_key():
assert "next / previous change" in html and "open the real file at this line" in html
# working-tree mode: nothing dimmed, every single-action row is a link
assert 'class="dim"' not in html and "uncommitted only" not in html
assert 'href="discard"' in html and 'href="files"' in html and 'href="close"' in html
assert 'href="discard"' in html and 'href="files"' in html and 'href="branches"' in html and 'href="close"' in html
# rows covering two actions stay plain text
assert 'href="next_change"' not in html and 'href="stage"' not in html
# the popup does not list the key that opens it
@@ -196,7 +196,7 @@ def test_help_popup_toggles_and_navigates():
# keymap spellings that the popup shows as a symbol
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc"}
DISPLAY_KEYS = {"enter": "⏎", "escape": "esc", "shift+enter": "shift+⏎"}
def keymap_bindings():
@@ -205,19 +205,37 @@ def keymap_bindings():
return json.load(f)
def binding_mode(binding):
"""The vdiff_mode a binding is limited to, None when it applies in every mode"""
modes = [c["operand"] for c in binding["context"] if c["key"] == "vdiff_mode"]
assert len(modes) <= 1, binding["keys"]
# a setting context does not compare strings; the plugin answers vdiff_mode itself
assert not any(c["key"] == "setting.vdiff_mode" for c in binding["context"]), binding["keys"]
return modes[0] if modes else None
def test_keymap_matches_help_table():
"""The keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
"""Per mode, the keymap, ACTIONS and the popup agree on both the actions and the keys that reach them"""
bindings = keymap_bindings()
assert all(b["command"] == "vdiff_action" for b in bindings)
bound = {b["args"]["action"] for b in bindings}
listed = {action for k in render.KEYS for action in k.actions}
# "?" opens the popup, so the popup leaves it out
assert bound == set(commands.ACTIONS) == listed | {"help"}
# rebinding a key without updating the table would leave the popup and status bar lying
bound_keys = {DISPLAY_KEYS.get(key, key) for b in bindings for key in b["keys"]}
assert bound_keys == {token for k in render.KEYS for token in k.keys.split() if token != "/"} | {"?"}
assert all({c["key"] for c in b["context"]} >= {"setting.vdiff_view"} for b in bindings)
assert {binding_mode(b) for b in bindings} == {None, "diff", "branches"}
bound_all = set()
for mode, table in render.MODE_KEY_GROUPS.items():
applicable = [b for b in bindings if binding_mode(b) in (None, mode)]
# one meaning per key within a mode
keys = [key for b in applicable for key in b["keys"]]
assert len(keys) == len(set(keys)), mode
bound = {b["args"]["action"] for b in applicable}
listed = {action for k in render.keys(mode) for action in k.actions}
# "?" opens the popup, so the popup leaves it out
assert bound == listed | {"help"}, mode
# rebinding a key without updating the table would leave the popup and status bar lying
bound_keys = {DISPLAY_KEYS.get(key, key) for key in keys}
assert bound_keys == {token for k in render.keys(mode) for token in k.keys.split() if token != "/"} | {"?"}, mode
bound_all |= bound
assert bound_all == set(commands.ACTIONS)
def test_no_binding_yields_to_vintage_command_mode():
@@ -258,13 +276,14 @@ def test_summary_counts_staged_and_unstaged_files():
git.FileChange("a.py", "M", True, 1, 1, None),
git.FileChange("b.py", "M", False, 1, 1, None),
git.FileChange("c.py", "?", False, 1, 0, None),
git.FileChange("d.py", "M", True, 2, 0, None, unstaged=True), # partially staged: on both sides, as in the branches view
]
render.render(view, st)
summary = header(view).split('<div class="summary">')[1].split("</div>")[0]
# the untracked file's whole content counts as insertions, the way git.changed_files records it
assert summary.startswith("1 staged, 2 unstaged · ")
assert '<span class="del">-2</span>' in summary and '<span class="ins">+3</span>' in summary
assert summary.startswith("2 staged, 3 unstaged · ")
assert '<span class="del">-2</span>' in summary and '<span class="ins">+5</span>' in summary
def test_summary_totals_skip_binary_files_but_still_count_them():
@@ -305,6 +324,23 @@ def test_summary_totals_a_commit_and_owns_the_date():
assert 'class="commit"' not in html
def test_the_all_files_title_shows_the_current_files_counts_not_the_totals():
st = state.ViewState("/repo", all_mode=True)
st.files = [git.FileChange("x.py", "M", False, 3, 2, None), git.FileChange("y.py", "M", True, 7, 0, None),
git.FileChange("logo.png", "M", False, None, None, None)]
st.change = st.files[1]
# every file's totals belong on the summary line only
html = render._all_header_html(st)
title = html.split("</div>", 1)[1]
assert '<span class="del">-0</span> <span class="ins">+7</span>' in title and "+10" not in title
assert '<span class="del">-2</span> <span class="ins">+10</span>' in html.split("</div>", 1)[0]
st.change = st.files[2]
assert '<span class="state">binary</span>' in render._all_header_html(st).split("</div>", 1)[1]
# file by file, the counts handed in are the shown file's own
st.all_mode, st.change = False, st.files[0]
assert '<span class="del">-4</span> <span class="ins">+5</span>' in render.header_html(st, 5, 4)
def test_summary_is_omitted_without_a_file_list():
view = sublime.View()
render.init_view(view)
@@ -432,3 +468,50 @@ def test_restart_notice_when_the_override_cannot_take_effect_yet(monkeypatch):
# said once per scheme, not once per render
render.render(view, make_state())
assert said == ["vDiff: " + render.NEEDS_RESTART]
# --- the branches view's keys ----------------------------------------------
def test_help_popup_follows_the_mode():
st = make_state()
st.mode = state.MODE_BRANCHES
_, html = help_popup(st)
for keys in ("J K", "j k", "o v", "⏎", "shift+⏎", "L", "P", "F", "d", "f", "c", "b", "r", "esc"):
assert f">{keys}<" in html, keys
assert "next / previous branch" in html and "fetch all remotes" in html
assert ">s / S<" not in html and ">n p<" not in html and ">v<" not in html
# a commit shown before switching dims nothing here: the branch keys do not depend on it
st.sha = "abc"
_, html = help_popup(st)
assert 'class="dim"' not in html and 'href="checkout"' in html and 'href="pull"' in html
# the diff popup lists the way in
st.mode = state.MODE_DIFF
_, html = help_popup(st)
assert ">b<" in html and 'href="branches"' in html and ">J K<" not in html
def test_status_hints_follow_the_mode():
view = sublime.View()
render.init_view(view)
assert view.status[render.STATUS_KEY] == render.key_hints() and "b branches" in render.key_hints()
hints = render.key_hints(state.MODE_BRANCHES)
assert "J K branch" in hints and "L pull" in hints and "s / S" not in hints
render.init_view(view, state.MODE_BRANCHES)
assert view.status[render.STATUS_KEY] == hints and view.settings().get(state.MODE_SETTING) == "branches"
def test_all_files_view_tokenises_only_once_the_colours_can_show(monkeypatch):
calls = []
monkeypatch.setattr(render, "_all_colours", lambda view, st, paths: calls.append(list(paths)))
st = make_state()
st.all_mode = True
st.files = [st.change]
st.all_diffs = {st.change.path: (st.lines, st.blocks, False)}
view = sublime.View()
render.init_view(view)
# before the restart that loads the scheme rules the colours could not be painted: no tokenising
render.render_all(view, st)
assert calls == []
render.render_all(with_loaded_override(view), st)
assert calls == [[st.change.path]]