git-repo/tests/test_project.py

537 lines
19 KiB
Python
Raw Permalink Normal View History

# Copyright (C) 2019 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unittests for the project.py module."""
import contextlib
import os
project: migrate worktree .git/ dirs to symlinks Historically we created a .git/ subdir in each source checkout and symlinked individual files to the .repo/projects/ paths. This layer of indirection isn't actually needed: the .repo/projects/ paths are guaranteed to only ever have a 1-to-1 mapping with the actual git checkout. So we don't need to worry about having files in .git/ be isolated. To that end, change how we manage the actual project checkouts from a dir full of symlinks (and a few files) to a symlink to the internal .repo/projects/ dir. This makes the code simpler & faster. The directory structure we have today is: .repo/ project-objects/chromiumos/third_party/kernel.git/ <paths omitted as not relevant to this change> projects/src/third_party/kernel/ v3.8.git/ config description -> …/project-objects/…/config FETCH_HEAD HEAD hooks/ -> …/project-objects/…/hooks/ info/ -> …/project-objects/…/info/ logs/ objects/ -> …/project-objects/…/objects/ packed-refs refs/ rr-cache/ -> …/project-objects/…/rr-cache/ src/third_party/kernel/ v3.8/ .git/ config -> …/projects/…/v3.8.git/config description -> …/project-objects/…/v3.8.git/description HEAD hooks/ -> …/project-objects/…/v3.8.git/hooks/ index info/ -> …/project-objects/…/v3.8.git/info/ logs/ -> …/projects/…/v3.8.git/logs/ objects/ -> …/project-objects/…/v3.8.git/objects/ packed-refs -> …/projects/…/v3.8.git/packed-refs refs/ -> …/projects/…/v3.8.git/refs/ rr-cache/ -> …/project-objects/…/v3.8.git/rr-cache/ The directory structure we have after this commit: .repo/ <nothing changes> src/third_party/kernel/ v3.8/ .git -> …/projects/…/v3.8.git Bug: https://crbug.com/gerrit/15273 Change-Id: I9dd8def23fbfb2f4cb209a93f8b1b2b24002a444 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/323695 Reviewed-by: Mike Nichols <mikenichols@google.com> Reviewed-by: Xin Li <delphij@google.com> Tested-by: Mike Frysinger <vapier@google.com>
2021-11-14 04:29:42 +00:00
from pathlib import Path
import subprocess
import tempfile
import unittest
import error
import git_command
import git_config
import manifest_xml
import platform_utils
import project
@contextlib.contextmanager
def TempGitTree():
"""Create a new empty git checkout for testing."""
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
# Tests need to assume, that main is default branch at init,
# which is not supported in config until 2.28.
cmd = ["git", "init"]
if git_command.git_require((2, 28, 0)):
cmd += ["--initial-branch=main"]
else:
# Use template dir for init.
templatedir = tempfile.mkdtemp(prefix=".test-template")
with open(os.path.join(templatedir, "HEAD"), "w") as fp:
fp.write("ref: refs/heads/main\n")
cmd += ["--template", templatedir]
subprocess.check_call(cmd, cwd=tempdir)
yield tempdir
class FakeProject:
"""A fake for Project for basic functionality."""
def __init__(self, worktree):
self.worktree = worktree
self.gitdir = os.path.join(worktree, ".git")
self.name = "fakeproject"
self.work_git = project.Project._GitGetByExec(
self, bare=False, gitdir=self.gitdir
)
self.bare_git = project.Project._GitGetByExec(
self, bare=True, gitdir=self.gitdir
)
self.config = git_config.GitConfig.ForRepository(gitdir=self.gitdir)
class ReviewableBranchTests(unittest.TestCase):
"""Check ReviewableBranch behavior."""
def test_smoke(self):
"""A quick run through everything."""
with TempGitTree() as tempdir:
fakeproj = FakeProject(tempdir)
# Generate some commits.
with open(os.path.join(tempdir, "readme"), "w") as fp:
fp.write("txt")
fakeproj.work_git.add("readme")
fakeproj.work_git.commit("-mAdd file")
fakeproj.work_git.checkout("-b", "work")
fakeproj.work_git.rm("-f", "readme")
fakeproj.work_git.commit("-mDel file")
# Start off with the normal details.
rb = project.ReviewableBranch(
fakeproj, fakeproj.config.GetBranch("work"), "main"
)
self.assertEqual("work", rb.name)
self.assertEqual(1, len(rb.commits))
self.assertIn("Del file", rb.commits[0])
d = rb.unabbrev_commits
self.assertEqual(1, len(d))
short, long = next(iter(d.items()))
self.assertTrue(long.startswith(short))
self.assertTrue(rb.base_exists)
# Hard to assert anything useful about this.
self.assertTrue(rb.date)
# Now delete the tracking branch!
fakeproj.work_git.branch("-D", "main")
rb = project.ReviewableBranch(
fakeproj, fakeproj.config.GetBranch("work"), "main"
)
self.assertEqual(0, len(rb.commits))
self.assertFalse(rb.base_exists)
# Hard to assert anything useful about this.
self.assertTrue(rb.date)
class ProjectTests(unittest.TestCase):
"""Check Project behavior."""
def test_encode_patchset_description(self):
self.assertEqual(
project.Project._encode_patchset_description("abcd00!! +"),
"abcd00%21%21_%2b",
)
class CopyLinkTestCase(unittest.TestCase):
"""TestCase for stub repo client checkouts.
It'll have a layout like this:
tempdir/ # self.tempdir
checkout/ # self.topdir
git-project/ # self.worktree
Attributes:
tempdir: A dedicated temporary directory.
worktree: The top of the repo client checkout.
topdir: The top of a project checkout.
"""
def setUp(self):
self.tempdirobj = tempfile.TemporaryDirectory(prefix="repo_tests")
self.tempdir = self.tempdirobj.name
self.topdir = os.path.join(self.tempdir, "checkout")
self.worktree = os.path.join(self.topdir, "git-project")
os.makedirs(self.topdir)
os.makedirs(self.worktree)
def tearDown(self):
self.tempdirobj.cleanup()
@staticmethod
def touch(path):
with open(path, "w"):
pass
def assertExists(self, path, msg=None):
"""Make sure |path| exists."""
if os.path.exists(path):
return
if msg is None:
msg = ["path is missing: %s" % path]
while path != "/":
path = os.path.dirname(path)
if not path:
# If we're given something like "foo", abort once we get to
# "".
break
result = os.path.exists(path)
msg.append(f"\tos.path.exists({path}): {result}")
if result:
msg.append("\tcontents: %r" % os.listdir(path))
break
msg = "\n".join(msg)
raise self.failureException(msg)
class CopyFile(CopyLinkTestCase):
"""Check _CopyFile handling."""
def CopyFile(self, src, dest):
return project._CopyFile(self.worktree, src, self.topdir, dest)
def test_basic(self):
"""Basic test of copying a file from a project to the toplevel."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
cf = self.CopyFile("foo.txt", "foo")
cf._Copy()
self.assertExists(os.path.join(self.topdir, "foo"))
def test_src_subdir(self):
"""Copy a file from a subdir of a project."""
src = os.path.join(self.worktree, "bar", "foo.txt")
os.makedirs(os.path.dirname(src))
self.touch(src)
cf = self.CopyFile("bar/foo.txt", "new.txt")
cf._Copy()
self.assertExists(os.path.join(self.topdir, "new.txt"))
def test_dest_subdir(self):
"""Copy a file to a subdir of a checkout."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
cf = self.CopyFile("foo.txt", "sub/dir/new.txt")
self.assertFalse(os.path.exists(os.path.join(self.topdir, "sub")))
cf._Copy()
self.assertExists(os.path.join(self.topdir, "sub", "dir", "new.txt"))
def test_update(self):
"""Make sure changed files get copied again."""
src = os.path.join(self.worktree, "foo.txt")
dest = os.path.join(self.topdir, "bar")
with open(src, "w") as f:
f.write("1st")
cf = self.CopyFile("foo.txt", "bar")
cf._Copy()
self.assertExists(dest)
with open(dest) as f:
self.assertEqual(f.read(), "1st")
with open(src, "w") as f:
f.write("2nd!")
cf._Copy()
with open(dest) as f:
self.assertEqual(f.read(), "2nd!")
def test_src_block_symlink(self):
"""Do not allow reading from a symlinked path."""
src = os.path.join(self.worktree, "foo.txt")
sym = os.path.join(self.worktree, "sym")
self.touch(src)
platform_utils.symlink("foo.txt", sym)
self.assertExists(sym)
cf = self.CopyFile("sym", "foo")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_src_block_symlink_traversal(self):
"""Do not allow reading through a symlink dir."""
realfile = os.path.join(self.tempdir, "file.txt")
self.touch(realfile)
src = os.path.join(self.worktree, "bar", "file.txt")
platform_utils.symlink(self.tempdir, os.path.join(self.worktree, "bar"))
self.assertExists(src)
cf = self.CopyFile("bar/file.txt", "foo")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_src_block_copy_from_dir(self):
"""Do not allow copying from a directory."""
src = os.path.join(self.worktree, "dir")
os.makedirs(src)
cf = self.CopyFile("dir", "foo")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_dest_block_symlink(self):
"""Do not allow writing to a symlink."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
platform_utils.symlink("dest", os.path.join(self.topdir, "sym"))
cf = self.CopyFile("foo.txt", "sym")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_dest_block_symlink_traversal(self):
"""Do not allow writing through a symlink dir."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
platform_utils.symlink(
tempfile.gettempdir(), os.path.join(self.topdir, "sym")
)
cf = self.CopyFile("foo.txt", "sym/foo.txt")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
def test_src_block_copy_to_dir(self):
"""Do not allow copying to a directory."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
os.makedirs(os.path.join(self.topdir, "dir"))
cf = self.CopyFile("foo.txt", "dir")
self.assertRaises(error.ManifestInvalidPathError, cf._Copy)
class LinkFile(CopyLinkTestCase):
"""Check _LinkFile handling."""
def LinkFile(self, src, dest):
return project._LinkFile(self.worktree, src, self.topdir, dest)
def test_basic(self):
"""Basic test of linking a file from a project into the toplevel."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
lf = self.LinkFile("foo.txt", "foo")
lf._Link()
dest = os.path.join(self.topdir, "foo")
self.assertExists(dest)
self.assertTrue(os.path.islink(dest))
self.assertEqual(
os.path.join("git-project", "foo.txt"), os.readlink(dest)
)
def test_src_subdir(self):
"""Link to a file in a subdir of a project."""
src = os.path.join(self.worktree, "bar", "foo.txt")
os.makedirs(os.path.dirname(src))
self.touch(src)
lf = self.LinkFile("bar/foo.txt", "foo")
lf._Link()
self.assertExists(os.path.join(self.topdir, "foo"))
def test_src_self(self):
"""Link to the project itself."""
dest = os.path.join(self.topdir, "foo", "bar")
lf = self.LinkFile(".", "foo/bar")
lf._Link()
self.assertExists(dest)
self.assertEqual(os.path.join("..", "git-project"), os.readlink(dest))
def test_dest_subdir(self):
"""Link a file to a subdir of a checkout."""
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
lf = self.LinkFile("foo.txt", "sub/dir/foo/bar")
self.assertFalse(os.path.exists(os.path.join(self.topdir, "sub")))
lf._Link()
self.assertExists(os.path.join(self.topdir, "sub", "dir", "foo", "bar"))
def test_src_block_relative(self):
"""Do not allow relative symlinks."""
BAD_SOURCES = (
"./",
"..",
"../",
"foo/.",
"foo/./bar",
"foo/..",
"foo/../foo",
)
for src in BAD_SOURCES:
lf = self.LinkFile(src, "foo")
self.assertRaises(error.ManifestInvalidPathError, lf._Link)
def test_update(self):
"""Make sure changed targets get updated."""
dest = os.path.join(self.topdir, "sym")
src = os.path.join(self.worktree, "foo.txt")
self.touch(src)
lf = self.LinkFile("foo.txt", "sym")
lf._Link()
self.assertEqual(
os.path.join("git-project", "foo.txt"), os.readlink(dest)
)
# Point the symlink somewhere else.
os.unlink(dest)
platform_utils.symlink(self.tempdir, dest)
lf._Link()
self.assertEqual(
os.path.join("git-project", "foo.txt"), os.readlink(dest)
)
project: migrate worktree .git/ dirs to symlinks Historically we created a .git/ subdir in each source checkout and symlinked individual files to the .repo/projects/ paths. This layer of indirection isn't actually needed: the .repo/projects/ paths are guaranteed to only ever have a 1-to-1 mapping with the actual git checkout. So we don't need to worry about having files in .git/ be isolated. To that end, change how we manage the actual project checkouts from a dir full of symlinks (and a few files) to a symlink to the internal .repo/projects/ dir. This makes the code simpler & faster. The directory structure we have today is: .repo/ project-objects/chromiumos/third_party/kernel.git/ <paths omitted as not relevant to this change> projects/src/third_party/kernel/ v3.8.git/ config description -> …/project-objects/…/config FETCH_HEAD HEAD hooks/ -> …/project-objects/…/hooks/ info/ -> …/project-objects/…/info/ logs/ objects/ -> …/project-objects/…/objects/ packed-refs refs/ rr-cache/ -> …/project-objects/…/rr-cache/ src/third_party/kernel/ v3.8/ .git/ config -> …/projects/…/v3.8.git/config description -> …/project-objects/…/v3.8.git/description HEAD hooks/ -> …/project-objects/…/v3.8.git/hooks/ index info/ -> …/project-objects/…/v3.8.git/info/ logs/ -> …/projects/…/v3.8.git/logs/ objects/ -> …/project-objects/…/v3.8.git/objects/ packed-refs -> …/projects/…/v3.8.git/packed-refs refs/ -> …/projects/…/v3.8.git/refs/ rr-cache/ -> …/project-objects/…/v3.8.git/rr-cache/ The directory structure we have after this commit: .repo/ <nothing changes> src/third_party/kernel/ v3.8/ .git -> …/projects/…/v3.8.git Bug: https://crbug.com/gerrit/15273 Change-Id: I9dd8def23fbfb2f4cb209a93f8b1b2b24002a444 Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/323695 Reviewed-by: Mike Nichols <mikenichols@google.com> Reviewed-by: Xin Li <delphij@google.com> Tested-by: Mike Frysinger <vapier@google.com>
2021-11-14 04:29:42 +00:00
class MigrateWorkTreeTests(unittest.TestCase):
"""Check _MigrateOldWorkTreeGitDir handling."""
_SYMLINKS = {
"config",
"description",
"hooks",
"info",
"logs",
"objects",
"packed-refs",
"refs",
"rr-cache",
"shallow",
"svn",
}
_FILES = {
"COMMIT_EDITMSG",
"FETCH_HEAD",
"HEAD",
"index",
"ORIG_HEAD",
"unknown-file-should-be-migrated",
}
_CLEAN_FILES = {
"a-vim-temp-file~",
"#an-emacs-temp-file#",
}
@classmethod
@contextlib.contextmanager
def _simple_layout(cls):
"""Create a simple repo client checkout to test against."""
with tempfile.TemporaryDirectory() as tempdir:
tempdir = Path(tempdir)
gitdir = tempdir / ".repo/projects/src/test.git"
gitdir.mkdir(parents=True)
cmd = ["git", "init", "--bare", str(gitdir)]
subprocess.check_call(cmd)
dotgit = tempdir / "src/test/.git"
dotgit.mkdir(parents=True)
for name in cls._SYMLINKS:
(dotgit / name).symlink_to(
f"../../../.repo/projects/src/test.git/{name}"
)
for name in cls._FILES | cls._CLEAN_FILES:
(dotgit / name).write_text(name)
yield tempdir
def test_standard(self):
"""Migrate a standard checkout that we expect."""
with self._simple_layout() as tempdir:
dotgit = tempdir / "src/test/.git"
project.Project._MigrateOldWorkTreeGitDir(str(dotgit))
# Make sure the dir was transformed into a symlink.
self.assertTrue(dotgit.is_symlink())
self.assertEqual(
os.readlink(dotgit),
os.path.normpath("../../.repo/projects/src/test.git"),
)
# Make sure files were moved over.
gitdir = tempdir / ".repo/projects/src/test.git"
for name in self._FILES:
self.assertEqual(name, (gitdir / name).read_text())
# Make sure files were removed.
for name in self._CLEAN_FILES:
self.assertFalse((gitdir / name).exists())
def test_unknown(self):
"""A checkout with unknown files should abort."""
with self._simple_layout() as tempdir:
dotgit = tempdir / "src/test/.git"
(tempdir / ".repo/projects/src/test.git/random-file").write_text(
"one"
)
(dotgit / "random-file").write_text("two")
with self.assertRaises(error.GitError):
project.Project._MigrateOldWorkTreeGitDir(str(dotgit))
# Make sure no content was actually changed.
self.assertTrue(dotgit.is_dir())
for name in self._FILES:
self.assertTrue((dotgit / name).is_file())
for name in self._CLEAN_FILES:
self.assertTrue((dotgit / name).is_file())
for name in self._SYMLINKS:
self.assertTrue((dotgit / name).is_symlink())
class ManifestPropertiesFetchedCorrectly(unittest.TestCase):
"""Ensure properties are fetched properly."""
def setUpManifest(self, tempdir):
repodir = os.path.join(tempdir, ".repo")
manifest_dir = os.path.join(repodir, "manifests")
manifest_file = os.path.join(repodir, manifest_xml.MANIFEST_FILE_NAME)
os.mkdir(repodir)
os.mkdir(manifest_dir)
manifest = manifest_xml.XmlManifest(repodir, manifest_file)
return project.ManifestProject(
manifest, "test/manifest", os.path.join(tempdir, ".git"), tempdir
)
def test_manifest_config_properties(self):
"""Test we are fetching the manifest config properties correctly."""
with TempGitTree() as tempdir:
fakeproj = self.setUpManifest(tempdir)
# Set property using the expected Set method, then ensure
# the porperty functions are using the correct Get methods.
fakeproj.config.SetString(
"manifest.standalone", "https://chicken/manifest.git"
)
self.assertEqual(
fakeproj.standalone_manifest_url, "https://chicken/manifest.git"
)
fakeproj.config.SetString(
"manifest.groups", "test-group, admin-group"
)
self.assertEqual(
fakeproj.manifest_groups, "test-group, admin-group"
)
fakeproj.config.SetString("repo.reference", "mirror/ref")
self.assertEqual(fakeproj.reference, "mirror/ref")
fakeproj.config.SetBoolean("repo.dissociate", False)
self.assertFalse(fakeproj.dissociate)
fakeproj.config.SetBoolean("repo.archive", False)
self.assertFalse(fakeproj.archive)
fakeproj.config.SetBoolean("repo.mirror", False)
self.assertFalse(fakeproj.mirror)
fakeproj.config.SetBoolean("repo.worktree", False)
self.assertFalse(fakeproj.use_worktree)
fakeproj.config.SetBoolean("repo.clonebundle", False)
self.assertFalse(fakeproj.clone_bundle)
fakeproj.config.SetBoolean("repo.submodules", False)
self.assertFalse(fakeproj.submodules)
fakeproj.config.SetBoolean("repo.git-lfs", False)
self.assertFalse(fakeproj.git_lfs)
fakeproj.config.SetBoolean("repo.superproject", False)
self.assertFalse(fakeproj.use_superproject)
fakeproj.config.SetBoolean("repo.partialclone", False)
self.assertFalse(fakeproj.partial_clone)
fakeproj.config.SetString("repo.depth", "48")
self.assertEqual(fakeproj.depth, 48)
fakeproj.config.SetString("repo.depth", "invalid_depth")
self.assertEqual(fakeproj.depth, None)
fakeproj.config.SetString("repo.clonefilter", "blob:limit=10M")
self.assertEqual(fakeproj.clone_filter, "blob:limit=10M")
fakeproj.config.SetString(
"repo.partialcloneexclude", "third_party/big_repo"
)
self.assertEqual(
fakeproj.partial_clone_exclude, "third_party/big_repo"
)
fakeproj.config.SetString("manifest.platform", "auto")
self.assertEqual(fakeproj.manifest_platform, "auto")