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 00aa4f0..e808655 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,19 +40,30 @@ steps: 2) [Fork the Git-Sim codebase](https://github.com/initialcommit-com/git-sim/fork) so that you have a copy on GitHub that you can clone and work with 3) Clone the codebase down to your local machine -4) If you previously installed Git-Sim normally using pip, uninstall it first using: +4) Checkout and commit new work to the `dev` branch +5) If you previously installed Git-Sim normally using pip, uninstall it first using: ```console $ pip uninstall git-sim ``` -5) To run the code locally from source, install the development package by running: +6) To run the code locally from source, install the development package by running: ```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: @@ -61,7 +72,7 @@ If you already have the dependencies, you can ignore those using the `--no-deps` $ python -m pip install --no-deps -e . ``` -6) You can run your local Git-Sim commands from within other local repos like this: +7) You can run your local Git-Sim commands from within other local repos like this: ```console $ git-sim [global options] [subcommand options] @@ -74,8 +85,8 @@ $ cd path/to/any/local/git/repo $ git-sim --animate add newfile.txt ``` -6) After pushing your code changes up to your fork, [submit a pull request](https://github.com/initialcommit-com/git-sim/compare) for me -to review your code, provide feedback, and integrate it into the codebase! +8) After pushing your code changes up to your fork, [submit a pull request to the `dev` branch](https://github.com/initialcommit-com/git-sim/compare) for me +to review your code, provide feedback, and merge it into the codebase! ## Code style guide 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 a4d71a0..37d8ee1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ # git-sim +![git-sim-logo-with-tagline-1440x376p45](https://user-images.githubusercontent.com/49353917/232990611-58d0693f-69c0-45c8-b51d-cd540793d18c.gif) + [![GitHub license](https://img.shields.io/github/license/initialcommit-com/git-sim)](https://github.com/initialcommit-com/git-sim/blob/main/LICENSE) [![GitHub tag](https://img.shields.io/github/v/release/initialcommit-com/git-sim)](https://img.shields.io/github/v/release/initialcommit-com/git-sim) [![Downloads](https://static.pepy.tech/badge/git-sim)](https://pepy.tech/project/git-sim) [![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. @@ -12,22 +21,30 @@ 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 - Prevent unexpected working directory and repository states by simulating before running -- Share visualizations (jpg image or mp4 video) of your Git commands with your team, or the world +- Share visualizations (jpg/png image or mp4/webm video) of your Git commands with your team, or the world - Save visualizations as a part of your team documentation to document workflow and prevent recurring issues -- Create static Git diagrams (jpg) or dynamic animated videos (mp4) to speed up content creation +- Create static Git diagrams (jpg/png) or dynamic animated videos (mp4/webm) to speed up content creation - Help visual learners understand how Git commands work - 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 ## 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` +- Supported commands: `add`, `branch`, `checkout`, `cherry-pick`, `clean`, `clone`, `commit`, `config`, `fetch`, `init`, `log`, `merge`, `mv`, `pull`, `push`, `rebase`, `remote`, `reset`, `restore`, `revert`, `rm`, `stash`, `status`, `switch`, `tag` - Generate an animated video (.mp4) instead of a static image using the `--animate` flag (note: significant performance slowdown, it is recommended to use `--low-quality` to speed up testing and remove when ready to generate presentation-quality video) +- Color commits by parameter, such as author with the `--color-by=author` option - Choose between dark mode (default) and light mode - 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 @@ -125,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", 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] @@ -136,6 +154,7 @@ The `[global options]` apply to the overarching `git-sim` simulation itself, inc `-n `: Number of commits to display from each branch head. `--all`: Display all local branches in the log output. `--animate`: Instead of outputting a static image, animate the Git command behavior in a .mp4 video. +`--color-by author`: Color commits by parameter, such as author. `--invert-branches`: Invert positioning of branches by reversing order of multiple parents where applicable. `--hide-merged-branches`: Hide commits from merged branches, i.e. only display mainline commits. `--media-dir`: The path at which to store the simulated output media files. @@ -145,7 +164,9 @@ The `[global options]` apply to the overarching `git-sim` simulation itself, inc `--img-format`: Output format for the image file, i.e. `jpg` or `png`. Default output format is `jpg`. `--stdout`: Write raw image data to stdout while suppressing all other program output. `--output-only-path`: Only output the path to the generated media file to stdout. Useful for other programs to ingest. -`--quiet, -q`: Suppress all output except errors. +`--quiet, -q`: Suppress all output except errors. +`--highlight-commit-messages`: Make commit message text bigger and bold, and hide commit ids. +`--style`: Graphical style of the output image or animated video, i.e. `clean` (default) or `thick`. Animation-only global options (to be used in conjunction with `--animate`): @@ -157,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 ... ` @@ -189,51 +194,143 @@ 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"` - Simulated output will show the new commit added to the tip of the active branch -- Specify your commit message after the -m option +- Specify a commit message with the `-m` option - HEAD and the active branch will be moved to the new commit - Simulated output will show files in the staging area being included in the new commit - Supports amending the last commit with: `$ git-sim commit --amend -m "Amended 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] ` +### git config +Usage: `git-sim config [--list] ` -- 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 +- Simulated output describes the specified configuration change +- Use `--list` or `-l` to display all configuration + +![git-sim-config_04-16-24_08-34-34](https://github.com/initialcommit-com/git-sim/assets/49353917/c123e7a7-1fff-4f5c-b4a2-1e34ea2a4d80) + +### git fetch +Usage: `git-sim fetch ` + +- Fetches the specified `` from the specified `` to the local repo + +![git-sim-fetch_04-09-23_21-47-59](https://user-images.githubusercontent.com/49353917/230828090-acae8979-4097-43a8-96ea-525890e0e0a8.jpg) + +### git init +Usage: `git-sim init` + +- Simulated output describes the initialized `.git/` directory and it's contents + +![git-sim-init_04-16-24_08-34-47](https://github.com/initialcommit-com/git-sim/assets/49353917/2abb1a4a-3022-4353-a828-2d337baa8383) + +### 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 merge +Usage: `git-sim merge [-m "Commit message"] [--no-ff]` + +- Specify `` as the branch name to merge into the active branch +- If desired, specify a commit message with the `-m` option +- Simulated output will depict a fast-forward merge if possible +- Otherwise, a three-way merge will be depicted +- To force a merge commit when a fast-forward is possible, use `--no-ff` +- If merge fails due to merge conflicts, the conflicting files are displayed + +![git-sim-merge_01-05-23_09-44-46](https://user-images.githubusercontent.com/49353917/210942030-c7229488-571a-4943-a1f4-c6e4a0c8ccf3.jpg) + +### git mv +Usage: `git-sim mv ` + +- 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-stash_01-05-23_22-11-18](https://user-images.githubusercontent.com/49353917/210941254-69c80b63-5c06-411a-a36a-1454b2906ee8.jpg) +![git-sim-mv_04-09-23_22-05-13](https://user-images.githubusercontent.com/49353917/230829978-0a64dbe2-d974-4cef-9c6e-ed26e987342f.jpg) -### git branch -Usage: `git-sim branch ` +### git pull +Usage: `git-sim pull [ ]` -- 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 +- Pulls the specified `` from the specified `` to the local repo +- If `` and `` are not specified, the active branch is pulled from the default remote +- If merge conflicts occur, they are displayed in a table -![git-sim-branch_01-05-23_22-13-17](https://user-images.githubusercontent.com/49353917/210941509-2a42a7a4-2168-4f62-913f-3f6fe74a0684.jpg) +![git-sim-pull_04-09-23_21-50-15](https://user-images.githubusercontent.com/49353917/230828298-455c0a9d-cf94-499e-9e35-623e7b218772.jpg) -### git tag -Usage: `git-sim tag ` +### git push +Usage: `git-sim push [ ]` -- 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 +- Pushes the specified `` to the specified `` and displays the local result +- If `` and `` are not specified, the active branch is pushed to the default remote +- If the push fails due to remote changes that don't exist in the local repo, a message is included telling the user to pull first, along with color coding which commits need to be pulled -![git-sim-tag_01-05-23_22-14-18](https://user-images.githubusercontent.com/49353917/210941647-79376ff7-2941-42b3-964a-b1d3a404a4fe.jpg) +![git-sim-push_04-21-23_13-41-57](https://user-images.githubusercontent.com/49353917/233731005-51fd7887-ae14-4ceb-a5d5-e5aed79e9fd8.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 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]` @@ -244,6 +341,15 @@ Usage: `git-sim reset [--mixed|--soft|--hard]` ![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 ` @@ -253,30 +359,48 @@ Usage: `git-sim revert ` ![git-sim-revert_01-05-23_22-16-59](https://user-images.githubusercontent.com/49353917/210941979-6db8b55c-2881-41d8-9e2e-6263b1dece13.jpg) -### git merge -Usage: `git-sim merge ` +### git rm +Usage: `git-sim rm ... ` -- Specify `` as the branch name to merge into the active branch -- Simulated output will depict a fast-forward merge if possible -- Otherwise, a three-way merge will be depicted -- To force a merge commit when a fast-forward is possible, use `--no-ff` +- Specify one or more `` as a *tracked* file +- Simulated output will show files being removed from Git tracking +- Note that simulated output will also show the most recent 5 commits on the active branch -![git-sim-merge_01-05-23_09-44-46](https://user-images.githubusercontent.com/49353917/210942030-c7229488-571a-4943-a1f4-c6e4a0c8ccf3.jpg) +![git-sim-rm_04-09-23_22-01-29](https://user-images.githubusercontent.com/49353917/230829899-f5d688ea-bc8e-46f9-a54a-55d251c8915d.jpg) -### git rebase -Usage: `git-sim rebase ` +### git stash +Usage: `git-sim stash [push|pop|apply] ` -- Specify `` as the branch name to rebase the active branch onto +- 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-rebase_01-05-23_09-53-34](https://user-images.githubusercontent.com/49353917/210942598-4ff8d1e6-464d-48f3-afb9-f46f7ec4828c.jpg) +![git-sim-stash_01-05-23_22-11-18](https://user-images.githubusercontent.com/49353917/210941254-69c80b63-5c06-411a-a36a-1454b2906ee8.jpg) -### git cherry-pick -Usage: `git-sim cherry-pick ` +### git status +Usage: `git-sim status` -- 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"` +- 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-cherry-pick_01-05-23_22-23-08](https://user-images.githubusercontent.com/49353917/210942811-fa5155b1-4c6f-4afc-bea2-d39b4cd594aa.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 @@ -453,7 +577,7 @@ $ docker build -t git-sim . Optional: On MacOS / Linux / or GitBash in Windows, create an alias for the long docker command so your can run it as a normal `git-sim` command. To do so add the following line to your `.bashrc` or equivalent, then restart your terminal: ```bash -git-sim() { docker run --rm -v $(pwd):/usr/src/git-sim git-sim "$@" } +git-sim() { docker run --rm -v $(pwd):/usr/src/git-sim git-sim "$@"; } ``` This will enable you to run git-sim subcommands as [described above](#commands). diff --git a/git_sim/__init__.py b/git_sim/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/git_sim/merge.py b/git_sim/merge.py deleted file mode 100644 index ae73f42..0000000 --- a/git_sim/merge.py +++ /dev/null @@ -1,131 +0,0 @@ -import sys -from argparse import Namespace - -import git -import manim as m -import numpy -import typer - -from git_sim.animations import handle_animations -from git_sim.git_sim_base_command import GitSimBaseCommand -from git_sim.settings import settings - - -class Merge(GitSimBaseCommand): - def __init__(self, branch: str, no_ff: bool): - super().__init__() - self.branch = branch - self.no_ff = no_ff - - try: - git.repo.fun.rev_parse(self.repo, self.branch) - except git.exc.BadName: - print( - "git-sim error: '" - + self.branch - + "' is not a valid Git ref or identifier." - ) - sys.exit(1) - - self.ff = False - if self.branch in [branch.name for branch in self.repo.heads]: - self.selected_branches.append(self.branch) - - try: - self.selected_branches.append(self.repo.active_branch.name) - except TypeError: - pass - - 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 ''}" - ) - - if self.repo.active_branch.name in self.repo.git.branch( - "--contains", self.branch - ): - print( - "git-sim error: Branch '" - + self.branch - + "' is already included in the history of active branch '" - + self.repo.active_branch.name - + "'." - ) - sys.exit(1) - - self.show_intro() - head_commit = self.get_commit() - branch_commit = self.get_commit(self.branch) - - if not self.is_remote_tracking_branch(self.branch): - if self.branch in self.repo.git.branch("--contains", head_commit.hexsha): - self.ff = True - else: - if self.branch in self.repo.git.branch( - "-r", "--contains", head_commit.hexsha - ): - self.ff = True - - if self.ff: - self.parse_commits(branch_commit) - self.parse_all() - reset_head_to = branch_commit.hexsha - shift = numpy.array([0.0, 0.6, 0.0]) - - if self.no_ff: - self.center_frame_on_commit(branch_commit) - commitId = self.setup_and_draw_parent(branch_commit, "Merge commit") - reset_head_to = "abcdef" - shift = numpy.array([0.0, 0.0, 0.0]) - - self.recenter_frame() - self.scale_frame() - if "HEAD" in self.drawnRefs and self.no_ff: - self.reset_head_branch(reset_head_to, shift=shift) - elif "HEAD" in self.drawnRefs: - self.reset_head_branch_to_ref(self.topref, shift=shift) - else: - self.draw_ref(branch_commit, commitId if self.no_ff else self.topref) - self.draw_ref( - branch_commit, - self.drawnRefs["HEAD"], - text=self.repo.active_branch.name, - color=m.GREEN, - ) - - else: - self.parse_commits(head_commit) - self.parse_commits(branch_commit, shift=4 * m.DOWN) - self.parse_all() - self.center_frame_on_commit(head_commit) - self.setup_and_draw_parent( - head_commit, - "Merge commit", - shift=2 * m.DOWN, - draw_arrow=False, - color=m.GRAY, - ) - self.draw_arrow_between_commits("abcdef", branch_commit.hexsha) - self.draw_arrow_between_commits("abcdef", head_commit.hexsha) - self.recenter_frame() - self.scale_frame() - self.reset_head_branch("abcdef") - - self.fadeout() - self.show_outro() - - -def merge( - branch: str = typer.Argument( - ..., - help="The name of the branch to merge into the active checked-out branch", - ), - no_ff: bool = typer.Option( - False, - "--no-ff", - help="Simulate creation of a merge commit in all cases, even when the merge could instead be resolved as a fast-forward", - ), -): - scene = Merge(branch=branch, no_ff=no_ff) - handle_animations(scene=scene) diff --git a/git_sim/settings.py b/git_sim/settings.py deleted file mode 100644 index 0b571ec..0000000 --- a/git_sim/settings.py +++ /dev/null @@ -1,53 +0,0 @@ -import pathlib - -from enum import Enum -from typing import List, Union -from pydantic import BaseSettings - - -class VideoFormat(str, Enum): - mp4 = "mp4" - webm = "webm" - - -class ImgFormat(str, Enum): - jpg = "jpg" - png = "png" - - -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 - 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 - - 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 4eb1135..0000000 --- a/git_sim/tag.py +++ /dev/null @@ -1,63 +0,0 @@ -import manim as m -import typer - -from git_sim.animations import handle_animations -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.fadeout() - self.show_outro() - - -def tag( - name: str = typer.Argument( - ..., - help="The name of the new tag", - ) -): - scene = Tag(name=name) - handle_animations(scene=scene) 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 f9c5df1..0000000 --- a/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -import setuptools - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - name="git-sim", - version="0.2.6", - 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 59% rename from git_sim/__main__.py rename to src/git_sim/__main__.py index 570d17a..5def5ea 100644 --- a/git_sim/__main__.py +++ b/src/git_sim/__main__.py @@ -1,30 +1,40 @@ -import pathlib -import typer +import contextlib +import datetime import os +import pathlib import sys -import datetime import time -import git - -import git_sim.add -import git_sim.branch -import git_sim.cherrypick -import git_sim.commit -import git_sim.log -import git_sim.merge -import git_sim.rebase -import git_sim.reset -import git_sim.restore -import git_sim.revert -import git_sim.stash -import git_sim.status -import git_sim.tag -from git_sim.settings import ImgFormat, VideoFormat, settings -from manim import config, WHITE +from pathlib import Path + +import typer +import manim as m + +from fontTools.ttLib import TTFont + +import git_sim.commands +from git_sim.settings import ( + ColorByOptions, + StyleOptions, + ImgFormat, + VideoFormat, + settings, +) 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__}") + raise typer.Exit() + + @app.callback(no_args_is_help=True) def main( ctx: typer.Context, @@ -49,9 +59,13 @@ def main( ), light_mode: bool = typer.Option( settings.light_mode, - "--light-mode", help="Enable light-mode with white background", ), + transparent_bg: bool = typer.Option( + settings.transparent_bg, + "--transparent-bg", + help="Make background transparent", + ), logo: pathlib.Path = typer.Option( settings.logo, help="The path to a custom logo to use in the animation intro/outro", @@ -134,12 +148,43 @@ def main( settings.all, help="Display all local branches in the log output", ), + color_by: ColorByOptions = typer.Option( + settings.color_by, + help="Color commits by parameter", + ), + highlight_commit_messages: bool = typer.Option( + settings.highlight_commit_messages, + help="Make the displayed commit messages more prominent", + ), + version: bool = typer.Option( + False, + "--version", + "-v", + help="Show the version of git-sim and exit", + callback=version_callback, + ), + style: StyleOptions = typer.Option( + 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 + settings.animate = animate settings.n = n settings.auto_open = auto_open settings.img_format = img_format settings.light_mode = light_mode + settings.transparent_bg = transparent_bg settings.logo = logo settings.low_quality = low_quality settings.max_branches_per_commit = max_branches_per_commit @@ -159,15 +204,31 @@ def main( settings.invert_branches = invert_branches settings.hide_merged_branches = hide_merged_branches settings.all = all + 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 sys.platform == "linux" or sys.platform == "darwin": - repo_name = git.repo.Repo( - search_parent_directories=True - ).working_tree_dir.split("/")[-1] - elif sys.platform == "win32": - repo_name = git.repo.Repo( - search_parent_directories=True - ).working_tree_dir.split("\\")[-1] + # 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": + repo_name = git.repo.Repo( + search_parent_directories=True + ).working_tree_dir.split("/")[-1] + elif sys.platform == "win32": + repo_name = git.repo.Repo( + search_parent_directories=True + ).working_tree_dir.split("\\")[-1] + except git.InvalidGitRepositoryError as e: + repo_name = "" settings.media_dir = os.path.join(settings.media_dir, repo_name) @@ -180,23 +241,38 @@ def main( if settings.light_mode: config.background_color = WHITE + if settings.transparent_bg: + settings.img_format = ImgFormat.PNG + t = datetime.datetime.fromtimestamp(time.time()).strftime("%m-%d-%y_%H-%M-%S") config.output_file = "git-sim-" + ctx.invoked_subcommand + "_" + t + ".mp4" -app.command()(git_sim.add.add) -app.command()(git_sim.branch.branch) -app.command()(git_sim.cherrypick.cherry_pick) -app.command()(git_sim.commit.commit) -app.command()(git_sim.log.log) -app.command()(git_sim.merge.merge) -app.command()(git_sim.rebase.rebase) -app.command()(git_sim.reset.reset) -app.command()(git_sim.restore.restore) -app.command()(git_sim.revert.revert) -app.command()(git_sim.stash.stash) -app.command()(git_sim.status.status) -app.command()(git_sim.tag.tag) +app.command()(git_sim.commands.add) +app.command()(git_sim.commands.branch) +app.command()(git_sim.commands.checkout) +app.command()(git_sim.commands.cherry_pick) +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) +app.command()(git_sim.commands.rm) +app.command()(git_sim.commands.stash) +app.command()(git_sim.commands.status) +app.command()(git_sim.commands.switch) +app.command()(git_sim.commands.tag) if __name__ == "__main__": diff --git a/git_sim/add.py b/src/git_sim/add.py similarity index 85% rename from git_sim/add.py rename to src/git_sim/add.py index cd436e4..cdcb382 100644 --- a/git_sim/add.py +++ b/src/git_sim/add.py @@ -1,11 +1,9 @@ import sys import git import manim as m -import typer from typing import List -from git_sim.animations import handle_animations from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings @@ -31,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() @@ -43,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() @@ -82,14 +81,3 @@ def populate_zones( firstColumnArrowMap[z] = m.Arrow( stroke_width=3, color=self.fontColor ) - - -def add( - files: List[str] = typer.Argument( - default=None, - help="The names of one or more files to add to Git's staging area", - ) -): - settings.hide_first_tag = True - scene = Add(files=files) - handle_animations(scene=scene) diff --git a/git_sim/animations.py b/src/git_sim/animations.py similarity index 79% rename from git_sim/animations.py rename to src/git_sim/animations.py index 141ed8d..027fa6a 100644 --- a/git_sim/animations.py +++ b/src/git_sim/animations.py @@ -11,12 +11,13 @@ from manim.utils.file_ops import open_file from git_sim.settings import settings +from git_sim.enums import VideoFormat def handle_animations(scene: Scene) -> None: scene.render() - if settings.video_format == "webm": + if settings.video_format == VideoFormat.WEBM: webm_file_path = str(scene.renderer.file_writer.movie_file_path)[:-3] + "webm" cmd = f"ffmpeg -y -i {scene.renderer.file_writer.movie_file_path} -hide_banner -loglevel error -c:v libvpx-vp9 -crf 50 -b:v 0 -b:a 128k -c:a libopus {webm_file_path}" print("Converting video output to .webm format...") @@ -37,7 +38,7 @@ def handle_animations(scene: Scene) -> None: ) image_file_name = ( "git-sim-" - + inspect.stack()[1].function + + inspect.stack()[2].function + "_" + t + "." @@ -46,6 +47,18 @@ def handle_animations(scene: Scene) -> None: image_file_path = os.path.join( os.path.join(settings.media_dir, "images"), image_file_name ) + if settings.transparent_bg: + unsharp_image = cv2.GaussianBlur(image, (0, 0), 3) + image = cv2.addWeighted(image, 1.5, unsharp_image, -0.5, 0) + + tmp = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + if settings.light_mode: + _, alpha = cv2.threshold(tmp, 225, 255, cv2.THRESH_BINARY_INV) + else: + _, alpha = cv2.threshold(tmp, 25, 255, cv2.THRESH_BINARY) + b, g, r = cv2.split(image) + rgba = [b, g, r, alpha] + image = cv2.merge(rgba, 4) cv2.imwrite(image_file_path, image) if ( not settings.stdout diff --git a/git_sim/branch.py b/src/git_sim/branch.py similarity index 78% rename from git_sim/branch.py rename to src/git_sim/branch.py index e75fae1..9a714d3 100644 --- a/git_sim/branch.py +++ b/src/git_sim/branch.py @@ -1,7 +1,5 @@ import manim as m -import typer -from git_sim.animations import handle_animations from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings @@ -10,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() @@ -22,7 +21,7 @@ def construct(self): branchText = m.Text( self.name, - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, ) @@ -49,15 +48,7 @@ def construct(self): self.recenter_frame() self.scale_frame() + self.color_by() + self.show_command_as_title() self.fadeout() self.show_outro() - - -def branch( - name: str = typer.Argument( - ..., - help="The name of the new branch", - ) -): - scene = Branch(name=name) - handle_animations(scene=scene) diff --git a/src/git_sim/checkout.py b/src/git_sim/checkout.py new file mode 100644 index 0000000..a11a0bb --- /dev/null +++ b/src/git_sim/checkout.py @@ -0,0 +1,124 @@ +import sys +from argparse import Namespace + +import git +import manim as m +import numpy + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Checkout(GitSimBaseCommand): + def __init__(self, branch: str, b: bool): + super().__init__() + self.branch = branch + self.b = b + + if self.b: + if self.branch in self.repo.heads: + print( + "git-sim error: can't create new branch '" + + self.branch + + "', it already exists" + ) + sys.exit(1) + else: + try: + git.repo.fun.rev_parse(self.repo, self.branch) + except git.exc.BadName: + print( + "git-sim error: '" + + self.branch + + "' is not a valid Git ref or identifier." + ) + sys.exit(1) + + if self.branch == self.repo.active_branch.name: + print("git-sim error: already on branch '" + self.branch + "'") + sys.exit(1) + + self.is_ancestor = False + self.is_descendant = False + + # branch being checked out is behind HEAD + if self.repo.active_branch.name in self.repo.git.branch( + "--contains", self.branch + ): + self.is_ancestor = True + # HEAD is behind branch being checked out + elif self.branch in self.repo.git.branch( + "--contains", self.repo.active_branch.name + ): + self.is_descendant = True + + if self.branch in [branch.name for branch in self.repo.heads]: + self.selected_branches.append(self.branch) + + try: + self.selected_branches.append(self.repo.active_branch.name) + 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} {self.cmd}") + + self.show_intro() + head_commit = self.get_commit() + + # using -b flag, create new branch label and exit + if self.b: + self.parse_commits(head_commit) + self.recenter_frame() + self.scale_frame() + self.draw_ref(head_commit, self.topref, text=self.branch, color=m.GREEN) + else: + branch_commit = self.get_commit(self.branch) + + if self.is_ancestor: + commits_in_range = list(self.repo.iter_commits(self.branch + "..HEAD")) + + # branch is reached from HEAD, so draw everything + if len(commits_in_range) <= self.n: + self.parse_commits(head_commit) + reset_head_to = branch_commit.hexsha + self.recenter_frame() + self.scale_frame() + self.reset_head(reset_head_to) + self.reset_branch(head_commit.hexsha) + + # branch is not reached, so start from branch + else: + self.parse_commits(branch_commit) + self.draw_ref(branch_commit, self.topref) + self.recenter_frame() + self.scale_frame() + + elif self.is_descendant: + self.parse_commits(branch_commit) + reset_head_to = branch_commit.hexsha + self.recenter_frame() + self.scale_frame() + if "HEAD" in self.drawnRefs: + self.reset_head(reset_head_to) + self.reset_branch(head_commit.hexsha) + else: + self.draw_ref(branch_commit, self.topref) + else: + self.parse_commits(head_commit) + self.parse_commits(branch_commit, shift=4 * m.DOWN) + self.center_frame_on_commit(branch_commit) + self.recenter_frame() + self.scale_frame() + self.reset_head(branch_commit.hexsha) + self.reset_branch(head_commit.hexsha) + + 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 75% rename from git_sim/cherrypick.py rename to src/git_sim/cherrypick.py index 822f75f..5f2d58b 100644 --- a/git_sim/cherrypick.py +++ b/src/git_sim/cherrypick.py @@ -2,9 +2,7 @@ import git import manim as m -import typer -from git_sim.animations import handle_animations from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings @@ -33,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 @@ -67,21 +66,7 @@ def construct(self): self.recenter_frame() self.scale_frame() self.reset_head_branch("abcdef") + self.color_by(offset=2) + self.show_command_as_title() self.fadeout() self.show_outro() - - -def cherry_pick( - commit: str = typer.Argument( - ..., - help="The ref (branch/tag), or commit ID to simulate cherry-pick onto active branch", - ), - edit: str = typer.Option( - None, - "--edit", - "-e", - help="Specify a new commit message for the cherry-picked commit", - ), -): - scene = CherryPick(commit=commit, edit=edit) - handle_animations(scene=scene) diff --git a/src/git_sim/clean.py b/src/git_sim/clean.py new file mode 100644 index 0000000..af0e110 --- /dev/null +++ b/src/git_sim/clean.py @@ -0,0 +1,125 @@ +import sys +import git +import manim as m + +from typing import List + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Clean(GitSimBaseCommand): + def __init__(self): + super().__init__() + self.hide_first_tag = True + self.allow_no_commits = True + settings.hide_merged_branches = True + self.n = self.n_default + + try: + self.selected_branches.append(self.repo.active_branch.name) + 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} {self.cmd}") + + self.show_intro() + self.parse_commits() + self.recenter_frame() + self.scale_frame() + self.vsplit_frame() + self.setup_and_draw_zones( + first_column_name="Untracked files", + second_column_name="----", + third_column_name="Deleted files", + ) + self.show_command_as_title() + self.fadeout() + self.show_outro() + + def create_zone_text( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnFiles, + secondColumnFiles, + thirdColumnFiles, + firstColumnFilesDict, + secondColumnFilesDict, + thirdColumnFilesDict, + firstColumnTitle, + secondColumnTitle, + thirdColumnTitle, + horizontal2, + ): + for i, f in enumerate(firstColumnFileNames): + text = ( + m.Text( + self.trim_path(f), + font=self.font, + font_size=24, + color=self.fontColor, + ) + .move_to( + (firstColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) + ) + .shift(m.DOWN * 0.5 * (i + 1)) + ) + firstColumnFiles.add(text) + firstColumnFilesDict[f] = text + + for j, f in enumerate(secondColumnFileNames): + text = ( + m.Text( + self.trim_path(f), + font=self.font, + font_size=24, + color=self.fontColor, + ) + .move_to( + (secondColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) + ) + .shift(m.DOWN * 0.5 * (j + 1)) + ) + secondColumnFiles.add(text) + secondColumnFilesDict[f] = text + + for h, f in enumerate(thirdColumnFileNames): + text = ( + m.MarkupText( + "" + + self.trim_path(f) + + "", + font=self.font, + font_size=24, + color=self.fontColor, + ) + .move_to( + (thirdColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) + ) + .shift(m.DOWN * 0.5 * (h + 1)) + ) + thirdColumnFiles.add(text) + thirdColumnFilesDict[f] = text + + def populate_zones( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnArrowMap={}, + secondColumnArrowMap={}, + thirdColumnArrowMap={}, + ): + for z in self.repo.untracked_files: + if "git-sim_media" not in z: + firstColumnFileNames.add(z) + thirdColumnFileNames.add(z) + firstColumnArrowMap[z] = m.Arrow(stroke_width=3, color=self.fontColor) diff --git a/src/git_sim/clone.py b/src/git_sim/clone.py new file mode 100644 index 0000000..bce5e19 --- /dev/null +++ b/src/git_sim/clone.py @@ -0,0 +1,106 @@ +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_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Clone(GitSimBaseCommand): + # Override since 'clone' subcommand shouldn't require repo to exist + def init_repo(self): + pass + + 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} {self.cmd}") + + self.show_intro() + + # Configure paths to make local clone to run networked commands in + repo_name = re.search(r"/([^/]+)/?$", self.url) + if repo_name: + 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 + try: + self.repo = git.Repo.clone_from(self.url, new_dir, no_hardlinks=True) + except git.GitCommandError as e: + print( + f"git-sim error: Invalid repo URL, please confirm repo URL and try again" + ) + sys.exit(1) + + head_commit = self.get_commit() + self.parse_commits(head_commit) + self.recenter_frame() + self.scale_frame() + self.add_details(repo_name) + self.color_by() + self.show_command_as_title() + self.fadeout() + self.show_outro() + + # Unlink the program from the filesystem + self.repo.git.clear_cache() + + # Delete the local clones + shutil.rmtree(new_dir, onerror=self.del_rw) + + def add_details(self, repo_name): + text1 = m.Text( + 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, + ) + text1.move_to([self.camera.frame.get_center()[0], 4, 0]) + + text2 = m.Text( + f"Cloned repo log:", + font=self.font, + font_size=20, + color=self.fontColor, + weight=m.BOLD, + ) + text2.move_to(text1.get_center()).shift(m.DOWN / 2) + + self.toFadeOut.add(text1) + self.toFadeOut.add(text2) + self.recenter_frame() + self.scale_frame() + + if settings.animate: + self.play(m.AddTextLetterByLetter(text1), m.AddTextLetterByLetter(text2)) + else: + self.add(text1, text2) diff --git a/src/git_sim/commands.py b/src/git_sim/commands.py new file mode 100644 index 0000000..61dc588 --- /dev/null +++ b/src/git_sim/commands.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import typer + +from typing import List, TYPE_CHECKING + +from git_sim.settings import settings +from git_sim.enums import ResetMode, StashSubCommand, RemoteSubCommand + +if TYPE_CHECKING: + from manim import Scene + + +def handle_animations(scene: Scene) -> None: + from git_sim.animations import handle_animations as _handle_animations + + with settings.font_context: + return _handle_animations(scene) + + +def add( + files: List[str] = typer.Argument( + default=None, + help="The names of one or more files to add to Git's staging area", + ) +): + from git_sim.add import Add + + settings.hide_first_tag = True + scene = Add(files=files) + handle_animations(scene=scene) + + +def branch( + name: str = typer.Argument( + ..., + help="The name of the new branch", + ) +): + from git_sim.branch import Branch + + scene = Branch(name=name) + handle_animations(scene=scene) + + +def checkout( + branch: str = typer.Argument( + ..., + help="The name of the branch to checkout", + ), + b: bool = typer.Option( + False, + "-b", + help="Create the specified branch if it doesn't already exist", + ), +): + from git_sim.checkout import Checkout + + scene = Checkout(branch=branch, b=b) + handle_animations(scene=scene) + + +def cherry_pick( + commit: str = typer.Argument( + ..., + help="The ref (branch/tag), or commit ID to simulate cherry-pick onto active branch", + ), + edit: str = typer.Option( + None, + "--edit", + "-e", + help="Specify a new commit message for the cherry-picked commit", + ), +): + from git_sim.cherrypick import CherryPick + + scene = CherryPick(commit=commit, edit=edit) + handle_animations(scene=scene) + + +def clean(): + from git_sim.clean import Clean + + settings.hide_first_tag = True + scene = Clean() + handle_animations(scene=scene) + + +def clone( + url: str = typer.Argument( + ..., + 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, path=path) + handle_animations(scene=scene) + + +def commit( + message: str = typer.Option( + "New commit", + "--message", + "-m", + help="The commit message of the new commit", + ), + amend: bool = typer.Option( + default=False, + help="Amend the last commit message, must be used with the --message flag", + ), +): + from git_sim.commit import Commit + + settings.hide_first_tag = True + scene = Commit(message=message, amend=amend) + 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, + help="The name of the remote to fetch from", + ), + branch: str = typer.Argument( + default=None, + help="The name of the branch to fetch", + ), +): + from git_sim.fetch import Fetch + + scene = Fetch(remote=remote, branch=branch) + 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( + None, + "-n", + help="Number of commits to display from branch heads", + ), + all: bool = typer.Option( + False, + "--all", + help="Display all local branches in the log output", + ), +): + from git_sim.log import Log + + scene = Log(ctx=ctx, n=n, all=all) + handle_animations(scene=scene) + + +def merge( + branch: str = typer.Argument( + ..., + help="The name of the branch to merge into the active checked-out branch", + ), + no_ff: bool = typer.Option( + False, + "--no-ff", + help="Simulate creation of a merge commit in all cases, even when the merge could instead be resolved as a fast-forward", + ), + message: str = typer.Option( + "Merge commit", + "--message", + "-m", + help="The commit message of the new merge commit", + ), +): + from git_sim.merge import Merge + + scene = Merge(branch=branch, no_ff=no_ff, message=message) + handle_animations(scene=scene) + + +def mv( + file: str = typer.Argument( + default=None, + help="The name of the file to change the name/path of", + ), + new_file: str = typer.Argument( + default=None, + help="The new name/path of the file", + ), +): + from git_sim.mv import Mv + + settings.hide_first_tag = True + scene = Mv(file=file, new_file=new_file) + handle_animations(scene=scene) + + +def pull( + remote: str = typer.Argument( + default=None, + help="The name of the remote to pull from", + ), + branch: str = typer.Argument( + default=None, + help="The name of the branch to pull", + ), +): + from git_sim.pull import Pull + + scene = Pull(remote=remote, branch=branch) + handle_animations(scene=scene) + + +def push( + remote: str = typer.Argument( + default=None, + help="The name of the remote to push to", + ), + branch: str = typer.Argument( + 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, set_upstream=set_upstream) + handle_animations(scene=scene) + + +def rebase( + branch: str = typer.Argument( + ..., + help="The branch to simulate rebasing the checked-out commit onto", + ) +): + from git_sim.rebase import Rebase + + scene = Rebase(branch=branch) + 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", + help="The ref (branch/tag), or commit ID to simulate reset to", + ), + mode: ResetMode = typer.Option( + default="mixed", + help="Either mixed, soft, or hard", + ), + soft: bool = typer.Option( + default=False, + help="Simulate a soft reset, shortcut for --mode=soft", + ), + mixed: bool = typer.Option( + default=False, + help="Simulate a mixed reset, shortcut for --mode=mixed", + ), + hard: bool = typer.Option( + default=False, + help="Simulate a soft reset, shortcut for --mode=hard", + ), +): + from git_sim.reset import Reset + + settings.hide_first_tag = True + scene = Reset(commit=commit, mode=mode, soft=soft, mixed=mixed, hard=hard) + handle_animations(scene=scene) + + +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, staged=staged) + handle_animations(scene=scene) + + +def revert( + commit: str = typer.Argument( + default="HEAD", + help="The ref (branch/tag), or commit ID to simulate revert", + ) +): + from git_sim.revert import Revert + + settings.hide_first_tag = True + scene = Revert(commit=commit) + handle_animations(scene=scene) + + +def rm( + files: List[str] = typer.Argument( + default=None, + help="The names of one or more files to remove from Git's index", + ) +): + from git_sim.rm import Rm + + settings.hide_first_tag = True + scene = Rm(files=files) + handle_animations(scene=scene) + + +def stash( + command: StashSubCommand = typer.Argument( + default=None, + help="Stash subcommand (push, pop, apply)", + ), + files: List[str] = typer.Argument( + 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, stash_index=stash_index) + handle_animations(scene=scene) + + +def status(): + from git_sim.status import Status + + settings.hide_first_tag = True + settings.allow_no_commits = True + + scene = Status() + handle_animations(scene=scene) + + +def switch( + branch: str = typer.Argument( + ..., + help="The name of the branch to switch to", + ), + c: bool = typer.Option( + False, + "-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, detach=detach) + handle_animations(scene=scene) + + +def tag( + name: str = typer.Argument( + ..., + 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, 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 2152007..7c02b29 100644 --- a/git_sim/commit.py +++ b/src/git_sim/commit.py @@ -2,9 +2,7 @@ import git import manim as m -import typer -from git_sim.animations import handle_animations from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings @@ -32,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() @@ -75,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() @@ -95,27 +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 - ) - - -def commit( - message: str = typer.Option( - "New commit", - "--message", - "-m", - help="The commit message of the new commit", - ), - amend: bool = typer.Option( - default=False, - help="Amend the last commit message, must be used with the --message flag", - ), -): - settings.hide_first_tag = True - scene = Commit(message=message, amend=amend) - handle_animations(scene=scene) + 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/src/git_sim/enums.py b/src/git_sim/enums.py new file mode 100644 index 0000000..6b8d806 --- /dev/null +++ b/src/git_sim/enums.py @@ -0,0 +1,44 @@ +from enum import Enum + + +class ResetMode(Enum): + DEFAULT = "mixed" + SOFT = "soft" + MIXED = "mixed" + HARD = "hard" + + +class ColorByOptions(Enum): + AUTHOR = "author" + BRANCH = "branch" + NOTLOCAL1 = "notlocal1" + NOTLOCAL2 = "notlocal2" + + +class StyleOptions(Enum): + CLEAN = "clean" + THICK = "thick" + + +class VideoFormat(str, Enum): + MP4 = "mp4" + WEBM = "webm" + + +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/src/git_sim/fetch.py b/src/git_sim/fetch.py new file mode 100644 index 0000000..af0a1a8 --- /dev/null +++ b/src/git_sim/fetch.py @@ -0,0 +1,86 @@ +import sys +import os +from argparse import Namespace + +import git +import manim as m +import numpy +import tempfile +import shutil +import stat + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Fetch(GitSimBaseCommand): + def __init__(self, remote: str, branch: str): + super().__init__() + self.remote = remote + self.branch = branch + 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()} {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} {self.cmd}") + + if not self.remote: + self.remote = "origin" + if not self.branch: + self.branch = self.repo.active_branch.name + + self.show_intro() + + git_root = self.repo.git.rev_parse("--show-toplevel") + repo_name = os.path.basename(self.repo.working_dir) + new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) + + orig_remotes = self.repo.remotes + self.repo = git.Repo.clone_from(git_root, new_dir, no_hardlinks=True) + for r1 in orig_remotes: + for r2 in self.repo.remotes: + if r1.name == r2.name: + r2.set_url(r1.url) + + try: + self.repo.git.fetch(self.remote, self.branch) + except git.GitCommandError as e: + print(e) + sys.exit(1) + + # local branch doesn't exist + if self.branch not in self.repo.heads: + start_parse_from_remote = True + # fetched branch is ahead of local branch + elif (self.remote + "/" + self.branch) in self.repo.git.branch( + "-r", "--contains", self.branch + ): + start_parse_from_remote = True + # fetched branch is behind local branch + elif self.branch in self.repo.git.branch( + "--contains", (self.remote + "/" + self.branch) + ): + start_parse_from_remote = False + else: + start_parse_from_remote = True + + if start_parse_from_remote: + commit = self.get_commit(self.remote + "/" + self.branch) + else: + commit = self.get_commit(self.branch) + self.parse_commits(commit) + + self.recenter_frame() + self.scale_frame() + self.color_by() + self.show_command_as_title() + self.fadeout() + self.show_outro() + self.repo.git.clear_cache() + shutil.rmtree(new_dir, onerror=self.del_rw) diff --git a/git_sim/git_sim_base_command.py b/src/git_sim/git_sim_base_command.py similarity index 67% rename from git_sim/git_sim_base_command.py rename to src/git_sim/git_sim_base_command.py index 196ee82..c07a8e1 100644 --- a/git_sim/git_sim_base_command.py +++ b/src/git_sim/git_sim_base_command.py @@ -1,5 +1,9 @@ +import os import platform +import shutil +import stat import sys +import tempfile import git import manim as m @@ -7,36 +11,86 @@ from git.exc import GitCommandError, InvalidGitRepositoryError from git.repo import Repo +from git_sim.enums import ColorByOptions, StyleOptions from git_sim.settings import settings 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 + self.n_dark_commits = 0 self.selected_branches = [] self.zone_title_offset = 2.6 if platform.system() == "Windows" else 2.6 self.arrow_map = [] + self.arrows = [] self.all = settings.all self.first_parse = True + self.author_groups = {} + self.colors = [ + m.ORANGE, + m.YELLOW, + m.GREEN, + m.BLUE, + m.MAROON, + m.PURPLE, + m.GOLD, + m.TEAL, + m.RED, + m.PINK, + m.DARK_BLUE, + ] self.logo = m.ImageMobject(settings.logo) self.logo.width = 3 + self.hide_first_tag = settings.hide_first_tag + + self.fill_opacity = 0.25 + self.ref_fill_opacity = 0.25 + if settings.transparent_bg: + self.fill_opacity = 0.5 + self.ref_fill_opacity = 1.0 + + if settings.style == StyleOptions.CLEAN: + self.commit_stroke_width = 5 + self.arrow_stroke_width = 5 + self.arrow_tip_shape = m.ArrowTriangleFilledTip + self.font_weight = m.NORMAL + elif settings.style == StyleOptions.THICK: + self.commit_stroke_width = 30 + self.arrow_stroke_width = 10 + self.arrow_tip_shape = m.StealthTip + self.font_weight = m.BOLD def init_repo(self): try: self.repo = Repo(search_parent_directories=True) + repo_name = os.path.basename(self.repo.working_dir) + new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) + new_dir2 = os.path.join(tempfile.gettempdir(), "git_sim", repo_name + "2") + try: + shutil.rmtree(new_dir, onerror=self.del_rw) + except FileNotFoundError: + pass + try: + shutil.rmtree(new_dir2, onerror=self.del_rw) + except FileNotFoundError: + pass except InvalidGitRepositoryError: print("git-sim error: No Git repository found at current path.") sys.exit(1) @@ -49,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()] @@ -63,10 +119,17 @@ def parse_commits( i=0, prevCircle=None, 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() - isNewCommit = commit.hexsha not in self.drawnCommits + if commit != "dark": + isNewCommit = commit.hexsha not in self.drawnCommits + else: + isNewCommit = True if i < self.n: commitId, circle, arrow, hide_refs = self.draw_commit( @@ -76,7 +139,9 @@ def parse_commits( if commit != "dark": if not hide_refs and isNewCommit: self.draw_head(commit, i, commitId) - self.draw_branch(commit, i) + self.draw_branch( + commit, i, make_branches_remote=make_branches_remote + ) self.draw_tag(commit, i) if ( not isinstance(arrow, m.CurvedArrow) @@ -98,7 +163,14 @@ def parse_commits( self.first_parse = False i += 1 - commitParents = list(commit.parents) + try: + commitParents = list(commit.parents) + except AttributeError: + if (len(self.drawnCommits) + self.n_dark_commits) < self.n_default: + self.n_dark_commits += 1 + self.parse_commits(self.create_dark_commit(), i, circle) + return + if len(commitParents) > 0: if settings.invert_branches: commitParents.reverse() @@ -108,6 +180,10 @@ def parse_commits( else: for p in range(len(commitParents)): self.parse_commits(commitParents[p], i, circle) + else: + if (len(self.drawnCommits) + self.n_dark_commits) < self.n_default: + self.n_dark_commits += 1 + self.parse_commits(self.create_dark_commit(), i, circle) def parse_all(self): if self.all: @@ -120,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) @@ -148,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) @@ -156,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) @@ -179,14 +255,17 @@ def get_centers(self): def draw_commit(self, commit, i, prevCircle, shift=numpy.array([0.0, 0.0, 0.0])): if commit == "dark": - commitFill = m.WHITE if settings.light_mode else m.BLACK + commit_fill = m.WHITE if settings.light_mode else m.BLACK elif len(commit.parents) <= 1: - commitFill = m.RED + commit_fill = m.RED else: - commitFill = m.GRAY + commit_fill = m.GRAY circle = m.Circle( - stroke_color=commitFill, fill_color=commitFill, fill_opacity=0.25 + stroke_color=commit_fill, + stroke_width=self.commit_stroke_width, + fill_color=commit_fill, + fill_opacity=self.fill_opacity, ) circle.height = 1 @@ -201,7 +280,10 @@ def draw_commit(self, commit, i, prevCircle, shift=numpy.array([0.0, 0.0, 0.0])) while any((circle.get_center() == c).all() for c in self.get_centers()): circle.shift(m.DOWN * 4) - isNewCommit = commit.hexsha not in self.drawnCommits + if commit != "dark": + isNewCommit = commit.hexsha not in self.drawnCommits + else: + isNewCommit = True if isNewCommit: start = ( @@ -219,7 +301,14 @@ def draw_commit(self, commit, i, prevCircle, shift=numpy.array([0.0, 0.0, 0.0])) ) end = self.drawnCommits[commit.hexsha].get_center() - arrow = m.Arrow(start, end, color=self.fontColor) + 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, + ) if commit == "dark": arrow = m.Arrow( @@ -238,7 +327,13 @@ def draw_commit(self, commit, i, prevCircle, shift=numpy.array([0.0, 0.0, 0.0])) for commitCircle in self.drawnCommits.values(): inter = m.Intersection(lineRect, commitCircle) if inter.has_points(): - arrow = m.CurvedArrow(start, end, color=self.fontColor) + arrow = m.CurvedArrow( + start, + end, + color=self.fontColor, + stroke_width=self.arrow_stroke_width, + tip_shape=self.arrow_tip_shape, + ) if start[1] == end[1]: arrow.shift(m.UP * 1.25) if start[0] < end[0] and start[1] == end[1]: @@ -256,29 +351,49 @@ 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_size=14, + font=self.font, + font_size=20 if settings.highlight_commit_messages else 14, color=self.fontColor, + weight=m.BOLD + if settings.highlight_commit_messages + or settings.style == StyleOptions.THICK + else m.NORMAL, ).next_to(circle, m.DOWN) if settings.animate and commit != "dark" and isNewCommit: self.play( self.camera.frame.animate.move_to(circle.get_center()), m.Create(circle), - m.AddTextLetterByLetter(commitId), + m.Text("") + if settings.highlight_commit_messages + else m.AddTextLetterByLetter(commitId), m.AddTextLetterByLetter(message), run_time=1 / settings.speed, ) elif isNewCommit: - self.add(circle, commitId, message) + self.add( + circle, + m.Text("") if settings.highlight_commit_messages else commitId, + message, + ) else: - return commitId, circle, arrow, hide_refs + return ( + m.Text("") if settings.highlight_commit_messages else commitId, + circle, + arrow, + hide_refs, + ) if commit != "dark": self.drawnCommits[commit.hexsha] = circle + group = m.Group(circle, commitId, message) + self.add_group_to_author_groups(commit.author.name, group) self.toFadeOut.add(circle, commitId, message) - self.prevRef = commitId + if settings.highlight_commit_messages: + self.prevRef = circle + else: + self.prevRef = commitId return commitId, circle, arrow, hide_refs @@ -295,26 +410,42 @@ def get_nonparent_branch_names(self): 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, + weight=self.font_weight, + ) commitMessage = "" else: commitId = m.Text( commit.hexsha[0:6], - font="Monospace", + font=self.font, font_size=20, color=self.fontColor, + weight=self.font_weight, ) commitMessage = commit.message.split("\n")[0][:40].replace("\n", " ") return commitId, commitMessage, commit, hide_refs def draw_head(self, commit, i, commitId): if commit.hexsha == self.repo.head.commit.hexsha: - headbox = m.Rectangle(color=m.BLUE, fill_color=m.BLUE, fill_opacity=0.25) + headbox = m.Rectangle( + color=m.BLUE, fill_color=m.BLUE, fill_opacity=self.ref_fill_opacity + ) headbox.width = 1 headbox.height = 0.4 - headbox.next_to(commitId, m.UP) + if settings.highlight_commit_messages: + headbox.next_to(self.drawnCommits[commit.hexsha], m.UP) + else: + headbox.next_to(commitId, m.UP) headText = m.Text( - "HEAD", font="Monospace", font_size=20, color=self.fontColor + "HEAD", + font=self.font, + font_size=20, + color=self.fontColor, + weight=self.font_weight, ).move_to(headbox.get_center()) head = m.VGroup(headbox, headText) @@ -326,12 +457,13 @@ 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: self.topref = self.prevRef - def draw_branch(self, commit, i): + def draw_branch(self, commit, i, make_branches_remote=False): x = 0 remote_tracking_branches = self.get_remote_tracking_branches() @@ -345,19 +477,29 @@ def draw_branch(self, commit, i): 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 branch not in remote_tracking_branches) + else branch + ) + branchText = m.Text( - branch, font="Monospace", font_size=20, color=self.fontColor + text, + font=self.font, + font_size=20, + color=self.fontColor, + weight=self.font_weight, ) branchRec = m.Rectangle( color=m.GREEN, fill_color=m.GREEN, - fill_opacity=0.25, + fill_opacity=self.ref_fill_opacity, height=0.4, width=branchText.width + 0.25, ) @@ -374,8 +516,9 @@ def draw_branch(self, commit, i): else: self.add(fullbranch) - self.toFadeOut.add(branchRec, branchText) + 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 @@ -387,7 +530,7 @@ def draw_branch(self, commit, i): def draw_tag(self, commit, i): x = 0 - if settings.hide_first_tag and i == 0: + if self.hide_first_tag and i == 0: return for tag in self.repo.tags: @@ -395,14 +538,15 @@ 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, ) tagRec = m.Rectangle( color=m.YELLOW, fill_color=m.YELLOW, - fill_opacity=0.25, + fill_opacity=self.ref_fill_opacity, height=0.4, width=tagText.width + 0.25, ) @@ -410,18 +554,21 @@ def draw_tag(self, commit, i): tagRec.next_to(self.prevRef, m.UP) tagText.move_to(tagRec.get_center()) + fulltag = m.VGroup(tagRec, tagText) + self.prevRef = tagRec if settings.animate: self.play( - m.Create(tagRec), - m.Create(tagText), + m.Create(fulltag), run_time=1 / settings.speed, ) else: - self.add(tagRec, tagText) + self.add(fulltag) - self.toFadeOut.add(tagRec, tagText) + self.toFadeOut.add(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 @@ -439,6 +586,7 @@ def draw_arrow(self, prevCircle, arrow): else: self.add(arrow) + self.arrows.append(arrow) self.toFadeOut.add(arrow) def recenter_frame(self): @@ -452,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 @@ -466,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 ) @@ -486,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], @@ -513,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], @@ -526,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], @@ -552,22 +705,25 @@ 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, ) .move_to(self.camera.frame.get_center()) .align_to(firstColumnTitle, m.UP) @@ -575,9 +731,10 @@ 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, ) .move_to((vert2.get_center()[0] + 4, 0, 0)) .align_to(firstColumnTitle, m.UP) @@ -747,6 +904,10 @@ def setup_and_draw_zones( self.toFadeOut.add(firstColumnFiles, secondColumnFiles, thirdColumnFiles) + self.firstColumnFiles = firstColumnFiles + self.secondColumnFiles = secondColumnFiles + self.thirdColumnFiles = thirdColumnFiles + def populate_zones( self, firstColumnFileNames, @@ -774,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( @@ -784,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( @@ -817,6 +984,46 @@ def reset_head_branch(self, hexsha, shift=numpy.array([0.0, 0.0, 0.0])): ) ) + def reset_head(self, hexsha, shift=numpy.array([0.0, 0.0, 0.0])): + if settings.animate: + self.play( + self.drawnRefs["HEAD"].animate.move_to( + ( + self.drawnCommits[hexsha].get_center()[0] + shift[0], + self.drawnCommits[hexsha].get_center()[1] + 2.0 + shift[1], + 0, + ) + ), + ) + else: + self.drawnRefs["HEAD"].move_to( + ( + self.drawnCommits[hexsha].get_center()[0] + shift[0], + self.drawnCommits[hexsha].get_center()[1] + 2.0 + shift[1], + 0, + ) + ) + + def reset_branch(self, hexsha, shift=numpy.array([0.0, 0.0, 0.0])): + if settings.animate: + self.play( + self.drawnRefs[self.repo.active_branch.name].animate.move_to( + ( + self.drawnCommits[hexsha].get_center()[0] + shift[0], + self.drawnCommits[hexsha].get_center()[1] + 1.4 + shift[1], + 0, + ) + ), + ) + else: + self.drawnRefs[self.repo.active_branch.name].move_to( + ( + self.drawnCommits[hexsha].get_center()[0] + shift[0], + self.drawnCommits[hexsha].get_center()[1] + 1.4 + shift[1], + 0, + ) + ) + def reset_head_branch_to_ref(self, ref, shift=numpy.array([0.0, 0.0, 0.0])): if settings.animate: self.play(self.drawnRefs["HEAD"].animate.next_to(ref, m.UP)) @@ -845,23 +1052,42 @@ def setup_and_draw_parent( draw_arrow=True, color=m.RED, ): - circle = m.Circle(stroke_color=color, fill_color=color, fill_opacity=0.25) - circle.height = 1 - circle.next_to( - self.drawnCommits[child.hexsha], - m.LEFT if settings.reverse else m.RIGHT, - buff=1.5, + circle = m.Circle( + stroke_color=color, + stroke_width=self.commit_stroke_width, + fill_color=color, + fill_opacity=self.ref_fill_opacity, ) + circle.height = 1 + 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) - 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_size=20, color=self.fontColor + "abcdef", + font=self.font, + font_size=20, + color=self.fontColor, + weight=self.font_weight, ).next_to(circle, m.UP) self.toFadeOut.add(commitId) @@ -870,9 +1096,10 @@ 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, ).next_to(circle, m.DOWN) self.toFadeOut.add(message) @@ -891,11 +1118,12 @@ 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: self.add(arrow) + self.arrows.append(arrow) self.toFadeOut.add(arrow) return commitId @@ -919,11 +1147,17 @@ def get_nondark_commits(self): return nondark_commits def draw_ref(self, commit, top, i=0, text="HEAD", color=m.BLUE): - refText = m.Text(text, font="Monospace", font_size=20, color=self.fontColor) + refText = m.Text( + text, + font=self.font, + font_size=20, + color=self.fontColor, + weight=self.font_weight, + ) refbox = m.Rectangle( color=color, fill_color=color, - fill_opacity=0.25, + fill_opacity=self.ref_fill_opacity, height=0.4, width=refText.width + 0.25, ) @@ -957,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] @@ -968,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, @@ -993,12 +1221,11 @@ def create_zone_text( thirdColumnTitle, horizontal2, ): - for i, f in enumerate(firstColumnFileNames): text = ( m.Text( self.trim_path(f), - font="Monospace", + font=self.font, font_size=24, color=self.fontColor, ) @@ -1014,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, ) @@ -1030,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, ) @@ -1042,6 +1269,112 @@ def create_zone_text( thirdColumnFiles.add(text) thirdColumnFilesDict[f] = text + def color_by(self, offset=0): + if settings.color_by == ColorByOptions.AUTHOR: + sorted_authors = sorted( + self.author_groups.keys(), + key=lambda k: len(self.author_groups[k]), + reverse=True, + ) + for i, author in enumerate(sorted_authors): + authorText = m.Text( + f"{author[:15]} ({str(len(self.author_groups[author]))})", + font=self.font, + font_size=36, + color=self.colors[int(i % 11)], + weight=self.font_weight, + ) + authorText.move_to( + [(-5 - offset) if settings.reverse else (5 + offset), -i, 0] + ) + self.toFadeOut.add(authorText) + if i == 0: + self.recenter_frame() + self.scale_frame() + if settings.animate: + self.play(m.AddTextLetterByLetter(authorText)) + else: + self.add(authorText) + for g in self.author_groups[author]: + g[0].set_color(self.colors[int(i % 11)]) + self.recenter_frame() + self.scale_frame() + + elif settings.color_by == ColorByOptions.BRANCH: + pass + + elif settings.color_by == ColorByOptions.NOTLOCAL1: + for commit_id in self.drawnCommits: + try: + self.orig_repo.commit(commit_id) + except ValueError: + self.drawnCommits[commit_id].set_color(m.GOLD) + + elif settings.color_by == ColorByOptions.NOTLOCAL2: + for commit_id in self.drawnCommits: + if not self.orig_repo.is_ancestor(commit_id, "HEAD"): + self.drawnCommits[commit_id].set_color(m.GOLD) + + def add_group_to_author_groups(self, author, group): + if author not in self.author_groups: + self.author_groups[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 65% rename from git_sim/log.py rename to src/git_sim/log.py index 93c7e4e..954a15a 100644 --- a/git_sim/log.py +++ b/src/git_sim/log.py @@ -1,6 +1,5 @@ import typer -from git_sim.animations import handle_animations from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings import numpy @@ -33,32 +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() - - -def log( - ctx: typer.Context, - n: int = typer.Option( - None, - "-n", - help="Number of commits to display from branch heads", - ), - all: bool = typer.Option( - False, - "--all", - help="Display all local branches in the log output", - ), -): - scene = Log(ctx=ctx, n=n, all=all) - handle_animations(scene=scene) 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/src/git_sim/merge.py b/src/git_sim/merge.py new file mode 100644 index 0000000..8d39ee9 --- /dev/null +++ b/src/git_sim/merge.py @@ -0,0 +1,202 @@ +import sys +import os + +import git +import manim as m +import numpy +import tempfile +import shutil +import stat + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Merge(GitSimBaseCommand): + def __init__(self, branch: str, no_ff: bool, message: str): + super().__init__() + self.branch = branch + self.no_ff = no_ff + self.message = message + + try: + git.repo.fun.rev_parse(self.repo, self.branch) + except git.exc.BadName: + print( + "git-sim error: '" + + self.branch + + "' is not a valid Git ref or identifier." + ) + sys.exit(1) + + self.ff = False + if self.branch in [branch.name for branch in self.repo.heads]: + self.selected_branches.append(self.branch) + + try: + self.selected_branches.append(self.repo.active_branch.name) + 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} {self.cmd}") + + if self.repo.active_branch.name in self.repo.git.branch( + "--contains", self.branch + ): + print( + "git-sim error: Branch '" + + self.branch + + "' is already included in the history of active branch '" + + self.repo.active_branch.name + + "'." + ) + sys.exit(1) + + self.show_intro() + head_commit = self.get_commit() + branch_commit = self.get_commit(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: + if self.branch in self.repo.git.branch( + "-r", "--contains", head_commit.hexsha + ): + self.ff = True + + if self.ff: + self.parse_commits(branch_commit) + self.parse_all() + reset_head_to = branch_commit.hexsha + shift = numpy.array([0.0, 0.6, 0.0]) + + if self.no_ff: + self.center_frame_on_commit(branch_commit) + commitId = self.setup_and_draw_parent(branch_commit, self.message) + + # If pre-merge HEAD is on screen, drawn an arrow to it as 2nd parent + if head_commit.hexsha in self.drawnCommits: + start = self.drawnCommits["abcdef"].get_center() + end = self.drawnCommits[head_commit.hexsha].get_center() + arrow = m.CurvedArrow( + start, + end, + color=self.fontColor, + stroke_width=self.arrow_stroke_width, + tip_shape=self.arrow_tip_shape, + ) + self.draw_arrow(True, arrow) + + reset_head_to = "abcdef" + shift = numpy.array([0.0, 0.0, 0.0]) + + self.recenter_frame() + self.scale_frame() + if "HEAD" in self.drawnRefs and self.no_ff: + self.reset_head_branch(reset_head_to, shift=shift) + elif "HEAD" in self.drawnRefs: + self.reset_head_branch_to_ref(self.topref, shift=shift) + else: + self.draw_ref(branch_commit, commitId if self.no_ff else self.topref) + self.draw_ref( + branch_commit, + self.drawnRefs["HEAD"], + text=self.repo.active_branch.name, + color=m.GREEN, + ) + if self.no_ff: + self.color_by(offset=2) + else: + self.color_by() + + else: + merge_result, new_dir = self.check_merge_conflict( + self.repo.active_branch.name, self.branch + ) + if merge_result: + self.hide_first_tag = True + self.parse_commits(head_commit) + self.recenter_frame() + self.scale_frame() + + # Show the conflicted files names in the table/zones + self.vsplit_frame() + self.setup_and_draw_zones( + first_column_name="----", + second_column_name="Conflicted files", + third_column_name="----", + ) + self.color_by() + else: + self.parse_commits(head_commit) + self.parse_commits(branch_commit, shift=4 * m.DOWN) + self.parse_all() + self.center_frame_on_commit(head_commit) + self.setup_and_draw_parent( + head_commit, + self.message, + shift=2 * m.DOWN, + draw_arrow=False, + color=m.GRAY, + ) + self.draw_arrow_between_commits("abcdef", branch_commit.hexsha) + self.draw_arrow_between_commits("abcdef", head_commit.hexsha) + self.recenter_frame() + self.scale_frame() + self.reset_head_branch("abcdef") + self.color_by(offset=2) + + self.show_command_as_title() + self.fadeout() + self.show_outro() + + # Unlink the program from the filesystem + self.repo.git.clear_cache() + + # Delete the local clone + try: + shutil.rmtree(new_dir, onerror=self.del_rw) + except (FileNotFoundError, UnboundLocalError): + pass + + def check_merge_conflict(self, branch1, branch2): + git_root = self.repo.git.rev_parse("--show-toplevel") + repo_name = os.path.basename(self.repo.working_dir) + new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) + + orig_repo = self.repo + orig_remotes = self.repo.remotes + self.repo = git.Repo.clone_from(git_root, new_dir, no_hardlinks=True) + self.repo.git.checkout(branch2) + self.repo.git.checkout(branch1) + + try: + self.repo.git.merge(branch2) + except git.GitCommandError as e: + if "CONFLICT" in e.stdout: + self.conflicted_files = [] + self.n = 5 + for entry in self.repo.index.entries: + if len(entry) == 2 and entry[1] > 0: + self.conflicted_files.append(entry[0]) + return 1, new_dir + self.repo = orig_repo + return 0, new_dir + + # Override to display conflicted filenames + def populate_zones( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnArrowMap={}, + secondColumnArrowMap={}, + thirdColumnArrowMap={}, + ): + for filename in self.conflicted_files: + secondColumnFileNames.add(filename) diff --git a/src/git_sim/mv.py b/src/git_sim/mv.py new file mode 100644 index 0000000..366783e --- /dev/null +++ b/src/git_sim/mv.py @@ -0,0 +1,91 @@ +import sys +import git +import manim as m + +from typing import List + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Mv(GitSimBaseCommand): + def __init__(self, file: str, new_file: str): + super().__init__() + self.hide_first_tag = True + self.allow_no_commits = True + self.file = file + self.new_file = new_file + settings.hide_merged_branches = True + self.n = self.n_default + + try: + self.selected_branches.append(self.repo.active_branch.name) + except TypeError: + pass + + try: + self.repo.git.ls_files("--error-unmatch", self.file) + except: + 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} {self.cmd}") + + self.show_intro() + self.parse_commits() + self.recenter_frame() + self.scale_frame() + self.vsplit_frame() + self.setup_and_draw_zones( + first_column_name="Working directory", + second_column_name="Staging area", + third_column_name="Renamed files", + ) + self.rename_moved_file() + self.show_command_as_title() + self.fadeout() + self.show_outro() + + def populate_zones( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnArrowMap={}, + secondColumnArrowMap={}, + thirdColumnArrowMap={}, + ): + if self.file in [x.a_path for x in self.repo.index.diff("HEAD")]: + secondColumnFileNames.add(self.file) + secondColumnArrowMap[self.file] = m.Arrow( + stroke_width=3, color=self.fontColor + ) + else: + firstColumnFileNames.add(self.file) + firstColumnArrowMap[self.file] = m.Arrow( + stroke_width=3, color=self.fontColor + ) + + thirdColumnFileNames.add(self.file) + + def rename_moved_file(self): + for file in self.thirdColumnFiles: + new_file = m.Text( + self.trim_path(self.new_file), + font=self.font, + font_size=24, + color=self.fontColor, + ) + new_file.move_to(file.get_center()) + if settings.animate: + self.play(m.FadeOut(file), run_time=1 / settings.speed) + self.toFadeOut.remove(file) + self.play(m.AddTextLetterByLetter(new_file)) + self.toFadeOut.add(new_file) + else: + self.remove(file) + self.add(new_file) diff --git a/src/git_sim/pull.py b/src/git_sim/pull.py new file mode 100644 index 0000000..9997f96 --- /dev/null +++ b/src/git_sim/pull.py @@ -0,0 +1,111 @@ +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_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Pull(GitSimBaseCommand): + def __init__(self, remote: str = None, branch: str = None): + super().__init__() + self.remote = remote + self.branch = branch + 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()} {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} {self.cmd}") + + self.show_intro() + + # Configure paths to make local clone to run networked commands in + git_root = self.repo.git.rev_parse("--show-toplevel") + repo_name = os.path.basename(self.repo.working_dir) + new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) + + # Save remotes and create the local clone + orig_remotes = self.repo.remotes + self.repo = git.Repo.clone_from(git_root, new_dir, no_hardlinks=True) + + # Reset the remotes in the local clone to the original remotes + for r1 in orig_remotes: + for r2 in self.repo.remotes: + if r1.name == r2.name: + r2.set_url(r1.url) + + # Pull the remote into the local clone + try: + self.repo.git.pull(self.remote, self.branch) + head_commit = self.get_commit() + self.parse_commits(head_commit) + self.recenter_frame() + self.scale_frame() + + # But if we get merge conflicts... + except git.GitCommandError as e: + if "CONFLICT" in e.stdout: + # Restrict to default number of commits since we'll show the table/zones + self.n = self.n_default + settings.hide_merged_branches = True + + # Get list of conflicted filenames + self.conflicted_files = re.findall(r"Merge conflict in (.+)", e.stdout) + + head_commit = self.get_commit() + self.parse_commits(head_commit) + self.recenter_frame() + self.scale_frame() + + # Show the conflicted files names in the table/zones + self.vsplit_frame() + self.setup_and_draw_zones( + first_column_name="----", + second_column_name="Conflicted files", + third_column_name="----", + ) + else: + print( + f"git-sim error: git pull failed for unhandled reason: {e.stdout}" + ) + self.repo.git.clear_cache() + shutil.rmtree(new_dir, onerror=self.del_rw) + sys.exit(1) + + self.color_by() + self.show_command_as_title() + self.fadeout() + self.show_outro() + + # Unlink the program from the filesystem + self.repo.git.clear_cache() + + # Delete the local clone + shutil.rmtree(new_dir, onerror=self.del_rw) + + # Override to display conflicted filenames + def populate_zones( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnArrowMap={}, + secondColumnArrowMap={}, + thirdColumnArrowMap={}, + ): + for filename in self.conflicted_files: + secondColumnFileNames.add(filename) diff --git a/src/git_sim/push.py b/src/git_sim/push.py new file mode 100644 index 0000000..49fcf53 --- /dev/null +++ b/src/git_sim/push.py @@ -0,0 +1,205 @@ +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_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings +from git_sim.enums import ColorByOptions + + +class Push(GitSimBaseCommand): + 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} {self.cmd}") + + self.show_intro() + + # Configure paths to make local clone to run networked commands in + git_root = self.repo.git.rev_parse("--show-toplevel") + repo_name = os.path.basename(self.repo.working_dir) + new_dir = os.path.join(tempfile.gettempdir(), "git_sim", repo_name) + new_dir2 = os.path.join(tempfile.gettempdir(), "git_sim", repo_name + "2") + + # Save remotes + orig_remotes = self.repo.remotes + + # Create local clone of local repo + self.repo = git.Repo.clone_from(git_root, new_dir, no_hardlinks=True) + if self.remote: + for r in orig_remotes: + if self.remote == r.name: + remote_url = r.url + break + else: + remote_url = orig_remotes[0].url + + # Create local clone of remote repo to simulate push to so we don't touch the real remote + self.remote_repo = git.Repo.clone_from( + remote_url, new_dir2, no_hardlinks=True, bare=True + ) + + # Reset local clone remote to the local clone of remote repo + if self.remote: + for r in self.repo.remotes: + if self.remote == r.name: + r.set_url(new_dir2) + else: + self.repo.remotes[0].set_url(new_dir2) + + # Push the local clone into the local clone of the remote repo + push_result = 0 + self.orig_repo = None + try: + self.repo.git.push(self.remote, self.branch) + # If push fails... + except git.GitCommandError as e: + if "rejected" in e.stderr and ("fetch first" in e.stderr): + push_result = 1 + self.orig_repo = self.repo + self.repo = self.remote_repo + settings.color_by = ColorByOptions.NOTLOCAL1 + elif "rejected" in e.stderr and ("non-fast-forward" in e.stderr): + push_result = 2 + self.orig_repo = self.repo + self.repo = self.remote_repo + settings.color_by = ColorByOptions.NOTLOCAL2 + else: + print(f"git-sim error: git push failed: {e.stderr}") + return + + head_commit = self.get_commit() + if push_result > 0: + self.parse_commits( + head_commit, + make_branches_remote=( + self.remote if self.remote else self.repo.remotes[0].name + ), + ) + else: + self.parse_commits(head_commit) + + self.recenter_frame() + self.scale_frame() + self.failed_push(push_result) + self.color_by() + self.show_command_as_title() + self.fadeout() + self.show_outro() + + # Unlink the program from the filesystem + self.repo.git.clear_cache() + if self.orig_repo: + self.orig_repo.git.clear_cache() + + # Delete the local clones + shutil.rmtree(new_dir, onerror=self.del_rw) + shutil.rmtree(new_dir2, onerror=self.del_rw) + + def failed_push(self, push_result): + texts = [] + if push_result == 1: + text1 = m.Text( + f"'git push' failed since the remote repo has commits that don't exist locally.", + font=self.font, + font_size=20, + color=self.fontColor, + weight=m.BOLD, + ) + text1.move_to([self.camera.frame.get_center()[0], 5, 0]) + + text2 = m.Text( + f"Run 'git pull' (or 'git-sim pull' to simulate first) and then try again.", + font=self.font, + font_size=20, + color=self.fontColor, + weight=m.BOLD, + ) + text2.move_to(text1.get_center()).shift(m.DOWN / 2) + + text3 = m.Text( + f"Gold commits exist in remote repo, but not locally (need to be pulled).", + font=self.font, + font_size=20, + color=m.GOLD, + weight=m.BOLD, + ) + text3.move_to(text2.get_center()).shift(m.DOWN / 2) + + text4 = m.Text( + f"Red commits exist in both local and remote repos.", + font=self.font, + font_size=20, + color=m.RED, + weight=m.BOLD, + ) + text4.move_to(text3.get_center()).shift(m.DOWN / 2) + texts = [text1, text2, text3, text4] + + elif push_result == 2: + text1 = m.Text( + f"'git push' failed since the tip of your current branch is behind the remote.", + font=self.font, + font_size=20, + color=self.fontColor, + weight=m.BOLD, + ) + text1.move_to([self.camera.frame.get_center()[0], 5, 0]) + + text2 = m.Text( + f"Run 'git pull' (or 'git-sim pull' to simulate first) and then try again.", + font=self.font, + font_size=20, + color=self.fontColor, + weight=m.BOLD, + ) + text2.move_to(text1.get_center()).shift(m.DOWN / 2) + + text3 = m.Text( + f"Gold commits are ahead of your current branch tip (need to be pulled).", + font=self.font, + font_size=20, + color=m.GOLD, + weight=m.BOLD, + ) + text3.move_to(text2.get_center()).shift(m.DOWN / 2) + + text4 = m.Text( + f"Red commits are up to date in both local and remote branches.", + font=self.font, + font_size=20, + color=m.RED, + weight=m.BOLD, + ) + text4.move_to(text3.get_center()).shift(m.DOWN / 2) + texts = [text1, text2, text3, text4] + + self.toFadeOut.add(*texts) + self.recenter_frame() + self.scale_frame() + if settings.animate: + self.play(*[m.AddTextLetterByLetter(t) for t in texts]) + else: + self.add(*texts) diff --git a/git_sim/rebase.py b/src/git_sim/rebase.py similarity index 88% rename from git_sim/rebase.py rename to src/git_sim/rebase.py index 91c7570..a455ae3 100644 --- a/git_sim/rebase.py +++ b/src/git_sim/rebase.py @@ -3,9 +3,7 @@ import git import manim as m import numpy -import typer -from git_sim.animations import handle_animations from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings @@ -33,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 @@ -102,6 +100,8 @@ def construct(self): self.recenter_frame() 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() @@ -112,7 +112,12 @@ def setup_and_draw_parent( shift=numpy.array([0.0, 0.0, 0.0]), draw_arrow=True, ): - circle = m.Circle(stroke_color=m.RED, fill_color=m.RED, fill_opacity=0.25) + circle = m.Circle( + stroke_color=m.RED, + stroke_width=self.commit_stroke_width, + fill_color=m.RED, + fill_opacity=0.25, + ) circle.height = 1 circle.next_to( self.drawnCommits[child], @@ -123,7 +128,14 @@ def setup_and_draw_parent( start = circle.get_center() end = self.drawnCommits[child].get_center() - arrow = m.Arrow(start, end, color=self.fontColor) + 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) @@ -138,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) @@ -149,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) @@ -178,13 +190,3 @@ def setup_and_draw_parent( self.toFadeOut.add(arrow) return sha - - -def rebase( - branch: str = typer.Argument( - ..., - help="The branch to simulate rebasing the checked-out commit onto", - ) -): - scene = Rebase(branch=branch) - handle_animations(scene=scene) 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 75% rename from git_sim/reset.py rename to src/git_sim/reset.py index 3f12e08..a1fed65 100644 --- a/git_sim/reset.py +++ b/src/git_sim/reset.py @@ -3,20 +3,12 @@ import git import manim as m -import typer -from git_sim.animations import handle_animations +from git_sim.enums import ResetMode from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings -class ResetMode(Enum): - DEFAULT = "mixed" - SOFT = "soft" - MIXED = "mixed" - HARD = "hard" - - class Reset(GitSimBaseCommand): def __init__( self, commit: str, mode: ResetMode, soft: bool, mixed: bool, hard: bool @@ -49,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() @@ -62,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 [ @@ -83,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, ) @@ -93,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, ) @@ -145,30 +136,3 @@ def populate_zones( secondColumnFileNames.add(y.a_path) elif self.mode == ResetMode.HARD: firstColumnFileNames.add(y.a_path) - - -def reset( - commit: str = typer.Argument( - default="HEAD", - help="The ref (branch/tag), or commit ID to simulate reset to", - ), - mode: ResetMode = typer.Option( - default=ResetMode.MIXED.value, - help="Either mixed, soft, or hard", - ), - soft: bool = typer.Option( - default=False, - help="Simulate a soft reset, shortcut for --mode=soft", - ), - mixed: bool = typer.Option( - default=False, - help="Simulate a mixed reset, shortcut for --mode=mixed", - ), - hard: bool = typer.Option( - default=False, - help="Simulate a soft reset, shortcut for --mode=hard", - ), -): - settings.hide_first_tag = True - scene = Reset(commit=commit, mode=mode, soft=soft, mixed=mixed, hard=hard) - handle_animations(scene=scene) 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 378833b..1050611 100644 --- a/git_sim/restore.py +++ b/src/git_sim/restore.py @@ -1,18 +1,17 @@ import sys import manim as m -import typer from typing import List -from git_sim.animations import handle_animations from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings 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 @@ -21,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() @@ -40,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() @@ -71,14 +77,3 @@ def populate_zones( firstColumnArrowMap[y.a_path] = m.Arrow( stroke_width=3, color=self.fontColor ) - - -def restore( - files: List[str] = typer.Argument( - default=None, - help="The names of one or more files to restore", - ) -): - settings.hide_first_tag = True - scene = Restore(files=files) - handle_animations(scene=scene) diff --git a/git_sim/revert.py b/src/git_sim/revert.py similarity index 82% rename from git_sim/revert.py rename to src/git_sim/revert.py index 32f0b55..8997850 100644 --- a/git_sim/revert.py +++ b/src/git_sim/revert.py @@ -3,9 +3,7 @@ import git import manim as m import numpy -import typer -from git_sim.animations import handle_animations from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings @@ -36,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() @@ -55,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 [ @@ -76,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, ) @@ -85,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, ) @@ -93,7 +90,12 @@ def build_commit_id_and_message(self, commit, i): return commitId, commitMessage, commit, hide_refs def setup_and_draw_revert_commit(self): - circle = m.Circle(stroke_color=m.RED, fill_color=m.RED, fill_opacity=0.25) + circle = m.Circle( + stroke_color=m.RED, + stroke_width=self.commit_stroke_width, + fill_color=m.RED, + fill_opacity=0.25, + ) circle.height = 1 circle.next_to( self.drawnCommits[self.get_commit().hexsha], @@ -103,12 +105,19 @@ def setup_and_draw_revert_commit(self): start = circle.get_center() end = self.drawnCommits[self.get_commit().hexsha].get_center() - arrow = m.Arrow(start, end, color=self.fontColor) + 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_size=20, color=self.fontColor + "abcdef", font=self.font, font_size=20, color=self.fontColor ).next_to(circle, m.UP) self.toFadeOut.add(commitId) @@ -118,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) @@ -157,14 +166,3 @@ def populate_zones( ): for filename in self.revert.stats.files: secondColumnFileNames.add(filename) - - -def revert( - commit: str = typer.Argument( - default="HEAD", - help="The ref (branch/tag), or commit ID to simulate revert", - ) -): - settings.hide_first_tag = True - scene = Revert(commit=commit) - handle_animations(scene=scene) diff --git a/src/git_sim/rm.py b/src/git_sim/rm.py new file mode 100644 index 0000000..ccd8c64 --- /dev/null +++ b/src/git_sim/rm.py @@ -0,0 +1,141 @@ +import sys +import git +import manim as m + +from typing import List + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Rm(GitSimBaseCommand): + def __init__(self, files: List[str]): + super().__init__() + self.hide_first_tag = True + self.allow_no_commits = True + self.files = files + settings.hide_merged_branches = True + self.n = self.n_default + + try: + self.selected_branches.append(self.repo.active_branch.name) + except TypeError: + pass + + for file in self.files: + try: + self.repo.git.ls_files("--error-unmatch", file) + except: + 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} {self.cmd}") + + self.show_intro() + self.parse_commits() + self.recenter_frame() + self.scale_frame() + self.vsplit_frame() + self.setup_and_draw_zones( + first_column_name="Working directory", + second_column_name="Staging area", + third_column_name="Removed files", + ) + self.show_command_as_title() + self.fadeout() + self.show_outro() + + def create_zone_text( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnFiles, + secondColumnFiles, + thirdColumnFiles, + firstColumnFilesDict, + secondColumnFilesDict, + thirdColumnFilesDict, + firstColumnTitle, + secondColumnTitle, + thirdColumnTitle, + horizontal2, + ): + for i, f in enumerate(firstColumnFileNames): + text = ( + m.Text( + self.trim_path(f), + font=self.font, + font_size=24, + color=self.fontColor, + ) + .move_to( + (firstColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) + ) + .shift(m.DOWN * 0.5 * (i + 1)) + ) + firstColumnFiles.add(text) + firstColumnFilesDict[f] = text + + for j, f in enumerate(secondColumnFileNames): + text = ( + m.Text( + self.trim_path(f), + font=self.font, + font_size=24, + color=self.fontColor, + ) + .move_to( + (secondColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) + ) + .shift(m.DOWN * 0.5 * (j + 1)) + ) + secondColumnFiles.add(text) + secondColumnFilesDict[f] = text + + for h, f in enumerate(thirdColumnFileNames): + text = ( + m.MarkupText( + "" + + self.trim_path(f) + + "", + font=self.font, + font_size=24, + color=self.fontColor, + ) + .move_to( + (thirdColumnTitle.get_center()[0], horizontal2.get_center()[1], 0) + ) + .shift(m.DOWN * 0.5 * (h + 1)) + ) + thirdColumnFiles.add(text) + thirdColumnFilesDict[f] = text + + def populate_zones( + self, + firstColumnFileNames, + secondColumnFileNames, + thirdColumnFileNames, + firstColumnArrowMap={}, + secondColumnArrowMap={}, + thirdColumnArrowMap={}, + ): + for file in self.files: + if file in [x.a_path for x in self.repo.index.diff("HEAD")]: + secondColumnFileNames.add(file) + secondColumnArrowMap[file] = m.Arrow( + stroke_width=3, color=self.fontColor + ) + else: + firstColumnFileNames.add(file) + firstColumnArrowMap[file] = m.Arrow( + stroke_width=3, color=self.fontColor + ) + + thirdColumnFileNames.add(file) 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 d490817..a30d7fd 100644 --- a/git_sim/stash.py +++ b/src/git_sim/stash.py @@ -1,23 +1,17 @@ +import re import sys from enum import Enum import manim as m -import typer from typing import List -from git_sim.animations import handle_animations +from git_sim.enums import StashSubCommand from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings -class StashSubCommand(Enum): - POP = "pop" - APPLY = "apply" - PUSH = "push" - - 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 @@ -25,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: @@ -51,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() @@ -70,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() @@ -93,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, ) @@ -109,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, ) @@ -131,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, ) @@ -152,21 +152,24 @@ def populate_zones( secondColumnArrowMap={}, 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): @@ -187,17 +190,13 @@ def populate_zones( stroke_width=3, color=self.fontColor ) - -def stash( - command: StashSubCommand = typer.Argument( - default=None, - help="Stash subcommand (push, pop, apply)", - ), - files: List[str] = typer.Argument( - default=None, - help="The name of the file to stash changes for", - ), -): - settings.hide_first_tag = True - scene = Stash(files=files, command=command) - handle_animations(scene=scene) + 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 72% rename from git_sim/status.py rename to src/git_sim/status.py index 75fa360..f96391f 100644 --- a/git_sim/status.py +++ b/src/git_sim/status.py @@ -1,4 +1,3 @@ -from git_sim.animations import handle_animations from git_sim.git_sim_base_command import GitSimBaseCommand from git_sim.settings import settings @@ -12,23 +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() - - -def status(): - settings.hide_first_tag = True - settings.allow_no_commits = True - - scene = Status() - handle_animations(scene=scene) diff --git a/src/git_sim/switch.py b/src/git_sim/switch.py new file mode 100644 index 0000000..3945ecd --- /dev/null +++ b/src/git_sim/switch.py @@ -0,0 +1,142 @@ +import sys +from argparse import Namespace + +import git +import manim as m +import numpy + +from git_sim.git_sim_base_command import GitSimBaseCommand +from git_sim.settings import settings + + +class Switch(GitSimBaseCommand): + 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: + print( + "git-sim error: can't create new branch '" + + self.branch + + "', 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) + except git.exc.BadName: + print( + "git-sim error: '" + + self.branch + + "' is not a valid Git ref or identifier." + ) + sys.exit(1) + + 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 + 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.head.commit.hexsha + ): + self.is_descendant = True + + if self.branch in [branch.name for branch in self.repo.heads]: + self.selected_branches.append(self.branch) + + try: + 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} {self.cmd}") + + self.show_intro() + head_commit = self.get_commit() + + # using -c flag, create new branch label and exit + if self.c: + self.parse_commits(head_commit) + self.recenter_frame() + self.scale_frame() + self.draw_ref(head_commit, self.topref, text=self.branch, color=m.GREEN) + else: + branch_commit = self.get_commit(self.branch) + + if self.is_ancestor: + commits_in_range = list(self.repo.iter_commits(self.branch + "..HEAD")) + + # branch is reached from HEAD, so draw everything + if len(commits_in_range) <= self.n: + self.parse_commits(head_commit) + reset_head_to = branch_commit.hexsha + self.recenter_frame() + self.scale_frame() + self.reset_head(reset_head_to) + self.reset_branch(head_commit.hexsha) + + # branch is not reached, so start from branch + else: + self.parse_commits(branch_commit) + self.draw_ref(branch_commit, self.topref) + self.recenter_frame() + self.scale_frame() + + elif self.is_descendant: + self.parse_commits(branch_commit) + reset_head_to = branch_commit.hexsha + self.recenter_frame() + self.scale_frame() + if "HEAD" in self.drawnRefs: + self.reset_head(reset_head_to) + if not self.repo.head.is_detached: + self.reset_branch(head_commit.hexsha) + else: + self.draw_ref(branch_commit, self.topref) + else: + self.parse_commits(head_commit) + self.parse_commits(branch_commit, shift=4 * m.DOWN) + self.center_frame_on_commit(branch_commit) + self.recenter_frame() + self.scale_frame() + self.reset_head(branch_commit.hexsha) + 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 74% rename from test.py rename to tests/unit_tests/test.py index 55b77d7..ba262cf 100644 --- a/test.py +++ b/tests/unit_tests/test.py @@ -1,15 +1,11 @@ import unittest, git, argparse from manim import * -from git_sim.git_sim import GitSim - class TestGitSim(unittest.TestCase): def test_git_sim(self): """Test git sim.""" - gs = GitSim(argparse.Namespace()) - self.assertEqual(1, 1)