# Copyright (C) 2008, 2009 Michael Trier (mtrier@gmail.com) and contributors
#
# This module is part of GitPython and is released under the
# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
import gc
import os
import os.path as osp
from pathlib import Path
import random
import socket
import sys
import tempfile
import threading
import time
from unittest import mock, skipIf
import pytest
from git import (
Commit,
FetchInfo,
GitCommandError,
Head,
PushInfo,
Reference,
Remote,
RemoteProgress,
RemoteReference,
Repo,
SymbolicReference,
TagReference,
)
from git.cmd import Git
from git.exc import UnsafeOptionError, UnsafeProtocolError
from git.util import HIDE_WINDOWS_FREEZE_ERRORS, IterableList, rmtree
from test.lib import (
GIT_DAEMON_PORT,
TestBase,
fixture,
with_rw_and_rw_remote_repo,
with_rw_repo,
)
# Make sure we have repeatable results.
random.seed(0)
class TestRemoteProgress(RemoteProgress):
__slots__ = ("_seen_lines", "_stages_per_op", "_num_progress_messages")
def __init__(self) -> None:
super().__init__()
self._seen_lines = []
self._stages_per_op = {}
self._num_progress_messages = 0
def _parse_progress_line(self, line):
# We may remove the line later if it is dropped.
# Keep it for debugging.
self._seen_lines.append(line)
rval = super()._parse_progress_line(line)
return rval
def line_dropped(self, line):
try:
self._seen_lines.remove(line)
except ValueError:
pass
def update(self, op_code, cur_count, max_count=None, message=""):
# Check each stage only comes once.
op_id = op_code & self.OP_MASK
assert op_id in (self.COUNTING, self.COMPRESSING, self.WRITING)
if op_code & self.WRITING > 0:
if op_code & self.BEGIN > 0:
assert not message, "should not have message when remote begins writing"
elif op_code & self.END > 0:
assert message
assert not message.startswith(", "), "Sanitize progress messages: '%s'" % message
assert not message.endswith(", "), "Sanitize progress messages: '%s'" % message
self._stages_per_op.setdefault(op_id, 0)
self._stages_per_op[op_id] = self._stages_per_op[op_id] | (op_code & self.STAGE_MASK)
if op_code & (self.WRITING | self.END) == (self.WRITING | self.END):
assert message
# END check we get message
self._num_progress_messages += 1
def make_assertion(self):
# We don't always receive messages.
if not self._seen_lines:
return
# Sometimes objects are not compressed which is okay.
assert len(self._seen_ops) in (2, 3), len(self._seen_ops)
assert self._stages_per_op
# Must have seen all stages.
for _op, stages in self._stages_per_op.items():
assert stages & self.STAGE_MASK == self.STAGE_MASK
# END for each op/stage
def assert_received_message(self):
assert self._num_progress_messages
TestRemoteProgress.__test__ = False # type: ignore
class TestRemote(TestBase):
def tearDown(self):
gc.collect()
def _print_fetchhead(self, repo):
with open(osp.join(repo.git_dir, "FETCH_HEAD")):
pass
def _do_test_fetch_result(self, results, remote):
# self._print_fetchhead(remote.repo)
self.assertGreater(len(results), 0)
self.assertIsInstance(results[0], FetchInfo)
for info in results:
self.assertIsInstance(info.note, str)
if isinstance(info.ref, Reference):
self.assertTrue(info.flags)
# END reference type flags handling
self.assertIsInstance(info.ref, (SymbolicReference, Reference))
if info.flags & (info.FORCED_UPDATE | info.FAST_FORWARD):
self.assertIsInstance(info.old_commit, Commit)
else:
self.assertIsNone(info.old_commit)
# END forced update checking
# END for each info
def _do_test_push_result(self, results, remote):
self.assertIsInstance(results, list)
self.assertIsInstance(results, IterableList)
self.assertGreater(len(results), 0)
self.assertIsInstance(results[0], PushInfo)
for info in results:
self.assertTrue(info.flags)
self.assertIsInstance(info.summary, str)
if info.old_commit is not None:
self.assertIsInstance(info.old_commit, Commit)
if info.flags & info.ERROR:
has_one = False
for bitflag in (
info.REJECTED,
info.REMOTE_REJECTED,
info.REMOTE_FAILURE,
):
has_one |= bool(info.flags & bitflag)
# END for each bitflag
self.assertTrue(has_one)
else:
# There must be a remote commit.
if info.flags & info.DELETED == 0:
self.assertIsInstance(info.local_ref, Reference)
else:
self.assertIsNone(info.local_ref)
self.assertIn(type(info.remote_ref), (TagReference, RemoteReference))
# END error checking
# END for each info
if any(info.flags & info.ERROR for info in results):
self.assertRaises(GitCommandError, results.raise_if_error)
else:
# No errors, so this should do nothing.
results.raise_if_error()
def _do_test_fetch_info(self, repo):
self.assertRaises(ValueError, FetchInfo._from_line, repo, "nonsense", "")
self.assertRaises(
ValueError,
FetchInfo._from_line,
repo,
"? [up to date] 0.1.7RC -> origin/0.1.7RC",
"",
)
def _commit_random_file(self, repo):
"""Create a file with a random name and random data and commit it to a repo.
:return: The committed absolute file path.
"""
index = repo.index
new_file = self._make_file(osp.basename(tempfile.mktemp()), str(random.random()), repo)
index.add([new_file])
index.commit("Committing %s" % new_file)
return new_file
def _do_test_fetch(self, remote, rw_repo, remote_repo, **kwargs):
"""Specialized fetch testing to de-clutter the main test."""
self._do_test_fetch_info(rw_repo)
def fetch_and_test(remote, **kwargs):
progress = TestRemoteProgress()
kwargs["progress"] = progress
res = remote.fetch(**kwargs)
progress.make_assertion()
self._do_test_fetch_result(res, remote)
return res
# END fetch and check
def get_info(res, remote, name):
return res["%s/%s" % (remote, name)]
# Put remote head to master as it is guaranteed to exist.
remote_repo.head.reference = remote_repo.heads.master
res = fetch_and_test(remote, **kwargs)
# All up to date.
for info in res:
self.assertTrue(info.flags & info.HEAD_UPTODATE)
# Rewind remote head to trigger rejection.
# index must be false as remote is a bare repo.
rhead = remote_repo.head
remote_commit = rhead.commit
rhead.reset("HEAD~2", index=False)
res = fetch_and_test(remote)
mkey = "%s/%s" % (remote, "master")
master_info = res[mkey]
self.assertTrue(master_info.flags & FetchInfo.FORCED_UPDATE)
self.assertIsNotNone(master_info.note)
# Normal fast forward - set head back to previous one.
rhead.commit = remote_commit
res = fetch_and_test(remote)
self.assertTrue(res[mkey].flags & FetchInfo.FAST_FORWARD)
# New remote branch.
new_remote_branch = Head.create(remote_repo, "new_branch")
res = fetch_and_test(remote)
new_branch_info = get_info(res, remote, new_remote_branch)
self.assertTrue(new_branch_info.flags & FetchInfo.NEW_HEAD)
# Remote branch rename (causes creation of a new one locally).
new_remote_branch.rename("other_branch_name")
res = fetch_and_test(remote)
other_branch_info = get_info(res, remote, new_remote_branch)
self.assertEqual(other_branch_info.ref.commit, new_branch_info.ref.commit)
# Remove new branch.
Head.delete(new_remote_branch.repo, new_remote_branch)
res = fetch_and_test(remote)
# Deleted remote will not be fetched.
self.assertRaises(IndexError, get_info, res, remote, new_remote_branch)
# Prune stale tracking branches.
stale_refs = remote.stale_refs
self.assertEqual(len(stale_refs), 2)
self.assertIsInstance(stale_refs[0], RemoteReference)
RemoteReference.delete(rw_repo, *stale_refs)
# Test single branch fetch with refspec including target remote.
res = fetch_and_test(remote, refspec="master:refs/remotes/%s/master" % remote)
self.assertEqual(len(res), 1)
self.assertTrue(get_info(res, remote, "master"))
# ...with respec and no target.
res = fetch_and_test(remote, refspec="master")
self.assertEqual(len(res), 1)
# ...multiple refspecs...works, but git command returns with error if one ref is
# wrong without doing anything. This is new in later binaries.
# res = fetch_and_test(remote, refspec=['master', 'fred'])
# self.assertEqual(len(res), 1)
# Add new tag reference.
rtag = TagReference.create(remote_repo, "1.0-RV_hello.there")
res = fetch_and_test(remote, tags=True)
tinfo = res[str(rtag)]
self.assertIsInstance(tinfo.ref, TagReference)
self.assertEqual(tinfo.ref.commit, rtag.commit)
self.assertTrue(tinfo.flags & tinfo.NEW_TAG)
# Adjust the local tag commit.
Reference.set_object(rtag, rhead.commit.parents[0].parents[0])
# As of git 2.20 one cannot clobber local tags that have changed without
# specifying --force, and the test assumes you can clobber, so...
force = None
if rw_repo.git.version_info[:2] >= (2, 20):
force = True
res = fetch_and_test(remote, tags=True, force=force)
tinfo = res[str(rtag)]
self.assertEqual(tinfo.commit, rtag.commit)
self.assertTrue(tinfo.flags & tinfo.TAG_UPDATE)
# Delete remote tag - local one will stay.
TagReference.delete(remote_repo, rtag)
res = fetch_and_test(remote, tags=True)
self.assertRaises(IndexError, get_info, res, remote, str(rtag))
# Provoke to receive actual objects to see what kind of output we have to
# expect. For that we need a remote transport protocol.
# Create a new UN-shared repo and fetch into it after we pushed a change to the
# shared repo.
other_repo_dir = tempfile.mktemp("other_repo")
# Must clone with a local path for the repo implementation not to freak out as
# it wants local paths only (which I can understand).
other_repo = remote_repo.clone(other_repo_dir, shared=False)
remote_repo_url = osp.basename(remote_repo.git_dir) # git-daemon runs with appropriate `--base-path`.
remote_repo_url = Git.polish_url("git://localhost:%s/%s" % (GIT_DAEMON_PORT, remote_repo_url))
# Put origin to git-url.
other_origin = other_repo.remotes.origin
with other_origin.config_writer as cw:
cw.set("url", remote_repo_url)
# It automatically creates alternates as remote_repo is shared as well.
# It will use the transport though and ignore alternates when fetching.
# assert not other_repo.alternates # this would fail
# Ensure we are in the right state.
rw_repo.head.reset(remote.refs.master, working_tree=True)
try:
self._commit_random_file(rw_repo)
remote.push(rw_repo.head.reference)
# Here I would expect to see remote-information about packing objects and so
# on. Unfortunately, this does not happen if we are redirecting the output -
# git explicitly checks for this and only provides progress information to
# ttys.
res = fetch_and_test(other_origin)
finally:
rmtree(other_repo_dir)
# END test and cleanup
def _assert_push_and_pull(self, remote, rw_repo, remote_repo):
# Push our changes.
lhead = rw_repo.head
# Ensure we are on master and it is checked out where the remote is.
try:
lhead.reference = rw_repo.heads.master
except AttributeError:
# If the author is on a non-master branch, the clones might not have a local
# master yet. We simply create it.
lhead.reference = rw_repo.create_head("master")
# END master handling
lhead.reset(remote.refs.master, working_tree=True)
# Push without spec should fail (without further configuration)
# well, works nicely
# self.assertRaises(GitCommandError, remote.push)
# Simple file push.
self._commit_random_file(rw_repo)
progress = TestRemoteProgress()
res = remote.push(lhead.reference, progress)
self.assertIsInstance(res, list)
self._do_test_push_result(res, remote)
progress.make_assertion()
# Rejected - undo last commit.
lhead.reset("HEAD~1")
res = remote.push(lhead.reference)
self.assertTrue(res[0].flags & PushInfo.ERROR)
self.assertTrue(res[0].flags & PushInfo.REJECTED)
self._do_test_push_result(res, remote)
# Force rejected pull.
res = remote.push("+%s" % lhead.reference)
self.assertEqual(res[0].flags & PushInfo.ERROR, 0)
self.assertTrue(res[0].flags & PushInfo.FORCED_UPDATE)
self._do_test_push_result(res, remote)
# Invalid refspec.
self.assertRaises(GitCommandError, remote.push, "hellothere")
# Push new tags.
progress = TestRemoteProgress()
to_be_updated = "my_tag.1.0RV"
new_tag = TagReference.create(rw_repo, to_be_updated) # @UnusedVariable
other_tag = TagReference.create(rw_repo, "my_obj_tag.2.1aRV", logmsg="my message")
res = remote.push(progress=progress, tags=True)
self.assertTrue(res[-1].flags & PushInfo.NEW_TAG)
progress.make_assertion()
self._do_test_push_result(res, remote)
# Update push new tags.
# Rejection is default.
new_tag = TagReference.create(rw_repo, to_be_updated, reference="HEAD~1", force=True)
res = remote.push(tags=True)
self._do_test_push_result(res, remote)
self.assertTrue(res[-1].flags & PushInfo.REJECTED)
self.assertTrue(res[-1].flags & PushInfo.ERROR)
# Force push this tag.
res = remote.push("+%s" % new_tag.path)
self.assertEqual(res[-1].flags & PushInfo.ERROR, 0)
self.assertTrue(res[-1].flags & PushInfo.FORCED_UPDATE)
# Delete tag - have to do it using refspec.
res = remote.push(":%s" % new_tag.path)
self._do_test_push_result(res, remote)
self.assertTrue(res[0].flags & PushInfo.DELETED)
# Currently progress is not properly transferred, especially not using
# the git daemon.
# progress.assert_received_message()
# Push new branch.
new_head = Head.create(rw_repo, "my_new_branch")
progress = TestRemoteProgress()
res = remote.push(new_head, progress)
self.assertGreater(len(res), 0)
self.assertTrue(res[0].flags & PushInfo.NEW_HEAD)
progress.make_assertion()
self._do_test_push_result(res, remote)
# Rejected stale delete.
force_with_lease = "%s:0000000000000000000000000000000000000000" % new_head.path
res = remote.push(":%s" % new_head.path, force_with_lease=force_with_lease)
self.assertTrue(res[0].flags & PushInfo.ERROR)
self.assertTrue(res[0].flags & PushInfo.REJECTED)
self.assertIsNone(res[0].local_ref)
self._do_test_push_result(res, remote)
# Delete new branch on the remote end and locally.
res = remote.push(":%s" % new_head.path)
self._do_test_push_result(res, remote)
Head.delete(rw_repo, new_head)
self.assertTrue(res[-1].flags & PushInfo.DELETED)
# --all
res = remote.push(all=True)
self._do_test_push_result(res, remote)
remote.pull("master", kill_after_timeout=10.0)
# Cleanup - delete created tags and branches as we are in an inner loop on
# the same repository.
TagReference.delete(rw_repo, new_tag, other_tag)
remote.push(":%s" % other_tag.path, kill_after_timeout=10.0)
@skipIf(HIDE_WINDOWS_FREEZE_ERRORS, "FIXME: Freezes!")
@with_rw_and_rw_remote_repo("0.1.6")
def test_base(self, rw_repo, remote_repo):
num_remotes = 0
remote_set = set()
ran_fetch_test = False
for remote in rw_repo.remotes:
num_remotes += 1
self.assertEqual(remote, remote)
self.assertNotEqual(str(remote), repr(remote))
remote_set.add(remote)
remote_set.add(remote) # Should already exist.
# REFS
refs = remote.refs
self.assertTrue(refs)
for ref in refs:
self.assertEqual(ref.remote_name, remote.name)
self.assertTrue(ref.remote_head)
# END for each ref
# OPTIONS
# Cannot use 'fetch' key anymore as it is now a method.
for opt in ("url",):
val = getattr(remote, opt)
reader = remote.config_reader
assert reader.get(opt) == val
assert reader.get_value(opt, None) == val
# Unable to write with a reader.
self.assertRaises(IOError, reader.set, opt, "test")
# Change value.
with remote.config_writer as writer:
new_val = "myval"
writer.set(opt, new_val)
assert writer.get(opt) == new_val
writer.set(opt, val)
assert writer.get(opt) == val
assert getattr(remote, opt) == val
# END for each default option key
# RENAME
other_name = "totally_other_name"
prev_name = remote.name
self.assertEqual(remote.rename(other_name), remote)
self.assertNotEqual(prev_name, remote.name)
# Multiple times.
for _ in range(2):
self.assertEqual(remote.rename(prev_name).name, prev_name)
# END for each rename ( back to prev_name )
# PUSH/PULL TESTING
self._assert_push_and_pull(remote, rw_repo, remote_repo)
# FETCH TESTING
# Only for remotes - local cases are the same or less complicated as
# additional progress information will never be emitted.
if remote.name == "daemon_origin":
self._do_test_fetch(remote, rw_repo, remote_repo, kill_after_timeout=10.0)
ran_fetch_test = True
# END fetch test
remote.update()
# END for each remote
self.assertTrue(ran_fetch_test)
self.assertTrue(num_remotes)
self.assertEqual(num_remotes, len(remote_set))
origin = rw_repo.remote("origin")
assert origin == rw_repo.remotes.origin
# Verify we can handle prunes when fetching.
# stderr lines look like this: x [deleted] (none) -> origin/experiment-2012
# These should just be skipped.
# If we don't have a manual checkout, we can't actually assume there are any
# non-master branches.
remote_repo.create_head("myone_for_deletion")
# Get the branch - to be pruned later
origin.fetch()
num_deleted = False
for branch in remote_repo.heads:
if branch.name != "master":
branch.delete(remote_repo, branch, force=True)
num_deleted += 1
# END for each branch
self.assertGreater(num_deleted, 0)
self.assertEqual(
len(rw_repo.remotes.origin.fetch(prune=True)),
1,
"deleted everything but master",
)
@with_rw_repo("HEAD", bare=True)
def test_creation_and_removal(self, bare_rw_repo):
new_name = "test_new_one"
arg_list = (new_name, "git@server:hello.git")
remote = Remote.create(bare_rw_repo, *arg_list)
self.assertEqual(remote.name, "test_new_one")
self.assertIn(remote, bare_rw_repo.remotes)
self.assertTrue(remote.exists())
# Create same one again.
self.assertRaises(GitCommandError, Remote.create, bare_rw_repo, *arg_list)
Remote.remove(bare_rw_repo, new_name)
self.assertTrue(remote.exists()) # We still have a cache that doesn't know we were deleted by name.
remote._clear_cache()
assert not remote.exists() # Cache should be renewed now. This is an issue...
for remote in bare_rw_repo.remotes:
if remote.name == new_name:
raise AssertionError("Remote removal failed")
# END if deleted remote matches existing remote's name
# END for each remote
# Issue #262 - the next call would fail if bug wasn't fixed.
bare_rw_repo.create_remote("bogus", "/bogus/path", mirror="push")
def test_fetch_info(self):
# Ensure we can handle remote-tracking branches.
fetch_info_line_fmt = "c437ee5deb8d00cf02f03720693e4c802e99f390 not-for-merge %s '0.3' of "
fetch_info_line_fmt += "git://github.com/gitpython-developers/GitPython"
remote_info_line_fmt = "* [new branch] nomatter -> %s"
self.assertRaises(
ValueError,
FetchInfo._from_line,
self.rorepo,
remote_info_line_fmt % "refs/something/branch",
"269c498e56feb93e408ed4558c8138d750de8893\t\t/Users/ben/test/foo\n",
)
fi = FetchInfo._from_line(
self.rorepo,
remote_info_line_fmt % "local/master",
fetch_info_line_fmt % "remote-tracking branch",
)
assert not fi.ref.is_valid()
self.assertEqual(fi.ref.name, "local/master")
# Handles non-default refspecs: One can specify a different path in refs/remotes
# or a special path just in refs/something for instance.
fi = FetchInfo._from_line(
self.rorepo,
remote_info_line_fmt % "subdir/tagname",
fetch_info_line_fmt % "tag",
)
self.assertIsInstance(fi.ref, TagReference)
assert fi.ref.path.startswith("refs/tags"), fi.ref.path
# It could be in a remote directory though.
fi = FetchInfo._from_line(
self.rorepo,
remote_info_line_fmt % "remotename/tags/tagname",
fetch_info_line_fmt % "tag",
)
self.assertIsInstance(fi.ref, TagReference)
assert fi.ref.path.startswith("refs/remotes/"), fi.ref.path
# It can also be anywhere!
tag_path = "refs/something/remotename/tags/tagname"
fi = FetchInfo._from_line(self.rorepo, remote_info_line_fmt % tag_path, fetch_info_line_fmt % "tag")
self.assertIsInstance(fi.ref, TagReference)
self.assertEqual(fi.ref.path, tag_path)
# Branches default to refs/remotes.
fi = FetchInfo._from_line(
self.rorepo,
remote_info_line_fmt % "remotename/branch",
fetch_info_line_fmt % "branch",
)
self.assertIsInstance(fi.ref, RemoteReference)
self.assertEqual(fi.ref.remote_name, "remotename")
# But you can force it anywhere, in which case we only have a references.
fi = FetchInfo._from_line(
self.rorepo,
remote_info_line_fmt % "refs/something/branch",
fetch_info_line_fmt % "branch",
)
assert type(fi.ref) is Reference, type(fi.ref)
self.assertEqual(fi.ref.path, "refs/something/branch")
def test_uncommon_branch_names(self):
stderr_lines = fixture("uncommon_branch_prefix_stderr").decode("ascii").splitlines()
fetch_lines = fixture("uncommon_branch_prefix_FETCH_HEAD").decode("ascii").splitlines()
# The contents of the files above must be fetched with a custom refspec:
# +refs/pull/*:refs/heads/pull/*
res = [
FetchInfo._from_line("ShouldntMatterRepo", stderr, fetch_line)
for stderr, fetch_line in zip(stderr_lines, fetch_lines)
]
self.assertGreater(len(res), 0)
self.assertEqual(res[0].remote_ref_path, "refs/pull/1/head")
self.assertEqual(res[0].ref.path, "refs/heads/pull/1/head")
self.assertIsInstance(res[0].ref, Head)
@with_rw_repo("HEAD", bare=False)
def test_multiple_urls(self, rw_repo):
# Test addresses.
test1 = "https://github.com/gitpython-developers/GitPython"
test2 = "https://github.com/gitpython-developers/gitdb"
test3 = "https://github.com/gitpython-developers/smmap"
remote = rw_repo.remotes[0]
# Test setting a single URL.
remote.set_url(test1)
self.assertEqual(list(remote.urls), [test1])
# Test replacing that single URL.
remote.set_url(test1)
self.assertEqual(list(remote.urls), [test1])
# Test adding new URLs.
remote.set_url(test2, add=True)
self.assertEqual(list(remote.urls), [test1, test2])
remote.set_url(test3, add=True)
self.assertEqual(list(remote.urls), [test1, test2, test3])
# Test removing a URL.
remote.set_url(test2, delete=True)
self.assertEqual(list(remote.urls), [test1, test3])
# Test changing a URL.
remote.set_url(test2, test3)
self.assertEqual(list(remote.urls), [test1, test2])
# will raise: fatal: --add --delete doesn't make sense
self.assertRaises(GitCommandError, remote.set_url, test2, add=True, delete=True)
# Test on another remote, with the add/delete URL.
remote = rw_repo.create_remote("another", url=test1)
remote.add_url(test2)
self.assertEqual(list(remote.urls), [test1, test2])
remote.add_url(test3)
self.assertEqual(list(remote.urls), [test1, test2, test3])
# Test removing all the URLs.
remote.delete_url(test2)
self.assertEqual(list(remote.urls), [test1, test3])
remote.delete_url(test1)
self.assertEqual(list(remote.urls), [test3])
# Will raise fatal: Will not delete all non-push URLs.
self.assertRaises(GitCommandError, remote.delete_url, test3)
@with_rw_repo("HEAD", bare=False)
def test_create_does_not_expand_environment_variables_in_url(self, rw_repo):
url = "https://example.com/${GITPYTHON_TEST_SECRET}/repo.git"
with mock.patch.dict(os.environ, {"GITPYTHON_TEST_SECRET": "sensitive-value"}):
remote = rw_repo.create_remote("untrusted", url)
assert remote.url == url
def test_fetch_error(self):
rem = self.rorepo.remote("origin")
msg = (
r"[Cc]ouldn't find remote ref __BAD_REF__|"
r"could not read Username|"
r"expected flush after ref listing"
)
with self.assertRaisesRegex(GitCommandError, msg):
rem.fetch("__BAD_REF__")
@with_rw_repo("0.1.6", bare=False)
def test_push_error(self, repo):
rem = repo.remote("origin")
with self.assertRaisesRegex(GitCommandError, "src refspec __BAD_REF__ does not match any"):
rem.push("__BAD_REF__")
@with_rw_repo("HEAD")
def test_set_unsafe_url(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
with self.assertRaises(UnsafeProtocolError):
remote.set_url(url)
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_set_unsafe_url_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
remote.set_url(url, allow_unsafe_protocols=True)
assert list(remote.urls)[-1] == url
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_add_unsafe_url(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
with self.assertRaises(UnsafeProtocolError):
remote.add_url(url)
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_add_unsafe_url_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
remote.add_url(url, allow_unsafe_protocols=True)
assert list(remote.urls)[-1] == url
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_create_remote_unsafe_url(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
with self.assertRaises(UnsafeProtocolError):
Remote.create(rw_repo, "origin", url)
assert not tmp_file.exists()
@pytest.mark.xfail(
sys.platform == "win32",
reason=R"Multiple '\' instead of '/' in remote.url make it differ from expected value",
raises=AssertionError,
)
@with_rw_repo("HEAD")
def test_create_remote_unsafe_url_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for i, url in enumerate(urls):
remote = Remote.create(rw_repo, f"origin{i}", url, allow_unsafe_protocols=True)
assert remote.url == url
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_fetch_unsafe_url(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
with self.assertRaises(UnsafeProtocolError):
remote.fetch(url)
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_fetch_unsafe_url_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
# The URL will be allowed into the command, but the command will fail
# since we don't have that protocol enabled in the Git config file.
with self.assertRaises(GitCommandError):
remote.fetch(url, allow_unsafe_protocols=True)
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_fetch_unsafe_options(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
remote = rw_repo.remote("origin")
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
unsafe_options = [
{"upload-pack": f"touch {tmp_file}"},
{"upload_pack": f"touch {tmp_file}"},
{"upload_p": f"touch {tmp_file}"},
]
for unsafe_option in unsafe_options:
with self.assertRaises(UnsafeOptionError):
remote.fetch(**unsafe_option)
assert not tmp_file.exists()
@pytest.mark.xfail(
sys.platform == "win32",
reason=(
"File not created. A separate Windows command may be needed. This and the "
"currently passing test test_fetch_unsafe_options must be adjusted in the "
"same way. Until then, test_fetch_unsafe_options is unreliable on Windows."
),
raises=AssertionError,
)
@with_rw_repo("HEAD")
def test_fetch_unsafe_options_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
remote = rw_repo.remote("origin")
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
unsafe_options = [{"upload-pack": f"touch {tmp_file}"}]
for unsafe_option in unsafe_options:
# The options will be allowed, but the command will fail.
assert not tmp_file.exists()
with self.assertRaises(GitCommandError):
remote.fetch(**unsafe_option, allow_unsafe_options=True)
assert tmp_file.exists()
tmp_file.unlink()
@with_rw_repo("HEAD")
def test_pull_unsafe_url(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
with self.assertRaises(UnsafeProtocolError):
remote.pull(url)
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_pull_unsafe_url_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
# The URL will be allowed into the command, but the command will fail
# since we don't have that protocol enabled in the Git config file.
with self.assertRaises(GitCommandError):
remote.pull(url, allow_unsafe_protocols=True)
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_pull_unsafe_options(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
remote = rw_repo.remote("origin")
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
unsafe_options = [
{"upload-pack": f"touch {tmp_file}"},
{"upload_pack": f"touch {tmp_file}"},
{"upload_p": f"touch {tmp_file}"},
]
for unsafe_option in unsafe_options:
with self.assertRaises(UnsafeOptionError):
remote.pull(**unsafe_option)
assert not tmp_file.exists()
@pytest.mark.xfail(
sys.platform == "win32",
reason=(
"File not created. A separate Windows command may be needed. This and the "
"currently passing test test_pull_unsafe_options must be adjusted in the "
"same way. Until then, test_pull_unsafe_options is unreliable on Windows."
),
raises=AssertionError,
)
@with_rw_repo("HEAD")
def test_pull_unsafe_options_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
remote = rw_repo.remote("origin")
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
unsafe_options = [{"upload-pack": f"touch {tmp_file}"}]
for unsafe_option in unsafe_options:
# The options will be allowed, but the command will fail.
assert not tmp_file.exists()
with self.assertRaises(GitCommandError):
remote.pull(**unsafe_option, allow_unsafe_options=True)
assert tmp_file.exists()
tmp_file.unlink()
@with_rw_repo("HEAD")
def test_push_unsafe_url(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
with self.assertRaises(UnsafeProtocolError):
remote.push(url)
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_push_unsafe_url_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
remote = rw_repo.remote("origin")
urls = [
f"ext::sh -c touch% {tmp_file}",
"fd::17/foo",
]
for url in urls:
# The URL will be allowed into the command, but the command will fail
# since we don't have that protocol enabled in the Git config file.
with self.assertRaises(GitCommandError):
remote.push(url, allow_unsafe_protocols=True)
assert not tmp_file.exists()
@with_rw_repo("HEAD")
def test_push_unsafe_options(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
remote = rw_repo.remote("origin")
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
unsafe_options = [
{"receive-pack": f"touch {tmp_file}"},
{"receive_pack": f"touch {tmp_file}"},
{"receive_p": f"touch {tmp_file}"},
{"exec": f"touch {tmp_file}"},
{"exe": f"touch {tmp_file}"},
]
for unsafe_option in unsafe_options:
assert not tmp_file.exists()
with self.assertRaises(UnsafeOptionError):
remote.push(**unsafe_option)
assert not tmp_file.exists()
@pytest.mark.xfail(
sys.platform == "win32",
reason=(
"File not created. A separate Windows command may be needed. This and the "
"currently passing test test_push_unsafe_options must be adjusted in the "
"same way. Until then, test_push_unsafe_options is unreliable on Windows."
),
raises=AssertionError,
)
@with_rw_repo("HEAD")
def test_push_unsafe_options_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
remote = rw_repo.remote("origin")
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
unsafe_options = [
{"receive-pack": f"touch {tmp_file}"},
{"receive_pack": f"touch {tmp_file}"},
{"exec": f"touch {tmp_file}"},
]
for unsafe_option in unsafe_options:
# The options will be allowed, but the command will fail.
assert not tmp_file.exists()
with self.assertRaises(GitCommandError):
remote.push(**unsafe_option, allow_unsafe_options=True)
assert tmp_file.exists()
tmp_file.unlink()
@with_rw_repo("HEAD")
def test_ls_remote_unsafe_options(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
unsafe_options = [
{"upload-pack": f"touch {tmp_file}"},
{"upload_pack": f"touch {tmp_file}"},
{"upl": f"touch {tmp_file}"},
{"exec": f"touch {tmp_file}"},
]
for unsafe_option in unsafe_options:
with self.assertRaises(UnsafeOptionError):
rw_repo.git.ls_remote(".", **unsafe_option)
with self.assertRaises(UnsafeOptionError):
rw_repo.git.ls_remote([f"--upload-pack={tmp_file}"], ".")
with self.assertRaises(UnsafeOptionError):
rw_repo.git.ls_remote([f"--upl={tmp_file}"], ".")
with self.assertRaises(UnsafeOptionError):
rw_repo.git.ls_remote(f"--upload-pack={tmp_file}", ".")
with self.assertRaises(UnsafeOptionError):
rw_repo.git.ls_remote(f"--upl={tmp_file}", ".")
with self.assertRaises(UnsafeOptionError):
rw_repo.git.ls_remote(f"--exec={tmp_file}", ".")
with self.assertRaises(UnsafeOptionError):
rw_repo.git.ls_remote("--upload-pack", "touch", ".")
with self.assertRaises(UnsafeOptionError):
rw_repo.git.ls_remote("--refs", ".", upl=f"touch {tmp_file}")
def test_ls_remote_allows_operand_named_like_unsafe_option(self):
with mock.patch.object(Git, "_call_process") as git:
Git().ls_remote("upload-pack")
git.assert_called_once()
@with_rw_repo("HEAD")
def test_ls_remote_unsafe_options_allowed(self, rw_repo):
with tempfile.TemporaryDirectory() as tdir:
tmp_dir = Path(tdir)
tmp_file = tmp_dir / "pwn"
unsafe_options = [{"upload-pack": f"touch {tmp_file}"}]
for unsafe_option in unsafe_options:
with self.assertRaises(GitCommandError):
rw_repo.git.ls_remote(".", **unsafe_option, allow_unsafe_options=True)
@with_rw_and_rw_remote_repo("0.1.6")
def test_fetch_unsafe_branch_name(self, rw_repo, remote_repo):
# Create branch with a name containing a NBSP
bad_branch_name = f"branch_with_{chr(160)}_nbsp"
Head.create(remote_repo, bad_branch_name)
# Fetch and get branches
remote = rw_repo.remote("origin")
branches = remote.fetch()
# Test for truncated branch name in branches
assert f"origin/{bad_branch_name}" in [b.name for b in branches]
# Cleanup branch
Head.delete(remote_repo, bad_branch_name)
@pytest.mark.skipif(sys.platform == "win32", reason="kill_after_timeout is not supported on Windows")
@pytest.mark.parametrize("protocol", ["git", "http", "https"])
@pytest.mark.parametrize("operation", ["fetch", "pull", "push"])
def test_timeout_stalled_remote(tmp_path, protocol, operation):
repo = Repo.init(tmp_path)
repo.git.symbolic_ref("HEAD", "refs/heads/main")
with repo.config_writer() as config:
config.set_value("user", "name", "Timeout Test")
config.set_value("user", "email", "timeout@example.com")
config.set_value("commit", "gpgSign", False)
repo.git.commit(allow_empty=True, message="initial commit")
with socket.socket() as server:
server.bind(("127.0.0.1", 0))
server.listen()
server.settimeout(10)
finished = threading.Event()
connected = threading.Event()
def stall():
with server.accept()[0]:
connected.set()
# Release the connection even if the timeout cleanup deadlocks.
finished.wait(5)
thread = threading.Thread(target=stall)
thread.start()
remote = repo.create_remote("origin", f"{protocol}://127.0.0.1:{server.getsockname()[1]}/stalled.git")
started = time.monotonic()
try:
with pytest.raises(GitCommandError, match="process killed because it timed out"):
getattr(remote, operation)("main", kill_after_timeout=0.5)
assert connected.is_set(), "the command did not reach the stalled remote"
assert time.monotonic() - started < 3, "timeout cleanup blocked on a pipe reader"
finally:
finished.set()
thread.join(10)
repo.close()
assert not thread.is_alive()
class TestTimeouts(TestBase):
@skipIf(sys.platform == "win32", "requires POSIX timeout signalling")
@with_rw_repo("HEAD", bare=False)
def test_timeout_partial_push(self, repo):
process = repo.git.execute(
[
sys.executable,
"-c",
"import time; print('=\\trefs/heads/main:refs/heads/main\\t[up to date]', flush=True); time.sleep(30)",
],
as_process=True,
universal_newlines=True,
)
with mock.patch("git.remote._logger.warning") as warning:
result = repo.remote("origin")._get_push_info(process, None, kill_after_timeout=0.5)
warning.assert_not_called()
assert len(result) == 1
with pytest.raises(GitCommandError, match="process killed because it timed out"):
result.raise_if_error()
@with_rw_repo("HEAD", bare=False)
def test_timeout_funcs(self, repo):
# Maintenance may outlive a timed-out fetch and race with fixture cleanup.
with repo.config_writer() as config:
config.set_value("maintenance", "auto", False)
config.set_value("gc", "auto", 0) # Older Git versions use auto-gc.
# Force error code to prevent a race condition if the python thread is slow.
with mock.patch.object(Git.AutoInterrupt, "_status_code_if_terminate", -15):
for function in ["pull", "fetch"]: # Can't get push to time out.
f = getattr(repo.remotes.origin, function)
assert f is not None # Make sure these functions exist.
_ = f() # Make sure the function runs.
with pytest.raises(GitCommandError, match="kill_after_timeout=0 s"):
f(kill_after_timeout=0)