Branches view initial version
This commit is contained in:
@@ -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)))
|
||||
|
||||
@@ -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
763
tests/test_branches.py
Normal 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] == "| " 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",)))
|
||||
cells = [line.split("| ")[0] for line in rows(view)]
|
||||
width = len(" both ↑12 ↓4") + branches.LEFT_PAD
|
||||
# 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.split("|")[0].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", "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)]
|
||||
@@ -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)
|
||||
@@ -744,9 +796,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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,34 @@ 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"
|
||||
|
||||
Reference in New Issue
Block a user