From e732ad0d23f404b26c382b9ee7f0f0d8eed30293 Mon Sep 17 00:00:00 2001 From: Merlin Mathesius Date: May 05 2020 20:48:28 +0000 Subject: Add functionality to manipulate merged config git repo and tests Signed-off-by: Merlin Mathesius --- diff --git a/cccc/git.py b/cccc/git.py new file mode 100644 index 0000000..db8e26e --- /dev/null +++ b/cccc/git.py @@ -0,0 +1,203 @@ +# SPDX-License-Identifier: MIT + +import io +import os + +from cccc.utils import execute_cmd + + +def _git_env(id_file): + """ + Customize environment variables for running the 'git' command. + + :param id_file: Path to a private key identity file to use for 'git' + operations, or None for anonymous operation. + :type id_file: str + :return: Customized environment for running 'git', else None if current + environment should be used. + :rtype: dict + """ + env = None + if id_file: + env = os.environ.copy() + env["GIT_SSH_COMMAND"] = "ssh -o IdentitiesOnly=yes -i %s" % id_file + return env + + +def clone_repo(repodir, url, branch="master", commit=None, id_file=None, + user_name="Unknown", user_email="<>"): + """ + Clones a remote 'git' repo from location `url` into local directory `repodir`. + + :param repodir: Local directory into which to clone 'git' repo. Directory + must be empty or nonexistent. + :type repodir: str + :param url: URL path to remote 'git' repo to clone. + :type url: str + :param branch: The branch of the remote 'git' repo to clone. Defaults to + 'master' if not specified. + :type branch: str + :param commit: A specific 'git' commit to optionally checkout after cloning. + :type commit: str + :param id_file: Path to a private key identity file to use for 'git' + operations, or None for anonymous operation. + :type id_file: str + :param user_name: An optional username to use for commits to the cloned + 'git' repo. Defaults to 'Unknown'. + :type user_name: str + :param user_email: An optional email address to use for commits to the + cloned 'git' repo. Defaults to '<>'. + :type user_email: str + :return: None + """ + # create destination directory if it does not exist + os.makedirs(repodir, exist_ok=True) + # make sure destination directory is empty + if os.listdir(repodir): + raise RuntimeError("Directory %s is not empty" % repodir) + + # clone provided repo into destination directory + cmd = ["git", "clone", "-b", branch, url, repodir] + execute_cmd(cmd, env=_git_env(id_file)) + + # make sure user name and email are set in the clone + cmd = ["git", "config", "user.name", user_name] + execute_cmd(cmd, cwd=repodir) + cmd = ["git", "config", "user.email", user_email] + execute_cmd(cmd, cwd=repodir) + + # checkout a specified commit + if commit: + cmd = ["git", "checkout", commit] + execute_cmd(cmd, cwd=repodir) + + +def merge_repo(repodir, url, commit="proposed/master"): + """ + Merges a remote 'git' repo from location `url` into the local 'git' repo + clone located at `repodir`. + + :param repodir: Local 'git' repo clone directory into which to merge + remote repo. + :type repodir: str + :param url: URL path to remote 'git' repo to merge into local repo. + :type url: str + :param commit: A optional specific 'git' commit to merge into local repo. + :type commit: str + :return: None + """ + # make sure remote "proposed" does NOT exist + cmd = ["git", "remote", "remove", "proposed"] + try: + execute_cmd(cmd, cwd=repodir) + except RuntimeError: + # there was no remote "proposed" to remove + pass + + # add our merge repo as remote "proposed" + cmd = ["git", "remote", "add", "proposed", url] + execute_cmd(cmd, cwd=repodir) + + # fetch refs + cmd = ["git", "fetch", "proposed"] + execute_cmd(cmd, cwd=repodir) + + # return to master branch of origin + cmd = ["git", "checkout", "origin/master"] + execute_cmd(cmd, cwd=repodir) + + # finally, merge the proposed update + cmd = ["git", "merge", "--no-ff", "-m", "Merge proposed", commit] + execute_cmd(cmd, cwd=repodir) + + +def new_empty_branch(repodir, branch): + """ + Creates a new empty branch named `branch` in the local 'git' repo clone + located at `repodir`. + + :param repodir: Local 'git' repo clone directory into which to create + a new empty branch. + :type repodir: str + :param branch: The name to use for the new empty 'git' branch. + :type branch: str + :return: None + """ + # create a new orphaned branch with no history + cmd = ["git", "checkout", "--orphan", branch] + execute_cmd(cmd, cwd=repodir) + + # clean up any residual files + cmd = ["git", "rm", "-rf", "."] + execute_cmd(cmd, cwd=repodir) + + +def commit_all(repodir, msg): + """ + Commit all updates in local git repo directory `repodir` using commit + message `msg`. + + :param repodir: Local 'git' repo clone directory into which to commit + an update. + :type repodir: str + :param msg: The commit message to use. + :type msg: str + :return: The 'git' hash for the commit that was made. + :rtype: str + """ + # sync everything to index + cmd = ["git", "add", "--all"] + execute_cmd(cmd, cwd=repodir) + + # commit the updates + cmd = ["git", "commit", "--allow-empty", "-m", msg] + execute_cmd(cmd, cwd=repodir) + + # return the hash of the commit + cmd = ["git", "rev-parse", "--verify", "HEAD"] + out = io.StringIO() + execute_cmd(cmd, cwd=repodir, stdout=out) + return out.getvalue().rstrip() + + +def push(repodir, id_file=None): + """ + Update repo 'git' repo from local 'git' repo clone located at `repodir`. + + :param repodir: Local 'git' repo clone directory to push to origin repo. + :type repodir: str + :param id_file: Path to a private key identity file to use for 'git' + operations, or None for anonymous operation. + :type id_file: str + :return: None + """ + # get the current branch name + cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"] + out = io.StringIO() + execute_cmd(cmd, cwd=repodir, stdout=out) + branch = out.getvalue().rstrip() + + cmd = ["git", "push", "--set-upstream", "origin", branch] + execute_cmd(cmd, cwd=repodir, env=_git_env(id_file)) + + +def import_contents(dest, src): + """ + Import contents from directory `src` into directory `dest`. Both + `src` and `dest` are presumed to be local 'git' repository clones. + Existing contents of `dest` are completely overwritten by the contents + of `src`--except for 'git' internal files which are ignored, thus any 'git' + history from `src` is discarded while 'git' history in `dest` is preserved. + + :param dest: Local directory into which to import `src`. Directory + must be empty or nonexistent. + :type dest: str + :param src: Local directoy to import into `dest`. Directory must exist. + :type src: str + :return: None + """ + # make sure destination directory exists + os.makedirs(dest, exist_ok=True) + # src needs to have a single trailing "/" for rsync + cmd = ["rsync", "-av", "--exclude=.git*", "--delete", src + "/", dest] + execute_cmd(cmd) diff --git a/cccc/utils.py b/cccc/utils.py new file mode 100644 index 0000000..f9156ac --- /dev/null +++ b/cccc/utils.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: MIT + +import subprocess +import sys + + +def execute_cmd(args, stdout=None, stderr=None, cwd=None, timeout=None, env=None): + """ + Executes command defined by `args`. If `stdout` or `stderr` is set to + Python file object, the stderr/stdout output is redirecter to that file. + If `cwd` is set, current working directory is set accordingly for the + executed command. + If `timeout` is set, kill process after that number of seconds. + If `env` is set, use that instead of current environment variables. + + :param args: List defining the command to execute. + :param stdout: Python file object to redirect the stdout to. + :type stdout: file + :param stderr: Python file object to redirect the stderr to. + :type stderr: file + :param cwd: String defining the current working directory for command. + :param timeout: Timeout in seconds after which the process is killed. + :param env: If not None, a mapping that defines environment variables + for running the command, used instead the current environment. + :type env: dict + :raises RuntimeError: Raised when command exits with non-zero exit code. + """ + + proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env) + + try: + out, err = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + err_msg = "Command '%s' has taken more time than allowed (%d seconds)" % (args, timeout) + raise RuntimeError(err_msg) + + if proc.returncode != 0: + sys.stdout.write(out.decode("utf-8")) + sys.stderr.write(err.decode("utf-8")) + err_msg = "Command '%s' returned non-zero value %d" % (args, proc.returncode) + raise RuntimeError(err_msg) + + if stdout: + stdout.write(out.decode("utf-8")) + if stderr: + stderr.write(err.decode("utf-8")) diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..6e8426b --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,206 @@ +# SPDX-License-Identifier: MIT + +import cccc.git +import os +import subprocess +import tempfile + +try: + import unittest2 as unittest +except ImportError: + import unittest + + +GIT_HASH_REGEX = r"^[0-9a-f]{5,40}$" + + +class TestGit(unittest.TestCase): + + def setUp(self): + self.git_merged_dirobj = tempfile.TemporaryDirectory() + self.git_default_dirobj = tempfile.TemporaryDirectory() + self.git_pr_dirobj = tempfile.TemporaryDirectory() + + def tearDown(self): + self.git_merged_dirobj.cleanup() + self.git_default_dirobj.cleanup() + self.git_pr_dirobj.cleanup() + + def _run_cmds(self, cmds): + cwd = None + for cmd in cmds: + if cmd[0] == "cd": + cwd = cmd[1] + proc = subprocess.Popen(cmd, cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + out, err = proc.communicate() + + def _setup_repos(self): + self.git_merged_dir = self.git_merged_dirobj.name + self.git_default_dir = self.git_default_dirobj.name + self.git_pr_dir = self.git_pr_dirobj.name + + clone_dirobj = tempfile.TemporaryDirectory() + clone_dir = clone_dirobj.name + + # setup two simple bare repos containing a README and an identifying + # file + testrepos = ( + (self.git_merged_dir, "merged"), + (self.git_default_dir, "default"), + ) + for repo_dir, repo_id in testrepos: + cmds = ( + ["cd", "/tmp"], + ["git", "init", "--bare", repo_dir], + ["rm", "-rf", clone_dir], + ["git", "clone", repo_dir, clone_dir], + ["cd", clone_dir], + ["git", "config", "user.name", "John Doe"], + ["git", "config", "user.email", "jdoe@example.com"], + ["bash", "-c", "echo %s > README" % repo_id], + ["touch", repo_id], + ["git", "add", "."], + ["git", "commit", "-m", "Initial commit"], + ["git", "push"], + ) + self._run_cmds(cmds) + + # setup another simple bare repo that starts as a clone of the + # "default" repo from the previous step + cmds = ( + ["cd", "/tmp"], + ["git", "clone", "--bare", self.git_default_dir, self.git_pr_dir], + ["rm", "-rf", clone_dir], + ["git", "clone", self.git_pr_dir, clone_dir], + ["cd", clone_dir], + ["git", "config", "user.name", "John Doe"], + ["git", "config", "user.email", "jdoe@example.com"], + ["bash", "-c", "echo PR > README"], + ["touch", "PR"], + ["git", "add", "."], + ["git", "commit", "-m", "PR update commit"], + ["git", "push"], + ) + self._run_cmds(cmds) + + def _last_commit(self, repodir): + cmd = ["git", "rev-parse", "--verify", "HEAD"] + proc = subprocess.Popen(cmd, cwd=repodir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + out, err = proc.communicate() + return out.rstrip().decode("utf-8") + + def test_clone_repo(self): + self._setup_repos() + with tempfile.TemporaryDirectory() as tmpdir: + cccc.git.clone_repo(tmpdir, self.git_merged_dir) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "merged"]) + readme = open(os.path.join(tmpdir, "README")).read().rstrip() + self.assertEqual(readme, "merged") + with tempfile.TemporaryDirectory() as tmpdir: + cccc.git.clone_repo(tmpdir, self.git_default_dir) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "default"]) + readme = open(os.path.join(tmpdir, "README")).read().rstrip() + self.assertEqual(readme, "default") + with tempfile.TemporaryDirectory() as tmpdir: + cccc.git.clone_repo(tmpdir, self.git_pr_dir) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "default", "PR"]) + readme = open(os.path.join(tmpdir, "README")).read().rstrip() + self.assertEqual(readme, "PR") + + def test_new_branch(self): + self._setup_repos() + with tempfile.TemporaryDirectory() as tmpdir: + cccc.git.clone_repo(tmpdir, self.git_merged_dir) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "merged"]) + cccc.git.new_empty_branch(tmpdir, "newbranch") + self.assertCountEqual(os.listdir(tmpdir), [".git"]) + + def test_commit(self): + self._setup_repos() + with tempfile.TemporaryDirectory() as tmpdir: + cccc.git.clone_repo(tmpdir, self.git_merged_dir) + commit_before = self._last_commit(tmpdir) + self.assertRegex(commit_before, GIT_HASH_REGEX) + self._run_cmds(( + ["cd", tmpdir], + ["bash", "-c", "echo updated > README"], + ["touch", "newfile"], + ["git", "add", "."], + )) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "merged", "newfile"]) + commit_after = cccc.git.commit_all(tmpdir, "After update") + self.assertRegex(commit_after, GIT_HASH_REGEX) + self._run_cmds(( + ["cd", tmpdir], + ["git", "checkout", commit_before], + )) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "merged"]) + readme = open(os.path.join(tmpdir, "README")).read().rstrip() + self.assertEqual(readme, "merged") + self._run_cmds(( + ["cd", tmpdir], + ["git", "checkout", commit_after], + )) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "merged", "newfile"]) + readme = open(os.path.join(tmpdir, "README")).read().rstrip() + self.assertEqual(readme, "updated") + + def test_merge_repo(self): + self._setup_repos() + with tempfile.TemporaryDirectory() as tmpdir: + cccc.git.clone_repo(tmpdir, self.git_default_dir) + cccc.git.merge_repo(tmpdir, self.git_pr_dir) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "default", "PR"]) + readme = open(os.path.join(tmpdir, "README")).read().rstrip() + self.assertEqual(readme, "PR") + + def test_push(self): + self._setup_repos() + with tempfile.TemporaryDirectory() as tmpdir: + cccc.git.clone_repo(tmpdir, self.git_merged_dir) + self._run_cmds(( + ["cd", tmpdir], + ["bash", "-c", "echo updated > README"], + ["touch", "newfile"], + ["git", "add", "."], + )) + commit = cccc.git.commit_all(tmpdir, "Update") + self.assertRegex(commit, GIT_HASH_REGEX) + cccc.git.push(tmpdir) + + # start over with a new clone of the repo + with tempfile.TemporaryDirectory() as tmpdir: + cccc.git.clone_repo(tmpdir, self.git_merged_dir) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "merged", "newfile"]) + readme = open(os.path.join(tmpdir, "README")).read().rstrip() + self.assertEqual(readme, "updated") + + def test_import(self): + self._setup_repos() + with tempfile.TemporaryDirectory() as tmpdir: + cccc.git.clone_repo(tmpdir, self.git_merged_dir) + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "merged"]) + readme = open(os.path.join(tmpdir, "README")).read().rstrip() + self.assertEqual(readme, "merged") + with tempfile.TemporaryDirectory() as dir_to_import: + cccc.git.clone_repo(dir_to_import, self.git_pr_dir) + cccc.git.import_contents(tmpdir, dir_to_import) + # original contents should be completely replaced + self.assertCountEqual(os.listdir(tmpdir), + [".git", "README", "default", "PR"]) + readme = open(os.path.join(tmpdir, "README")).read().rstrip() + self.assertEqual(readme, "PR") diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..a87e994 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: MIT + +import sys +import time + +from io import StringIO +from mock import patch + +from cccc.utils import execute_cmd + +try: + import unittest2 as unittest +except ImportError: + import unittest + + +class TestUtilsExecuteCmd(unittest.TestCase): + + @patch("sys.stdout", new=StringIO()) + def test_execute_cmd_success(self): + execute_cmd(["/usr/bin/echo", "Hello world!"], stdout=sys.stdout) + output = sys.stdout.getvalue() + self.assertIn("Hello world!", output) + + def test_execute_cmd_fail(self): + with self.assertRaisesRegex( + RuntimeError, "Command .* returned non-zero value .*"): + execute_cmd(["/usr/bin/false"]) + + def test_execute_cmd_timeout_called(self): + start_time = time.time() + with self.assertRaisesRegex( + RuntimeError, "Command .* has taken more time .*"): + execute_cmd(["/usr/bin/sleep", "5"], timeout=1) + stop_time = time.time() + + self.assertTrue(stop_time - start_time < 2) + + @patch("sys.stdout", new=StringIO()) + def test_execute_cmd_with_env(self): + execute_cmd(["/usr/bin/printenv", "mY_RanDoM_EnV_varblE"], stdout=sys.stdout, + env=dict(mY_RanDoM_EnV_varblE="RanDoM_vAl")) + output = sys.stdout.getvalue() + self.assertEqual("RanDoM_vAl", output.rstrip()) + + with self.assertRaisesRegex( + RuntimeError, "Command .* returned non-zero value .*"): + execute_cmd(["/usr/bin/printenv", "mY_RanDoM_EnV_varblE"]) diff --git a/tox.ini b/tox.ini index 4d4e3c3..0579303 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ exclude = .tox,.git,build,.env deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt -commands = pytest +commands = pytest -v {posargs} setenv = PYTHONPATH = {toxinidir}