Branches view initial version
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user