diff --git a/.gitignore b/.gitignore index 34952e9..f159c07 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ git-sim_media/ build/ dist/ git_sim.egg-info/ + +.venv/ 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 60691c2..37d8ee1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,13 @@ [![Contributors](https://img.shields.io/github/contributors/initialcommit-com/git-sim)](https://github.com/initialcommit-com/git-sim/graphs/contributors) [![Share](https://img.shields.io/twitter/url?label=Share&url=https%3A%2F%2Ftwitter.com%2Finitcommit)](https://twitter.com/intent/tweet?text=Check%20out%20%23gitsim%20%2D%20a%20tool%20to%20visualize%20%23Git%20operations%20in%20your%20local%20repos%20with%20a%20single%20terminal%20command,%20by%20%40initcommit!%20https%3A%2F%2Fgithub%2Ecom%2Finitialcommit%2Dcom%2Fgit%2Dsim) +--- +🚨 I'm working on a new project called [Devlands](https://devlands.com) that I consider to be the next generation of git-sim and an even more intuitive way to learn and use Git. + +🌱 It enables you to visualize your entire Git repo, literally walk through your codebase, simulate + run Git commands, do a character-guided Git tutorial, and experience your codebase from a fresh perspective. Consider checking it out! + +--- + Visually simulate Git operations in your own repos with a single terminal command. This generates an image (default) or video visualization depicting the Git command's behavior. @@ -14,8 +21,15 @@ This generates an image (default) or video visualization depicting the Git comma Command syntax is based directly on Git's command-line syntax, so using git-sim is as familiar as possible. 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! -![git-sim-merge_01-05-23_09-44-46](https://user-images.githubusercontent.com/49353917/210939840-1d51493a-6cac-43fd-9d12-3d2948d32c61.jpg) +## 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 @@ -28,9 +42,9 @@ 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 the `--color-by=author` option +- Color commits by parameter, such as author with the `--color-by=author` option - Choose between dark mode (default) and light mode - Specify output formats of either jpg, png, mp4, or webm - Combine with bundled command [git-dummy](https://github.com/initialcommit-com/git-dummy) to generate a dummy Git repo and then simulate operations on it @@ -128,7 +142,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 +178,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 +194,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 +246,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 ` - -- 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 +- Simulated output describes the specified configuration change +- Use `--list` or `-l` to display all configuration -![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 +289,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 +317,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 +368,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 493f741..0000000 --- a/git_sim/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.3.0" diff --git a/git_sim/settings.py b/git_sim/settings.py deleted file mode 100644 index 014610b..0000000 --- a/git_sim/settings.py +++ /dev/null @@ -1,48 +0,0 @@ -import pathlib -from typing import List, Union - -from pydantic import BaseSettings - -from git_sim.enums import StyleOptions, ColorByOptions, ImgFormat, VideoFormat - - -class Settings(BaseSettings): - allow_no_commits = False - animate = False - auto_open = True - n_default = 5 - n = 5 - files: Union[List[pathlib.Path], None] = None - hide_first_tag = False - img_format: ImgFormat = ImgFormat.JPG - INFO_STRING = "Simulating: git" - light_mode = False - transparent_bg = False - logo = pathlib.Path(__file__).parent.resolve() / "logo.png" - low_quality = False - max_branches_per_commit = 1 - max_tags_per_commit = 1 - media_dir = pathlib.Path().cwd() - outro_bottom_text = "Learn more at initialcommit.com" - outro_top_text = "Thanks for using Initial Commit!" - reverse = False - show_intro = False - show_outro = False - speed = 1.5 - title = "Git-Sim, by initialcommit.com" - video_format: VideoFormat = VideoFormat.MP4 - stdout = False - output_only_path = False - quiet = False - invert_branches = False - hide_merged_branches = False - all = False - color_by: Union[ColorByOptions, None] = None - highlight_commit_messages = False - style: Union[StyleOptions, None] = StyleOptions.CLEAN - - class Config: - env_prefix = "git_sim_" - - -settings = Settings() 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 65b84f1..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", - "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 a8de08b..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 ( - not self.is_remote_tracking_branch(branch) # local branch + branch not in remote_tracking_branches # local branch and commit.hexsha == self.repo.heads[branch].commit.hexsha ) or ( - self.is_remote_tracking_branch(branch) # 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 not self.is_remote_tracking_branch(branch) - ) + 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] @@ -1178,15 +1205,6 @@ def get_remote_tracking_branches(self): remote_tracking_branches[ref.name] = ref.commit.hexsha return remote_tracking_branches - def is_remote_tracking_branch(self, branch): - remote_refs = [remote.refs for remote in self.repo.remotes] - remote_tracking_branches = {} - for reflist in remote_refs: - for ref in reflist: - if "HEAD" not in ref.name and ref.name not in remote_tracking_branches: - remote_tracking_branches[ref.name] = ref.commit.hexsha - return branch in remote_tracking_branches - def create_zone_text( self, firstColumnFileNames, @@ -1207,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, ) @@ -1223,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, ) @@ -1239,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, ) @@ -1261,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, @@ -1303,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 96% rename from git_sim/merge.py rename to src/git_sim/merge.py index d00a90d..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 @@ -60,7 +60,7 @@ def construct(self): head_commit = self.get_commit() branch_commit = self.get_commit(self.branch) - if not self.is_remote_tracking_branch(self.branch): + if self.branch not in self.get_remote_tracking_branches(): if self.branch in self.repo.git.branch("--contains", head_commit.hexsha): self.ff = True else: @@ -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/src/git_sim/settings.py b/src/git_sim/settings.py new file mode 100644 index 0000000..752b56b --- /dev/null +++ b/src/git_sim/settings.py @@ -0,0 +1,51 @@ +import pathlib +from typing import List, Union + +from pydantic_settings import BaseSettings + +from git_sim.enums import StyleOptions, ColorByOptions, ImgFormat, VideoFormat + + +class Settings(BaseSettings): + allow_no_commits: bool = False + animate: bool = False + auto_open: bool = True + n_default: int = 5 + n: int = 5 + files: Union[List[pathlib.Path], None] = None + hide_first_tag: bool = False + img_format: ImgFormat = ImgFormat.JPG + INFO_STRING: str = "Simulating:" + light_mode: bool = False + transparent_bg: bool = False + logo: pathlib.Path = pathlib.Path(__file__).parent.resolve() / "logo.png" + low_quality: bool = False + max_branches_per_commit: int = 1 + max_tags_per_commit: int = 1 + media_dir: pathlib.Path = pathlib.Path().cwd() + outro_bottom_text: str = "Learn more at initialcommit.com" + outro_top_text: str = "Thanks for using Initial Commit!" + reverse: bool = False + show_intro: bool = False + show_outro: bool = False + speed: float = 1.5 + title: str = "Git-Sim, by initialcommit.com" + video_format: VideoFormat = VideoFormat.MP4 + stdout: bool = False + output_only_path: bool = False + quiet: bool = False + invert_branches: bool = False + hide_merged_branches: bool = False + all: bool = False + 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_" + + +settings = Settings() 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 new file mode 100644 index 0000000..7139766 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,74 @@ +# Testing +--- + +Testing is done with pytest. The focus for now is on end-to-end tests, which show that the overall project is working as it should. + +## Running tests + +The following instructions will let you run tests as soon as you clone the repository: + +```sh +$ git clone https://github.com/initialcommit-com/git-sim.git +$ cd git-sim +$ python3 -m venv .venv +$ source venv/bin/activate +(.venv)$ pip install -e . +(.venv)$ pip install pytest +(.venv)$ pytest -s +``` + +Including the `-s` flag tells pytest to include diagnostic information in the test output. This will show you where the test data is being written: + +```sh +(.venv)$ pytest -s +===== test session starts ========================================== +platform darwin -- Python 3.11.2, pytest-7.3.2, pluggy-1.0.0 +rootdir: /Users/.../git-sim +collected 3 items + +tests/e2e_tests/test_core_commands.py + +Temp repo directory: + /private/var/folders/.../pytest-108/sample_repo0 + +... + +===== 3 passed in 6.58s ============================================ +``` + +## Helpful pytest notes + +- `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 + +To add another test: + +- Work in `tests/e2e_tests/test_core_commands.py`. +- Duplicate one of the existing test functions. +- Replace the value of `raw_cmd` with the command you want to test. +- Run the test suite once with `pytest -sx`. The test should fail, but it will generate the output you need to finish the process. +- Look in the "Temp repo directory" specified at the start of the test output. + - Find the `git-sim_media/` directory there, and find the output file that was generated for the test you just wrote. + - Open that file, and make sure it's correct. + - If it is, copy that file into `tests/e2e_tests/reference_files/`, with an appropriate name. + - Update your new test function so that `fp_reference` points to this new reference file. +- Run the test suite again, and your test should pass. +- You will need to repeat this process once on macOS or Linux, and once on Windows. + +## Cross-platform issues + +There are two cross-platform issues to be aware of. + +### Inconsistent png and jpg output + +When git-sim generates a jpg or png file, that file can be slightly different on different systems. Files can be slightly different depending on the architecture, and which system libraries are installed. Even Intel and Apple-silicon Macs can end up generating non-identical image files. + +These issues are mostly addressed by checking that image files are similar within a given threshold, rather than identical. + +### Inconsistent Windows and macOS output + +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 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/conftest.py b/tests/e2e_tests/conftest.py new file mode 100644 index 0000000..310197a --- /dev/null +++ b/tests/e2e_tests/conftest.py @@ -0,0 +1,31 @@ +import subprocess, os +from pathlib import Path +from shlex import split + +import pytest + +import utils + + +@pytest.fixture(scope="session") +def tmp_repo(tmp_path_factory): + """Create a copy of the sample repo, which we can run all tests against. + + Returns: path to tmp dir containing sample test repository. + """ + + tmp_repo_dir = tmp_path_factory.mktemp("sample_repo") + + # To see where tmp_repo_dir is located, run pytest with the `-s` flag. + print(f"\n\nTemp repo directory:\n {tmp_repo_dir}\n") + + # Create the sample repo for testing. + os.chdir(tmp_repo_dir) + + # When defining cmd, as_posix() is required for Windows compatibility. + git_dummy_path = utils.get_venv_path() / "git-dummy" + cmd = f"{git_dummy_path.as_posix()} --commits=10 --branches=4 --merge=1 --constant-sha --name=sample_repo --diverge-at=2" + cmd_parts = split(cmd) + subprocess.run(cmd_parts) + + return tmp_repo_dir / "sample_repo" 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 new file mode 100644 index 0000000..eae29d6 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-log.png differ diff --git a/tests/e2e_tests/reference_files/git-sim-merge.png b/tests/e2e_tests/reference_files/git-sim-merge.png new file mode 100644 index 0000000..55f719e Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-merge.png 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 new file mode 100644 index 0000000..1d765d1 Binary files /dev/null and b/tests/e2e_tests/reference_files/git-sim-status.png 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 new file mode 100644 index 0000000..12f033c --- /dev/null +++ b/tests/e2e_tests/test_core_commands.py @@ -0,0 +1,67 @@ +"""Tests for the core commands implemented in git-sim. + +All test runs use the -d flag to prevent images from opening automatically. + +To induce failure, include a call to `run_git_reset()` in one of the + test functions. +""" + +import os, subprocess +from pathlib import Path + +from utils import get_cmd_parts, compare_images, run_git_reset + +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) + + # Get file paths to generated and reference files. + fp_generated = Path(output.stdout.decode().strip()) + fp_reference = Path(__file__).parent / f"reference_files/{filename_element}.png" + + # 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 new file mode 100644 index 0000000..8dfc623 --- /dev/null +++ b/tests/e2e_tests/utils.py @@ -0,0 +1,111 @@ +import os, subprocess +from pathlib import Path +from shlex import split + +import numpy as np + +from PIL import Image, ImageChops + + +def compare_images(path_gen, path_ref): + """Compare a generated image against a reference image. + + This is a simple pixel-by-pixel comparison, with a threshold for + an allowable difference. + + Parameters: file path to generated and reference image files + Returns: True/ False + """ + # 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) + + img_diff = ImageChops.difference(img_gen, img_ref) + + # We're only concerned with pixels that differ by a total of 20 or more + # over all RGB values. + # Convert the image data to a NumPy array for processing. + data_diff = np.array(img_diff) + + # Calculate the sum along the color axis (axis 2) and then check + # if the sum is greater than or equal to 20. This will return a 2D + # boolean array where True represents pixels that differ significantly. + pixels_diff = np.sum(data_diff, axis=2) >= 20 + + # Calculate the ratio of pixels that differ significantly. + ratio_diff = np.mean(pixels_diff) + + # 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. + msg = f"bad pixel ratio ({path_ref.stem[8:]}): {ratio_diff}" + assert ratio_diff < 0.015, msg + + +def get_cmd_parts(raw_command): + """ + Convert a raw git-sim command to the full version we need to use + when testing, then split the full command into parts for use in + subprocess.run(). This allows test functions to explicitly state + the actual command that users would run. + + For example, the command: + `git-sim log` + becomes: + ` -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. + + 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", + 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. + # 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 ", f"{git_sim_path.as_posix()} ") + + # Show full test command when run in diagnostic mode. + print(f" Test command: {cmd}") + + return split(cmd) + + +def run_git_reset(tmp_repo): + """Run `git reset`, in order to induce a failure. + + This is particularly useful when testing the image comparison algorithm. + - Running `git reset` makes many of the generated images different. + - For example, `git-sim log` then generates a valid image, but it doesn't + match the reference image. + + Note: tmp_repo is a required argument, to make sure this command is not + accidentally called in a different directory. + """ + cmd = "git reset --hard 60bce95465a890960adcacdcd7fa726d6fad4cf3" + cmd_parts = split(cmd) + + os.chdir(tmp_repo) + subprocess.run(cmd_parts) + + +def get_venv_path(): + """Get the path to the active virtual environment. + + We actually need the bin/ or Scripts/ dir, not just the path to venv/. + """ + if os.name == "nt": + return Path(os.environ.get("VIRTUAL_ENV")) / "Scripts" + else: + return Path(os.environ.get("VIRTUAL_ENV")) / "bin" 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