diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 319d682..e808655 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,9 +51,19 @@ $ pip uninstall git-sim ```console $ cd path/to/git-sim -$ python -m pip install -e . +$ python -m pip install -e .[dev] ``` +> Explanation: `python -m pip` uses the `pip` module of the currently active python interpreter. +> +> `install -e .[dev]` is the command that `pip` executes, where +> +> `-e` means to make it an [editable install](https://setuptools.pypa.io/en/latest/userguide/development_mode.html), +> +> the dot `.` refers to the current directory, +> +> and `[dev]` tells pip to install the "`dev`" [Extras](https://packaging.python.org/en/latest/tutorials/installing-packages/#installing-extras) (which are defined in the `project.optional-dependencies` section of [`pyproject.toml`](./pyproject.toml)). + This will install sources from your cloned repo such that you can edit the source and the changes are reflected instantly. If you already have the dependencies, you can ignore those using the `--no-deps` flag: diff --git a/MANIFEST.in b/MANIFEST.in index 38532f0..796cb43 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1 @@ -include git_sim/logo.png +include src/git_sim/logo.png diff --git a/README.md b/README.md index f36fae1..dca8fa0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ Example: `$ git-sim merge `

![git-sim-merge_04-22-23_21-04-32_cropped](https://user-images.githubusercontent.com/49353917/233821875-a7bb640d-10be-4433-a8fb-bd25646eeff4.jpg) +Check out the [git-sim release blog post](https://initialcommit.com/blog/git-sim) for the full scoop! + +## Support git-sim +Git-Sim is Free and Open-Source Software (FOSS). Your support will help me work on it (and other Git projects) full time! +- [Sponsor Git-Sim on GitHub](https://github.com/sponsors/initialcommit-com) +- [Support Git-Sim via Patreon](https://patreon.com/user?u=92322459) + ## Use cases - Visualize Git commands to understand their effects on your repo before actually running them - Prevent unexpected working directory and repository states by simulating before running @@ -28,7 +35,7 @@ Example: `$ git-sim merge ` ## Features - Run a one-liner git-sim command in the terminal to generate a custom Git command visualization (.jpg) from your repo -- Supported commands: `log`, `status`, `add`, `restore`, `commit`, `stash`, `branch`, `tag`, `reset`, `revert`, `merge`, `rebase`, `cherry-pick`, `switch`, `checkout`, `fetch`, `pull`, `push`, `clone`, `rm`, `mv`, `clean` +- Supported commands: `add`, `branch`, `checkout`, `cherry-pick`, `clean`, `clone`, `commit`, `config`, `fetch`, `init`, `log`, `merge`, `mv`, `pull`, `push`, `rebase`, `remote`, `reset`, `restore`, `revert`, `rm`, `stash`, `status`, `switch`, `tag` - Generate an animated video (.mp4) instead of a static image using the `--animate` flag (note: significant performance slowdown, it is recommended to use `--low-quality` to speed up testing and remove when ready to generate presentation-quality video) - Color commits by parameter, such as author with the `--color-by=author` option - Choose between dark mode (default) and light mode @@ -128,7 +135,8 @@ $ git-sim -h * [Manim (Community version)](https://www.manim.community/) ## Commands -Basic usage is similar to Git itself - `git-sim` takes a familiar set of subcommands including "log", "status", "add", "restore", "commit", "stash", "branch", "tag", "reset", "revert", "merge", "rebase", "cherry-pick", "switch", "checkout", "fetch", "pull", "push", "clone", "rm", "mv", "clean" along with corresponding options. +Basic usage is similar to Git itself - `git-sim` takes a familiar set of subcommands including "add", "branch", "checkout", "cherry-pick", "clean", "clone", "commit", "config", "fetch", "init", "log", "merge", "mv", "pull", "push", "rebase", "remote", "reset", "restore", "revert", "rm", "stash", "status", "switch", "tag" along with corresponding options. + ```console $ git-sim [global options] [subcommand options] @@ -163,29 +171,13 @@ Animation-only global options (to be used in conjunction with `--animate`): `--title=title`: Custom title to display at the beginning of the animation. `--logo=logo.png`: The path to a custom logo to use in the animation intro/outro. `--outro-top-text`: Custom text to display above the logo during the outro. -`--outro-bottom-text`: Custom text to display below the logo during the outro. +`--outro-bottom-text`: Custom text to display below the logo during the outro. +`--font`: Font family used to display rendered text. The `[subcommand options]` are like regular Git options specific to the specified subcommand (see below for a full list). The following is a list of Git commands that can be simulated and their corresponding options/flags. -### git log -Usage: `git-sim log [-n ] [--all]` - -- Simulated output will show the most recent 5 commits on the active branch by default -- Use `-n ` to set number of commits to display from each branch head -- Set `--all` to display all local branches in the log output - -![git-sim-log_01-05-23_22-02-39](https://user-images.githubusercontent.com/49353917/210940300-aadd14c6-72ab-4529-a1be-b494ed5dd4c9.jpg) - -### git status -Usage: `git-sim status` - -- Simulated output will show the state of the working directory, staging area, and untracked files -- Note that simulated output will also show the most recent 5 commits on the active branch - -![git-sim-status_01-05-23_22-06-28](https://user-images.githubusercontent.com/49353917/210940685-735665e2-fa12-4043-979c-54c295b13800.jpg) - ### git add Usage: `git-sim add ... ` @@ -195,14 +187,46 @@ Usage: `git-sim add ... ` ![git-sim-add_01-05-23_22-07-40](https://user-images.githubusercontent.com/49353917/210940814-7e8dc318-6116-4e56-b415-bc547401a56a.jpg) -### git restore -Usage: `git-sim restore ... ` +### git branch +Usage: `git-sim branch ` -- Specify one or more `` as a *modified* working directory file, or staged file -- Simulated output will show files being moved back to the working directory or discarded changes +- Specify `` as the name of the new branch to simulate creation of +- Simulated output will show the newly create branch ref along with most recent 5 commits on the active branch + +![git-sim-branch_01-05-23_22-13-17](https://user-images.githubusercontent.com/49353917/210941509-2a42a7a4-2168-4f62-913f-3f6fe74a0684.jpg) + +### git checkout +Usage: `git-sim checkout [-b] ` + +- Checks out `` into the working directory, i.e. moves `HEAD` to the specified `` +- The `-b` flag creates a new branch with the specified name `` and checks it out, assuming it doesn't already exist + +![git-sim-checkout_04-09-23_21-46-04](https://user-images.githubusercontent.com/49353917/230827836-e9f23a0e-2576-4716-b2fb-6327d3cf9b22.jpg) + +### git cherry-pick +Usage: `git-sim cherry-pick ` + +- Specify `` as a ref (branch name/tag) or commit ID to cherry-pick onto the active branch +- Supports editing the cherry-picked commit message with: `$ git-sim cherry-pick -e "Edited commit message"` + +![git-sim-cherry-pick_01-05-23_22-23-08](https://user-images.githubusercontent.com/49353917/210942811-fa5155b1-4c6f-4afc-bea2-d39b4cd594aa.jpg) + +### git clean +Usage: `git-sim clean` + +- Simulated output will show untracked files being deleted +- Since this is just a simulation, no need to specify `-i`, `-n`, `-f` as in regular Git - Note that simulated output will also show the most recent 5 commits on the active branch -![git-sim-restore_01-05-23_22-09-14](https://user-images.githubusercontent.com/49353917/210941009-e6bf7271-ce9b-4e41-9a0b-24cc4b8d3b15.jpg) +![git-sim-clean_04-09-23_22-05-54](https://user-images.githubusercontent.com/49353917/230830043-779e7230-f439-461a-a408-b19b263e86e4.jpg) + +### git clone +Usage: `git-sim clone ` + +- Clone the remote repo from `` (web URL or filesystem path) to a new folder in the current directory +- Output will report if clone operation is successful and show log of local clone + +![git-sim-clone_04-09-23_21-51-53](https://user-images.githubusercontent.com/49353917/230828521-80c8d2d1-2a31-46bb-aeed-746f0441c86e.jpg) ### git commit Usage: `git-sim commit -m "Commit message"` @@ -215,49 +239,36 @@ Usage: `git-sim commit -m "Commit message"` ![git-sim-commit_01-05-23_22-10-21](https://user-images.githubusercontent.com/49353917/210941149-d83677a1-3ab7-4880-bc0f-871b1f150087.jpg) -### git stash -Usage: `git-sim stash [push|pop|apply] ` - -- Specify one or more `` as a *modified* working directory file, or staged file -- If no `` is specified, all available files will be included -- Simulated output will show files being moved in/out of the Git stash -- Note that simulated output will also show the most recent 5 commits on the active branch - -![git-sim-stash_01-05-23_22-11-18](https://user-images.githubusercontent.com/49353917/210941254-69c80b63-5c06-411a-a36a-1454b2906ee8.jpg) +### git config +Usage: `git-sim config [--list] ` -### git branch -Usage: `git-sim branch ` +- Simulated output describes the specified configuration change +- Use `--list` or `-l` to display all configuration -- Specify `` as the name of the new branch to simulate creation of -- Simulated output will show the newly create branch ref along with most recent 5 commits on the active branch - -![git-sim-branch_01-05-23_22-13-17](https://user-images.githubusercontent.com/49353917/210941509-2a42a7a4-2168-4f62-913f-3f6fe74a0684.jpg) +![git-sim-config_04-16-24_08-34-34](https://github.com/initialcommit-com/git-sim/assets/49353917/c123e7a7-1fff-4f5c-b4a2-1e34ea2a4d80) -### git tag -Usage: `git-sim tag ` +### git fetch +Usage: `git-sim fetch ` -- Specify `` as the name of the new tag to simulate creation of -- Simulated output will show the newly create tag ref along with most recent 5 commits on the active branch +- Fetches the specified `` from the specified `` to the local repo -![git-sim-tag_01-05-23_22-14-18](https://user-images.githubusercontent.com/49353917/210941647-79376ff7-2941-42b3-964a-b1d3a404a4fe.jpg) +![git-sim-fetch_04-09-23_21-47-59](https://user-images.githubusercontent.com/49353917/230828090-acae8979-4097-43a8-96ea-525890e0e0a8.jpg) -### git reset -Usage: `git-sim reset [--mixed|--soft|--hard]` +### git init +Usage: `git-sim init` -- Specify `` as any commit id, branch name, tag, or other ref to simulate reset to from the current HEAD (default: `HEAD`) -- As with a normal git reset command, default reset mode is `--mixed`, but can be specified using `--soft`, `--hard`, or `--mixed` -- Simulated output will show branch/HEAD resets and resulting state of the working directory, staging area, and whether any file changes would be deleted by running the actual command +- Simulated output describes the initialized `.git/` directory and it's contents -![git-sim-reset_01-05-23_22-15-49](https://user-images.githubusercontent.com/49353917/210941835-80f032d2-4f06-4032-8dd0-98c8a2569049.jpg) +![git-sim-init_04-16-24_08-34-47](https://github.com/initialcommit-com/git-sim/assets/49353917/2abb1a4a-3022-4353-a828-2d337baa8383) -### git revert -Usage: `git-sim revert ` +### git log +Usage: `git-sim log [-n ] [--all]` -- Specify `` as any commit id, branch name, tag, or other ref to simulate revert for -- Simulated output will show the new commit which reverts the changes from `` -- Simulated output will include the next 4 most recent commits on the active branch +- Simulated output will show the most recent 5 commits on the active branch by default +- Use `-n ` to set number of commits to display from each branch head +- Set `--all` to display all local branches in the log output -![git-sim-revert_01-05-23_22-16-59](https://user-images.githubusercontent.com/49353917/210941979-6db8b55c-2881-41d8-9e2e-6263b1dece13.jpg) +![git-sim-log_01-05-23_22-02-39](https://user-images.githubusercontent.com/49353917/210940300-aadd14c6-72ab-4529-a1be-b494ed5dd4c9.jpg) ### git merge Usage: `git-sim merge [-m "Commit message"] [--no-ff]` @@ -271,43 +282,15 @@ Usage: `git-sim merge [-m "Commit message"] [--no-ff]` ![git-sim-merge_01-05-23_09-44-46](https://user-images.githubusercontent.com/49353917/210942030-c7229488-571a-4943-a1f4-c6e4a0c8ccf3.jpg) -### git rebase -Usage: `git-sim rebase ` - -- Specify `` as the branch name to rebase the active branch onto - -![git-sim-rebase_01-05-23_09-53-34](https://user-images.githubusercontent.com/49353917/210942598-4ff8d1e6-464d-48f3-afb9-f46f7ec4828c.jpg) - -### git cherry-pick -Usage: `git-sim cherry-pick ` - -- Specify `` as a ref (branch name/tag) or commit ID to cherry-pick onto the active branch -- Supports editing the cherry-picked commit message with: `$ git-sim cherry-pick -e "Edited commit message"` - -![git-sim-cherry-pick_01-05-23_22-23-08](https://user-images.githubusercontent.com/49353917/210942811-fa5155b1-4c6f-4afc-bea2-d39b4cd594aa.jpg) - -### git switch -Usage: `git-sim switch [-c] ` - -- Switches the checked-out branch to ``, i.e. moves `HEAD` to the specified `` -- The `-c` flag creates a new branch with the specified name `` and switches to it, assuming it doesn't already exist - -![git-sim-switch_04-09-23_21-42-43](https://user-images.githubusercontent.com/49353917/230827783-a8740ace-b66f-4cac-b94e-5d101d27e0b5.jpg) - -### git checkout -Usage: `git-sim checkout [-b] ` - -- Checks out `` into the working directory, i.e. moves `HEAD` to the specified `` -- The `-b` flag creates a new branch with the specified name `` and checks it out, assuming it doesn't already exist - -![git-sim-checkout_04-09-23_21-46-04](https://user-images.githubusercontent.com/49353917/230827836-e9f23a0e-2576-4716-b2fb-6327d3cf9b22.jpg) - -### git fetch -Usage: `git-sim fetch ` +### git mv +Usage: `git-sim mv ` -- Fetches the specified `` from the specified `` to the local repo +- Specify `` as file to update name/path +- Specify `` as new name/path of file +- Simulated output will show the name/path of the file being updated +- Note that simulated output will also show the most recent 5 commits on the active branch -![git-sim-fetch_04-09-23_21-47-59](https://user-images.githubusercontent.com/49353917/230828090-acae8979-4097-43a8-96ea-525890e0e0a8.jpg) +![git-sim-mv_04-09-23_22-05-13](https://user-images.githubusercontent.com/49353917/230829978-0a64dbe2-d974-4cef-9c6e-ed26e987342f.jpg) ### git pull Usage: `git-sim pull [ ]` @@ -327,13 +310,47 @@ Usage: `git-sim push [ ]` ![git-sim-push_04-21-23_13-41-57](https://user-images.githubusercontent.com/49353917/233731005-51fd7887-ae14-4ceb-a5d5-e5aed79e9fd8.jpg) -### git clone -Usage: `git-sim clone ` +### git rebase +Usage: `git-sim rebase ` -- Clone the remote repo from `` (web URL or filesystem path) to a new folder in the current directory -- Output will report if clone operation is successful and show log of local clone +- Specify `` as the branch name to rebase the active branch onto -![git-sim-clone_04-09-23_21-51-53](https://user-images.githubusercontent.com/49353917/230828521-80c8d2d1-2a31-46bb-aeed-746f0441c86e.jpg) +![git-sim-rebase_01-05-23_09-53-34](https://user-images.githubusercontent.com/49353917/210942598-4ff8d1e6-464d-48f3-afb9-f46f7ec4828c.jpg) + +### git remote +Usage: `git-sim remote [add|rename|remove|get-url|set-url] [] []` + +- Simulated output will show remotes being added, renamed, removed, modified as indicated +- Running `git-sim remote` with no options will list all existing remotes and their details + +![git-sim-remote_04-16-24_08-40-37](https://github.com/initialcommit-com/git-sim/assets/49353917/ebaff04c-d5b6-4691-97b3-60bb502ba444) + +### git reset +Usage: `git-sim reset [--mixed|--soft|--hard]` + +- Specify `` as any commit id, branch name, tag, or other ref to simulate reset to from the current HEAD (default: `HEAD`) +- As with a normal git reset command, default reset mode is `--mixed`, but can be specified using `--soft`, `--hard`, or `--mixed` +- Simulated output will show branch/HEAD resets and resulting state of the working directory, staging area, and whether any file changes would be deleted by running the actual command + +![git-sim-reset_01-05-23_22-15-49](https://user-images.githubusercontent.com/49353917/210941835-80f032d2-4f06-4032-8dd0-98c8a2569049.jpg) + +### git restore +Usage: `git-sim restore ... ` + +- Specify one or more `` as a *modified* working directory file, or staged file +- Simulated output will show files being moved back to the working directory or discarded changes +- Note that simulated output will also show the most recent 5 commits on the active branch + +![git-sim-restore_01-05-23_22-09-14](https://user-images.githubusercontent.com/49353917/210941009-e6bf7271-ce9b-4e41-9a0b-24cc4b8d3b15.jpg) + +### git revert +Usage: `git-sim revert ` + +- Specify `` as any commit id, branch name, tag, or other ref to simulate revert for +- Simulated output will show the new commit which reverts the changes from `` +- Simulated output will include the next 4 most recent commits on the active branch + +![git-sim-revert_01-05-23_22-16-59](https://user-images.githubusercontent.com/49353917/210941979-6db8b55c-2881-41d8-9e2e-6263b1dece13.jpg) ### git rm Usage: `git-sim rm ... ` @@ -344,24 +361,39 @@ Usage: `git-sim rm ... ` ![git-sim-rm_04-09-23_22-01-29](https://user-images.githubusercontent.com/49353917/230829899-f5d688ea-bc8e-46f9-a54a-55d251c8915d.jpg) -### git mv -Usage: `git-sim mv ` +### git stash +Usage: `git-sim stash [push|pop|apply] ` -- Specify `` as file to update name/path -- Specify `` as new name/path of file -- Simulated output will show the name/path of the file being updated +- Specify one or more `` as a *modified* working directory file, or staged file +- If no `` is specified, all available files will be included +- Simulated output will show files being moved in/out of the Git stash - Note that simulated output will also show the most recent 5 commits on the active branch -![git-sim-mv_04-09-23_22-05-13](https://user-images.githubusercontent.com/49353917/230829978-0a64dbe2-d974-4cef-9c6e-ed26e987342f.jpg) +![git-sim-stash_01-05-23_22-11-18](https://user-images.githubusercontent.com/49353917/210941254-69c80b63-5c06-411a-a36a-1454b2906ee8.jpg) -### git clean -Usage: `git-sim clean` +### git status +Usage: `git-sim status` -- Simulated output will show untracked files being deleted -- Since this is just a simulation, no need to specify `-i`, `-n`, `-f` as in regular Git +- Simulated output will show the state of the working directory, staging area, and untracked files - Note that simulated output will also show the most recent 5 commits on the active branch -![git-sim-clean_04-09-23_22-05-54](https://user-images.githubusercontent.com/49353917/230830043-779e7230-f439-461a-a408-b19b263e86e4.jpg) +![git-sim-status_01-05-23_22-06-28](https://user-images.githubusercontent.com/49353917/210940685-735665e2-fa12-4043-979c-54c295b13800.jpg) + +### git switch +Usage: `git-sim switch [-c] ` + +- Switches the checked-out branch to ``, i.e. moves `HEAD` to the specified `` +- The `-c` flag creates a new branch with the specified name `` and switches to it, assuming it doesn't already exist + +![git-sim-switch_04-09-23_21-42-43](https://user-images.githubusercontent.com/49353917/230827783-a8740ace-b66f-4cac-b94e-5d101d27e0b5.jpg) + +### git tag +Usage: `git-sim tag ` + +- Specify `` as the name of the new tag to simulate creation of +- Simulated output will show the newly create tag ref along with most recent 5 commits on the active branch + +![git-sim-tag_01-05-23_22-14-18](https://user-images.githubusercontent.com/49353917/210941647-79376ff7-2941-42b3-964a-b1d3a404a4fe.jpg) ## Video animation examples ```console diff --git a/git_sim/__init__.py b/git_sim/__init__.py deleted file mode 100644 index e19434e..0000000 --- a/git_sim/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.3.3" diff --git a/git_sim/tag.py b/git_sim/tag.py deleted file mode 100644 index cded5c8..0000000 --- a/git_sim/tag.py +++ /dev/null @@ -1,52 +0,0 @@ -import manim as m - -from git_sim.git_sim_base_command import GitSimBaseCommand -from git_sim.settings import settings - - -class Tag(GitSimBaseCommand): - def __init__(self, name: str): - super().__init__() - self.name = name - - def construct(self): - if not settings.stdout and not settings.output_only_path and not settings.quiet: - print(f"{settings.INFO_STRING } {type(self).__name__.lower()} {self.name}") - - self.show_intro() - self.parse_commits() - self.parse_all() - self.center_frame_on_commit(self.get_commit()) - - tagText = m.Text( - self.name, - font="Monospace", - font_size=20, - color=self.fontColor, - ) - tagRec = m.Rectangle( - color=m.YELLOW, - fill_color=m.YELLOW, - fill_opacity=0.25, - height=0.4, - width=tagText.width + 0.25, - ) - - tagRec.next_to(self.topref, m.UP) - tagText.move_to(tagRec.get_center()) - - fulltag = m.VGroup(tagRec, tagText) - - if settings.animate: - self.play(m.Create(fulltag), run_time=1 / settings.speed) - else: - self.add(fulltag) - - self.toFadeOut.add(tagRec, tagText) - self.drawnRefs[self.name] = fulltag - - self.recenter_frame() - self.scale_frame() - self.color_by() - self.fadeout() - self.show_outro() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1d821bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "git-sim" +authors = [{ name = "Jacob Stopak", email = "jacob@initialcommit.io" }] +description = "Simulate Git commands on your own repos by generating an image (default) or video visualization depicting the command's behavior." +readme = "README.md" +requires-python = ">=3.7" +keywords = [ + "git", + "sim", + "simulation", + "simulate", + "git-simulate", + "git-simulation", + "git-sim", + "manim", + "animation", + "gitanimation", + "image", + "video", + "dryrun", + "dry-run", +] +license = { text = "GPL-2.0" } +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", + "Operating System :: OS Independent", +] +dependencies = [ + "git-dummy", + "gitpython", + "manim", + "opencv-python-headless", + "pydantic_settings", + "typer", + "fonttools", +] +dynamic = ["version"] + +[tool.setuptools.dynamic] +version = { attr = "git_sim.__version__" } + +[project.optional-dependencies] +dev = ["black", "numpy", "pillow", "pytest"] + +[project.scripts] +git-sim = "git_sim.__main__:app" + +[project.urls] +Homepage = "https://initialcommit.com/tools/git-sim" +Source = "https://github.com/initialcommit-com/git-sim" diff --git a/setup.py b/setup.py deleted file mode 100644 index 88793fe..0000000 --- a/setup.py +++ /dev/null @@ -1,44 +0,0 @@ -import setuptools - -from git_sim import __version__ - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - name="git-sim", - version=__version__, - author="Jacob Stopak", - author_email="jacob@initialcommit.io", - description="Simulate Git commands on your own repos by generating an image (default) or video visualization depicting the command's behavior.", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://initialcommit.com/tools/git-sim", - packages=setuptools.find_packages(), - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires=">=3.7", - install_requires=[ - "gitpython", - "manim", - "opencv-python-headless", - "typer", - "pydantic_settings", - "git-dummy", - ], - keywords="git sim simulation simulate git-simulate git-simulation git-sim manim animation gitanimation image video dryrun dry-run", - project_urls={ - "Homepage": "https://initialcommit.com/tools/git-sim", - "Source": "https://github.com/initialcommit-com/git-sim", - }, - entry_points={ - "console_scripts": [ - "git-sim=git_sim.__main__:app", - "git-dummy=git_dummy.__main__:app", - ], - }, - include_package_data=True, -) diff --git a/src/git_sim/__init__.py b/src/git_sim/__init__.py new file mode 100644 index 0000000..a8d4557 --- /dev/null +++ b/src/git_sim/__init__.py @@ -0,0 +1 @@ +__version__ = "0.3.5" diff --git a/git_sim/__main__.py b/src/git_sim/__main__.py similarity index 88% rename from git_sim/__main__.py rename to src/git_sim/__main__.py index 3b523d4..5def5ea 100644 --- a/git_sim/__main__.py +++ b/src/git_sim/__main__.py @@ -1,10 +1,15 @@ +import contextlib import datetime import os import pathlib import sys import time +from pathlib import Path import typer +import manim as m + +from fontTools.ttLib import TTFont import git_sim.commands from git_sim.settings import ( @@ -18,6 +23,12 @@ app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]}) +def get_font_name(font_path): + """Get the name of a font from its .ttf file.""" + font = TTFont(font_path) + return font["name"].getName(4, 3, 1, 1033).toUnicode() + + def version_callback(value: bool) -> None: if value: print(f"git-sim version {git_sim.__version__}") @@ -48,7 +59,6 @@ def main( ), light_mode: bool = typer.Option( settings.light_mode, - "--light-mode", help="Enable light-mode with white background", ), transparent_bg: bool = typer.Option( @@ -157,6 +167,14 @@ def main( settings.style.value, help="Graphical style of the output image or animated video", ), + font: str = typer.Option( + settings.font, + help="Font family used to display rendered text", + ), + show_command_as_title: bool = typer.Option( + settings.show_command_as_title, + help="Use the simulated git command as the title of the output image or animated video", + ), ): import git from manim import WHITE, config @@ -189,6 +207,16 @@ def main( settings.color_by = color_by settings.highlight_commit_messages = highlight_commit_messages settings.style = style + settings.show_command_as_title = show_command_as_title + + # If font is a path, define the context that will be used when using Manim. + if Path(font).exists(): + font_path = Path(font) + settings.font_context = m.register_font(font_path) + settings.font = get_font_name(font_path) + else: + settings.font_context = contextlib.nullcontext() + settings.font = font try: if sys.platform == "linux" or sys.platform == "darwin": @@ -227,13 +255,16 @@ def main( app.command()(git_sim.commands.clean) app.command()(git_sim.commands.clone) app.command()(git_sim.commands.commit) +app.command()(git_sim.commands.config) app.command()(git_sim.commands.fetch) +app.command()(git_sim.commands.init) app.command()(git_sim.commands.log) app.command()(git_sim.commands.merge) app.command()(git_sim.commands.mv) app.command()(git_sim.commands.pull) app.command()(git_sim.commands.push) app.command()(git_sim.commands.rebase) +app.command()(git_sim.commands.remote) app.command()(git_sim.commands.reset) app.command()(git_sim.commands.restore) app.command()(git_sim.commands.revert) diff --git a/git_sim/add.py b/src/git_sim/add.py similarity index 93% rename from git_sim/add.py rename to src/git_sim/add.py index 8f425e1..cdcb382 100644 --- a/git_sim/add.py +++ b/src/git_sim/add.py @@ -29,11 +29,11 @@ def __init__(self, files: List[str]): print(f"git-sim error: No modified file with name: '{file}'") sys.exit() + self.cmd += f"{type(self).__name__.lower()} {' '.join(self.files)}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING} {type(self).__name__.lower()} {' '.join(self.files)}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() @@ -41,6 +41,7 @@ def construct(self): self.scale_frame() self.vsplit_frame() self.setup_and_draw_zones() + self.show_command_as_title() self.fadeout() self.show_outro() diff --git a/git_sim/animations.py b/src/git_sim/animations.py similarity index 100% rename from git_sim/animations.py rename to src/git_sim/animations.py diff --git a/git_sim/branch.py b/src/git_sim/branch.py similarity index 87% rename from git_sim/branch.py rename to src/git_sim/branch.py index 97b3de2..9a714d3 100644 --- a/git_sim/branch.py +++ b/src/git_sim/branch.py @@ -8,10 +8,11 @@ class Branch(GitSimBaseCommand): def __init__(self, name: str): super().__init__() self.name = name + self.cmd += f"{type(self).__name__.lower()} {self.name}" def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print(f"{settings.INFO_STRING} {type(self).__name__.lower()} {self.name}") + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() @@ -20,7 +21,7 @@ def construct(self): branchText = m.Text( self.name, - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) @@ -48,5 +49,6 @@ def construct(self): self.recenter_frame() self.scale_frame() self.color_by() + self.show_command_as_title() self.fadeout() self.show_outro() diff --git a/git_sim/checkout.py b/src/git_sim/checkout.py similarity index 95% rename from git_sim/checkout.py rename to src/git_sim/checkout.py index 9c6d44b..a11a0bb 100644 --- a/git_sim/checkout.py +++ b/src/git_sim/checkout.py @@ -60,11 +60,13 @@ def __init__(self, branch: str, b: bool): except TypeError: pass + self.cmd += ( + f"{type(self).__name__.lower()}{' -b' if self.b else ''} {self.branch}" + ) + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()}{' -b' if self.b else ''} {self.branch}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() head_commit = self.get_commit() @@ -118,4 +120,5 @@ def construct(self): self.color_by() self.fadeout() + self.show_command_as_title() self.show_outro() diff --git a/git_sim/cherrypick.py b/src/git_sim/cherrypick.py similarity index 90% rename from git_sim/cherrypick.py rename to src/git_sim/cherrypick.py index 9cbcd0f..5f2d58b 100644 --- a/git_sim/cherrypick.py +++ b/src/git_sim/cherrypick.py @@ -31,12 +31,13 @@ def __init__(self, commit: str, edit: str): except TypeError: pass + self.cmd += f"cherry-pick {self.commit}" + ( + (' -e "' + self.edit + '"') if self.edit else "" + ) + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING} cherry-pick {self.commit}" - + ((' -e "' + self.edit + '"') if self.edit else "") - ) + print(f"{settings.INFO_STRING} {self.cmd}") if self.repo.active_branch.name in self.repo.git.branch( "--contains", self.commit @@ -66,5 +67,6 @@ def construct(self): self.scale_frame() self.reset_head_branch("abcdef") self.color_by(offset=2) + self.show_command_as_title() self.fadeout() self.show_outro() diff --git a/git_sim/clean.py b/src/git_sim/clean.py similarity index 93% rename from git_sim/clean.py rename to src/git_sim/clean.py index e95afd7..af0e110 100644 --- a/git_sim/clean.py +++ b/src/git_sim/clean.py @@ -21,9 +21,11 @@ def __init__(self): except TypeError: pass + self.cmd += f"{type(self).__name__.lower()}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print(f"{settings.INFO_STRING} {type(self).__name__.lower()}") + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() @@ -35,6 +37,7 @@ def construct(self): second_column_name="----", third_column_name="Deleted files", ) + self.show_command_as_title() self.fadeout() self.show_outro() @@ -58,7 +61,7 @@ def create_zone_text( text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -74,7 +77,7 @@ def create_zone_text( text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -94,7 +97,7 @@ def create_zone_text( + "'>" + self.trim_path(f) + "", - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) diff --git a/git_sim/clone.py b/src/git_sim/clone.py similarity index 78% rename from git_sim/clone.py rename to src/git_sim/clone.py index b126ddf..bce5e19 100644 --- a/git_sim/clone.py +++ b/src/git_sim/clone.py @@ -19,14 +19,16 @@ class Clone(GitSimBaseCommand): def init_repo(self): pass - def __init__(self, url: str): + def __init__(self, url: str, path: str): super().__init__() self.url = url + self.path = path settings.max_branches_per_commit = 2 + self.cmd += f"{type(self).__name__.lower()} {self.url + ('' if self.path == '.' else ' ' + self.path)}" def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print(f"{settings.INFO_STRING } {type(self).__name__.lower()} {self.url}") + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() @@ -36,11 +38,17 @@ def construct(self): repo_name = repo_name.group(1) if repo_name.endswith(".git"): repo_name = repo_name[:-4] + elif self.url == "." or self.url == "./" or self.url == ".\\": + repo_name = os.path.split(os.getcwd())[1] else: print( f"git-sim error: Invalid repo URL, please confirm repo URL and try again" ) sys.exit(1) + + if self.url == os.path.join(self.path, repo_name): + print(f"git-sim error: Cannot clone into same path, please try again") + sys.exit(1) new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) # Create local clone of local repo @@ -58,6 +66,7 @@ def construct(self): self.scale_frame() self.add_details(repo_name) self.color_by() + self.show_command_as_title() self.fadeout() self.show_outro() @@ -69,8 +78,8 @@ def construct(self): def add_details(self, repo_name): text1 = m.Text( - f"Successfully cloned from {self.url} into ./{repo_name}", - font="Monospace", + f"Successfully cloned from {self.url} into {repo_name if self.path == '.' else self.path}", + font=self.font, font_size=20, color=self.fontColor, weight=m.BOLD, @@ -79,7 +88,7 @@ def add_details(self, repo_name): text2 = m.Text( f"Cloned repo log:", - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=m.BOLD, diff --git a/git_sim/commands.py b/src/git_sim/commands.py similarity index 76% rename from git_sim/commands.py rename to src/git_sim/commands.py index 54ae298..61dc588 100644 --- a/git_sim/commands.py +++ b/src/git_sim/commands.py @@ -4,8 +4,8 @@ from typing import List, TYPE_CHECKING -from git_sim.enums import ResetMode, StashSubCommand from git_sim.settings import settings +from git_sim.enums import ResetMode, StashSubCommand, RemoteSubCommand if TYPE_CHECKING: from manim import Scene @@ -14,7 +14,8 @@ def handle_animations(scene: Scene) -> None: from git_sim.animations import handle_animations as _handle_animations - return _handle_animations(scene) + with settings.font_context: + return _handle_animations(scene) def add( @@ -90,10 +91,14 @@ def clone( ..., help="The web URL or filesystem path of the Git repo to clone", ), + path: str = typer.Argument( + default=".", + help="The web URL or filesystem path of the Git repo to clone", + ), ): from git_sim.clone import Clone - scene = Clone(url=url) + scene = Clone(url=url, path=path) handle_animations(scene=scene) @@ -116,6 +121,24 @@ def commit( handle_animations(scene=scene) +def config( + l: bool = typer.Option( + False, + "-l", + "--list", + help="List existing local repo config settings", + ), + settings: List[str] = typer.Argument( + default=None, + help="The names and values of one or more config settings to set", + ), +): + from git_sim.config import Config + + scene = Config(l=l, settings=settings) + handle_animations(scene=scene) + + def fetch( remote: str = typer.Argument( default=None, @@ -132,6 +155,13 @@ def fetch( handle_animations(scene=scene) +def init(): + from git_sim.init import Init + + scene = Init() + handle_animations(scene=scene) + + def log( ctx: typer.Context, n: int = typer.Option( @@ -216,10 +246,15 @@ def push( default=None, help="The name of the branch to push", ), + set_upstream: bool = typer.Option( + False, + "--set-upstream", + help="Map the local branch to the specified upstream branch", + ), ): from git_sim.push import Push - scene = Push(remote=remote, branch=branch) + scene = Push(remote=remote, branch=branch, set_upstream=set_upstream) handle_animations(scene=scene) @@ -235,6 +270,26 @@ def rebase( handle_animations(scene=scene) +def remote( + command: RemoteSubCommand = typer.Argument( + default=None, + help="Remote subcommand (add, rename, remove, get-url, set-url)", + ), + remote: str = typer.Argument( + default=None, + help="The name of the remote", + ), + url_or_path: str = typer.Argument( + default=None, + help="The url or path to the remote", + ), +): + from git_sim.remote import Remote + + scene = Remote(command=command, remote=remote, url_or_path=url_or_path) + handle_animations(scene=scene) + + def reset( commit: str = typer.Argument( default="HEAD", @@ -268,12 +323,17 @@ def restore( files: List[str] = typer.Argument( default=None, help="The names of one or more files to restore", - ) + ), + staged: bool = typer.Option( + False, + "--staged", + help="Restore staged file to working directory", + ), ): from git_sim.restore import Restore settings.hide_first_tag = True - scene = Restore(files=files) + scene = Restore(files=files, staged=staged) handle_animations(scene=scene) @@ -312,11 +372,15 @@ def stash( default=None, help="The name of the file to stash changes for", ), + stash_index: str = typer.Argument( + default="0", + help="Stash index", + ), ): from git_sim.stash import Stash settings.hide_first_tag = True - scene = Stash(files=files, command=command) + scene = Stash(files=files, command=command, stash_index=stash_index) handle_animations(scene=scene) @@ -340,20 +404,34 @@ def switch( "-c", help="Create the specified branch if it doesn't already exist", ), + detach: bool = typer.Option( + False, + "--detach", + help="Allow switch resulting in detached HEAD state", + ), ): from git_sim.switch import Switch - scene = Switch(branch=branch, c=c) + scene = Switch(branch=branch, c=c, detach=detach) handle_animations(scene=scene) def tag( name: str = typer.Argument( ..., - help="The name of the new tag", - ) + help="The name of the tag", + ), + commit: str = typer.Argument( + default=None, + help="The commit to tag", + ), + d: bool = typer.Option( + False, + "-d", + help="Delete the specified tag", + ), ): from git_sim.tag import Tag - scene = Tag(name=name) + scene = Tag(name=name, commit=commit, d=d) handle_animations(scene=scene) diff --git a/git_sim/commit.py b/src/git_sim/commit.py similarity index 69% rename from git_sim/commit.py rename to src/git_sim/commit.py index 2fb6a99..7c02b29 100644 --- a/git_sim/commit.py +++ b/src/git_sim/commit.py @@ -30,14 +30,16 @@ def __init__(self, message: str, amend: bool): ) sys.exit(1) + self.cmd += ( + f"{type(self).__name__.lower()} {'--amend ' if self.amend else ''}" + + '-m "' + + self.message + + '"' + ) + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()} {'--amend ' if self.amend else ''}" - + '-m "' - + self.message - + '"' - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() head_commit = self.get_commit() @@ -73,10 +75,11 @@ def construct(self): self.vsplit_frame() self.setup_and_draw_zones( first_column_name="Working directory", - second_column_name="Staging area", + second_column_name="Staged files", third_column_name="New commit", ) + self.show_command_as_title() self.fadeout() self.show_outro() @@ -93,10 +96,19 @@ def populate_zones( if "git-sim_media" not in x.a_path: firstColumnFileNames.add(x.a_path) - for y in self.repo.index.diff("HEAD"): - if "git-sim_media" not in y.a_path: - secondColumnFileNames.add(y.a_path) - thirdColumnFileNames.add(y.a_path) - secondColumnArrowMap[y.a_path] = m.Arrow( - stroke_width=3, color=self.fontColor - ) + if self.head_exists(): + for y in self.repo.index.diff("HEAD"): + if "git-sim_media" not in y.a_path: + secondColumnFileNames.add(y.a_path) + thirdColumnFileNames.add(y.a_path) + secondColumnArrowMap[y.a_path] = m.Arrow( + stroke_width=3, color=self.fontColor + ) + else: + for y in self.repo.index.diff(None, staged=True): + if "git-sim_media" not in y.a_path: + secondColumnFileNames.add(y.a_path) + thirdColumnFileNames.add(y.a_path) + secondColumnArrowMap[y.a_path] = m.Arrow( + stroke_width=3, color=self.fontColor + ) diff --git a/src/git_sim/config.py b/src/git_sim/config.py new file mode 100644 index 0000000..c901006 --- /dev/null +++ b/src/git_sim/config.py @@ -0,0 +1,271 @@ +import os +import re +import git +import sys +import stat +import numpy +import shutil +import tempfile + +import manim as m + +from typing import List +from git.repo import Repo +from argparse import Namespace +from configparser import NoSectionError +from git.exc import GitCommandError, InvalidGitRepositoryError + +from git_sim.settings import settings +from git_sim.git_sim_base_command import GitSimBaseCommand + + +class Config(GitSimBaseCommand): + def __init__(self, l: bool, settings: List[str]): + super().__init__() + self.l = l + self.settings = settings + self.time_per_char = 0.05 + + for i, setting in enumerate(self.settings): + if " " in setting: + self.settings[i] = f'"{setting}"' + + if self.l: + self.cmd += f"{type(self).__name__.lower()} {'--list'}" + else: + self.cmd += f"{type(self).__name__.lower()} {' '.join(self.settings)}" + + def construct(self): + if not settings.stdout and not settings.output_only_path and not settings.quiet: + print(f"{settings.INFO_STRING} {self.cmd}") + + self.show_intro() + self.add_details() + self.recenter_frame() + self.scale_frame() + self.fadeout() + self.show_outro() + + def add_details(self): + down_shift = m.DOWN * 0.5 + project_root = m.Rectangle( + height=9.0, + width=18.0, + color=self.fontColor, + ).move_to((0, 1000, 0)) + self.camera.frame.scale_to_fit_width(18 * 1.1) + self.camera.frame.move_to(project_root.get_center()) + + cmd_text = m.Text( + self.trim_cmd(self.cmd, 50), + font=self.font, + font_size=36, + color=self.fontColor, + ) + cmd_text.align_to(project_root, m.UP).shift(m.UP * 0.25 + cmd_text.height) + + project_root_text = m.Text( + os.path.basename(os.getcwd()) + "/", + font=self.font, + font_size=20, + color=self.fontColor, + ) + project_root_text.align_to(project_root, m.LEFT).align_to( + project_root, m.UP + ).shift(m.RIGHT * 0.25).shift(m.DOWN * 0.25) + + dot_git_text = m.Text( + ".git/", + font=self.font, + font_size=20, + color=self.fontColor, + ) + dot_git_text.align_to(project_root_text, m.UP).shift(down_shift).align_to( + project_root_text, m.LEFT + ).shift(m.RIGHT * 0.5) + + config_text = m.Text( + "config", + font=self.font, + font_size=20, + color=self.fontColor, + ) + config_text.align_to(dot_git_text, m.UP).shift(down_shift).align_to( + dot_git_text, m.LEFT + ).shift(m.RIGHT * 0.5) + + if settings.animate: + if settings.show_command_as_title: + self.play( + m.AddTextLetterByLetter(cmd_text, time_per_char=self.time_per_char) + ) + self.play(m.Create(project_root, time_per_char=self.time_per_char)) + self.play( + m.AddTextLetterByLetter( + project_root_text, time_per_char=self.time_per_char + ) + ) + self.play( + m.AddTextLetterByLetter(dot_git_text, time_per_char=self.time_per_char) + ) + self.play( + m.AddTextLetterByLetter(config_text, time_per_char=self.time_per_char) + ) + else: + if settings.show_command_as_title: + self.add(cmd_text) + self.add(project_root) + self.add(project_root_text) + self.add(dot_git_text) + self.add(config_text) + + config = self.repo.config_reader() + if self.l: + last_element = config_text + for i, section in enumerate(config.sections()): + section_text = ( + m.Text( + f"[{section}]", + font=self.font, + color=self.fontColor, + font_size=20, + ) + .align_to(last_element, m.UP) + .shift(down_shift) + .align_to(config_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + self.toFadeOut.add(section_text) + if settings.animate: + self.play( + m.AddTextLetterByLetter( + section_text, time_per_char=self.time_per_char + ) + ) + else: + self.add(section_text) + last_element = section_text + project_root = self.resize_rectangle(project_root, last_element) + for j, option in enumerate(config.options(section)): + if option != "__name__": + option_text = ( + m.Text( + f"{option} = {config.get_value(section, option)}", + font=self.font, + color=self.fontColor, + font_size=20, + ) + .align_to(last_element, m.UP) + .shift(down_shift) + .align_to(section_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + self.toFadeOut.add(option_text) + last_element = option_text + if settings.animate: + self.play( + m.AddTextLetterByLetter( + option_text, time_per_char=self.time_per_char + ) + ) + else: + self.add(option_text) + if not ( + i == len(config.sections()) - 1 + and j == len(config.options(section)) - 1 + ): + project_root = self.resize_rectangle( + project_root, last_element + ) + else: + if not self.settings: + print("git-sim error: no config option specified") + sys.exit(1) + elif len(self.settings) > 2: + print("git-sim error: too many config options specified") + sys.exit(1) + elif "." not in self.settings[0]: + print("git-sim error: specify config option as 'section.option'") + sys.exit(1) + section = self.settings[0][: self.settings[0].index(".")] + option = self.settings[0][self.settings[0].index(".") + 1 :] + if len(self.settings) == 1: + try: + value = config.get_value(section, option) + except NoSectionError: + print(f"git-sim error: section '{section}' doesn't exist in config") + sys.exit(1) + elif len(self.settings) == 2: + value = self.settings[1].strip('"').strip("'").strip("\\") + section_text = ( + m.Text( + f"[{self.trim_cmd(section, 50)}]", + font=self.font, + color=self.fontColor, + font_size=20, + weight=m.BOLD, + ) + .align_to(config_text, m.UP) + .shift(down_shift) + .align_to(config_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + option_text = ( + m.Text( + f"{self.trim_cmd(option, 40)} = {self.trim_cmd(value, 40)}", + font=self.font, + color=self.fontColor, + font_size=20, + weight=m.BOLD, + ) + .align_to(section_text, m.UP) + .shift(down_shift) + .align_to(section_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + self.toFadeOut.add(section_text) + self.toFadeOut.add(option_text) + if settings.animate: + self.play( + m.AddTextLetterByLetter( + section_text, time_per_char=self.time_per_char + ) + ) + self.play( + m.AddTextLetterByLetter( + option_text, time_per_char=self.time_per_char + ) + ) + else: + self.add(section_text) + self.add(option_text) + + if settings.show_command_as_title: + self.toFadeOut.add(cmd_text) + self.toFadeOut.add(project_root) + self.toFadeOut.add(project_root_text) + self.toFadeOut.add(dot_git_text) + self.toFadeOut.add(config_text) + + def resize_rectangle(self, rect, last_element): + if ( + last_element.get_bottom()[1] - 3 * last_element.height + > rect.get_bottom()[1] + ): + return rect + new_rect = m.Rectangle( + width=rect.width, + height=rect.height + 2 * last_element.height, + color=rect.color, + ) + new_rect.align_to(rect, m.UP) + self.toFadeOut.remove(rect) + self.toFadeOut.add(new_rect) + if settings.animate: + self.recenter_frame() + self.scale_frame() + self.play(m.ReplacementTransform(rect, new_rect)) + else: + self.remove(rect) + self.add(new_rect) + return new_rect diff --git a/git_sim/enums.py b/src/git_sim/enums.py similarity index 79% rename from git_sim/enums.py rename to src/git_sim/enums.py index 7a22e17..6b8d806 100644 --- a/git_sim/enums.py +++ b/src/git_sim/enums.py @@ -8,12 +8,6 @@ class ResetMode(Enum): HARD = "hard" -class StashSubCommand(Enum): - POP = "pop" - APPLY = "apply" - PUSH = "push" - - class ColorByOptions(Enum): AUTHOR = "author" BRANCH = "branch" @@ -34,3 +28,17 @@ class VideoFormat(str, Enum): class ImgFormat(str, Enum): JPG = "jpg" PNG = "png" + + +class StashSubCommand(Enum): + POP = "pop" + APPLY = "apply" + PUSH = "push" + + +class RemoteSubCommand(Enum): + ADD = "add" + RENAME = "rename" + REMOVE = "remove" + GET_URL = "get-url" + SET_URL = "set-url" diff --git a/git_sim/fetch.py b/src/git_sim/fetch.py similarity index 92% rename from git_sim/fetch.py rename to src/git_sim/fetch.py index 0a2a9bf..af0a1a8 100644 --- a/git_sim/fetch.py +++ b/src/git_sim/fetch.py @@ -24,11 +24,11 @@ def __init__(self, remote: str, branch: str): print("git-sim error: no remote with name '" + self.remote + "'") sys.exit(1) + self.cmd += f"{type(self).__name__.lower()} {self.remote if self.remote else ''} {self.branch if self.branch else ''}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()} {self.remote if self.remote else ''} {self.branch if self.branch else ''}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") if not self.remote: self.remote = "origin" @@ -79,6 +79,7 @@ def construct(self): self.recenter_frame() self.scale_frame() self.color_by() + self.show_command_as_title() self.fadeout() self.show_outro() self.repo.git.clear_cache() diff --git a/git_sim/git_sim_base_command.py b/src/git_sim/git_sim_base_command.py similarity index 89% rename from git_sim/git_sim_base_command.py rename to src/git_sim/git_sim_base_command.py index 1b7e041..c07a8e1 100644 --- a/git_sim/git_sim_base_command.py +++ b/src/git_sim/git_sim_base_command.py @@ -18,15 +18,19 @@ class GitSimBaseCommand(m.MovingCameraScene): def __init__(self): super().__init__() + self.cmd = "git " self.init_repo() + self.font = settings.font self.fontColor = m.BLACK if settings.light_mode else m.WHITE self.drawnCommits = {} self.drawnRefs = {} + self.drawnRefsByCommit = {} self.drawnCommitIds = {} self.toFadeOut = m.Group() self.prevRef = None self.topref = None + self.topelement = None self.n_default = settings.n_default self.n = settings.n self.n_orig = self.n @@ -99,7 +103,9 @@ def construct(self): self.show_outro() def get_commit(self, sha_or_ref="HEAD"): - return self.repo.commit(sha_or_ref) + if self.head_exists(): + return self.repo.commit(sha_or_ref) + return "dark" def get_default_commits(self): defaultCommits = [self.get_commit()] @@ -115,6 +121,9 @@ def parse_commits( shift=numpy.array([0.0, 0.0, 0.0]), make_branches_remote=False, ): + if not self.head_exists(): + commit = self.create_dark_commit() + commit = commit or self.get_commit() if commit != "dark": @@ -187,7 +196,7 @@ def show_intro(self): initialCommitText = m.Text( settings.title, - font="Monospace", + font=self.font, font_size=36, color=self.fontColor, ).to_edge(m.UP, buff=1) @@ -215,7 +224,7 @@ def show_outro(self): outroTopText = m.Text( settings.outro_top_text, - font="Monospace", + font=self.font, font_size=36, color=self.fontColor, ).to_edge(m.UP, buff=1) @@ -223,7 +232,7 @@ def show_outro(self): outroBottomText = m.Text( settings.outro_bottom_text, - font="Monospace", + font=self.font, font_size=36, color=self.fontColor, ).to_edge(m.DOWN, buff=1) @@ -342,7 +351,7 @@ def draw_commit(self, commit, i, prevCircle, shift=numpy.array([0.0, 0.0, 0.0])) "\n".join( commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) )[:100], - font="Monospace", + font=self.font, font_size=20 if settings.highlight_commit_messages else 14, color=self.fontColor, weight=m.BOLD @@ -403,7 +412,7 @@ def build_commit_id_and_message(self, commit, i): if commit == "dark": commitId = m.Text( "", - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=self.font_weight, @@ -412,7 +421,7 @@ def build_commit_id_and_message(self, commit, i): else: commitId = m.Text( commit.hexsha[0:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=self.font_weight, @@ -433,7 +442,7 @@ def draw_head(self, commit, i, commitId): headbox.next_to(commitId, m.UP) headText = m.Text( "HEAD", - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=self.font_weight, @@ -448,6 +457,7 @@ def draw_head(self, commit, i, commitId): self.toFadeOut.add(head) self.drawnRefs["HEAD"] = head + self.add_ref_to_drawn_refs_by_commit(commit.hexsha, head) self.prevRef = head if i == 0 and self.first_parse: @@ -467,24 +477,21 @@ def draw_branch(self, commit, i, make_branches_remote=False): for branch in branches: if ( - branch not in remote_tracking_branches # local branch + branch not in remote_tracking_branches # local branch and commit.hexsha == self.repo.heads[branch].commit.hexsha ) or ( - branch in remote_tracking_branches # remote tracking branch + branch in remote_tracking_branches # remote tracking branch and commit.hexsha == remote_tracking_branches[branch] ): text = ( (make_branches_remote + "/" + branch) - if ( - make_branches_remote - and branch not in remote_tracking_branches - ) + if (make_branches_remote and branch not in remote_tracking_branches) else branch ) branchText = m.Text( text, - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=self.font_weight, @@ -511,6 +518,7 @@ def draw_branch(self, commit, i, make_branches_remote=False): self.toFadeOut.add(fullbranch) self.drawnRefs[branch] = fullbranch + self.add_ref_to_drawn_refs_by_commit(commit.hexsha, fullbranch) if i == 0 and self.first_parse: self.topref = self.prevRef @@ -530,7 +538,7 @@ def draw_tag(self, commit, i): if commit.hexsha == tag.commit.hexsha: tagText = m.Text( tag.name, - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=self.font_weight, @@ -559,7 +567,8 @@ def draw_tag(self, commit, i): self.add(fulltag) self.toFadeOut.add(fulltag) - self.drawnRefs[tag] = fulltag + self.drawnRefs[tag.name] = fulltag + self.add_ref_to_drawn_refs_by_commit(commit.hexsha, fulltag) if i == 0 and self.first_parse: self.topref = self.prevRef @@ -591,13 +600,14 @@ def recenter_frame(self): def scale_frame(self): if settings.animate: - self.play( - self.camera.frame.animate.scale_to_fit_width( - self.toFadeOut.get_width() * 1.1 - ), - run_time=1 / settings.speed, - ) - if self.toFadeOut.get_height() >= self.camera.frame.get_height(): + if self.toFadeOut.get_width() > self.camera.frame.get_width(): + self.play( + self.camera.frame.animate.scale_to_fit_width( + self.toFadeOut.get_width() * 1.1 + ), + run_time=1 / settings.speed, + ) + if self.toFadeOut.get_height() > self.camera.frame.get_height(): self.play( self.camera.frame.animate.scale_to_fit_height( self.toFadeOut.get_height() * 1.25 @@ -605,8 +615,9 @@ def scale_frame(self): run_time=1 / settings.speed, ) else: - self.camera.frame.scale_to_fit_width(self.toFadeOut.get_width() * 1.1) - if self.toFadeOut.get_height() >= self.camera.frame.get_height(): + if self.toFadeOut.get_width() > self.camera.frame.get_width(): + self.camera.frame.scale_to_fit_width(self.toFadeOut.get_width() * 1.1) + if self.toFadeOut.get_height() > self.camera.frame.get_height(): self.camera.frame.scale_to_fit_height( self.toFadeOut.get_height() * 1.25 ) @@ -625,21 +636,24 @@ def vsplit_frame(self): if settings.animate: self.play( self.toFadeOut.animate.align_to(self.camera.frame, m.UP).shift( - m.DOWN * 0.75 + m.DOWN * 2.25 ) ) else: - self.toFadeOut.align_to(self.camera.frame, m.UP).shift(m.DOWN * 0.75) + self.toFadeOut.align_to(self.camera.frame, m.UP).shift(m.DOWN * 2.25) except ValueError: pass def setup_and_draw_zones( self, first_column_name="Untracked files", - second_column_name="Working directory mods", - third_column_name="Staging area", + second_column_name="Modified files", + third_column_name="Staged files", reverse=False, ): + if self.check_all_dark(): + self.zone_title_offset = 2.0 if platform.system() == "Windows" else 2.0 + horizontal = m.Line( ( self.camera.frame.get_left()[0], @@ -652,7 +666,7 @@ def setup_and_draw_zones( 0, ), color=self.fontColor, - ).shift(m.UP * 2.5) + ).shift(m.UP * 1.75) horizontal2 = m.Line( ( self.camera.frame.get_left()[0], @@ -665,7 +679,7 @@ def setup_and_draw_zones( 0, ), color=self.fontColor, - ).shift(m.UP * 1.5) + ).shift(m.UP * 0.75) vert1 = m.DashedLine( ( self.camera.frame.get_left()[0], @@ -691,21 +705,22 @@ def setup_and_draw_zones( first_column_name = "Staging area" third_column_name = "Deleted changes" + title_v_shift = abs(horizontal2.get_start()[1] - horizontal.get_start()[1]) / 2 firstColumnTitle = ( m.Text( first_column_name, - font="Monospace", + font=self.font, font_size=28, color=self.fontColor, weight=m.BOLD, ) - .move_to((vert1.get_center()[0] - 4, 0, 0)) - .shift(m.UP * self.zone_title_offset) + .move_to((vert1.get_center()[0] - 4, horizontal.get_start()[1], 0)) + .shift(m.DOWN * title_v_shift) ) secondColumnTitle = ( m.Text( second_column_name, - font="Monospace", + font=self.font, font_size=28, color=self.fontColor, weight=m.BOLD, @@ -716,7 +731,7 @@ def setup_and_draw_zones( thirdColumnTitle = ( m.Text( third_column_name, - font="Monospace", + font=self.font, font_size=28, color=self.fontColor, weight=m.BOLD, @@ -920,6 +935,9 @@ def populate_zones( firstColumnFileNames.add(z) def center_frame_on_commit(self, commit): + if not commit or commit == "dark": + return + if settings.animate: self.play( self.camera.frame.animate.move_to( @@ -930,6 +948,9 @@ def center_frame_on_commit(self, commit): self.camera.frame.move_to(self.drawnCommits[commit.hexsha].get_center()) def reset_head_branch(self, hexsha, shift=numpy.array([0.0, 0.0, 0.0])): + if not self.head_exists(): + return + if settings.animate: self.play( self.drawnRefs["HEAD"].animate.move_to( @@ -1038,29 +1059,32 @@ def setup_and_draw_parent( fill_opacity=self.ref_fill_opacity, ) circle.height = 1 - circle.next_to( - self.drawnCommits[child.hexsha], - m.LEFT if settings.reverse else m.RIGHT, - buff=1.5, - ) + if child != "dark": + circle.next_to( + self.drawnCommits[child.hexsha], + m.LEFT if settings.reverse else m.RIGHT, + buff=1.5, + ) + circle.shift(shift) - start = circle.get_center() - end = self.drawnCommits[child.hexsha].get_center() - arrow = m.Arrow( - start, - end, - color=self.fontColor, - stroke_width=self.arrow_stroke_width, - tip_shape=self.arrow_tip_shape, - max_stroke_width_to_length_ratio=1000, - ) - length = numpy.linalg.norm(start - end) - (1.5 if start[1] == end[1] else 3) - arrow.set_length(length) + if child != "dark": + start = circle.get_center() + end = self.drawnCommits[child.hexsha].get_center() + arrow = m.Arrow( + start, + end, + color=self.fontColor, + stroke_width=self.arrow_stroke_width, + tip_shape=self.arrow_tip_shape, + max_stroke_width_to_length_ratio=1000, + ) + length = numpy.linalg.norm(start - end) - (1.5 if start[1] == end[1] else 3) + arrow.set_length(length) commitId = m.Text( "abcdef", - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=self.font_weight, @@ -1072,7 +1096,7 @@ def setup_and_draw_parent( "\n".join( commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) )[:100], - font="Monospace", + font=self.font, font_size=14, color=self.fontColor, weight=self.font_weight, @@ -1094,7 +1118,7 @@ def setup_and_draw_parent( self.drawnCommits["abcdef"] = circle self.toFadeOut.add(circle) - if draw_arrow: + if draw_arrow and child != "dark": if settings.animate: self.play(m.Create(arrow), run_time=1 / settings.speed) else: @@ -1125,7 +1149,7 @@ def get_nondark_commits(self): def draw_ref(self, commit, top, i=0, text="HEAD", color=m.BLUE): refText = m.Text( text, - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=self.font_weight, @@ -1167,7 +1191,10 @@ def draw_dark_ref(self): self.prevRef = refRec def trim_path(self, path): - return (path[:15] + "..." + path[-15:]) if len(path) > 30 else path + return f"{path[:15]}...{path[-15:]}" if len(path) > 33 else path + + def trim_cmd(self, path, length=30): + return f"{path[:length]}..." if len(path) > (length + 3) else path def get_remote_tracking_branches(self): remote_refs = [remote.refs for remote in self.repo.remotes] @@ -1198,7 +1225,7 @@ def create_zone_text( text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -1214,7 +1241,7 @@ def create_zone_text( text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -1230,7 +1257,7 @@ def create_zone_text( text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -1252,7 +1279,7 @@ def color_by(self, offset=0): for i, author in enumerate(sorted_authors): authorText = m.Text( f"{author[:15]} ({str(len(self.author_groups[author]))})", - font="Monospace", + font=self.font, font_size=36, color=self.colors[int(i % 11)], weight=self.font_weight, @@ -1294,10 +1321,60 @@ def add_group_to_author_groups(self, author, group): else: self.author_groups[author].append(group) + def show_command_as_title(self): + if settings.show_command_as_title: + titleText = m.Text( + self.trim_cmd(self.cmd), + font=self.font, + font_size=36, + color=self.fontColor, + ) + top = 0 + for element in self.toFadeOut: + if element.get_top()[1] > top: + top = element.get_top()[1] + titleText.move_to( + ( + self.camera.frame.get_x(), + top + titleText.height * 2, + 0, + ) + ) + ul = m.Underline( + titleText, + color=self.fontColor, + ) + self.toFadeOut.add(titleText, ul) + self.scale_frame() + if settings.animate: + self.play(m.AddTextLetterByLetter(titleText), m.Create(ul)) + else: + self.add(titleText, ul) + def del_rw(self, action, name, exc): os.chmod(name, stat.S_IWRITE) os.remove(name) + def head_exists(self): + try: + hc = self.repo.head.commit + except ValueError: + return False + return True + + def check_all_dark(self): + if not self.drawnCommits: + return True + return False + + def add_ref_to_drawn_refs_by_commit(self, hexsha, ref): + try: + self.drawnRefsByCommit[hexsha].append(ref) + except KeyError: + self.drawnRefsByCommit[hexsha] = [ + ref, + ] + class DottedLine(m.Line): def __init__(self, *args, dot_spacing=0.4, dot_kwargs={}, **kwargs): diff --git a/src/git_sim/init.py b/src/git_sim/init.py new file mode 100644 index 0000000..7a6f152 --- /dev/null +++ b/src/git_sim/init.py @@ -0,0 +1,321 @@ +import sys +import os +from argparse import Namespace + +import git +import manim as m +import numpy +import tempfile +import shutil +import stat +import re + +from git.exc import GitCommandError, InvalidGitRepositoryError +from git.repo import Repo + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Init(GitSimBaseCommand): + def __init__(self): + super().__init__() + self.cmd += f"{type(self).__name__.lower()}" + + def init_repo(self): + pass + + def construct(self): + if not settings.stdout and not settings.output_only_path and not settings.quiet: + print(f"{settings.INFO_STRING} {self.cmd}") + + self.show_intro() + self.add_details() + self.recenter_frame() + self.scale_frame() + self.fadeout() + self.show_outro() + + def add_details(self): + self.camera.frame.scale_to_fit_width(18 * 1.1) + project_root = m.Rectangle( + height=9.0, + width=18.0, + color=self.fontColor, + ) + + cmd_text = m.Text( + self.cmd, + font=self.font, + font_size=36, + color=self.fontColor, + ) + cmd_text.align_to(project_root, m.UP).shift(m.UP * 0.25 + cmd_text.height) + + project_root_text = m.Text( + os.path.basename(os.getcwd()) + "/", + font=self.font, + font_size=20, + color=self.fontColor, + ) + project_root_text.align_to(project_root, m.LEFT).align_to( + project_root, m.UP + ).shift(m.RIGHT * 0.25).shift(m.DOWN * 0.25) + + dot_git_text = m.Text( + ".git/", + font=self.font, + font_size=20, + color=self.fontColor, + ) + dot_git_text.align_to(project_root_text, m.UP).shift(m.DOWN).align_to( + project_root_text, m.LEFT + ).shift(m.RIGHT * 0.5) + + head_text = ( + m.Text("HEAD", font=self.font, color=self.fontColor, font_size=20) + .align_to(dot_git_text, m.UP) + .shift(m.DOWN) + .align_to(dot_git_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + + down_shift = m.DOWN + config_text = ( + m.Text("config", font=self.font, color=self.fontColor, font_size=20) + .align_to(head_text, m.UP) + .shift(down_shift) + .align_to(dot_git_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + description_text = ( + m.Text("description", font=self.font, color=self.fontColor, font_size=20) + .align_to(config_text, m.UP) + .shift(down_shift) + .align_to(dot_git_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + hooks_text = ( + m.Text("hooks/", font=self.font, color=self.fontColor, font_size=20) + .align_to(description_text, m.UP) + .shift(down_shift) + .align_to(dot_git_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + info_text = ( + m.Text("info/", font=self.font, color=self.fontColor, font_size=20) + .align_to(hooks_text, m.UP) + .shift(down_shift) + .align_to(dot_git_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + objects_text = ( + m.Text("objects/", font=self.font, color=self.fontColor, font_size=20) + .align_to(info_text, m.UP) + .shift(down_shift) + .align_to(dot_git_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + refs_text = ( + m.Text("refs/", font=self.font, color=self.fontColor, font_size=20) + .align_to(objects_text, m.UP) + .shift(down_shift) + .align_to(dot_git_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + + dot_git_text_arrow = m.Arrow( + start=dot_git_text.get_right(), + end=dot_git_text.get_right() + m.RIGHT * 3.5, + color=self.fontColor, + ) + head_text_arrow = m.Arrow( + start=head_text.get_right(), + end=(dot_git_text_arrow.end[0], head_text.get_right()[1], 0), + color=self.fontColor, + ) + config_text_arrow = m.Arrow( + start=config_text.get_right(), + end=(dot_git_text_arrow.end[0], config_text.get_right()[1], 0), + color=self.fontColor, + ) + description_text_arrow = m.Arrow( + start=description_text.get_right(), + end=(dot_git_text_arrow.end[0], description_text.get_right()[1], 0), + color=self.fontColor, + ) + hooks_text_arrow = m.Arrow( + start=hooks_text.get_right(), + end=(dot_git_text_arrow.end[0], hooks_text.get_right()[1], 0), + color=self.fontColor, + ) + info_text_arrow = m.Arrow( + start=info_text.get_right(), + end=(dot_git_text_arrow.end[0], info_text.get_right()[1], 0), + color=self.fontColor, + ) + objects_text_arrow = m.Arrow( + start=objects_text.get_right(), + end=(dot_git_text_arrow.end[0], objects_text.get_right()[1], 0), + color=self.fontColor, + ) + refs_text_arrow = m.Arrow( + start=refs_text.get_right(), + end=(dot_git_text_arrow.end[0], refs_text.get_right()[1], 0), + color=self.fontColor, + ) + + dot_git_desc = m.Text( + "The hidden .git/ folder is created after running the 'git init' command.", + font=self.font, + font_size=18, + color=self.fontColor, + ).next_to(dot_git_text_arrow, m.RIGHT) + head_desc = m.Text( + "A label (ref) that points to the currently checked-out commit.", + font=self.font, + font_size=18, + color=self.fontColor, + ).next_to(head_text_arrow, m.RIGHT) + config_desc = m.Text( + "A file containing Git configuration settings for the local repo.", + font=self.font, + font_size=18, + color=self.fontColor, + ).next_to(config_text_arrow, m.RIGHT) + description_desc = m.Text( + "A file containing an optional description for your Git repo.", + font=self.font, + font_size=18, + color=self.fontColor, + ).next_to(description_text_arrow, m.RIGHT) + hooks_desc = m.Text( + "A folder containing 'hooks' which allow triggering custom\nscripts after running Git actions.", + font=self.font, + font_size=18, + color=self.fontColor, + ).next_to(hooks_text_arrow, m.RIGHT) + info_desc = m.Text( + "A folder containing the 'exclude' file, tells Git to ignore\nspecific file patterns on your system.", + font=self.font, + font_size=18, + color=self.fontColor, + ).next_to(info_text_arrow, m.RIGHT) + objects_desc = m.Text( + "A folder containing Git's object database, which stores the\nobjects representing code files, changes and commits tracked by Git.", + font=self.font, + font_size=18, + color=self.fontColor, + ).next_to(objects_text_arrow, m.RIGHT) + refs_desc = m.Text( + "A folder holding the refs (labels) Git uses to represent branches & tags.", + font=self.font, + font_size=18, + color=self.fontColor, + ).next_to(refs_text_arrow, m.RIGHT) + + if settings.animate: + if settings.show_command_as_title: + self.play(m.AddTextLetterByLetter(cmd_text)) + self.play(m.Create(project_root)) + self.play(m.AddTextLetterByLetter(project_root_text)) + self.play( + m.AddTextLetterByLetter(dot_git_text), + m.Create(dot_git_text_arrow), + m.AddTextLetterByLetter(dot_git_desc), + ) + self.play( + m.AddTextLetterByLetter(head_text), + m.Create(head_text_arrow), + m.AddTextLetterByLetter(head_desc), + ) + self.play( + m.AddTextLetterByLetter(config_text), + m.Create(config_text_arrow), + m.AddTextLetterByLetter(config_desc), + ) + self.play( + m.AddTextLetterByLetter(description_text), + m.Create(description_text_arrow), + m.AddTextLetterByLetter(description_desc), + ) + self.play( + m.AddTextLetterByLetter(hooks_text), + m.Create(hooks_text_arrow), + m.AddTextLetterByLetter(hooks_desc), + ) + self.play( + m.AddTextLetterByLetter(info_text), + m.Create(info_text_arrow), + m.AddTextLetterByLetter(info_desc), + ) + self.play( + m.AddTextLetterByLetter(objects_text), + m.Create(objects_text_arrow), + m.AddTextLetterByLetter(objects_desc), + ) + self.play( + m.AddTextLetterByLetter(refs_text), + m.Create(refs_text_arrow), + m.AddTextLetterByLetter(refs_desc), + ) + else: + if settings.show_command_as_title: + self.add(cmd_text) + self.add(project_root) + self.add(project_root_text) + self.add(dot_git_text) + self.add( + head_text, + config_text, + description_text, + hooks_text, + info_text, + objects_text, + refs_text, + ) + self.add( + dot_git_text_arrow, + head_text_arrow, + config_text_arrow, + description_text_arrow, + hooks_text_arrow, + info_text_arrow, + objects_text_arrow, + refs_text_arrow, + ) + self.add( + dot_git_desc, + head_desc, + config_desc, + description_desc, + hooks_desc, + info_desc, + objects_desc, + refs_desc, + ) + + if settings.show_command_as_title: + self.toFadeOut.add(cmd_text) + self.toFadeOut.add(project_root) + self.toFadeOut.add(project_root_text) + self.toFadeOut.add( + head_text, + config_text, + description_text, + hooks_text, + info_text, + objects_text, + refs_text, + ) + self.toFadeOut.add( + dot_git_text_arrow, + head_text_arrow, + config_text_arrow, + description_text_arrow, + hooks_text_arrow, + info_text_arrow, + objects_text_arrow, + refs_text_arrow, + ) + self.toFadeOut.add(dot_git_desc, head_desc) diff --git a/git_sim/log.py b/src/git_sim/log.py similarity index 83% rename from git_sim/log.py rename to src/git_sim/log.py index 33bd3d3..954a15a 100644 --- a/git_sim/log.py +++ b/src/git_sim/log.py @@ -32,16 +32,17 @@ def __init__(self, ctx: typer.Context, n: int, all: bool): except TypeError: pass + self.cmd += f"{type(self).__name__.lower()}{' --all' if self.all_subcommand else ''}{' -n ' + str(self.n) if self.n_subcommand else ''}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING} {type(self).__name__.lower()}{' --all' if self.all_subcommand else ''}{' -n ' + str(self.n) if self.n_subcommand else ''}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() self.parse_all() self.recenter_frame() self.scale_frame() self.color_by() + self.show_command_as_title() self.fadeout() self.show_outro() diff --git a/git_sim/logo.png b/src/git_sim/logo.png similarity index 100% rename from git_sim/logo.png rename to src/git_sim/logo.png diff --git a/git_sim/merge.py b/src/git_sim/merge.py similarity index 97% rename from git_sim/merge.py rename to src/git_sim/merge.py index 7142e72..8d39ee9 100644 --- a/git_sim/merge.py +++ b/src/git_sim/merge.py @@ -38,11 +38,11 @@ def __init__(self, branch: str, no_ff: bool, message: str): except TypeError: pass + self.cmd += f"{type(self).__name__.lower()} {self.branch} {'--no-ff' if self.no_ff else ''}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()} {self.branch} {'--no-ff' if self.no_ff else ''}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") if self.repo.active_branch.name in self.repo.git.branch( "--contains", self.branch @@ -151,6 +151,7 @@ def construct(self): self.reset_head_branch("abcdef") self.color_by(offset=2) + self.show_command_as_title() self.fadeout() self.show_outro() diff --git a/git_sim/mv.py b/src/git_sim/mv.py similarity index 92% rename from git_sim/mv.py rename to src/git_sim/mv.py index a882cd7..366783e 100644 --- a/git_sim/mv.py +++ b/src/git_sim/mv.py @@ -29,11 +29,11 @@ def __init__(self, file: str, new_file: str): print(f"git-sim error: No tracked file with name: '{file}'") sys.exit() + self.cmd += f"{type(self).__name__.lower()} {self.file} {self.new_file}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING} {type(self).__name__.lower()} {self.file} {self.new_file}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() @@ -46,6 +46,7 @@ def construct(self): third_column_name="Renamed files", ) self.rename_moved_file() + self.show_command_as_title() self.fadeout() self.show_outro() @@ -75,7 +76,7 @@ def rename_moved_file(self): for file in self.thirdColumnFiles: new_file = m.Text( self.trim_path(self.new_file), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) diff --git a/git_sim/pull.py b/src/git_sim/pull.py similarity index 94% rename from git_sim/pull.py rename to src/git_sim/pull.py index 83eb528..9997f96 100644 --- a/git_sim/pull.py +++ b/src/git_sim/pull.py @@ -25,11 +25,11 @@ def __init__(self, remote: str = None, branch: str = None): print("git-sim error: no remote with name '" + self.remote + "'") sys.exit(1) + self.cmd += f"{type(self).__name__.lower()} {self.remote if self.remote else ''} {self.branch if self.branch else ''}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()} {self.remote if self.remote else ''} {self.branch if self.branch else ''}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() @@ -87,6 +87,7 @@ def construct(self): sys.exit(1) self.color_by() + self.show_command_as_title() self.fadeout() self.show_outro() diff --git a/git_sim/push.py b/src/git_sim/push.py similarity index 90% rename from git_sim/push.py rename to src/git_sim/push.py index a8781bf..49fcf53 100644 --- a/git_sim/push.py +++ b/src/git_sim/push.py @@ -16,21 +16,24 @@ class Push(GitSimBaseCommand): - def __init__(self, remote: str = None, branch: str = None): + def __init__( + self, remote: str = None, branch: str = None, set_upstream: bool = False + ): super().__init__() self.remote = remote self.branch = branch + self.set_upstream = set_upstream settings.max_branches_per_commit = 2 if self.remote and self.remote not in self.repo.remotes: print("git-sim error: no remote with name '" + self.remote + "'") sys.exit(1) + self.cmd += f"{type(self).__name__.lower()} {'--set-upstream ' if self.set_upstream else ''}{self.remote if self.remote else ''} {self.branch if self.branch else ''}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()} {self.remote if self.remote else ''} {self.branch if self.branch else ''}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() @@ -102,6 +105,7 @@ def construct(self): self.scale_frame() self.failed_push(push_result) self.color_by() + self.show_command_as_title() self.fadeout() self.show_outro() @@ -119,7 +123,7 @@ def failed_push(self, push_result): if push_result == 1: text1 = m.Text( f"'git push' failed since the remote repo has commits that don't exist locally.", - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=m.BOLD, @@ -128,7 +132,7 @@ def failed_push(self, push_result): text2 = m.Text( f"Run 'git pull' (or 'git-sim pull' to simulate first) and then try again.", - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=m.BOLD, @@ -137,7 +141,7 @@ def failed_push(self, push_result): text3 = m.Text( f"Gold commits exist in remote repo, but not locally (need to be pulled).", - font="Monospace", + font=self.font, font_size=20, color=m.GOLD, weight=m.BOLD, @@ -146,7 +150,7 @@ def failed_push(self, push_result): text4 = m.Text( f"Red commits exist in both local and remote repos.", - font="Monospace", + font=self.font, font_size=20, color=m.RED, weight=m.BOLD, @@ -157,7 +161,7 @@ def failed_push(self, push_result): elif push_result == 2: text1 = m.Text( f"'git push' failed since the tip of your current branch is behind the remote.", - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=m.BOLD, @@ -166,7 +170,7 @@ def failed_push(self, push_result): text2 = m.Text( f"Run 'git pull' (or 'git-sim pull' to simulate first) and then try again.", - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, weight=m.BOLD, @@ -175,7 +179,7 @@ def failed_push(self, push_result): text3 = m.Text( f"Gold commits are ahead of your current branch tip (need to be pulled).", - font="Monospace", + font=self.font, font_size=20, color=m.GOLD, weight=m.BOLD, @@ -184,7 +188,7 @@ def failed_push(self, push_result): text4 = m.Text( f"Red commits are up to date in both local and remote branches.", - font="Monospace", + font=self.font, font_size=20, color=m.RED, weight=m.BOLD, diff --git a/git_sim/rebase.py b/src/git_sim/rebase.py similarity index 96% rename from git_sim/rebase.py rename to src/git_sim/rebase.py index 8950717..a455ae3 100644 --- a/git_sim/rebase.py +++ b/src/git_sim/rebase.py @@ -31,11 +31,11 @@ def __init__(self, branch: str): except TypeError: pass + self.cmd += f"{type(self).__name__.lower()} {self.branch}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()} {self.branch}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") if self.branch in self.repo.git.branch( "--contains", self.repo.active_branch.name @@ -101,6 +101,7 @@ def construct(self): self.scale_frame() self.reset_head_branch(parent) self.color_by(offset=2 * len(to_rebase)) + self.show_command_as_title() self.fadeout() self.show_outro() @@ -149,7 +150,7 @@ def setup_and_draw_parent( ) commitId = m.Text( sha if commitMessage != "..." else "...", - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ).next_to(circle, m.UP) @@ -160,7 +161,7 @@ def setup_and_draw_parent( "\n".join( commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) )[:100], - font="Monospace", + font=self.font, font_size=14, color=self.fontColor, ).next_to(circle, m.DOWN) diff --git a/src/git_sim/remote.py b/src/git_sim/remote.py new file mode 100644 index 0000000..ce56b8f --- /dev/null +++ b/src/git_sim/remote.py @@ -0,0 +1,384 @@ +import os +import git +import sys + +import manim as m + +from git.repo import Repo + +from git_sim.settings import settings +from git_sim.enums import RemoteSubCommand +from git_sim.git_sim_base_command import GitSimBaseCommand + + +class Remote(GitSimBaseCommand): + def __init__(self, command: RemoteSubCommand, remote: str, url_or_path: str): + super().__init__() + self.command = command + self.remote = remote + self.url_or_path = url_or_path + + self.config = self.repo.config_reader() + self.time_per_char = 0.05 + self.down_shift = m.DOWN * 0.5 + + self.cmd += f"{type(self).__name__.lower()}" + if self.command in (RemoteSubCommand.ADD, RemoteSubCommand.RENAME, RemoteSubCommand.SET_URL): + self.cmd += f" {self.command.value} {self.remote} {self.url_or_path}" + elif self.command in (RemoteSubCommand.REMOVE, RemoteSubCommand.GET_URL): + self.cmd += f" {self.command.value} {self.remote}" + + def construct(self): + if not settings.stdout and not settings.output_only_path and not settings.quiet: + print(f"{settings.INFO_STRING} {self.cmd}") + + self.show_intro() + self.add_details() + self.recenter_frame() + self.scale_frame() + self.fadeout() + self.show_outro() + + def add_details(self): + self.camera.frame.scale_to_fit_width(18 * 1.1) + self.project_root = m.Rectangle( + height=9.0, + width=18.0, + color=self.fontColor, + ).move_to((0, 1000, 0)) + self.camera.frame.scale_to_fit_width(18 * 1.1) + self.camera.frame.move_to(self.project_root.get_center()) + + cmd_text = m.Text( + self.trim_cmd(self.cmd, 50), + font=self.font, + font_size=36, + color=self.fontColor, + ) + cmd_text.align_to(self.project_root, m.UP).shift(m.UP * 0.25 + cmd_text.height) + + project_root_text = m.Text( + os.path.basename(os.getcwd()) + "/", + font=self.font, + font_size=20, + color=self.fontColor, + ) + project_root_text.align_to(self.project_root, m.LEFT).align_to( + self.project_root, m.UP + ).shift(m.RIGHT * 0.25).shift(m.DOWN * 0.25) + + dot_git_text = m.Text( + ".git/", + font=self.font, + font_size=20, + color=self.fontColor, + ) + dot_git_text.align_to(project_root_text, m.UP).shift(self.down_shift).align_to( + project_root_text, m.LEFT + ).shift(m.RIGHT * 0.5) + + self.config_text = m.Text( + "config", + font=self.font, + font_size=20, + color=self.fontColor, + ) + self.config_text.align_to(dot_git_text, m.UP).shift(self.down_shift).align_to( + dot_git_text, m.LEFT + ).shift(m.RIGHT * 0.5) + self.last_element = self.config_text + + if settings.animate: + if settings.show_command_as_title: + self.play( + m.AddTextLetterByLetter(cmd_text, time_per_char=self.time_per_char) + ) + self.play(m.Create(self.project_root, time_per_char=self.time_per_char)) + self.play( + m.AddTextLetterByLetter( + project_root_text, time_per_char=self.time_per_char + ) + ) + self.play( + m.AddTextLetterByLetter(dot_git_text, time_per_char=self.time_per_char) + ) + self.play( + m.AddTextLetterByLetter( + self.config_text, time_per_char=self.time_per_char + ) + ) + else: + if settings.show_command_as_title: + self.add(cmd_text) + self.add(self.project_root) + self.add(project_root_text) + self.add(dot_git_text) + self.add(self.config_text) + + if not self.command: + self.render_remote_data() + elif self.command == RemoteSubCommand.ADD: + if not self.remote: + print("git-sim error: no new remote name specified") + sys.exit(1) + elif not self.url_or_path: + print("git-sim error: no new remote url or path specified") + sys.exit(1) + elif any( + self.remote in r + for r in [s for s in self.config.sections() if "remote" in s] + ): + print(f"git-sim error: remote '{self.remote}' already exists") + sys.exit(1) + self.render_remote_data() + section_text = ( + m.Text( + f'[remote "{self.remote}"]', + font=self.font, + color=self.fontColor, + font_size=20, + weight=m.BOLD, + ) + .align_to(self.last_element, m.UP) + .shift(self.down_shift) + .align_to(self.config_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + url_text = ( + m.Text( + f"url = {self.url_or_path}", + font=self.font, + color=self.fontColor, + font_size=20, + weight=m.BOLD, + ) + .align_to(section_text, m.UP) + .shift(self.down_shift) + .align_to(section_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + fetch_text = ( + m.Text( + f"fetch = +refs/heads/*:refs/remotes/{self.remote}/*", + font=self.font, + color=self.fontColor, + font_size=20, + weight=m.BOLD, + ) + .align_to(url_text, m.UP) + .shift(self.down_shift) + .align_to(section_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + self.toFadeOut.add(section_text) + self.toFadeOut.add(url_text) + self.toFadeOut.add(fetch_text) + if settings.animate: + self.play( + m.AddTextLetterByLetter( + section_text, time_per_char=self.time_per_char + ) + ) + self.play( + m.AddTextLetterByLetter(url_text, time_per_char=self.time_per_char) + ) + self.play( + m.AddTextLetterByLetter( + fetch_text, time_per_char=self.time_per_char + ) + ) + else: + self.add(section_text) + self.add(url_text) + self.add(fetch_text) + elif self.command in (RemoteSubCommand.RENAME, RemoteSubCommand.SET_URL): + if not self.remote: + print("git-sim error: no new remote name specified") + sys.exit(1) + elif not any( + self.remote in r + for r in [s for s in self.config.sections() if "remote" in s] + ): + print(f"git-sim error: remote '{self.remote}' doesn't exist") + sys.exit(1) + elif not self.url_or_path: + print(f"git-sim error: new remote name not specified") + sys.exit(1) + self.render_remote_data() + elif self.command in (RemoteSubCommand.REMOVE, RemoteSubCommand.GET_URL): + if not self.remote: + print("git-sim error: no new remote name specified") + sys.exit(1) + elif not any( + self.remote in r + for r in [s for s in self.config.sections() if "remote" in s] + ): + print(f"git-sim error: remote '{self.remote}' doesn't exist") + sys.exit(1) + self.render_remote_data() + + if settings.show_command_as_title: + self.toFadeOut.add(cmd_text) + self.toFadeOut.add(self.project_root) + self.toFadeOut.add(project_root_text) + self.toFadeOut.add(dot_git_text) + self.toFadeOut.add(self.config_text) + + def resize_rectangle(self): + if ( + self.last_element.get_bottom()[1] - 3 * self.last_element.height + > self.project_root.get_bottom()[1] + ): + return + new_rect = m.Rectangle( + width=rect.width, + height=rect.height + 2 * self.last_element.height, + color=rect.color, + ) + new_rect.align_to(rect, m.UP) + self.toFadeOut.remove(rect) + self.toFadeOut.add(new_rect) + if settings.animate: + self.recenter_frame() + self.scale_frame() + self.play(m.ReplacementTransform(rect, new_rect)) + else: + self.remove(rect) + self.add(new_rect) + self.project_root = new_rect + + def render_remote_data(self): + for i, section in enumerate(self.config.sections()): + if "remote" in section: + if self.command == RemoteSubCommand.RENAME and self.remote in section: + section_text = ( + m.Text( + f'[remote "{self.url_or_path}"]', + font=self.font, + color=self.fontColor, + font_size=20, + weight=m.BOLD, + ) + .align_to(self.last_element, m.UP) + .shift(self.down_shift) + .align_to(self.config_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + elif self.command == RemoteSubCommand.REMOVE and self.remote in section: + section_text = ( + m.MarkupText( + "" + + f"[{section}]" + + "", + font=self.font, + color=self.fontColor, + font_size=20, + weight=m.BOLD, + ) + .align_to(self.last_element, m.UP) + .shift(self.down_shift) + .align_to(self.config_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + else: + section_text = ( + m.Text( + f"[{section}]", + font=self.font, + color=self.fontColor, + font_size=20, + ) + .align_to(self.last_element, m.UP) + .shift(self.down_shift) + .align_to(self.config_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + self.toFadeOut.add(section_text) + if settings.animate: + self.play( + m.AddTextLetterByLetter( + section_text, time_per_char=self.time_per_char + ) + ) + else: + self.add(section_text) + self.last_element = section_text + self.resize_rectangle() + for j, option in enumerate(self.config.options(section)): + if option != "__name__": + option_value = ( + f"{option} = {self.config.get_value(section, option)}" + ) + if ( + self.command == RemoteSubCommand.REMOVE + and self.remote in section + ): + option_text = ( + m.MarkupText( + "" + + option_value + + "", + font=self.font, + color=self.fontColor, + font_size=20, + weight=m.BOLD, + ) + .align_to(self.last_element, m.UP) + .shift(self.down_shift) + .align_to(section_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + else: + weight = m.NORMAL + if ( + self.command == RemoteSubCommand.RENAME + and option == "fetch" + and self.remote in section + ): + option_value = f"fetch = +refs/heads/*:refs/remotes/{self.url_or_path}/*" + weight = m.BOLD + elif ( + self.command == RemoteSubCommand.GET_URL + and option == "url" + and self.remote in section + ): + weight = m.BOLD + elif ( + self.command == RemoteSubCommand.SET_URL + and option == "url" + and self.remote in section + ): + option_value = f"{option} = {self.url_or_path}" + weight = m.BOLD + option_text = ( + m.Text( + option_value, + font=self.font, + color=self.fontColor, + font_size=20, + weight=weight, + ) + .align_to(self.last_element, m.UP) + .shift(self.down_shift) + .align_to(section_text, m.LEFT) + .shift(m.RIGHT * 0.5) + ) + self.toFadeOut.add(option_text) + self.last_element = option_text + if settings.animate: + self.play( + m.AddTextLetterByLetter( + option_text, time_per_char=self.time_per_char + ) + ) + else: + self.add(option_text) + if not ( + i == len(self.config.sections()) - 1 + and j == len(self.config.options(section)) - 1 + ): + self.resize_rectangle() diff --git a/git_sim/reset.py b/src/git_sim/reset.py similarity index 90% rename from git_sim/reset.py rename to src/git_sim/reset.py index ae1ddef..a1fed65 100644 --- a/git_sim/reset.py +++ b/src/git_sim/reset.py @@ -41,11 +41,11 @@ def __init__( if soft: self.mode = ResetMode.SOFT + self.cmd += f"{type(self).__name__.lower()}{' --' + self.mode.value if self.mode != ResetMode.DEFAULT else ''} {self.commit}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()}{' --' + self.mode.value if self.mode != ResetMode.DEFAULT else ''} {self.commit}", - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() @@ -54,20 +54,19 @@ def construct(self): self.reset_head_branch(self.resetTo.hexsha) self.vsplit_frame() self.setup_and_draw_zones(first_column_name="Changes deleted from") + self.show_command_as_title() self.fadeout() self.show_outro() def build_commit_id_and_message(self, commit, i): hide_refs = False if commit == "dark": - commitId = m.Text("", font="Monospace", font_size=20, color=self.fontColor) + commitId = m.Text("", font=self.font, font_size=20, color=self.fontColor) commitMessage = "" elif i == 3 and self.resetTo.hexsha not in [ c.hexsha for c in self.get_default_commits() ]: - commitId = m.Text( - "...", font="Monospace", font_size=20, color=self.fontColor - ) + commitId = m.Text("...", font=self.font, font_size=20, color=self.fontColor) commitMessage = "..." hide_refs = True elif i == 4 and self.resetTo.hexsha not in [ @@ -75,7 +74,7 @@ def build_commit_id_and_message(self, commit, i): ]: commitId = m.Text( self.resetTo.hexsha[:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) @@ -85,7 +84,7 @@ def build_commit_id_and_message(self, commit, i): else: commitId = m.Text( commit.hexsha[:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) diff --git a/git_sim/restore.py b/src/git_sim/restore.py similarity index 69% rename from git_sim/restore.py rename to src/git_sim/restore.py index a2cc5f9..1050611 100644 --- a/git_sim/restore.py +++ b/src/git_sim/restore.py @@ -8,9 +8,10 @@ class Restore(GitSimBaseCommand): - def __init__(self, files: List[str]): + def __init__(self, files: List[str], staged: bool): super().__init__() self.files = files + self.staged = staged settings.hide_merged_branches = True self.n = self.n_default @@ -19,18 +20,24 @@ def __init__(self, files: List[str]): except TypeError: pass - for file in self.files: - if file not in [x.a_path for x in self.repo.index.diff(None)] + [ - y.a_path for y in self.repo.index.diff("HEAD") - ]: - print(f"git-sim error: No modified or staged file with name: '{file}'") - sys.exit() + if not self.staged: + for file in self.files: + if file not in [x.a_path for x in self.repo.index.diff(None)]: + print(f"git-sim error: No modified file with name: '{file}'") + sys.exit() + else: + for file in self.files: + if file not in [y.a_path for y in self.repo.index.diff("HEAD")]: + print( + f"git-sim error: No modified or staged file with name: '{file}'" + ) + sys.exit() + + self.cmd += f"{type(self).__name__.lower()}{' --staged' if self.staged else ''} {' '.join(self.files)}" def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()} {' '.join(self.files)}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() @@ -38,6 +45,7 @@ def construct(self): self.scale_frame() self.vsplit_frame() self.setup_and_draw_zones(reverse=True) + self.show_command_as_title() self.fadeout() self.show_outro() diff --git a/git_sim/revert.py b/src/git_sim/revert.py similarity index 90% rename from git_sim/revert.py rename to src/git_sim/revert.py index 9552501..8997850 100644 --- a/git_sim/revert.py +++ b/src/git_sim/revert.py @@ -34,11 +34,11 @@ def __init__(self, commit: str): except TypeError: pass + self.cmd += f"{type(self).__name__.lower()} {self.commit}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()} {self.commit}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() @@ -53,20 +53,19 @@ def construct(self): second_column_name="Changes reverted from", third_column_name="----", ) + self.show_command_as_title() self.fadeout() self.show_outro() def build_commit_id_and_message(self, commit, i): hide_refs = False if commit == "dark": - commitId = m.Text("", font="Monospace", font_size=20, color=self.fontColor) + commitId = m.Text("", font=self.font, font_size=20, color=self.fontColor) commitMessage = "" elif i == 2 and self.revert.hexsha not in [ commit.hexsha for commit in self.get_default_commits() ]: - commitId = m.Text( - "...", font="Monospace", font_size=20, color=self.fontColor - ) + commitId = m.Text("...", font=self.font, font_size=20, color=self.fontColor) commitMessage = "..." hide_refs = True elif i == 3 and self.revert.hexsha not in [ @@ -74,7 +73,7 @@ def build_commit_id_and_message(self, commit, i): ]: commitId = m.Text( self.revert.hexsha[:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) @@ -83,7 +82,7 @@ def build_commit_id_and_message(self, commit, i): else: commitId = m.Text( commit.hexsha[:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) @@ -118,7 +117,7 @@ def setup_and_draw_revert_commit(self): arrow.set_length(length) commitId = m.Text( - "abcdef", font="Monospace", font_size=20, color=self.fontColor + "abcdef", font=self.font, font_size=20, color=self.fontColor ).next_to(circle, m.UP) self.toFadeOut.add(commitId) @@ -128,7 +127,7 @@ def setup_and_draw_revert_commit(self): "\n".join( commitMessage[j : j + 20] for j in range(0, len(commitMessage), 20) )[:100], - font="Monospace", + font=self.font, font_size=14, color=self.fontColor, ).next_to(circle, m.DOWN) diff --git a/git_sim/rm.py b/src/git_sim/rm.py similarity index 93% rename from git_sim/rm.py rename to src/git_sim/rm.py index fcf7183..ccd8c64 100644 --- a/git_sim/rm.py +++ b/src/git_sim/rm.py @@ -29,11 +29,11 @@ def __init__(self, files: List[str]): print(f"git-sim error: No tracked file with name: '{file}'") sys.exit() + self.cmd += f"{type(self).__name__.lower()} {' '.join(self.files)}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING} {type(self).__name__.lower()} {' '.join(self.files)}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() @@ -45,6 +45,7 @@ def construct(self): second_column_name="Staging area", third_column_name="Removed files", ) + self.show_command_as_title() self.fadeout() self.show_outro() @@ -68,7 +69,7 @@ def create_zone_text( text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -84,7 +85,7 @@ def create_zone_text( text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -104,7 +105,7 @@ def create_zone_text( + "'>" + self.trim_path(f) + "", - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) diff --git a/git_sim/settings.py b/src/git_sim/settings.py similarity index 91% rename from git_sim/settings.py rename to src/git_sim/settings.py index 9eba41c..752b56b 100644 --- a/git_sim/settings.py +++ b/src/git_sim/settings.py @@ -15,7 +15,7 @@ class Settings(BaseSettings): files: Union[List[pathlib.Path], None] = None hide_first_tag: bool = False img_format: ImgFormat = ImgFormat.JPG - INFO_STRING: str = "Simulating: git" + INFO_STRING: str = "Simulating:" light_mode: bool = False transparent_bg: bool = False logo: pathlib.Path = pathlib.Path(__file__).parent.resolve() / "logo.png" @@ -40,6 +40,9 @@ class Settings(BaseSettings): color_by: Union[ColorByOptions, None] = None highlight_commit_messages: bool = False style: Union[StyleOptions, None] = StyleOptions.CLEAN + font: str = "Monospace" + font_context: bool = False + show_command_as_title: bool = True class Config: env_prefix = "git_sim_" diff --git a/git_sim/stash.py b/src/git_sim/stash.py similarity index 73% rename from git_sim/stash.py rename to src/git_sim/stash.py index 89a5cd2..a30d7fd 100644 --- a/git_sim/stash.py +++ b/src/git_sim/stash.py @@ -1,3 +1,4 @@ +import re import sys from enum import Enum import manim as m @@ -10,7 +11,7 @@ class Stash(GitSimBaseCommand): - def __init__(self, files: List[str], command: StashSubCommand): + def __init__(self, files: List[str], command: StashSubCommand, stash_index: int): super().__init__() self.files = files self.no_files = True if not self.files else False @@ -18,6 +19,11 @@ def __init__(self, files: List[str], command: StashSubCommand): settings.hide_merged_branches = True self.n = self.n_default + self.stash_index = self.parse_stash_format(stash_index) + if self.stash_index is None: + print("git-sim error: specify stash index as either integer or stash@{i}") + sys.exit() + try: self.selected_branches.append(self.repo.active_branch.name) except TypeError: @@ -44,14 +50,14 @@ def __init__(self, files: List[str], command: StashSubCommand): and not settings.quiet ): print( - "Files are not required in apply/pop subcommand. Ignoring the file list....." + "Files are not required in apply/pop subcommand. Ignoring the file list..." ) + self.cmd += f"{type(self).__name__.lower()} {self.command.value if self.command else ''} {' '.join(self.files) if not self.no_files else ''}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()} {self.command.value if self.command else ''} {' '.join(self.files) if not self.no_files else ''}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() @@ -63,6 +69,7 @@ def construct(self): second_column_name="Staging area", third_column_name="Stashed changes", ) + self.show_command_as_title() self.fadeout() self.show_outro() @@ -86,7 +93,7 @@ def create_zone_text( text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -102,7 +109,7 @@ def create_zone_text( text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -124,7 +131,7 @@ def create_zone_text( + "" if self.command == StashSubCommand.POP else self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -146,19 +153,23 @@ def populate_zones( thirdColumnArrowMap={}, ): if self.command in [StashSubCommand.POP, StashSubCommand.APPLY]: - for x in self.repo.index.diff(None): - thirdColumnFileNames.add(x.a_path) - firstColumnFileNames.add(x.a_path) - thirdColumnArrowMap[x.a_path] = m.Arrow( - stroke_width=3, color=self.fontColor + try: + stashedFileNames = self.repo.git.stash( + "show", "--name-only", self.stash_index ) - - for y in self.repo.index.diff("HEAD"): - firstColumnFileNames.add(y.a_path) - thirdColumnFileNames.add(y.a_path) - thirdColumnArrowMap[y.a_path] = m.Arrow( - stroke_width=3, color=self.fontColor + stashedFileNames = stashedFileNames.split("\n") + except: + print( + f"git-sim error: No stash entry with index {self.stashIndex} exists in stash" ) + sys.exit() + for s in stashedFileNames: + thirdColumnFileNames.add(s) + firstColumnFileNames.add(s) + thirdColumnArrowMap[s] = m.Arrow(stroke_width=3, color=self.fontColor) + firstColumnFileNames.add(s) + thirdColumnFileNames.add(s) + thirdColumnArrowMap[s] = m.Arrow(stroke_width=3, color=self.fontColor) else: for x in self.repo.index.diff(None): @@ -178,3 +189,14 @@ def populate_zones( secondColumnArrowMap[y.a_path] = m.Arrow( stroke_width=3, color=self.fontColor ) + + def parse_stash_format(self, s): + # Regular expression to match either a plain integer or stash@{integer} + match = re.match(r"^(?:stash@\{(\d+)\}|\b(\d+)\b)$", s) + if match: + # match.group(1) is the integer in the stash@{integer} format + # match.group(2) is the integer if it's just a plain number + # One of these groups will be None, the other will have our number as a string + number_str = match.group(1) or match.group(2) + return int(number_str) + return None diff --git a/git_sim/status.py b/src/git_sim/status.py similarity index 83% rename from git_sim/status.py rename to src/git_sim/status.py index d237ba0..f96391f 100644 --- a/git_sim/status.py +++ b/src/git_sim/status.py @@ -11,15 +11,17 @@ def __init__(self): pass settings.hide_merged_branches = True self.n = self.n_default + self.cmd += f"{type(self).__name__.lower()}" def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print(f"{settings.INFO_STRING } {type(self).__name__.lower()}") + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() self.parse_commits() self.recenter_frame() self.scale_frame() self.vsplit_frame() self.setup_and_draw_zones() + self.show_command_as_title() self.fadeout() self.show_outro() diff --git a/git_sim/switch.py b/src/git_sim/switch.py similarity index 71% rename from git_sim/switch.py rename to src/git_sim/switch.py index e265585..3945ecd 100644 --- a/git_sim/switch.py +++ b/src/git_sim/switch.py @@ -10,10 +10,11 @@ class Switch(GitSimBaseCommand): - def __init__(self, branch: str, c: bool): + def __init__(self, branch: str, c: bool, detach: bool): super().__init__() self.branch = branch self.c = c + self.detach = detach if self.c: if self.branch in self.repo.heads: @@ -23,6 +24,9 @@ def __init__(self, branch: str, c: bool): + "', it already exists" ) sys.exit(1) + if detach: + print("git-sim error: can't use both '-c' and '--detach' flags") + sys.exit(1) else: try: git.repo.fun.rev_parse(self.repo, self.branch) @@ -34,21 +38,35 @@ def __init__(self, branch: str, c: bool): ) sys.exit(1) - if self.branch == self.repo.active_branch.name: + if ( + not self.repo.head.is_detached + and self.branch == self.repo.active_branch.name + ): print("git-sim error: already on branch '" + self.branch + "'") sys.exit(1) + if not self.detach: + if self.branch not in self.repo.heads: + print("git-sim error: include --detach to allow detached HEAD") + sys.exit(1) + self.is_ancestor = False self.is_descendant = False # branch being switched to is behind HEAD - if self.repo.active_branch.name in self.repo.git.branch( - "--contains", self.branch - ): + branch_names = self.repo.git.branch("--contains", self.branch) + branch_names = branch_names.split("\n") + for i, bn in enumerate(branch_names): + branch_names[i] = bn.strip("*").strip() + branch_hexshas = [ + self.repo.branches[branch].commit.hexsha for branch in branch_names + ] + if self.repo.head.commit.hexsha in branch_hexshas: self.is_ancestor = True + # HEAD is behind branch being switched to elif self.branch in self.repo.git.branch( - "--contains", self.repo.active_branch.name + "--contains", self.repo.head.commit.hexsha ): self.is_descendant = True @@ -56,15 +74,16 @@ def __init__(self, branch: str, c: bool): self.selected_branches.append(self.branch) try: - self.selected_branches.append(self.repo.active_branch.name) + if not self.repo.head.is_detached: + self.selected_branches.append(self.repo.active_branch.name) except TypeError: pass + self.cmd += f"{type(self).__name__.lower()}{' -c' if self.c else ''}{' --detach' if self.detach else ''} {self.branch}" + def construct(self): if not settings.stdout and not settings.output_only_path and not settings.quiet: - print( - f"{settings.INFO_STRING } {type(self).__name__.lower()}{' -c' if self.c else ''} {self.branch}" - ) + print(f"{settings.INFO_STRING} {self.cmd}") self.show_intro() head_commit = self.get_commit() @@ -104,7 +123,8 @@ def construct(self): self.scale_frame() if "HEAD" in self.drawnRefs: self.reset_head(reset_head_to) - self.reset_branch(head_commit.hexsha) + if not self.repo.head.is_detached: + self.reset_branch(head_commit.hexsha) else: self.draw_ref(branch_commit, self.topref) else: @@ -117,5 +137,6 @@ def construct(self): self.reset_branch(head_commit.hexsha) self.color_by() + self.show_command_as_title() self.fadeout() self.show_outro() diff --git a/src/git_sim/tag.py b/src/git_sim/tag.py new file mode 100644 index 0000000..fa9eee3 --- /dev/null +++ b/src/git_sim/tag.py @@ -0,0 +1,106 @@ +import sys +import manim as m + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Tag(GitSimBaseCommand): + def __init__(self, name: str, commit: str, d: bool): + super().__init__() + self.name = name + self.commit = commit + self.d = d + + if self.d: + if self.commit: + print( + "git-sim error: can't specify commit '" + + self.commit + + "', when using -d flag" + ) + sys.exit(1) + if self.name not in self.repo.tags: + print( + "git-sim error: can't delete tag '" + + self.name + + "', tag doesn't exist" + ) + sys.exit(1) + else: + if self.name in self.repo.tags: + print( + "git-sim error: can't create tag '" + + self.name + + "', tag already exists" + ) + sys.exit(1) + + self.cmd += f"{type(self).__name__.lower()}{' -d' if self.d else ''}{' self.commit' if self.commit else ''} {self.name}" + + def construct(self): + if not settings.stdout and not settings.output_only_path and not settings.quiet: + print(f"{settings.INFO_STRING} {self.cmd}") + + self.show_intro() + self.parse_commits() + self.parse_all() + self.center_frame_on_commit(self.get_commit()) + + if not self.d: + tagText = m.Text( + self.name, + font=self.font, + font_size=20, + color=self.fontColor, + ) + tagRec = m.Rectangle( + color=m.YELLOW, + fill_color=m.YELLOW, + fill_opacity=0.25, + height=0.4, + width=tagText.width + 0.25, + ) + + if self.commit: + commit = self.repo.commit(self.commit) + try: + tagRec.next_to(self.drawnRefsByCommit[commit.hexsha][-1], m.UP) + except KeyError: + try: + tagRec.next_to(self.drawnCommitIds[commit.hexsha], m.UP) + except KeyError: + print( + "git-sim error: can't create tag '" + + self.name + + "' on commit '" + + self.commit + + "', commit not in frame" + ) + sys.exit(1) + else: + tagRec.next_to(self.topref, m.UP) + tagText.move_to(tagRec.get_center()) + + fulltag = m.VGroup(tagRec, tagText) + + if settings.animate: + self.play(m.Create(fulltag), run_time=1 / settings.speed) + else: + self.add(fulltag) + + self.toFadeOut.add(tagRec, tagText) + self.drawnRefs[self.name] = fulltag + else: + fulltag = self.drawnRefs[self.name] + if settings.animate: + self.play(m.Uncreate(fulltag), run_time=1 / settings.speed) + else: + self.remove(fulltag) + + self.recenter_frame() + self.scale_frame() + self.color_by() + self.show_command_as_title() + self.fadeout() + self.show_outro() diff --git a/tests/README.md b/tests/README.md index 8b582a1..7139766 100644 --- a/tests/README.md +++ b/tests/README.md @@ -38,7 +38,8 @@ Temp repo directory: ## Helpful pytest notes -- `pytest -x`: Stop after the first test fails +- `pytest -x`: Stop after the first test fails. +- `pytest -n auto`: Tests can be executed in parallel to dramatically speed up performance (up to ~70%). To do this first run `pip install pytest-xdist` then run `pytest -n auto`. Note that test output is not supported when executing tests in parallel. If a failure occurs and you need output for troubleshooting, execute tests in series as outlined above. ## Adding more tests @@ -70,4 +71,4 @@ These issues are mostly addressed by checking that image files are similar withi The differences across OSes is even greater. I believe this may have something to do with which fonts are available on each system. -This is dealt with by having Windows-specific reference files. \ No newline at end of file +This is dealt with by having Windows-specific reference files and by using Courier New as the font for all test reference images. diff --git a/tests/e2e_tests/ProggyClean.ttf b/tests/e2e_tests/ProggyClean.ttf new file mode 100644 index 0000000..0270cdf Binary files /dev/null and b/tests/e2e_tests/ProggyClean.ttf differ diff --git a/tests/e2e_tests/reference_files/git-sim-add.png b/tests/e2e_tests/reference_files/git-sim-add.png new file mode 100644 index 0000000..1d765d1 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-add.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-branch.png b/tests/e2e_tests/reference_files/git-sim-branch.png new file mode 100644 index 0000000..d3ad5cf Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-branch.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-checkout.png b/tests/e2e_tests/reference_files/git-sim-checkout.png new file mode 100644 index 0000000..8a92aac Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-checkout.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-cherry_pick.png b/tests/e2e_tests/reference_files/git-sim-cherry_pick.png new file mode 100644 index 0000000..8cc6f54 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-cherry_pick.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-clean.png b/tests/e2e_tests/reference_files/git-sim-clean.png new file mode 100644 index 0000000..9611e88 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-clean.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-commit.png b/tests/e2e_tests/reference_files/git-sim-commit.png new file mode 100644 index 0000000..0a2b990 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-commit.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-log.png b/tests/e2e_tests/reference_files/git-sim-log.png index 444c271..eae29d6 100644 Binary files a/tests/e2e_tests/reference_files/git-sim-log.png and b/tests/e2e_tests/reference_files/git-sim-log.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-log_windows.png b/tests/e2e_tests/reference_files/git-sim-log_windows.png deleted file mode 100644 index fffb6d8..0000000 Binary files a/tests/e2e_tests/reference_files/git-sim-log_windows.png and /dev/null differ diff --git a/tests/e2e_tests/reference_files/git-sim-merge.png b/tests/e2e_tests/reference_files/git-sim-merge.png index 1ea9b8f..55f719e 100644 Binary files a/tests/e2e_tests/reference_files/git-sim-merge.png and b/tests/e2e_tests/reference_files/git-sim-merge.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-merge_windows.png b/tests/e2e_tests/reference_files/git-sim-merge_windows.png deleted file mode 100644 index e6e8d29..0000000 Binary files a/tests/e2e_tests/reference_files/git-sim-merge_windows.png and /dev/null differ diff --git a/tests/e2e_tests/reference_files/git-sim-mv.png b/tests/e2e_tests/reference_files/git-sim-mv.png new file mode 100644 index 0000000..a884463 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-mv.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-rebase.png b/tests/e2e_tests/reference_files/git-sim-rebase.png new file mode 100644 index 0000000..aadd11e Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-rebase.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-reset.png b/tests/e2e_tests/reference_files/git-sim-reset.png new file mode 100644 index 0000000..aca81d7 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-reset.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-restore.png b/tests/e2e_tests/reference_files/git-sim-restore.png new file mode 100644 index 0000000..33a9867 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-restore.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-revert.png b/tests/e2e_tests/reference_files/git-sim-revert.png new file mode 100644 index 0000000..ca6c4f1 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-revert.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-rm.png b/tests/e2e_tests/reference_files/git-sim-rm.png new file mode 100644 index 0000000..c0df35a Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-rm.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-stash.png b/tests/e2e_tests/reference_files/git-sim-stash.png new file mode 100644 index 0000000..92fc564 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-stash.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-status.png b/tests/e2e_tests/reference_files/git-sim-status.png index a66cd06..1d765d1 100644 Binary files a/tests/e2e_tests/reference_files/git-sim-status.png and b/tests/e2e_tests/reference_files/git-sim-status.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-status_windows.png b/tests/e2e_tests/reference_files/git-sim-status_windows.png deleted file mode 100644 index f005b18..0000000 Binary files a/tests/e2e_tests/reference_files/git-sim-status_windows.png and /dev/null differ diff --git a/tests/e2e_tests/reference_files/git-sim-switch.png b/tests/e2e_tests/reference_files/git-sim-switch.png new file mode 100644 index 0000000..8a92aac Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-switch.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-tag.png b/tests/e2e_tests/reference_files/git-sim-tag.png new file mode 100644 index 0000000..264ac4d Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-tag.png differ diff --git a/tests/e2e_tests/test_core_commands.py b/tests/e2e_tests/test_core_commands.py index 894d618..12f033c 100644 --- a/tests/e2e_tests/test_core_commands.py +++ b/tests/e2e_tests/test_core_commands.py @@ -11,44 +11,57 @@ from utils import get_cmd_parts, compare_images, run_git_reset - -def test_log(tmp_repo): - """Test a simple `git-sim log` command.""" - raw_cmd = "git-sim log" - cmd_parts = get_cmd_parts(raw_cmd) - - os.chdir(tmp_repo) - output = subprocess.run(cmd_parts, capture_output=True) - - fp_generated = Path(output.stdout.decode().strip()) - fp_reference = Path(__file__).parent / "reference_files/git-sim-log.png" - - assert compare_images(fp_generated, fp_reference) - - -def test_status(tmp_repo): - """Test a simple `git-sim status` command.""" - raw_cmd = "git-sim status" +import pytest + + +git_sim_commands = [ + # Simple commands. + "git-sim add", + "git-sim log", + "git-sim clean", + "git-sim commit", + "git-sim restore", + "git-sim stash", + "git-sim status", + # Complex commands. + "git-sim branch new_branch", + "git-sim checkout branch2", + "git-sim cherry-pick branch2", + "git-sim merge branch2", + "git-sim mv main.1 main.100", + "git-sim rebase branch2", + "git-sim reset HEAD^", + "git-sim revert HEAD^", + "git-sim rm main.1", + "git-sim switch branch2", + "git-sim tag new_tag", +] + + +@pytest.mark.parametrize("raw_cmd", git_sim_commands) +def test_command(tmp_repo, raw_cmd): + """Test a git-sim command. + + This function works for any command of the forms + `git-sim ` + """ + + # Generate the string to look for in the filename. + # `git-sim log` -> "git-sim-log" + # `git-sim cherry-pick branch2` -> "git-sim-cherry_pick"" + raw_cmd_parts = raw_cmd.split(" ") + filename_element = f"git-sim-{raw_cmd_parts[1].replace('-', '_')}" + + # Get version of the command needed for testing, and run command. cmd_parts = get_cmd_parts(raw_cmd) - - os.chdir(tmp_repo) - output = subprocess.run(cmd_parts, capture_output=True) - - fp_generated = Path(output.stdout.decode().strip()) - fp_reference = Path(__file__).parent / "reference_files/git-sim-status.png" - - assert compare_images(fp_generated, fp_reference) - - -def test_merge(tmp_repo): - """Test a simple `git-sim merge` command.""" - raw_cmd = "git-sim merge branch2" - cmd_parts = get_cmd_parts(raw_cmd) - os.chdir(tmp_repo) output = subprocess.run(cmd_parts, capture_output=True) + # Get file paths to generated and reference files. fp_generated = Path(output.stdout.decode().strip()) - fp_reference = Path(__file__).parent / "reference_files/git-sim-merge.png" + fp_reference = Path(__file__).parent / f"reference_files/{filename_element}.png" - assert compare_images(fp_generated, fp_reference) + # Validate filename elements, and compare output image to reference image. + assert filename_element in str(fp_generated) + compare_images(fp_generated, fp_reference) diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index 6f63092..8dfc623 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -16,9 +16,9 @@ def compare_images(path_gen, path_ref): Parameters: file path to generated and reference image files Returns: True/ False """ - if os.name == "nt": - # Use Windows-specific reference files. - path_ref = path_ref.with_name(path_ref.stem + "_windows" + path_ref.suffix) + # Verify that the path to the generated file exists. + assert ".png" in str(path_gen) + assert path_gen.exists() img_gen = Image.open(path_gen) img_ref = Image.open(path_ref) @@ -41,11 +41,8 @@ def compare_images(path_gen, path_ref): # Images are similar if only a small % of pixels differ significantly. # This value can be increased if tests are failing when they shouldn't. # It can be decreased if tests are passing when they shouldn't. - if ratio_diff < 0.0075: - return True - else: - print("bad pixel ratio:", ratio_diff) - return False + msg = f"bad pixel ratio ({path_ref.stem[8:]}): {ratio_diff}" + assert ratio_diff < 0.015, msg def get_cmd_parts(raw_command): @@ -58,7 +55,7 @@ def get_cmd_parts(raw_command): For example, the command: `git-sim log` becomes: - ` -d --output-only-path --img-format=png log` + ` -d --output-only-path --img-format=png --font="/path/to/test/font.ttf" log` This prevents images from auto-opening, simplifies parsing output to identify the images we need to check, and prefers png for test runs. @@ -66,14 +63,21 @@ def get_cmd_parts(raw_command): Returns: list of command parts, ready to be run with subprocess.run() """ # Add the global flags needed for testing. + font_path = Path(__file__).parent / "ProggyClean.ttf" cmd = raw_command.replace( - "git-sim", "git-sim -d --output-only-path --img-format=png" + "git-sim", + f"git-sim -d --output-only-path --img-format=png --font='{font_path}'", ) # Replace `git-sim` with the full path to the binary. - # as_posix() is needed for Windows compatibility. + # as_posix() is needed for Windows compatibility. + # The space is included in "git-sim " to avoid replacing any occurrences + # of git-sim in a font path. git_sim_path = get_venv_path() / "git-sim" - cmd = cmd.replace("git-sim", git_sim_path.as_posix()) + cmd = cmd.replace("git-sim ", f"{git_sim_path.as_posix()} ") + + # Show full test command when run in diagnostic mode. + print(f" Test command: {cmd}") return split(cmd) diff --git a/test.py b/tests/unit_tests/test.py similarity index 100% rename from test.py rename to tests/unit_tests/test.py