Repository: VirtusLab/git-machete
Pull request #434 (2022-02-14 11:15:53)

Release for milestone 3.7.2


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index b313400..4497e6a 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -8,14 +8,6 @@ executors:
       # Needed to avoid warning: https://superuser.com/questions/575844/warning-terminal-is-not-fully-functional
       TERM: ansi
 
-mandatory_jobs: &mandatory_jobs
-  - general checks
-  - test against python 3_6 and git 1_8_10
-  - test against python 3_7 and git 2_7_6
-  - test against python 3_8 and git 2_25_0 with PEP8 check
-  - test against python 3_9 and git 2_30_1 with coverage report
-  - test against python 3_10 and git 2_33_1 with building docs
-
 jobs:
   "general checks":
     executor: ubuntu_executor
@@ -47,8 +39,8 @@ jobs:
             enforce-issue-number-for-todos.sh
             ensure-completion-scripts-correct.sh
             ensure-links-correct.sh
-            ensure-shell-scripts-pass-shellcheck.sh
             ensure-release-notes-up-to-date.sh
+            ensure-shell-scripts-pass-shellcheck.sh
             ensure-version-bumped.sh
             prohibit-tab-character.sh
             prohibit-trailing-whitespace.sh
@@ -60,6 +52,8 @@ jobs:
       - run:
           name: Run tests against Python 3.6 and git 1.8.0
           command: PYTHON_VERSION=3.6 GIT_VERSION=1.8.0  bash ci/tox/ci-run.sh # Earliest versions of python/git supported by git-machete
+      - store_test_results:
+          path: test-results/
 
   "test against python 3_7 and git 2_7_6":
     executor: ubuntu_executor
@@ -68,6 +62,8 @@ jobs:
       - run:
           name: Run tests against Python 3.7 and git 2.7.6
           command: PYTHON_VERSION=3.7 GIT_VERSION=2.7.6  bash ci/tox/ci-run.sh
+      - store_test_results:
+          path: test-results/
 
   # Note that there is no particular reason for the below assignment of PEP8/coverage/docs checks to Python version.
   # Each of these needs to be run against just one Python version
@@ -82,6 +78,8 @@ jobs:
       - run:
           name: Run tests against Python 3.8 and git 2.25.0 (with PEP8 check)
           command: PYTHON_VERSION=3.8 GIT_VERSION=2.25.0 CHECK_PEP8=true  bash ci/tox/ci-run.sh
+      - store_test_results:
+          path: test-results/
 
   "test against python 3_9 and git 2_30_1 with coverage report":
     executor: ubuntu_executor
@@ -90,8 +88,10 @@ jobs:
       - run:
           name: Run tests against Python 3.9 and git 2.30.1 (with coverage report)
           command: PYTHON_VERSION=3.9 GIT_VERSION=2.30.1 CHECK_COVERAGE=true  bash ci/tox/ci-run.sh
+      - store_test_results:
+          path: test-results/
       - store_artifacts:
-          path: htmlcov
+          path: htmlcov/
           destination: coverage
 
   "test against python 3_10 and git 2_33_1 with building docs":
@@ -101,14 +101,17 @@ jobs:
       - run:
           name: Run tests against Python 3.10 and git 2.33.1 (with building docs)
           command: PYTHON_VERSION=3.10 GIT_VERSION=2.33.1 BUILD_DOCS=true  bash ci/tox/ci-run.sh
+      - store_test_results:
+          path: test-results/
       - store_artifacts:
-          path: docs/html
+          path: docs/html/
           destination: docs
 
   "dry run deploy":
     executor: ubuntu_executor
     steps:
       - checkout
+
       - deploy:
           name: '[Dry run] Deploy to PyPI'
           # Side effects: does an actual release, but to the test PyPI
@@ -128,9 +131,15 @@ jobs:
           # Side effects: potentially pushes a regenerated Docker image
           command: bash ci/deb-ppa-upload/ci-deploy.sh --dry-run
 
+      - deploy:
+          name: '[Dry run] Build Nix package'
+          # Side effects: potentially pushes a regenerated Docker image
+          # Other than that, it just builds the package without publishing anything; there's no special dry-run mode
+          command: bash ci/nixpkgs-build/ci-run.sh
+
       - deploy:
           name: '[Dry run] Build snap'
-          command: bash ci/snap/ci-run-dry-run.sh
+          command: bash ci/snap/ci-run.sh
 
       - deploy:
           name: '[Dry run] Build rpm package'
@@ -142,6 +151,7 @@ jobs:
     executor: ubuntu_executor
     steps:
       - checkout
+
       - deploy:
           name: 'Deploy to PyPI'
           command: bash ci/pypi/ci-deploy.sh
@@ -181,6 +191,14 @@ jobs:
       # 3) every time a https://github.com/r-ryantm bot runs on NixOS/nixpkgs repository,
       #    Nix git-machete package is updated with the fresh version/package hash according to the data from PyPI.
 
+mandatory_jobs: &mandatory_jobs
+  - general checks
+  - test against python 3_6 and git 1_8_10
+  - test against python 3_7 and git 2_7_6
+  - test against python 3_8 and git 2_25_0 with PEP8 check
+  - test against python 3_9 and git 2_30_1 with coverage report
+  - test against python 3_10 and git 2_33_1 with building docs
+
 workflows:
   version: 2
   build:
diff --git a/.gitignore b/.gitignore
index 5a11a99..70dee1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,7 @@ docs/html/
 htmlcov/
 *.pyc
 .python-version
-.stestr/
+test-results/
 .tox/
 venv/
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 38e2935..97b0f10 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -92,7 +92,18 @@ Review fixes should be pushed on separate commits for easier viewing on GitHub (
 
 ## Release TODO list
 
-1. Merge the changes from `develop` to `master` and push `master`.
+1. Create release PR from `develop` into `master`.
+
+1. Verify that all checks have passed.
+
+1. Merge develop into master and push to remote repository using console:
+
+         git checkout develop
+         git pull origin develop
+         git checkout master
+         git pull origin master
+         git merge --no-ff develop
+         git push origin master
 
 1. Verify that the release has been created on [Github](https://github.com/VirtusLab/git-machete/releases)
    and that a `git-machete-<VERSION>-1.noarch.rpm` file is present under the Assets.
@@ -133,6 +144,9 @@ Review fixes should be pushed on separate commits for easier viewing on GitHub (
    Verify that [version-bump PRs to NixOS/nixpkgs](https://github.com/NixOS/nixpkgs/pulls?q=is%3Apr+git-machete),
    are regularly opened so that Nix package is kept up to date with git-machete releases.
 
+   If package-update PR has not been opened for a long time, it's probably due to the failure of Nix package build.
+   Check [r-ryantm build system](https://r.ryantm.com/log/updatescript/git-machete/) for recent build logs.
+
 1. Thanks to the courtesy of [Ila&iuml; Deutel](https://github.com/ilai-deutel),
    a [git-machete package](https://aur.archlinux.org/packages/git-machete) is hosted in Arch User Repository (AUR).
    If the release introduces significant changes/critical bugfixes, please [flag the package as out of date](https://aur.archlinux.org/pkgbase/git-machete/flag).
diff --git a/README.md b/README.md
index 44718f1..dc94910 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
 # git-machete
 
-[![Join the chat at https://gitter.im/VirtusLab/git-machete](https://badges.gitter.im/VirtusLab/git-machete.svg)](https://gitter.im/VirtusLab/git-machete)
-[![CircleCI](https://circleci.com/gh/VirtusLab/git-machete/tree/master.svg?style=shield)](https://app.circleci.com/pipelines/github/VirtusLab/git-machete?branch=master)
 [![Read the Docs](https://readthedocs.org/projects/git-machete/badge/?version=latest)](https://git-machete.readthedocs.io/en/stable)
 [![PyPI package](https://img.shields.io/pypi/v/git-machete.svg)](https://pypi.org/project/git-machete)
-[![PyPI package monthly downloads](https://img.shields.io/pypi/dm/git-machete.svg)](https://pypistats.org/packages/git-machete)
+[![PyPI package monthly downloads](https://img.shields.io/pypi/dm/git-machete.svg?label=pypi%20downloads)](https://pypistats.org/packages/git-machete)
 [![Snap](https://snapcraft.io/git-machete/badge.svg)](https://snapcraft.io/git-machete)
+[![AUR package (Arch Linux)](https://repology.org/badge/version-for-repo/aur/git-machete.svg)](https://aur.archlinux.org/packages/git-machete)
 [![License: MIT](https://img.shields.io/github/license/VirtusLab/git-machete)](https://github.com/VirtusLab/git-machete/blob/master/LICENSE)
+[![CircleCI](https://circleci.com/gh/VirtusLab/git-machete/tree/master.svg?style=shield)](https://app.circleci.com/pipelines/github/VirtusLab/git-machete?branch=master)
 
 <img src="https://raw.githubusercontent.com/VirtusLab/git-machete/develop/graphics/logo_with_name.svg" style="width: 100%; display: block; margin-bottom: 10pt;" />
 <!-- The image is referenced by full URL, corresponding develop branch to ensure it renders correctly on https://pypi.org/project/git-machete/ -->
@@ -90,6 +90,10 @@ nix-channel --add https://nixos.org/channels/nixos-unstable unstable  # if you h
 nix-env -i git-machete
 ```
 
+**Note:** since `nixos-21.05`, `git-machete` is included in the stable channels as well.
+The latest released version, however, is generally available in the unstable channel.
+Stable channels may lag behind; see [repology](https://repology.org/project/git-machete/versions) for the current channel-package mapping.
+
 ### Using pip with sudo (system-wide install)
 
 You need to have Python and `pip` installed from system packages.
diff --git a/blogs/docker-ci-tricks-1/blog.md b/blogs/docker-ci-tricks-1/blog.md
index bbf73e7..29a508a 100644
--- a/blogs/docker-ci-tricks-1/blog.md
+++ b/blogs/docker-ci-tricks-1/blog.md
@@ -109,6 +109,7 @@ services:
     build:
       context: build-context
       dockerfile: ../Dockerfile # relative to build-context
+      target: ${TARGET:-circle_ci}
       args:
         - user_id=${USER_ID:-0}
         - group_id=${GROUP_ID:-0}
@@ -117,12 +118,13 @@ services:
         - check_coverage=${CHECK_COVERAGE:-false}
     volumes:
       # Host path is relative to current directory, not build-context
-      - ../..:/home/ci-user/git-machete
+      - ../..:${MOUNT_POINT:-/root}/git-machete
 ```
 
-We'll return to the `image:` section and explain the origin of `DIRECTORY_HASH` later.
+We'll return to the `image:` section and explain the origin of `DIRECTORY_HASH` later, as well as `target:` section
+and define `TARGET` and `MOUNT_POINT` environment variables.
 
-As the `volumes:` section shows, the entire codebase of git-machete is mounted under /home/ci-user/git-machete/ inside the container.
+As the `volumes:` section shows, the entire codebase of git-machete is mounted under /home/ci-user/git-machete/ (/root/git-machete/ on CircleCi) inside the container.
 The variables `PYTHON_VERSION` and `GIT_VERSION`, which correspond to `python_version` and `git_version` build args,
 are provided by CircleCI based on the configuration in [.circleci/config.yml](https://github.com/VirtusLab/git-machete/blob/master/.circleci/config.yml). Below there is a snippet of this configuration with definition of a one CircleCI `job` named `tests_py38`. In its second step job runs bash command `ci/tox/ci-run.sh` with environment variables `PYTHON_VERSION=3.8` and `GIT_VERSION=2.25.0` (for more information about passing running bash script with environment variables see [link](https://virtuslab.github.io/tips/#shell/passing-environment-vars)):
 
diff --git a/blogs/docker-ci-tricks-2/blog.md b/blogs/docker-ci-tricks-2/blog.md
index 44499c7..a92bafc 100644
--- a/blogs/docker-ci-tricks-2/blog.md
+++ b/blogs/docker-ci-tricks-2/blog.md
@@ -138,6 +138,8 @@ check_var GIT_VERSION
 check_var PYTHON_VERSION
 
 set -x
+export TARGET=local
+export MOUNT_POINT=/home/ci-user
 docker-compose build --build-arg user_id="$(id -u)" --build-arg group_id="$(id -g)" tox
 docker-compose run tox
 
@@ -145,6 +147,7 @@ docker-compose run tox
 
 The difference is just additional sanity checks like whether variables are defined (`check_var`) and whether there are any uncommitted changes (part of responsibilities of `export_directory_hash`).
 Also, we don't attempt to push the freshly-built image to the Docker Hub since we can rely on a local build cache instead.
+We also export `TARGET` and `MOUNT_POINT` environment variables to be used to decide which stage of Dockerfile to build.
 
 Our entire setup assumes that the git-machete directory from the host is mounted as a volume inside the Docker container
 (as described in detail in [part 1](https://medium.com/virtuslab/nifty-docker-tricks-for-your-ci-vol-1-c4a36d2192ea)).
@@ -157,16 +160,49 @@ will belong to root.
 
 If only Docker had an option to run commands as a non-root user...
 
-Let's take a look at [ci/tox/Dockerfile](https://github.com/VirtusLab/git-machete/blob/develop/ci/tox/Dockerfile) again, this time at the bottom part:
+Let's take a look at [ci/tox/docker-compose.yml](https://github.com/VirtusLab/git-machete/blob/master/ci/tox/docker-compose.yml) again,
+especially at the `target` and `volumes` sections that are defined by the `TARGET` and `MOUNT_POINT` environment variables.
+```yaml
+version: '3'
+services:
+  tox:
+    image: gitmachete/ci-tox:git${GIT_VERSION}-python${PYTHON_VERSION}-${DIRECTORY_HASH:-unspecified}
+    build:
+      context: build-context
+      dockerfile: ../Dockerfile # relative to build-context
+      target: ${TARGET:-circle_ci}
+      args:
+        - user_id=${USER_ID:-0}
+        - group_id=${GROUP_ID:-0}
+        - git_version=${GIT_VERSION:-0.0.0}
+        - python_version=${PYTHON_VERSION:-0.0.0}
+        - check_coverage=${CHECK_COVERAGE:-false}
+    volumes:
+      # Host path is relative to current directory, not build-context
+      - ../..:${MOUNT_POINT:-/root}/git-machete
+```
+
+as well as at [ci/tox/Dockerfile](https://github.com/VirtusLab/git-machete/blob/develop/ci/tox/Dockerfile), this time at the bottom part:
 ```dockerfile
+ARG python_version
+FROM python:${python_version}-alpine as base
 # ... git & python setup - skipped ...
 
+FROM base AS circle_ci
+RUN $PYTHON -m pip install tox
+ENV PATH=$PATH:/root/.local/bin/
+COPY entrypoint.sh /root/
+RUN chmod +x /root/entrypoint.sh
+CMD ["/root/entrypoint.sh"]
+WORKDIR /root/git-machete
+
+FROM base AS local
 ARG user_id
 ARG group_id
 RUN set -x \
     && [ ${user_id:-0} -ne 0 ] \
     && [ ${group_id:-0} -ne 0 ] \
-    # sometimes given group_id is already taken and addgroup outputs error, so let's check it's existence first
+    # sometimes the given `group_id` is already taken and `addgroup` raises an error, so let's check its existence first
     && (getent group $group_id || addgroup --gid=${group_id} ci-user) \
     && adduser --uid=${user_id} --ingroup=$(getent group $group_id | cut -d: -f1) --disabled-password ci-user
 USER ci-user
@@ -179,14 +215,21 @@ WORKDIR /home/ci-user/git-machete
 
 ```
 
-The trick is to create a new user with the same user and group ID as your user on the host.
+In order to be able to use **non-root user** for local runs and **root user** for the CI/CD runs on CircleCI's host machines,
+we used multi-stage builds. Single Dockerfile stage is defined with `FROM` <base_stage_being_build_upon> `AS` <name_of_the_new_stage> and executes each statement
+until the next `FROM` - defining a new stage.
+
+We can select which stage to build using docker-compose's `target` argument. \
+e.g. when the `TARGET=local` and `MOUNT_POINT=/home/ci-user`,
+the `circle_ci` stage is skipped and `local` stage gets build (and each stage it depends upon, which in this case is `base`)
+
+### Run locally as a non-root user
+The trick is to create a new user with the same user and group ID as your user on the local machine.
 This is accomplished by
 `docker-compose build --build-arg user_id="$(id -u)" --build-arg group_id="$(id -g)" tox`
 in `ci/tox/local-run.sh`.
-If you use any modern Linux distribution and have just one non-root user on your machine, they're both likely equal to 1000 (see `UID_MIN` and `GID_MIN` in `/etc/login.defs`);
-on CircleCI VMs they both turn out to be 2000.
 
-We switch from `root` to the newly-created user by calling `USER ci-user`.
+We switch from default `root` to the newly-created user by calling `USER ci-user`.
 
 Using rather unusual syntax (Unix-style `--something` options are rarely passed directly to a Dockerfile instruction), we need to specify `--chown` flag for `COPY`.
 Otherwise, the files would end up owned by `root:root`.
diff --git a/ci/deb-ppa-upload/Dockerfile b/ci/deb-ppa-upload/Dockerfile
index fa34b43..b24d90e 100644
--- a/ci/deb-ppa-upload/Dockerfile
+++ b/ci/deb-ppa-upload/Dockerfile
@@ -1,4 +1,4 @@
-FROM ubuntu:20.04
+FROM ubuntu:20.04 as base
 SHELL ["/bin/bash", "-e", "-o", "pipefail", "-u", "-x", "-c"]
 
 # See https://github.com/phusion/baseimage-docker/issues/58 for why DEBIAN_FRONTEND=noninteractive is needed.
@@ -6,15 +6,30 @@ RUN apt-get update \
     && DEBIAN_FRONTEND=noninteractive apt-get install -y debhelper devscripts fakeroot dh-python gawk python3-all python3-paramiko python3-setuptools \
     && rm -rfv /var/lib/apt/lists/*
 
+FROM base AS circle_ci
+RUN install -d -m 0700 ~/.gnupg/ ~/.gnupg/private-keys-v1.d/ ~/.ssh/
+
+COPY dput.cf /etc/
+
+COPY gpg-sign.sh /root/
+RUN chmod +x ~/gpg-sign.sh
+
+COPY release-notes-to-changelog.awk /root/
+COPY entrypoint.sh /root/
+RUN chmod +x /root/entrypoint.sh
+CMD ["/root/entrypoint.sh"]
+WORKDIR /root/git-machete
+
+FROM base AS local
 ARG user_id
 ARG group_id
-
 RUN set -x \
     && [ ${user_id:-0} -ne 0 ] \
     && [ ${group_id:-0} -ne 0 ] \
-    && groupadd -g ${group_id} ci-user \
-    && useradd -l -u ${user_id} -g ci-user ci-user \
-    && install -d -m 0755 -o ci-user -g ci-user /home/ci-user
+    # sometimes the given `group_id` is already taken and `addgroup` raises an error, so let's check its existence first
+    && (getent group $group_id || addgroup --gid=${group_id} ci-user) \
+    && useradd -l -u ${user_id} -g $group_id ci-user \
+    && install -d -m 0755 -o ci-user -g $group_id /home/ci-user
 
 USER ci-user
 
@@ -26,9 +41,7 @@ COPY --chown=ci-user:ci-user gpg-sign.sh /home/ci-user/
 RUN chmod +x ~/gpg-sign.sh
 
 COPY --chown=ci-user:ci-user release-notes-to-changelog.awk /home/ci-user/
-
 COPY --chown=ci-user:ci-user entrypoint.sh /home/ci-user/
 RUN chmod +x ~/entrypoint.sh
 CMD ["/home/ci-user/entrypoint.sh"]
-
 WORKDIR /home/ci-user/git-machete
diff --git a/ci/deb-ppa-upload/docker-compose.yml b/ci/deb-ppa-upload/docker-compose.yml
index eab7a17..ee23caf 100644
--- a/ci/deb-ppa-upload/docker-compose.yml
+++ b/ci/deb-ppa-upload/docker-compose.yml
@@ -5,12 +5,13 @@ services:
     build:
       context: build-context
       dockerfile: ../Dockerfile # relative to build-context
+      target: ${TARGET:-circle_ci}
       args:
         - user_id=${USER_ID:-0}
         - group_id=${GROUP_ID:-0}
     volumes:
       # Host path is relative to current directory, not build-context
-      - ./../..:/home/ci-user/git-machete
+      - ./../..:${MOUNT_POINT:-/root}/git-machete
     environment:
       - DO_DPUT
       - TARGET_DISTRO_NAME
diff --git a/ci/deb-ppa-upload/local-run.sh b/ci/deb-ppa-upload/local-run.sh
index 51aea49..4b1a06f 100755
--- a/ci/deb-ppa-upload/local-run.sh
+++ b/ci/deb-ppa-upload/local-run.sh
@@ -9,6 +9,8 @@ cd "$(git rev-parse --show-toplevel)"/ci/deb-ppa-upload/
 
 
 set -x
+export TARGET=local
+export MOUNT_POINT=/home/ci-user
 docker-compose build --build-arg user_id="$(id -u)" --build-arg group_id="$(id -g)" deb-ppa-upload
 docker-compose run -e TARGET_DISTRO_NAME=bionic -e TARGET_DISTRO_NUMBER=18.04 -e DO_DPUT=false deb-ppa-upload
 docker-compose run -e TARGET_DISTRO_NAME=focal  -e TARGET_DISTRO_NUMBER=20.04 -e DO_DPUT=false deb-ppa-upload
diff --git a/ci/docker-pull-or-build-and-push.sh b/ci/docker-pull-or-build-and-push.sh
index b694d11..d9a7893 100644
--- a/ci/docker-pull-or-build-and-push.sh
+++ b/ci/docker-pull-or-build-and-push.sh
@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 
-set -e -o pipefail -u -x
+set -e -o pipefail -u
 image_name=$1
 
 DIRECTORY_HASH=$(git rev-parse HEAD:ci/$image_name)
@@ -31,7 +31,7 @@ retry 3 docker-compose --ansi never pull $image_name
 # (since v1.25.1, `docker-compose pull` is NOT failing when it can't fetch the image).
 image_tag=$(docker-compose --ansi never config | yq eval ".services.$image_name.image" -)
 docker image inspect "$image_tag" &>/dev/null || {
-  docker-compose --ansi never build --build-arg user_id="$(id -u)" --build-arg group_id="$(id -g)" $image_name
+  docker-compose --ansi never build $image_name
   # In builds coming from forks, secret vars are unavailable for security reasons; hence, we have to skip pushing the newly built image.
   if [[ ${DOCKER_PASSWORD-} && ${DOCKER_USERNAME-} ]]; then
     echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
diff --git a/ci/homebrew-tap/ci-deploy.sh b/ci/homebrew-tap/ci-deploy.sh
index 6a8e15c..fc3821f 100644
--- a/ci/homebrew-tap/ci-deploy.sh
+++ b/ci/homebrew-tap/ci-deploy.sh
@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 
-set -e -o pipefail -u -x
+set -e -o pipefail -u
 
 if [[ ${1-} == "--dry-run" || ${CIRCLE_BRANCH-} != "master" ]]; then
   do_push=false
@@ -15,6 +15,7 @@ version=$(grep '__version__ = ' git_machete/__init__.py | cut -d\' -f2)
 git clone https://${GITHUB_TOKEN}@github.com/VirtusLab/homebrew-git-machete.git ../homebrew-git-machete
 cd ../homebrew-git-machete/
 
+set -x
 git config user.email "gitmachete@virtuslab.com"
 git config user.name "Git Machete Release Bot"
 sha256=$(
diff --git a/ci/nixpkgs-build/Dockerfile b/ci/nixpkgs-build/Dockerfile
new file mode 100644
index 0000000..f113f24
--- /dev/null
+++ b/ci/nixpkgs-build/Dockerfile
@@ -0,0 +1,11 @@
+FROM nixos/nix:2.5.1
+
+RUN nix-env -i bash curl git gnused
+SHELL ["bash", "-e", "-o", "pipefail", "-u", "-x", "-c"]
+
+COPY entrypoint.sh /root/
+RUN chmod +x /root/entrypoint.sh
+CMD /root/entrypoint.sh
+
+WORKDIR /root/nixpkgs
+ENV EXPRESSION_PATH=pkgs/applications/version-management/git-and-tools/git-machete/default.nix
diff --git a/ci/nixpkgs-build/README.md b/ci/nixpkgs-build/README.md
new file mode 100644
index 0000000..afb60df
--- /dev/null
+++ b/ci/nixpkgs-build/README.md
@@ -0,0 +1,5 @@
+## Build of git-machete NixOS package from local machine
+
+```bash
+./ci/nixpkgs-build/local-run.sh
+```
diff --git a/ci/nixpkgs-build/build-context/entrypoint.sh b/ci/nixpkgs-build/build-context/entrypoint.sh
new file mode 100755
index 0000000..0efcc7c
--- /dev/null
+++ b/ci/nixpkgs-build/build-context/entrypoint.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+
+set -e -o pipefail -u -x
+
+git clone --depth=1 https://github.com/NixOS/nixpkgs.git .
+
+source_hash=$(nix-prefetch-url --unpack https://github.com/VirtusLab/git-machete/archive/$GIT_REVISION.tar.gz)
+version=$(curl https://raw.githubusercontent.com/VirtusLab/git-machete/$GIT_REVISION/git_machete/__init__.py | cut -d\' -f2)
+sed -i -f- $EXPRESSION_PATH <<EOF
+  s/version = ".*"/version = "$version"/
+  s/rev = \".*\"/rev = \"$GIT_REVISION\"/
+  s/sha256 = ".*"/sha256 = "$source_hash"/
+  /git init/d
+  s/stestr run/pytest/
+  s/stestr/pytest/
+EOF
+cat $EXPRESSION_PATH
+
+nix-build -A gitAndTools.git-machete
diff --git a/ci/nixpkgs-build/ci-run.sh b/ci/nixpkgs-build/ci-run.sh
new file mode 100644
index 0000000..be08af9
--- /dev/null
+++ b/ci/nixpkgs-build/ci-run.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+set -e -o pipefail -u -x
+
+source ci/docker-pull-or-build-and-push.sh nixpkgs-build
+
+git_revision=$(git rev-parse HEAD)
+docker-compose --ansi never run -e GIT_REVISION=$git_revision nixpkgs-build
diff --git a/ci/nixpkgs-build/docker-compose.yml b/ci/nixpkgs-build/docker-compose.yml
new file mode 100644
index 0000000..f553c58
--- /dev/null
+++ b/ci/nixpkgs-build/docker-compose.yml
@@ -0,0 +1,10 @@
+version: '3'
+services:
+  nixpkgs-build:
+    image: gitmachete/ci-nixpkgs-build:${DIRECTORY_HASH:-unspecified}
+    build:
+      context: build-context
+      dockerfile: ../Dockerfile # relative to build-context
+    tty: true
+    environment:
+      - GIT_REVISION
diff --git a/ci/nixpkgs-build/local-run.sh b/ci/nixpkgs-build/local-run.sh
new file mode 100755
index 0000000..ddff71c
--- /dev/null
+++ b/ci/nixpkgs-build/local-run.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+set -e -o pipefail -u
+
+cd "$(git rev-parse --show-toplevel)"/ci/nixpkgs-build/
+
+source ../local-run-commons.sh nixpkgs-build
+export_directory_hash nixpkgs-build
+
+git_revision=$(git rev-parse "@{upstream}")
+
+docker-compose build nixpkgs-build
+docker-compose run -e GIT_REVISION=$git_revision nixpkgs-build
diff --git a/ci/pypi/ci-deploy.sh b/ci/pypi/ci-deploy.sh
index 3d89c21..4035375 100644
--- a/ci/pypi/ci-deploy.sh
+++ b/ci/pypi/ci-deploy.sh
@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 
-set -e -o pipefail -u -x
+set -e -o pipefail -u
 
 if [[ ${1-} == "--dry-run" || ${CIRCLE_BRANCH-} != "master" ]]; then
   repository=testpypi
@@ -16,6 +16,7 @@ username = __token__
 password = $token
 EOF
 
+set -x
 pip3 install twine wheel
 python3 setup.py sdist
 python3 setup.py bdist_wheel
diff --git a/ci/rpm/Dockerfile b/ci/rpm/Dockerfile
index a59de71..94e8e0e 100644
--- a/ci/rpm/Dockerfile
+++ b/ci/rpm/Dockerfile
@@ -1,17 +1,25 @@
-FROM fedora:31
+FROM fedora:35 as base
 SHELL ["/bin/bash", "-e", "-o", "pipefail", "-u", "-x", "-c"]
 
 RUN dnf install -y python3-pip rpm-build
 RUN ln -s /usr/bin/python3 /usr/local/bin/python
 
+FROM base AS circle_ci
+COPY entrypoint.sh /root/
+RUN chmod +x /root/entrypoint.sh
+CMD ["/root/entrypoint.sh"]
+WORKDIR /root/git-machete
+
+FROM base AS local
 ARG user_id
 ARG group_id
 RUN set -x \
     && [ ${user_id:-0} -ne 0 ] \
     && [ ${group_id:-0} -ne 0 ] \
-    && groupadd -g ${group_id} ci-user \
-    && useradd -l -u ${user_id} -g ci-user ci-user \
-    && install -d -m 0755 -o ci-user -g ci-user /home/ci-user
+    # sometimes the given `group_id` is already taken and `addgroup` raises an error, so let's check its existence first
+    && (getent group $group_id || groupadd --gid=${group_id} ci-user) \
+    && useradd -l -u ${user_id} -g $group_id ci-user \
+    && install -d -m 0755 -o ci-user -g $group_id /home/ci-user
 USER ci-user
 COPY --chown=ci-user:ci-user entrypoint.sh /home/ci-user/
 RUN chmod +x ~/entrypoint.sh
diff --git a/ci/rpm/docker-compose.yml b/ci/rpm/docker-compose.yml
index 112256c..cd39001 100644
--- a/ci/rpm/docker-compose.yml
+++ b/ci/rpm/docker-compose.yml
@@ -5,9 +5,10 @@ services:
     build:
       context: build-context
       dockerfile: ../Dockerfile # relative to build-context
+      target: ${TARGET:-circle_ci}
       args:
         - user_id=${USER_ID:-0}
         - group_id=${GROUP_ID:-0}
     volumes:
       # Host path is relative to current directory, not build-context
-      - ./../..:/home/ci-user/git-machete
+      - ./../..:${MOUNT_POINT:-/root}/git-machete
diff --git a/ci/rpm/local-run.sh b/ci/rpm/local-run.sh
index 537af39..81e664e 100755
--- a/ci/rpm/local-run.sh
+++ b/ci/rpm/local-run.sh
@@ -7,5 +7,7 @@ export_directory_hash rpm
 cd "$(git rev-parse --show-toplevel)"/ci/rpm/
 
 set -x
+export TARGET=local
+export MOUNT_POINT=/home/ci-user
 docker-compose build --build-arg user_id="$(id -u)" --build-arg group_id="$(id -g)" rpm
 docker-compose run rpm
diff --git a/ci/snap/ci-run-dry-run.sh b/ci/snap/ci-run.sh
similarity index 100%
rename from ci/snap/ci-run-dry-run.sh
rename to ci/snap/ci-run.sh
diff --git a/ci/tox/Dockerfile b/ci/tox/Dockerfile
index 71f8e62..7efd684 100644
--- a/ci/tox/Dockerfile
+++ b/ci/tox/Dockerfile
@@ -1,5 +1,5 @@
 ARG python_version
-FROM python:${python_version}-alpine
+FROM python:${python_version}-alpine as base
 SHELL ["/bin/sh", "-e", "-o", "pipefail", "-u", "-x", "-c"]
 # Required since the earlier versions of git assume the location of python to be /usr/bin/python during the build.
 RUN ln -s /usr/local/bin/python /usr/bin/python
@@ -13,6 +13,7 @@ RUN set -x \
     && cd git-$git_version/ \
     && make configure \
     && ./configure \
+    && sed -i 's/#warning .*//' /usr/include/sys/poll.h  `# to reduce amount of spam warnings in logs` \
     && make \
     && make install \
     && cd .. \
@@ -28,6 +29,15 @@ ENV PYTHON_VERSION=${python_version}
 ENV PYTHON=python${python_version}
 RUN apk add --no-cache gcc musl-dev # both packages are required to install mypy
 
+FROM base AS circle_ci
+RUN $PYTHON -m pip install tox
+ENV PATH=$PATH:/root/.local/bin/
+COPY entrypoint.sh /root/
+RUN chmod +x /root/entrypoint.sh
+CMD ["/root/entrypoint.sh"]
+WORKDIR /root/git-machete
+
+FROM base AS local
 ARG user_id
 ARG group_id
 RUN set -x \
diff --git a/ci/tox/docker-compose.yml b/ci/tox/docker-compose.yml
index eb8a4ca..c11b3e6 100644
--- a/ci/tox/docker-compose.yml
+++ b/ci/tox/docker-compose.yml
@@ -5,6 +5,7 @@ services:
     build:
       context: build-context
       dockerfile: ../Dockerfile # relative to build-context
+      target: ${TARGET:-circle_ci}
       args:
         - user_id=${USER_ID:-0}
         - group_id=${GROUP_ID:-0}
@@ -16,4 +17,4 @@ services:
       - CHECK_PEP8=${CHECK_PEP8:-false}
     volumes:
       # Host path is relative to current directory, not build-context
-      - ../..:/home/ci-user/git-machete
+      - ../..:${MOUNT_POINT:-/root}/git-machete
diff --git a/ci/tox/local-run.sh b/ci/tox/local-run.sh
index 08bf426..d70f053 100755
--- a/ci/tox/local-run.sh
+++ b/ci/tox/local-run.sh
@@ -10,5 +10,7 @@ check_var GIT_VERSION
 check_var PYTHON_VERSION
 
 set -x
+export TARGET=local
+export MOUNT_POINT=/home/ci-user
 docker-compose build --build-arg user_id="$(id -u)" --build-arg group_id="$(id -g)" tox
-docker-compose run tox
+docker-compose run tox
\ No newline at end of file
diff --git a/debian/source/options b/debian/source/options
index a84c771..0e3cd9a 100644
--- a/debian/source/options
+++ b/debian/source/options
@@ -8,6 +8,7 @@ tar-ignore = completion
 tar-ignore = CONTRIBUTING.md
 tar-ignore = .coverage
 tar-ignore = dist
+tar-ignore = .dockerignore
 tar-ignore = docs
 tar-ignore = *.egg-info
 tar-ignore = .eggs
@@ -22,14 +23,17 @@ tar-ignore = ISSUE_TEMPLATE.md
 tar-ignore = LICENSE
 tar-ignore = Makefile
 tar-ignore = mypy.ini
+tar-ignore = .mypy_cache
 tar-ignore = __pycache__
 tar-ignore = *.pyc
 tar-ignore = .python-version
+tar-ignore = .remarkrc.yml
+tar-ignore = .remarkrc_master.yml
 tar-ignore = RELEASE_NOTES.md
 tar-ignore = snap
-tar-ignore = .stestr
 tar-ignore = .stestr.conf
 tar-ignore = *.swp
+tar-ignore = test-results
 tar-ignore = tests
 tar-ignore = .tox
 tar-ignore = tox.ini
\ No newline at end of file
diff --git a/git_machete/cli.py b/git_machete/cli.py
index 8bdf771..fc8766a 100644
--- a/git_machete/cli.py
+++ b/git_machete/cli.py
@@ -472,7 +472,7 @@ def get_branch_arg_or_current_branch(
     return cli_opts.opt_branch or git_context.get_current_branch()
 
 
-def exit_script(status_code: Optional[int] = None) -> None:
+def exit_script(status_code: Optional[int] = None, error: Optional[BaseException] = None) -> None:
     # Single point of exit is useful, because we can mock this method in tests
     # and verify that actual errors like MacheteException are raised with
     # appropriate messages. Otherwise it's not possible, because SystemError
@@ -480,6 +480,8 @@ def exit_script(status_code: Optional[int] = None) -> None:
     # there are some additional points of exit from the script in client.py
     # method MacheteClient.pick() there are left on purpose since removing them
     # would enforce significant refactor.
+    if error:
+        print(f"{error}", file=sys.stderr)
     sys.exit(status_code)
 
 
@@ -790,14 +792,9 @@ def launch(orig_args: List[str]) -> None:
 
     except (argparse.ArgumentError, argparse.ArgumentTypeError) as e:
         print(get_short_general_usage())
-        print(f"\n{e}", file=sys.stderr)
-        exit_script(2)
-    except MacheteException as e:
-        print(f"\n{e}", file=sys.stderr)
-        exit_script(1)
-    except KeyboardInterrupt:
-        print("\nInterrupted by the user", file=sys.stderr)
-        exit_script(1)
+        exit_script(2, e)
+    except (MacheteException, KeyboardInterrupt) as e:
+        exit_script(1, e)
     except StopInteraction:
         pass
     finally:
diff --git a/requirements.txt b/requirements.txt
index e69de29..d2e58c3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+# Keeping the list of dependencies empty
+# to make sure that installation via different package managers is as smooth as possible.
+# Yes, that's far from ideal... but https://xkcd.com/1987/ :/
diff --git a/tox.ini b/tox.ini
index 1c6e477..bc416e2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -6,15 +6,17 @@ skipsdist = True
 [testenv]
 setenv =
   VIRTUAL_ENV={envdir}
-  PYTHONWARNINGS=default::DeprecationWarning,ignore::DeprecationWarning:distutils,ignore::DeprecationWarning:site
+  PYTHONWARNINGS=default::DeprecationWarning,ignore:The distutils package is deprecated,ignore:The distutils.sysconfig module is deprecated,ignore:the imp module is deprecated,ignore:SelectableGroups dict interface is deprecated
 usedevelop = False
 install_command =
   pip install {opts} {packages}
 deps =
-  -r{toxinidir}/requirements.txt
-  -r{toxinidir}/test-requirements.txt
+  mypy
+  pytest
+allowlist_externals = mkdir
 commands =
-  stestr run {posargs}
+  mkdir -p test-results/
+  pytest --junitxml=test-results/{envname}.xml {posargs}
 
 [testenv:pep8]
 deps = flake8
@@ -33,10 +35,12 @@ import-order-style = pep8
 
 [testenv:coverage]
 description = "Check the test coverage of the code"
-deps = coverage
+deps =
+  {[testenv]deps}
+  coverage
 commands =
   coverage erase
-  coverage run --branch --include='git_machete/*' --omit='git_machete/tests/*' setup.py test
+  coverage run --branch --include='git_machete/*' --omit='git_machete/tests/*' -m pytest --junitxml=test-results/{envname}.xml
   coverage report
   coverage html
 
@@ -44,7 +48,7 @@ commands =
 description = "Build documentation"
 usedevelop=true
 deps =
-  sphinx==4.2.0
+  sphinx==4.4.0
   sphinx-rtd-theme==0.5.2
 commands =
   sphinx-build -b html docs/source docs/html


Test patch:
diff --git a/git_machete/tests/functional/test_machete.py b/git_machete/tests/functional/test_machete.py
index bff3a51..0a155aa 100644
--- a/git_machete/tests/functional/test_machete.py
+++ b/git_machete/tests/functional/test_machete.py
@@ -17,13 +17,12 @@ from urllib.parse import urlparse, ParseResult, parse_qs
 from urllib.error import HTTPError
 
 from git_machete import cli
-from git_machete.client import MacheteClient
 from git_machete.docs import long_docs
 from git_machete.exceptions import MacheteException
 from git_machete.github import get_parsed_github_remote_url
 from git_machete.git_operations import FullCommitHash, GitContext, LocalBranchShortName
 from git_machete.options import CommandLineOptions
-from git_machete.utils import dim, fmt
+from git_machete.utils import dim
 
 
 cli_opts: CommandLineOptions = CommandLineOptions()
@@ -32,10 +31,21 @@ git: GitContext = GitContext()
 FAKE_GITHUB_REMOTE_PATTERNS = ['(.*)/(.*)']
 
 
+def popen(command: str) -> str:
+    with os.popen(command) as process:
+        return process.read().strip()
+
+
 def get_current_commit_hash() -> FullCommitHash:
     """Returns hash of a commit of the current branch head."""
-    with os.popen("git rev-parse HEAD") as git_call:
-        return FullCommitHash.of(git_call.read().strip())
+    return FullCommitHash.of(popen("git rev-parse HEAD"))
+
+
+def mock_exit_script(status_code: Optional[int] = None, error: Optional[BaseException] = None) -> None:
+    if error:
+        raise error
+    else:
+        sys.exit(status_code)
 
 
 def mock_fetch_ref(cls: Any, remote: str, ref: str) -> None:
@@ -72,6 +82,10 @@ def mock_ask_if(*args: str, **kwargs: Any) -> str:
     return 'y'
 
 
+def mock__get_github_token() -> Optional[str]:
+    return None
+
+
 class FakeCommandLineOptions(CommandLineOptions):
     def __init__(self) -> None:
         super().__init__()
@@ -186,7 +200,7 @@ class MockGithubAPIRequest:
                 'user': {'login': 'github_user'},
                 'html_url': 'www.github.com',
                 'state': 'open',
-                'head': {'ref': "", 'repo': {'full_name': 'testing:checkout_prs', 'html_url': os.popen("mktemp -d").read().strip()}},
+                'head': {'ref': "", 'repo': {'full_name': 'testing:checkout_prs', 'html_url': popen("mktemp -d")}},
                 'base': {'ref': ""}}
         return self.fill_pull_request_data(json.loads(self.json_data), pull)
 
@@ -286,14 +300,11 @@ class MockContextManager:
 
 
 class GitRepositorySandbox:
-    with os.popen("mktemp -d") as local_temp_folder:
-        second_remote_path = local_temp_folder.read().strip()
+    second_remote_path = popen("mktemp -d")
 
     def __init__(self) -> None:
-        with os.popen("mktemp -d") as temp_remote_folder:
-            self.remote_path = temp_remote_folder.read().strip()
-        with os.popen("mktemp -d") as temp_local_folder:
-            self.local_path = temp_local_folder.read().strip()
+        self.remote_path = popen("mktemp -d")
+        self.local_path = popen("mktemp -d")
 
     def execute(self, command: str) -> "GitRepositorySandbox":
         subprocess.check_output(command, stderr=subprocess.STDOUT, shell=True)
@@ -329,8 +340,7 @@ class GitRepositorySandbox:
         return self
 
     def push(self) -> "GitRepositorySandbox":
-        with os.popen("git symbolic-ref -q --short HEAD") as git_call:
-            branch = git_call.read()
+        branch = popen("git symbolic-ref -q --short HEAD")
         self.execute(f"git push -u origin {branch}")
         return self
 
@@ -457,6 +467,7 @@ class MacheteTester(unittest.TestCase):
             """,
         )
 
+    @mock.patch('git_machete.cli.exit_script', mock_exit_script)
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     def test_branch_reappears_in_definition(self) -> None:
         body: str = \
@@ -465,18 +476,18 @@ class MacheteTester(unittest.TestCase):
             \t\n
             develop
             """
-        expected_error_msg: str = fmt('.git/machete, line 5: branch `develop` re-appears in the tree definition. '
-                                      'Edit the definition file manually with `git machete edit`')
 
         self.repo_sandbox.new_branch("root")
         self.rewrite_definition_file(body)
 
-        machete_client = MacheteClient(git)  # Only to workaround sys.exit while calling launch(['status'])
-        try:
-            machete_client.read_definition_file()
-        except MacheteException as e:
-            if e.parameter != expected_error_msg:
-                self.fail(f'Actual Exception message: {e} \nis not equal to expected message: {expected_error_msg}')
+        expected_error_message: str = '.git/machete, line 5: branch `develop` re-appears in the tree definition. ' \
+                                      'Edit the definition file manually with `git machete edit`'
+
+        with self.assertRaises(MacheteException) as e:
+            self.launch_command('status')
+        if e:
+            self.assertEqual(e.exception.parameter, expected_error_message,
+                             'Verify that expected error message has appeared a branch re-appears in tree definition.')
 
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     def test_show(self) -> None:
@@ -1794,9 +1805,9 @@ class MacheteTester(unittest.TestCase):
 
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     def test_update_with_fork_point_not_specified(self) -> None:
-        """Verify behaviour of a 'git machete update --no-interactive rebase' command.
+        """Verify behaviour of a 'git machete update --no-interactive-rebase' command.
 
-        Verify that 'git machete update --no-interactive rebase' performs
+        Verify that 'git machete update --no-interactive-rebase' performs
         'git rebase' to the parent branch of the current branch.
 
         """
@@ -1820,15 +1831,15 @@ class MacheteTester(unittest.TestCase):
         self.assertEqual(
             parents_new_commit_hash,
             new_forkpoint_hash,
-            msg="Verify that 'git machete update --no-interactive rebase' perform"
+            msg="Verify that 'git machete update --no-interactive-rebase' perform"
                 "'git rebase' to the parent branch of the current branch."
         )
 
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     def test_update_with_fork_point_specified(self) -> None:
-        """Verify behaviour of a 'git machete update --no-interactive rebase -f <commit_hash>' cmd.
+        """Verify behaviour of a 'git machete update --no-interactive-rebase -f <commit_hash>' cmd.
 
-        Verify that 'git machete update --no-interactive rebase -f <commit_hash>'
+        Verify that 'git machete update --no-interactive-rebase -f <commit_hash>'
         performs 'git rebase' to the upstream branch and drops the commits until
         (included) fork point specified by the option '-f'.
 
@@ -1855,20 +1866,19 @@ class MacheteTester(unittest.TestCase):
         self.launch_command(
             "update", "--no-interactive-rebase", "-f", branch_second_commit_hash)
         new_forkpoint_hash = self.launch_command("fork-point").strip()
-        with os.popen('git log -10 --oneline') as git_call:
-            branch_history = git_call.read()
+        branch_history = popen('git log -10 --oneline')
 
         self.assertEqual(
             roots_second_commit_hash,
             new_forkpoint_hash,
-            msg="Verify that 'git machete update --no-interactive rebase -f "
+            msg="Verify that 'git machete update --no-interactive-rebase -f "
                 "<commit_hash>' performs 'git rebase' to the upstream branch."
         )
 
         self.assertNotIn(
             branchs_first_commit_msg,
             branch_history,
-            msg="Verify that 'git machete update --no-interactive rebase -f "
+            msg="Verify that 'git machete update --no-interactive-rebase -f "
                 "<commit_hash>' drops the commits until (included) fork point "
                 "specified by the option '-f' from the current branch."
         )
@@ -1876,7 +1886,7 @@ class MacheteTester(unittest.TestCase):
         self.assertNotIn(
             branchs_second_commit_msg,
             branch_history,
-            msg="Verify that 'git machete update --no-interactive rebase -f "
+            msg="Verify that 'git machete update --no-interactive-rebase -f "
                 "<commit_hash>' drops the commits until (included) fork point "
                 "specified by the option '-f' from the current branch."
         )
@@ -1986,14 +1996,15 @@ class MacheteTester(unittest.TestCase):
     git_api_state_for_test_create_pr = MockGithubAPIState([{'head': {'ref': 'ignore-trailing', 'repo': mock_repository_info}, 'user': {'login': 'github_user'}, 'base': {'ref': 'hotfix/add-trigger'}, 'number': '3', 'html_url': 'www.github.com', 'state': 'open'}],
                                                           issues=[{'number': '4'}, {'number': '5'}, {'number': '6'}])
 
-    @mock.patch('urllib.error.HTTPError', MockHTTPError)  # need to provide read() method, which does not actually reads error from url
+    @mock.patch('git_machete.cli.exit_script', mock_exit_script)
+    @mock.patch('git_machete.client.MacheteClient.ask_if', mock_ask_if)
     # We need to mock GITHUB_REMOTE_PATTERNS in the tests for `test_github_create_pr` due to `git fetch` executed by `create-pr` subcommand.
     @mock.patch('git_machete.github.GITHUB_REMOTE_PATTERNS', FAKE_GITHUB_REMOTE_PATTERNS)
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
-    @mock.patch('git_machete.client.MacheteClient.ask_if', mock_ask_if)
-    @mock.patch('urllib.request.urlopen', MockContextManager)
+    @mock.patch('urllib.error.HTTPError', MockHTTPError)  # need to provide read() method, which does not actually reads error from url
     @mock.patch('urllib.request.Request', git_api_state_for_test_create_pr.new_request())
+    @mock.patch('urllib.request.urlopen', MockContextManager)
     def test_github_create_pr(self) -> None:
         (
             self.repo_sandbox.new_branch("root")
@@ -2120,12 +2131,9 @@ class MacheteTester(unittest.TestCase):
               x-drop-constraint (untracked)
             """,
         )
-        # check against attempt to create already existing pull request
-        machete_client = MacheteClient(git)
         expected_error_message = "A pull request already exists for test_repo:hotfix/add-trigger."
-        machete_client.read_definition_file()
         with self.assertRaises(MacheteException) as e:
-            machete_client.create_github_pr(head=LocalBranchShortName.of('hotfix/add-trigger'), opt_draft=False, opt_onto=None)
+            self.launch_command("github", "create-pr")
         if e:
             self.assertEqual(e.exception.msg, expected_error_message,  # type: ignore
                              'Verify that expected error message has appeared when given pull request to create is already created.')
@@ -2138,11 +2146,9 @@ class MacheteTester(unittest.TestCase):
         )
         self.launch_command('discover')
 
-        machete_client = MacheteClient(git)
-        machete_client.read_definition_file()
         expected_error_message = "All commits in `testing/endpoints` branch are already included in `develop` branch.\nCannot create pull request."
         with self.assertRaises(MacheteException) as e:
-            machete_client.create_github_pr(head=LocalBranchShortName.of('testing/endpoints'), opt_draft=False, opt_onto=None)
+            self.launch_command("github", "create-pr")
         if e:
             self.assertEqual(e.exception.parameter, expected_error_message,
                              'Verify that expected error message has appeared when head branch is equal or ancestor of base branch.')
@@ -2150,7 +2156,7 @@ class MacheteTester(unittest.TestCase):
         self.repo_sandbox.check_out('develop')
         expected_error_message = "Branch `develop` does not have a parent branch (it is a root), base branch for the PR cannot be established."
         with self.assertRaises(MacheteException) as e:
-            machete_client.create_github_pr(head=LocalBranchShortName.of('develop'), opt_draft=False, opt_onto=None)
+            self.launch_command("github", "create-pr")
         if e:
             self.assertEqual(e.exception.parameter, expected_error_message,
                              'Verify that expected error message has appeared when creating PR from root branch.')
@@ -2159,6 +2165,7 @@ class MacheteTester(unittest.TestCase):
 
     # We need to mock GITHUB_REMOTE_PATTERNS in the tests for `test_github_create_pr` due to `git fetch` executed by `create-pr` subcommand.
     @mock.patch('git_machete.github.GITHUB_REMOTE_PATTERNS', FAKE_GITHUB_REMOTE_PATTERNS)
+    @mock.patch('git_machete.github.__get_github_token', mock__get_github_token)
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
     @mock.patch('git_machete.client.MacheteClient.ask_if', mock_ask_if)
@@ -2208,12 +2215,13 @@ class MacheteTester(unittest.TestCase):
         {'head': {'ref': 'bugfix/remove-n-option', 'repo': {'full_name': 'testing/checkout_prs', 'html_url': GitRepositorySandbox.second_remote_path}}, 'user': {'login': 'github_user'}, 'base': {'ref': 'develop'}, 'number': '5', 'html_url': 'www.github.com', 'state': 'closed'}
     ])
 
-    @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
+    @mock.patch('git_machete.cli.exit_script', mock_exit_script)
     # We need to mock GITHUB_REMOTE_PATTERNS in the tests for `test_checkout_prs` due to `git fetch` executed by `checkout-prs` subcommand.
     @mock.patch('git_machete.github.GITHUB_REMOTE_PATTERNS', FAKE_GITHUB_REMOTE_PATTERNS)
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
-    @mock.patch('urllib.request.urlopen', MockContextManager)
+    @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     @mock.patch('urllib.request.Request', git_api_state_for_test_checkout_prs.new_request())
+    @mock.patch('urllib.request.urlopen', MockContextManager)
     def test_checkout_prs(self) -> None:
         (
             self.repo_sandbox.new_branch("root")
@@ -2361,22 +2369,20 @@ class MacheteTester(unittest.TestCase):
               o-chore/comments *  PR #24 (github_user)
             """
         )
+
         # check against wrong pr number
-        machete_client = MacheteClient(git)
         repo: str
         org: str
         (org, repo) = get_parsed_github_remote_url(self.repo_sandbox.remote_path)
         expected_error_message = f"PR #100 is not found in repository `{org}/{repo}`"
-        machete_client.read_definition_file()
         with self.assertRaises(MacheteException) as e:
-            machete_client.checkout_github_prs(100)
+            self.launch_command('github', 'checkout-prs', '100')
         if e:
             self.assertEqual(e.exception.parameter, expected_error_message,
                              'Verify that expected error message has appeared when given pull request to checkout does not exists.')
 
         # Check against closed pull request with head branch deleted from remote
-        with os.popen("mktemp -d") as local_temp_folder:
-            local_path = local_temp_folder.read().strip()
+        local_path = popen("mktemp -d")
         self.repo_sandbox.new_repo(GitRepositorySandbox.second_remote_path)
         (self.repo_sandbox.new_repo(local_path)
             .execute(f"git remote add origin {GitRepositorySandbox.second_remote_path}")
@@ -2388,11 +2394,9 @@ class MacheteTester(unittest.TestCase):
          )
         os.chdir(self.repo_sandbox.local_path)
 
-        machete_client = MacheteClient(git)
-        machete_client.read_definition_file()
         expected_error_message = "Could not check out PR #5 because its head branch `bugfix/remove-n-option` is already deleted from `testing`."
         with self.assertRaises(MacheteException) as e:
-            machete_client.checkout_github_prs(5)
+            self.launch_command('github', 'checkout-prs', '5')
         if e:
             self.assertEqual(e.exception.parameter, expected_error_message,
                              'Verify that expected error message has appeared when given pull request to checkout have already deleted branch from remote.')
@@ -2457,8 +2461,7 @@ class MacheteTester(unittest.TestCase):
         )
         for branch in ('develop', 'chore/sync_to_docs', 'improve/refactor', 'comments/add_docstrings'):
             self.repo_sandbox.execute(f"git branch -D {branch}")
-        with os.popen("mktemp -d") as local_temp_folder:
-            local_path = local_temp_folder.read().strip()
+        local_path = popen("mktemp -d")
         os.chdir(local_path)
         self.repo_sandbox.execute(f'git clone {self.repo_sandbox.remote_path}')
         os.chdir(os.path.join(local_path, os.listdir()[0]))
@@ -2466,8 +2469,7 @@ class MacheteTester(unittest.TestCase):
         for branch in ('develop', 'chore/sync_to_docs', 'improve/refactor', 'comments/add_docstrings'):
             self.repo_sandbox.execute(f"git branch -D -r origin/{branch}")
 
-        with os.popen("mktemp -d") as local_temp_folder:
-            local_path = local_temp_folder.read().strip()
+        local_path = popen("mktemp -d")
         self.repo_sandbox.new_repo(GitRepositorySandbox.second_remote_path)
         (
             self.repo_sandbox.new_repo(local_path)
@@ -2595,14 +2597,13 @@ class MacheteTester(unittest.TestCase):
 
         self.launch_command('squash', '-f', fork_point)
 
-        current_branch_log = os.popen('git log -3 --format=%s').read()
-
         expected_branch_log = (
             "Third commit.\n"
             "Second commit.\n"
-            "First commit.\n"
+            "First commit."
         )
 
+        current_branch_log = popen('git log -3 --format=%s')
         self.assertEqual(
             current_branch_log,
             expected_branch_log,
@@ -2649,6 +2650,7 @@ class MacheteTester(unittest.TestCase):
             # First exception MacheteException is raised, followed by SystemExit.
             self.launch_command('update', '-f', branch_1a_hash)
 
+    @mock.patch('git_machete.utils.run_cmd', mock_run_cmd_and_forward_stdout)
     def test_slide_out_with_valid_down_fork_point(self) -> None:
         (
             self.repo_sandbox.new_branch('branch-0')
diff --git a/test-requirements.txt b/test-requirements.txt
deleted file mode 100644
index 799cdaf..0000000
--- a/test-requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-mypy
-stestr